diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index ad47de482fef..7a31088c8a7d 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -157,6 +157,22 @@ jobs: pattern: prebuild-ios-core-headers-${{ matrix.flavor }}-* path: packages/react-native/.build/headers merge-multiple: true + - name: Download ReactNativeDependencies + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + uses: actions/download-artifact@v7 + with: + name: ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz + path: /tmp/third-party/ + - name: Extract ReactNativeDependencies + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + shell: bash + run: | + # ReactNativeHeaders.xcframework (built by the compose step) folds in + # the third-party deps namespaces (folly/glog/boost/...), so the deps + # headers must be staged here too — not just in build-slices. + tar -xzf /tmp/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz -C /tmp/third-party/ + mkdir -p packages/react-native/third-party/ + mv /tmp/third-party/packages/react-native/third-party/ReactNativeDependencies.xcframework packages/react-native/third-party/ReactNativeDependencies.xcframework - name: Setup Keychain if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT != '' }} uses: apple-actions/import-codesign-certs@v3 # https://github.com/marketplace/actions/import-code-signing-certificates @@ -177,7 +193,10 @@ jobs: if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | cd packages/react-native/.build/output/xcframeworks/${{matrix.flavor}} - tar -cz -f ../ReactCore${{matrix.flavor}}.xcframework.tar.gz React.xcframework + # Ship BOTH xcframeworks: React-Core-prebuilt's prepare_command flattens + # ReactNativeHeaders.xcframework's Headers (incl. module.modulemap) into the + # pod. Omitting it leaves consumers without React-Core-prebuilt/Headers/module.modulemap. + tar -cz -f ../ReactCore${{matrix.flavor}}.xcframework.tar.gz React.xcframework ReactNativeHeaders.xcframework - name: Compress and Rename dSYM if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | diff --git a/package.json b/package.json index 1f25c794fff7..0cda9e06e1e2 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "workspaces": [ "packages/*", + "packages/react-native-test-library/*", "private/*", "!private/helloworld" ], diff --git a/packages/react-native-test-library/.gitignore b/packages/react-native-test-library/.gitignore new file mode 100644 index 000000000000..531ed06a6fec --- /dev/null +++ b/packages/react-native-test-library/.gitignore @@ -0,0 +1,11 @@ +# Generated SPM artifacts (written by setup-ios-spm.js's earlier in-place +# layout). The current autolinker emits these under the consumer app's +# build/generated/autolinking/ tree instead, so any copy that lands here is +# stale and should not be committed. +Package.swift +Package.resolved +include/ + +# SwiftPM caches +.build/ +.swiftpm/ diff --git a/packages/react-native-test-library/apple/TestLibraryApple.h b/packages/react-native-test-library/apple/TestLibraryApple.h new file mode 100644 index 000000000000..fe19590fdc6d --- /dev/null +++ b/packages/react-native-test-library/apple/TestLibraryApple.h @@ -0,0 +1,11 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@interface TestLibraryApple : NSObject +@end diff --git a/packages/react-native-test-library/apple/TestLibraryApple.mm b/packages/react-native-test-library/apple/TestLibraryApple.mm new file mode 100644 index 000000000000..1edfc0ad7eea --- /dev/null +++ b/packages/react-native-test-library/apple/TestLibraryApple.mm @@ -0,0 +1,29 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "TestLibraryApple.h" + +// Synth library products are emitted as .library(type: .dynamic, ...), so SPM +// wraps each autolinked dep as a Foo.framework under PackageFrameworks/. That +// gives angle-bracket imports the standard resolution path, +// matching how most React Native libraries already organize their headers. +#import + +@implementation TestLibraryApple + +RCT_EXPORT_MODULE() + +RCT_EXPORT_METHOD(echo + : (NSString *)message resolve + : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + NSString *prefix = [TestLibraryCommon defaultPrefix]; + resolve([NSString stringWithFormat:@"%@apple: %@", prefix, message]); +} + +@end diff --git a/packages/react-native-test-library/apple/TestLibraryApple.podspec b/packages/react-native-test-library/apple/TestLibraryApple.podspec new file mode 100644 index 000000000000..c715e7970b76 --- /dev/null +++ b/packages/react-native-test-library/apple/TestLibraryApple.podspec @@ -0,0 +1,23 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "TestLibraryApple" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://github.com/facebook/react-native" + s.license = "MIT" + s.platforms = min_supported_versions + s.author = "Meta Platforms, Inc. and its affiliates" + s.source = { :git => "https://github.com/facebook/react-native.git", :tag => "#{s.version}" } + s.source_files = "*.{h,m,mm,swift}" + s.requires_arc = true + + install_modules_dependencies(s) +end diff --git a/packages/react-native-test-library/apple/__tests__/TestLibraryAppleTests.cpp b/packages/react-native-test-library/apple/__tests__/TestLibraryAppleTests.cpp new file mode 100644 index 000000000000..9be1bd0955b8 --- /dev/null +++ b/packages/react-native-test-library/apple/__tests__/TestLibraryAppleTests.cpp @@ -0,0 +1,15 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * Smoke test that the SPM autolinker excludes `__tests__/` directories from + * the `sources:` allowlist. If this file ever ends up in a target's sources, + * the build fails immediately with an unresolved header — making the + * regression loud. + */ + +#include + +static_assert(false, "TestLibraryAppleTests.cpp must not be compiled by the SPM autolinker"); diff --git a/packages/react-native-test-library/apple/index.d.ts b/packages/react-native-test-library/apple/index.d.ts new file mode 100644 index 000000000000..3da35ace31a8 --- /dev/null +++ b/packages/react-native-test-library/apple/index.d.ts @@ -0,0 +1,3 @@ +import type {Greeting} from 'react-native-test-library-common'; + +export function greet(g: Greeting): Promise; diff --git a/packages/react-native-test-library/apple/index.js b/packages/react-native-test-library/apple/index.js new file mode 100644 index 000000000000..9f2352c3e546 --- /dev/null +++ b/packages/react-native-test-library/apple/index.js @@ -0,0 +1,28 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import type {Greeting} from 'react-native-test-library-common'; + +import {NativeModules, Platform} from 'react-native'; +import {formatGreeting} from 'react-native-test-library-common'; + +export function greet(g: Greeting): Promise { + const TestLibraryApple = NativeModules.TestLibraryApple; + if (TestLibraryApple == null) { + return Promise.reject( + new Error( + `react-native-test-library-apple: native module unavailable on ${Platform.OS}. This package is iOS-only; install a platform-specific sibling (e.g. react-native-test-library-android) for cross-platform coverage.`, + ), + ); + } + return TestLibraryApple.echo(formatGreeting(g)); +} diff --git a/packages/react-native-test-library/apple/package.json b/packages/react-native-test-library/apple/package.json new file mode 100644 index 000000000000..8dda511cbefa --- /dev/null +++ b/packages/react-native-test-library/apple/package.json @@ -0,0 +1,31 @@ +{ + "name": "react-native-test-library-apple", + "version": "0.87.0-main", + "description": "Apple platform implementation for the React Native autolinking fixture. Depends on react-native-test-library-common; used to validate iOS/macOS autolinking discovery and transitive native dependency resolution.", + "private": true, + "main": "index.js", + "types": "index.d.ts", + "license": "MIT", + "files": [ + "index.js", + "index.d.ts", + "react-native.config.js", + "TestLibraryApple.podspec", + "TestLibraryApple.h", + "TestLibraryApple.mm" + ], + "keywords": [ + "react-native", + "fixture", + "autolinking", + "ios", + "macos" + ], + "dependencies": { + "react-native-test-library-common": "0.87.0-main" + }, + "peerDependencies": { + "react": "*", + "react-native": "1000.0.0" + } +} diff --git a/packages/react-native-test-library/apple/react-native.config.js b/packages/react-native-test-library/apple/react-native.config.js new file mode 100644 index 000000000000..982ed353de95 --- /dev/null +++ b/packages/react-native-test-library/apple/react-native.config.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +module.exports = { + dependency: { + platforms: { + ios: {}, + }, + }, + spm: { + dependencies: ['react-native-test-library-common'], + }, +}; diff --git a/packages/react-native-test-library/common/TestLibraryCommon.h b/packages/react-native-test-library/common/TestLibraryCommon.h new file mode 100644 index 000000000000..a84f31084b51 --- /dev/null +++ b/packages/react-native-test-library/common/TestLibraryCommon.h @@ -0,0 +1,15 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@interface TestLibraryCommon : NSObject + +/** Shared prefix used by other test-library packages that depend on common. */ ++ (NSString *)defaultPrefix; + +@end diff --git a/packages/react-native-test-library/common/TestLibraryCommon.mm b/packages/react-native-test-library/common/TestLibraryCommon.mm new file mode 100644 index 000000000000..34640a1a6709 --- /dev/null +++ b/packages/react-native-test-library/common/TestLibraryCommon.mm @@ -0,0 +1,26 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "TestLibraryCommon.h" + +@implementation TestLibraryCommon + +RCT_EXPORT_MODULE() + ++ (NSString *)defaultPrefix +{ + return @"[common] "; +} + +RCT_EXPORT_METHOD(version + : (RCTPromiseResolveBlock)resolve reject + : (RCTPromiseRejectBlock)reject) +{ + resolve(@"common@0.87.0-main"); +} + +@end diff --git a/packages/react-native-test-library/common/TestLibraryCommon.podspec b/packages/react-native-test-library/common/TestLibraryCommon.podspec new file mode 100644 index 000000000000..4218183fb657 --- /dev/null +++ b/packages/react-native-test-library/common/TestLibraryCommon.podspec @@ -0,0 +1,23 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "TestLibraryCommon" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://github.com/facebook/react-native" + s.license = "MIT" + s.platforms = min_supported_versions + s.author = "Meta Platforms, Inc. and its affiliates" + s.source = { :git => "https://github.com/facebook/react-native.git", :tag => "#{s.version}" } + s.source_files = "*.{h,m,mm,swift}" + s.requires_arc = true + + install_modules_dependencies(s) +end diff --git a/packages/react-native-test-library/common/index.d.ts b/packages/react-native-test-library/common/index.d.ts new file mode 100644 index 000000000000..0eabfbfec83b --- /dev/null +++ b/packages/react-native-test-library/common/index.d.ts @@ -0,0 +1,7 @@ +export type Greeting = Readonly<{ + name: string; + language: string; +}>; + +export function formatGreeting(g: Greeting): string; +export function getVersion(): Promise; diff --git a/packages/react-native-test-library/common/index.js b/packages/react-native-test-library/common/index.js new file mode 100644 index 000000000000..71eeefcf1ba2 --- /dev/null +++ b/packages/react-native-test-library/common/index.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import {NativeModules, Platform} from 'react-native'; + +export type Greeting = Readonly<{ + name: string, + language: string, +}>; + +export function formatGreeting(g: Greeting): string { + return `[${g.language}] Hello, ${g.name}!`; +} + +export function getVersion(): Promise { + const TestLibraryCommon = NativeModules.TestLibraryCommon; + if (TestLibraryCommon == null) { + return Promise.reject( + new Error( + `react-native-test-library-common: native module unavailable on ${Platform.OS}.`, + ), + ); + } + return TestLibraryCommon.version(); +} diff --git a/packages/react-native-test-library/common/package.json b/packages/react-native-test-library/common/package.json new file mode 100644 index 000000000000..0a911df6a303 --- /dev/null +++ b/packages/react-native-test-library/common/package.json @@ -0,0 +1,24 @@ +{ + "name": "react-native-test-library-common", + "version": "0.87.0-main", + "description": "Shared JS utilities consumed by react-native-test-library-apple. Used as a fixture for validating autolinking discovery and transitive native dependency resolution in the React Native monorepo.", + "private": true, + "main": "index.js", + "types": "index.d.ts", + "license": "MIT", + "files": [ + "index.js", + "index.d.ts", + "react-native.config.js", + "TestLibraryCommon.podspec", + "TestLibraryCommon.h", + "TestLibraryCommon.mm" + ], + "keywords": [ + "react-native", + "fixture", + "autolinking", + "ios", + "macos" + ] +} diff --git a/packages/react-native-test-library/common/react-native.config.js b/packages/react-native-test-library/common/react-native.config.js new file mode 100644 index 000000000000..3de9a9829be7 --- /dev/null +++ b/packages/react-native-test-library/common/react-native.config.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +module.exports = { + dependency: { + platforms: { + ios: {}, + }, + }, +}; diff --git a/packages/react-native/React-Core-prebuilt.podspec b/packages/react-native/React-Core-prebuilt.podspec index 98aa6a09b1df..0d7c08c3aee7 100644 --- a/packages/react-native/React-Core-prebuilt.podspec +++ b/packages/react-native/React-Core-prebuilt.podspec @@ -17,36 +17,51 @@ Pod::Spec.new do |s| s.author = "Meta Platforms, Inc. and its affiliates" s.platforms = min_supported_versions s.source = source + + # We vend two xcframeworks that ship together in the prebuilt tarball: + # - React.xcframework: the compiled core. Its per-slice React.framework carries + # every header + the framework module map, so `#import ` + # and `@import React;` resolve through FRAMEWORK_SEARCH_PATHS automatically. + # - ReactNativeHeaders.xcframework: headers-only. Carries every other namespace + # (, , folly, glog, ...). Its headers are flattened into a + # top-level Headers/ (see prepare_command) and exposed via the standard pod + # header search path. ( is supplied by the hermes-engine pod here; + # it is folded into ReactNativeHeaders only on the SwiftPM consumer side.) + # There is no clang VFS overlay. s.vendored_frameworks = "React.xcframework" s.preserve_paths = '**/*.*' - s.header_mappings_dir = 'React.xcframework/Headers' - s.source_files = 'React.xcframework/Headers/**/*.{h,hpp}' - - s.module_name = 'React' - s.module_map = 'React.xcframework/Modules/module.modulemap' - s.public_header_files = 'React.xcframework/Headers/**/*.h' + s.header_mappings_dir = 'Headers' + s.source_files = 'Headers/**/*.{h,hpp}' + s.public_header_files = 'Headers/**/*.h' add_rn_third_party_dependencies(s) - # We need to make sure that the React.xcframework is copied correctly - in the downloaded tarball - # the root directory is the framework, but when using it we need to have it in a subdirectory - # called React.xcframework, so we need to move the contents of the tarball into that directory. - # This is done in the prepare_command. - # We need to make sure that the headers are copied to the right place - local tar.gz has a different structure - # than the one from the maven repo + # The downloaded tarball ships React.xcframework and ReactNativeHeaders.xcframework + # at its root. We make sure React.xcframework is in its own subdirectory (the Maven + # tarball lays the framework contents at the root; the local tar.gz has a different + # structure) and flatten ReactNativeHeaders' headers into a top-level Headers/ dir + # so CocoaPods exposes them on the header search path. s.prepare_command = <<~'CMD' CURRENT_PATH=$(pwd) XCFRAMEWORK_PATH="${CURRENT_PATH}/React.xcframework" - # Check if XCFRAMEWORK_PATH is empty - if [ -z "$XCFRAMEWORK_PATH" ]; then - echo "ERROR: XCFRAMEWORK_PATH is empty." - exit 0 + # Flatten ReactNativeHeaders' headers (identical across slices) into Headers/ + # BEFORE we sweep stray root entries into React.xcframework. + mkdir -p Headers + RNH_XCFRAMEWORK_PATH=$(find "$CURRENT_PATH" -type d -name "ReactNativeHeaders.xcframework" | head -n 1) + if [ -n "$RNH_XCFRAMEWORK_PATH" ]; then + RNH_HEADERS_PATH=$(find "$RNH_XCFRAMEWORK_PATH" -type d -name "Headers" | head -n 1) + if [ -n "$RNH_HEADERS_PATH" ]; then + cp -R "$RNH_HEADERS_PATH/." Headers + fi + rm -rf "$RNH_XCFRAMEWORK_PATH" fi mkdir -p "${XCFRAMEWORK_PATH}" - find "$CURRENT_PATH" -mindepth 1 -maxdepth 1 ! -name "$(basename "$XCFRAMEWORK_PATH")" -exec mv {} "$XCFRAMEWORK_PATH" \; + find "$CURRENT_PATH" -mindepth 1 -maxdepth 1 \ + ! -name "$(basename "$XCFRAMEWORK_PATH")" ! -name "Headers" \ + -exec mv {} "$XCFRAMEWORK_PATH" \; CMD # If we are passing a local tarball, we don't want to switch between Debug and Release diff --git a/packages/react-native/React-Core.podspec b/packages/react-native/React-Core.podspec index 63eb78aeb59a..8c4c77a8d2d0 100644 --- a/packages/react-native/React-Core.podspec +++ b/packages/react-native/React-Core.podspec @@ -51,7 +51,6 @@ Pod::Spec.new do |s| s.author = "Meta Platforms, Inc. and its affiliates" s.platforms = min_supported_versions s.source = source - s.resource_bundle = { "RCTI18nStrings" => ["React/I18n/strings/*.lproj"]} s.compiler_flags = js_engine_flags() s.header_dir = "React" s.weak_framework = "JavaScriptCore" @@ -122,7 +121,15 @@ Pod::Spec.new do |s| s.dependency "React-hermes" end - s.resource_bundles = {'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy'} + # Both bundles in one declaration: a second `resource_bundle(s) =` would replace + # (not merge) the first. RCTI18nStrings holds React-Core's localized strings + # (loaded by RCTLocalizedString); React-Core_privacy is the privacy manifest. + # (Prebuilt/SwiftPM get both from inside React.xcframework instead — see + # scripts/ios-prebuild/{i18n,privacy}.js — but source builds ship them here.) + s.resource_bundles = { + 'RCTI18nStrings' => ['React/I18n/strings/*.lproj'], + 'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy', + } add_dependency(s, "React-runtimeexecutor", :additional_framework_paths => ["platform/ios"]) add_dependency(s, "React-jsinspector", :framework_name => 'jsinspector_modern') diff --git a/packages/react-native/React/I18n/RCTLocalizedString.mm b/packages/react-native/React/I18n/RCTLocalizedString.mm index 6a09613b77a0..1f4034c9cf93 100644 --- a/packages/react-native/React/I18n/RCTLocalizedString.mm +++ b/packages/react-native/React/I18n/RCTLocalizedString.mm @@ -9,6 +9,29 @@ #if !defined(WITH_FBI18N) || !(WITH_FBI18N) +// Anchors resource lookups to the bundle that contains this code: React.framework +// when React Native is consumed prebuilt / via SwiftPM, or the app's main bundle +// for static source builds. +@interface RCTI18nStringsAnchor : NSObject +@end +@implementation RCTI18nStringsAnchor +@end + +// Resolves RCTI18nStrings.bundle wherever it ships: the code's own bundle first +// (prebuilt/SwiftPM embed it inside React.framework), then the app's main bundle +// (source builds copy it there via the podspec resource_bundles). Returns nil +// when absent, so the caller falls back to the untranslated default value. +static NSBundle *RCTI18nStringsBundle(void) +{ + NSBundle *codeBundle = [NSBundle bundleForClass:[RCTI18nStringsAnchor class]]; + NSURL *url = [codeBundle URLForResource:@"RCTI18nStrings" withExtension:@"bundle"]; + if (url != nil) { + return [NSBundle bundleWithURL:url]; + } + NSString *mainPath = [[NSBundle mainBundle] pathForResource:@"RCTI18nStrings" ofType:@"bundle"]; + return mainPath != nil ? [NSBundle bundleWithPath:mainPath] : nil; +} + extern "C" { static NSString *FBTStringByConvertingIntegerToBase64(uint64_t number) @@ -33,8 +56,7 @@ NSString *RCTLocalizedStringFromKey(uint64_t key, NSString *defaultValue) { - static NSBundle *bundle = [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:@"RCTI18nStrings" - ofType:@"bundle"]]; + static NSBundle *bundle = RCTI18nStringsBundle(); if (bundle == nil) { return defaultValue; } else { diff --git a/packages/react-native/package.json b/packages/react-native/package.json index ec26182439fe..41d53ec032ec 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -115,6 +115,8 @@ "scripts/react_native_pods_utils/script_phases.sh", "scripts/react_native_pods.rb", "scripts/react-native-xcode.sh", + "scripts/setup-apple-spm.js", + "scripts/spm", "scripts/xcode/ccache-clang.sh", "scripts/xcode/ccache-clang++.sh", "scripts/xcode/ccache.conf", diff --git a/packages/react-native/react-native.config.js b/packages/react-native/react-native.config.js index 713290231165..aec6073c1ddc 100644 --- a/packages/react-native/react-native.config.js +++ b/packages/react-native/react-native.config.js @@ -112,6 +112,93 @@ const codegenCommand /*: Command */ = { commands.push(codegenCommand); +const spmCommand /*: Command */ = { + name: 'spm [action]', + description: + 'Set up or maintain Swift Package Manager support for the iOS/macOS app. ' + + 'Actions: add, update, deinit, scaffold. With no action: add (or update ' + + 'if SPM is already set up).', + options: [ + { + name: '--version ', + description: + 'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json.', + }, + { + name: '--flavor ', + description: 'Artifact flavor: debug or release.', + }, + { + name: '--yes', + description: 'Skip the dirty-pbxproj confirmation prompt.', + }, + { + name: '--xcodeproj ', + description: + '[add] Path to the .xcodeproj to inject SPM packages into ' + + '(disambiguates when several exist).', + }, + { + name: '--productName ', + description: + '[add] App target to inject into (disambiguates when several exist).', + }, + { + name: '--deintegrate', + description: + '[add] Run `pod deintegrate` and strip React Native from the Podfile ' + + 'before injecting (CocoaPods → SwiftPM migration).', + }, + { + name: '--artifacts ', + description: + '[advanced] Local artifact source: a .xcframework file (used directly, ' + + 'no download) or a directory (cache dir to read/download into).', + }, + { + name: '--download ', + description: + '[advanced] Artifact download policy: auto (default), skip, or force.', + }, + { + name: '--skipCodegen', + description: '[advanced] Skip the react-native codegen step.', + }, + ], + func: async (argv, _config, args) => { + const passthrough /*: Array */ = []; + if (argv[0] != null) { + passthrough.push(argv[0]); + } + const stringOpts /*: Array<[string, string]> */ = [ + ['version', '--version'], + ['flavor', '--flavor'], + ['productName', '--product-name'], + ['xcodeproj', '--xcodeproj'], + ['artifacts', '--artifacts'], + ['download', '--download'], + ]; + for (const [key, flag] of stringOpts) { + if (args[key] != null) { + passthrough.push(flag, String(args[key])); + } + } + const boolOpts /*: Array<[string, string]> */ = [ + ['skipCodegen', '--skip-codegen'], + ['deintegrate', '--deintegrate'], + ['yes', '--yes'], + ]; + for (const [key, flag] of boolOpts) { + if (args[key]) { + passthrough.push(flag); + } + } + await require('./scripts/setup-apple-spm').main(passthrough); + }, +}; + +commands.push(spmCommand); + const config = { commands, platforms: {} /*:: as {[string]: Readonly<{ diff --git a/packages/react-native/scripts/cocoapods/fabric.rb b/packages/react-native/scripts/cocoapods/fabric.rb index b6c8264a3546..ccb10438940b 100644 --- a/packages/react-native/scripts/cocoapods/fabric.rb +++ b/packages/react-native/scripts/cocoapods/fabric.rb @@ -11,7 +11,7 @@ def setup_fabric!(react_native_path: "../node_modules/react-native") pod 'React-Fabric', :path => "#{react_native_path}/ReactCommon" pod 'React-FabricComponents', :path => "#{react_native_path}/ReactCommon" pod 'React-graphics', :path => "#{react_native_path}/ReactCommon/react/renderer/graphics" - pod 'React-RCTFabric', :path => "#{react_native_path}/React", :modular_headers => true + rncore_pod 'React-RCTFabric', :path => "#{react_native_path}/React", :modular_headers => true pod 'React-ImageManager', :path => "#{react_native_path}/ReactCommon/react/renderer/imagemanager/platform/ios" pod 'React-FabricImage', :path => "#{react_native_path}/ReactCommon" end diff --git a/packages/react-native/scripts/cocoapods/rncore.rb b/packages/react-native/scripts/cocoapods/rncore.rb index 252588c98432..83d393ce807d 100644 --- a/packages/react-native/scripts/cocoapods/rncore.rb +++ b/packages/react-native/scripts/cocoapods/rncore.rb @@ -11,30 +11,16 @@ ### Adds ReactNativeCore-prebuilt as a dependency to the given podspec if we're not ### building ReactNativeCore from source (then this function does nothing). +### +### `` resolves through the vendored React.framework; every other namespace +### (``, ``, ``, ...) resolves through the flattened +### ReactNativeHeaders headers that React-Core-prebuilt exposes. The header search path +### and the ReactNativeHeaders module-map activation are NOT added here: they are applied +### post-install by configure_aggregate_xcconfig, which covers aggregate, third-party AND +### these pods from a single injection site. No clang VFS overlay. def add_rncore_dependency(s) if !ReactNativeCoreUtils.build_rncore_from_source() - # Add the dependency s.dependency "React-Core-prebuilt" - - current_pod_target_xcconfig = s.to_hash["pod_target_xcconfig"] || {} - current_pod_target_xcconfig = current_pod_target_xcconfig.to_h unless current_pod_target_xcconfig.is_a?(Hash) - - # Add VFS overlay flags for both Objective-C and Swift - # The VFS overlay file is pre-resolved at pod install time for each platform slice. - # We reference it directly in the xcframework using the React-VFS.yaml file that - # is written to the React-Core-prebuilt folder during setup_vfs_overlay. - # See scripts/ios-prebuild/__docs__/README.md for more details on VFS overlays. - vfs_overlay_flag = "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - current_pod_target_xcconfig["OTHER_CFLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_CFLAGS"] += " #{vfs_overlay_flag}" - current_pod_target_xcconfig["OTHER_CPLUSPLUSFLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_CPLUSPLUSFLAGS"] += " #{vfs_overlay_flag}" - # For Swift, we need to use -Xcc to pass flags to the underlying Clang compiler - # Both the flag and its argument need separate -Xcc prefixes - current_pod_target_xcconfig["OTHER_SWIFT_FLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_SWIFT_FLAGS"] += " -Xcc -ivfsoverlay -Xcc $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - - s.pod_target_xcconfig = current_pod_target_xcconfig end end @@ -521,71 +507,34 @@ def self.get_nightly_npm_version() return latest_nightly end - # Processes the VFS overlay file from the React.xcframework to resolve the ${ROOT_PATH} placeholder. - # This method should be called from react_native_post_install after pod install completes. + # Single post-install injection site for the prebuilt header resolution. Adds the + # ReactNativeHeaders search path + module-map activation to the aggregate (main app) + # target AND every pod target — RN core pods, third-party pods alike. (add_rncore_dependency + # only declares the React-Core-prebuilt dependency; it no longer touches xcconfigs.) # - # The VFS overlay file maps header import paths to their actual locations within the xcframework. - # Since the xcframework contains platform-specific slices, we generate a resolved VFS file for each - # slice and also create a default VFS file that can be used immediately (before script phases run). - def self.process_vfs_overlay() - return if @@build_from_source - - prebuilt_path = File.join(Pod::Config.instance.project_pods_root, "React-Core-prebuilt") - xcframework_path = File.join(prebuilt_path, "React.xcframework") - vfs_template_path = File.join(xcframework_path, "React-VFS-template.yaml") - - unless File.exist?(vfs_template_path) - rncore_log("VFS overlay template not found at #{vfs_template_path}", :error) - exit 1 - end - - rncore_log("Processing VFS overlay file...") - - # Read the template content - vfs_template_content = File.read(vfs_template_path) - - # Write the VFS file - use the top-level xcframework path - # so that ${ROOT_PATH}/Headers points to the xcframework's Headers folder - resolved_vfs_content = vfs_template_content.gsub('${ROOT_PATH}', xcframework_path) - resolved_vfs_path = File.join(prebuilt_path, "React-VFS.yaml") - File.write(resolved_vfs_path, resolved_vfs_content) - rncore_log(" Created VFS overlay at #{resolved_vfs_path}") - - rncore_log("VFS overlay setup complete") - end - - # Configures the xcconfig files for aggregate (main app) targets to enable VFS overlay for React Native Core. - # This is needed because the main app target does not go through podspec processing, - # so it won't get the VFS overlay flags from add_rncore_dependency. + # `` resolves through the vendored React.framework; this adds the search + # path to the flattened ReactNativeHeaders headers (every other namespace). There is + # no clang VFS overlay. # # Parameters: # - installer: The CocoaPods installer object def self.configure_aggregate_xcconfig(installer) return if @@build_from_source - prebuilt_path = File.join(Pod::Config.instance.project_pods_root, "React-Core-prebuilt") - vfs_overlay_path = File.join(prebuilt_path, "React-VFS.yaml") - - unless File.exist?(vfs_overlay_path) - rncore_log("VFS overlay not found at #{vfs_overlay_path}, skipping prebuilt xcconfig configuration", :error) - exit 1 - end - rncore_log("Configuring xcconfig for prebuilt React Native Core...") - vfs_overlay_flag = " -ivfsoverlay \"#{vfs_overlay_path}\"" - swift_vfs_overlay_flag = " -Xcc -ivfsoverlay -Xcc \"#{vfs_overlay_path}\"" + headers_search_path = " \"$(PODS_ROOT)/React-Core-prebuilt/Headers\"" - # Add flags to aggregate target xcconfigs (these are used by the main app target) + # Add the header search path to aggregate target xcconfigs (used by the main app target) installer.aggregate_targets.each do |aggregate_target| aggregate_target.xcconfigs.each do |config_name, config_file| - add_vfs_overlay_flags(config_file.attributes, vfs_overlay_flag, swift_vfs_overlay_flag) + add_prebuilt_header_search_paths(config_file.attributes, headers_search_path) xcconfig_path = aggregate_target.xcconfig_path(config_name) config_file.save_as(xcconfig_path) end end - # Add flags to ALL pod targets (for third-party pods that don't call add_rncore_dependency) + # Add the header search path to ALL pod targets (for third-party pods that don't call add_rncore_dependency) installer.pod_targets.each do |pod_target| pod_target.build_settings.each do |config_name, build_settings| xcconfig_path = pod_target.xcconfig_path(config_name) @@ -593,11 +542,11 @@ def self.configure_aggregate_xcconfig(installer) xcconfig = Xcodeproj::Config.new(xcconfig_path) - # Check if VFS overlay is already present - other_cflags = xcconfig.attributes["OTHER_CFLAGS"] || "" - next if other_cflags.include?("ivfsoverlay") + # Skip if the prebuilt header search path is already present + header_search_paths = xcconfig.attributes["HEADER_SEARCH_PATHS"] || "" + next if header_search_paths.include?("React-Core-prebuilt/Headers") - add_vfs_overlay_flags(xcconfig.attributes, vfs_overlay_flag, swift_vfs_overlay_flag) + add_prebuilt_header_search_paths(xcconfig.attributes, headers_search_path) xcconfig.save_as(xcconfig_path) end end @@ -605,12 +554,17 @@ def self.configure_aggregate_xcconfig(installer) rncore_log("Prebuilt xcconfig configuration complete") end - # Helper method to add VFS overlay flags to an xcconfig attributes map - def self.add_vfs_overlay_flags(attributes, vfs_overlay_flag, swift_vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CPLUSPLUSFLAGS", vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", swift_vfs_overlay_flag) + # Helper method to add the prebuilt ReactNativeHeaders header search path to an xcconfig attributes map + def self.add_prebuilt_header_search_paths(attributes, headers_search_path) + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "HEADER_SEARCH_PATHS", headers_search_path) # Suppress incomplete umbrella warnings for the prebuilt frameworks (it is expected, as our umbrella headers do not include all headers) ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc -Wno-incomplete-umbrella") + # Activate the ReactNativeHeaders module map so the relocated namespaces + # (`yoga`, `RCTDeprecation`, `ReactNativeHeaders_react`, ...) are modular — + # otherwise the React framework's clang explicit-module precompile trips + # -Wnon-modular-include-in-framework-module on `` / ``. + module_map_flag = " -fmodule-map-file=$(PODS_ROOT)/React-Core-prebuilt/Headers/module.modulemap" + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", module_map_flag) + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc" + module_map_flag) end end diff --git a/packages/react-native/scripts/cocoapods/rncore_facades.rb b/packages/react-native/scripts/cocoapods/rncore_facades.rb new file mode 100644 index 000000000000..8676f10c1c79 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/rncore_facades.rb @@ -0,0 +1,232 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require 'json' +require 'fileutils' + +# Facade podspecs for the prebuilt React Native Core path. +# +# In prebuilt mode the compiled code AND headers for the React core pods live +# entirely inside React.xcframework + React-Core-prebuilt (which flattens the +# ReactNativeHeaders namespaces into its Headers/). Re-installing the SOURCE +# podspecs in that mode is what makes them ship duplicate headers that shadow the +# prebuilt artifact (via HEADER_SEARCH_PATHS, CocoaPods .hmap header maps, and +# the all-product-headers VFS overlay) and break the React framework's clang +# explicit-module precompile. +# +# Instead we install dependency-only FACADE podspecs for those names: they ship +# no source files and (with one narrow exception, see FACADE_REEXPOSED_HEADERS) +# no headers, so CocoaPods makes them PBXAggregateTarget +# placeholders (should_build? == false) and nothing is laid down to shadow. Each +# facade depends on React-Core-prebuilt so its consumers transitively pick up the +# prebuilt framework + headers. The pod NAMES still resolve, so ReactCodegen, +# third-party modules, and RN's own podspec graph keep resolving `React-Core`, +# `Yoga`, `React-Core/Default`, etc. +# +# MAINTENANCE MODEL: the set of facaded pods is explicit (FACADE_PODS) so the +# prebuilt rollout can be staged, but each facade's VERSION and SUBSPECS are +# DERIVED from the real podspec at `pod install` time (Pod::Specification.from_file). +# That removes the drift risk that would otherwise bite third-party libraries: +# if React adds/renames `React-Core/`, the facade exposes it +# automatically — nobody has to hand-maintain a parallel subspec list. +# +# This is staged: phase 1 facades a small set and KEEPS the existing +# podspec_sources / add_rncore_dependency / configure_aggregate_xcconfig / +# -fmodule-map-file machinery. The set is expanded until the cold prebuilt +# build passes; the distributed prebuilt helpers are only deleted afterwards. +module RNCoreFacades + # pod name => podspec path (relative to the react-native package root). + # These are the React-core pods whose code + headers are fully provided by + # the prebuilt React.xcframework / React-Core-prebuilt. Start small; expand as + # the cold build surfaces more shadowing pods. (NOTE: not every caller of + # add_rncore_dependency belongs here — e.g. ReactCodegen depends on the + # prebuilt but still builds its own generated sources, so it is NOT a facade.) + FACADE_PODS = { + "React-Core" => "React-Core.podspec", + "React-RCTFabric" => "React/React-RCTFabric.podspec", + "React-RCTRuntime" => "React/Runtime/React-RCTRuntime.podspec", + "Yoga" => "ReactCommon/yoga/Yoga.podspec", + "RCTDeprecation" => "ReactApple/Libraries/RCTFoundation/RCTDeprecation/RCTDeprecation.podspec", + "FBLazyVector" => "Libraries/FBLazyVector/FBLazyVector.podspec", + "RCTRequired" => "Libraries/Required/RCTRequired.podspec", + } + + # A facade ships NO headers by default — the prebuilt React.framework / + # React-Core-prebuilt own them. NARROW EXCEPTION: a few headers live ONLY + # inside React.framework/Headers (served angle-only as , e.g. + # because they reach unguarded C++ and are excluded from the framework module + # map) and are NOT in the flattened React-Core-prebuilt/Headers. A quoted + # `#import ".h"` therefore has no resolution target in prebuilt mode. + # + # Community Fabric modules quote-import RCTFabricComponentsPlugins.h (47x in a + # full app: slider, maps, pager-view, keyboard-controller, ...). In source it + # was vended by React-RCTFabric at header_dir "React", which put it in + # dependents' CocoaPods header maps so the bare quoted name resolved. The + # facade dropped it. We re-vend JUST that header here so dependents' header + # maps carry it again, exactly as the source pod did. + # + # Re-exposing a SINGLE header (not the whole React/ namespace) does not put + # / on -I, so it does NOT reintroduce the + # -Wnon-modular-include-in-framework-module shadowing the modular prebuilt + # layout exists to eliminate. The header is core-only (matches source: it + # only ever declared RN's built-in components; third-party Fabric components + # register via the codegen RCTThirdPartyComponentsProvider, shipped by the + # non-facaded ReactCodegen pod). + # + # pod name => { "header_dir" => , "globs" => [] } + FACADE_REEXPOSED_HEADERS = { + "React-RCTFabric" => { + "header_dir" => "React", + "globs" => ["Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h"], + }, + } + + # Sub-directory (relative to the install root) that holds the generated facades. + FACADE_RELDIR = File.join("build", "rncore-facades") + + @@install_root = nil + + # True when `name` should be installed as a facade instead of its source podspec. + def self.facade?(name) + FACADE_PODS.key?(name) + end + + # Generates the facade podspecs and returns the base directory holding them. + # Each facade gets its OWN sub-directory containing a single + # `.podspec.json`, so it can be installed as a LOCAL pod via + # `:path => `. `:path` (PathSource) uses the spec in place and never + # downloads `spec.source` — unlike `:podspec` (PodspecSource), which is an + # *external* source whose `root_spec.source` CocoaPods would actually fetch + # (i.e. git-clone react-native for every empty facade). Idempotent; safe to + # call once per `pod install`. + # + # `react_native_path` locates the real podspecs we mirror. version + subspecs + + # default_subspecs are DERIVED from the real spec so the facade stays + # graph-equivalent to the source pod (resources are NOT carried — they live in + # the prebuilt artifact; see the note in the loop). A facaded pod whose real + # podspec can't be read is a hard error (see load_real_spec) — silently shipping + # an empty facade would hide exactly the drift this guards against. + def self.generate(react_native_path, install_root, version, ios_version) + @@install_root = install_root.to_s + abs_base = File.join(@@install_root, FACADE_RELDIR) + FileUtils.mkdir_p(abs_base) + FACADE_PODS.each do |name, podspec_rel_path| + podspec_path = File.join(react_native_path.to_s, podspec_rel_path) + podspec_dir = File.dirname(podspec_path) + real = load_real_spec(podspec_path, name) + dir = File.join(abs_base, name) + FileUtils.mkdir_p(dir) + + spec = { + "name" => name, + "version" => real.version.to_s, + "summary" => "Prebuilt facade for #{name} (code + headers live in React-Core-prebuilt).", + "homepage" => "https://reactnative.dev/", + "license" => "MIT", + "authors" => "Meta Platforms, Inc. and its affiliates", + "platforms" => { "ios" => ios_version }, + # Required podspec attribute, but never fetched: the pod is installed + # as a LOCAL pod (`:path => `), which uses this spec in place and + # ships no source_files. Placeholder only. + "source" => { "git" => "https://github.com/facebook/react-native.git" }, + "dependencies" => { "React-Core-prebuilt" => [] }, + } + + # NOTE: the facade carries NO resources. The pods' non-code resources + # (e.g. the privacy manifest) are embedded directly in the prebuilt + # React.xcframework by the ios-prebuild compose (see ios-prebuild/privacy.js), + # so they reach both CocoaPods-prebuilt and SwiftPM from the artifact — + # the facade only needs to declare the React-Core-prebuilt dependency. + + # Re-vend the narrow set of angle-only framework headers that community + # modules quote-import (see FACADE_REEXPOSED_HEADERS). The header is + # COPIED into the facade dir (a self-contained snapshot — robust, no + # `..` globs reaching out of the pod) and exposed as a public header. + # It's header-only (a `.h` has nothing to compile, so the facade stays + # a placeholder), but CocoaPods lays it into + # Pods/Headers/Public/// and dependents' .hmap, + # restoring quoted resolution exactly as the source pod did. + reexposed = FACADE_REEXPOSED_HEADERS[name] + if reexposed + copied = copy_reexposed_headers(reexposed["globs"], podspec_dir, dir, name) + unless copied.empty? + spec["source_files"] = copied + spec["public_header_files"] = copied + spec["header_dir"] = reexposed["header_dir"] if reexposed["header_dir"] + end + end + + # Preserve default_subspec so a bare `pod ''` resolves to the SAME + # subspec graph as the source pod (without it CocoaPods pulls every + # subspec, which is not graph-equivalent). + defaults = Array(real.default_subspecs) + spec["default_subspecs"] = defaults unless defaults.empty? + + subspecs = derive_subspecs(real) + unless subspecs.empty? + spec["subspecs"] = subspecs.map do |ss| + { "name" => ss, "dependencies" => { "React-Core-prebuilt" => [] } } + end + end + + File.write(File.join(dir, "#{name}.podspec.json"), JSON.pretty_generate(spec)) + end + abs_base + end + + # Facade dir for ``, RELATIVE to the install root — pass to `pod :path =>`. + # Relative (not absolute) so the path CocoaPods records in Podfile.lock is + # portable rather than machine-specific. + def self.facade_path(name) + File.join(FACADE_RELDIR, name) + end + + # Loads the real podspec so we can mirror its structure. A facaded pod MUST have + # a readable real podspec — if it's missing or unparseable we raise rather than + # ship an empty facade, since that would silently drop subspecs (the very drift + # this mechanism exists to prevent). + def self.load_real_spec(path, name) + unless File.exist?(path) + raise "[RNCoreFacades] Real podspec for facaded pod '#{name}' not found at #{path}. " \ + "Update FACADE_PODS in rncore_facades.rb if the podspec moved." + end + Pod::Specification.from_file(path) + rescue => e + raise "[RNCoreFacades] Failed to read real podspec for facaded pod '#{name}' at #{path}: #{e.message}" + end + private_class_method :load_real_spec + + # Library (non-test, non-app) subspec names of the real spec, so third-party + # libs depending on `/` keep resolving. Derived, never hand-listed. + def self.derive_subspecs(real) + real.subspecs + .reject { |ss| ss.test_specification? || (ss.respond_to?(:app_specification?) && ss.app_specification?) } + .map(&:base_name) + end + private_class_method :derive_subspecs + + # Copy the re-exposed header(s) into the facade dir (flat) and return the + # facade-relative source_files entries. `globs` are resolved against the real + # podspec dir. A glob that matches nothing is a hard error: the whole point is + # to keep a quoted-import header resolvable, so silently shipping a facade + # without it would reintroduce the exact "file not found" we're fixing. + def self.copy_reexposed_headers(globs, podspec_dir, facade_dir, name) + copied = [] + Array(globs).each do |g| + matches = Dir.glob(File.expand_path(g, podspec_dir)) + if matches.empty? + raise "[RNCoreFacades] Re-exposed header glob '#{g}' for facade '#{name}' " \ + "matched no files under #{podspec_dir}. Update FACADE_REEXPOSED_HEADERS." + end + matches.each do |src| + base = File.basename(src) + FileUtils.cp(src, File.join(facade_dir, base)) + copied << base + end + end + copied.uniq + end + private_class_method :copy_reexposed_headers +end diff --git a/packages/react-native/scripts/codegen/templates/Package.swift.spm-template b/packages/react-native/scripts/codegen/templates/Package.swift.spm-template new file mode 100644 index 000000000000..c5f95b4a0450 --- /dev/null +++ b/packages/react-native/scripts/codegen/templates/Package.swift.spm-template @@ -0,0 +1,80 @@ +// swift-tools-version: 6.0 +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// AUTO-GENERATED by scripts/setup-apple-spm.js – do not edit manually. +// This SPM-specific template replaces the CocoaPods codegen template +// with xcframework configuration built in. + +import PackageDescription + +// React headers need NO search paths: React/react namespaces come from the +// React binaryTarget (auto -F; headers + module map inside the framework), +// every other namespace from the ReactNativeHeaders binaryTarget, and the +// per-app generated headers from the ReactAppHeaders target below — all +// auto-served by SPM through product/target dependencies. +let headersDep: [Target.Dependency] = [ + .product(name: "ReactNativeHeaders", package: "ReactNative"), + "ReactAppHeaders", +] + +let package = Package( + name: "React-GeneratedCode", + platforms: [.iOS(.v15), .macCatalyst(SupportedPlatform.MacCatalystVersion.v13)], + products: [ + .library( + name: "ReactCodegen", + targets: ["ReactCodegen"]), + .library( + name: "ReactAppDependencyProvider", + targets: ["ReactAppDependencyProvider"]), + .library( + name: "ReactAppHeaders", + targets: ["ReactAppHeaders"]), + ], + dependencies: [ + .package(name: "ReactNative", path: "../../xcframeworks"), + ], + targets: [ + .target( + name: "ReactCodegen", + dependencies: [ + .product(name: "ReactNative", package: "ReactNative"), + .product(name: "ReactNativeDependencies", package: "ReactNative"), + ] + headersDep, + path: "ReactCodegen", + exclude: ["ReactCodegen.podspec"], + publicHeadersPath: ".", + cSettings: [.headerSearchPath("headers")], + cxxSettings: [.headerSearchPath("headers")], + linkerSettings: [ + .linkedFramework("Foundation") + ] + ), + .target( + name: "ReactAppDependencyProvider", + dependencies: ["ReactCodegen"] + headersDep, + path: "ReactAppDependencyProvider", + exclude: ["ReactAppDependencyProvider.podspec"], + publicHeadersPath: ".", + cSettings: [.headerSearchPath(".."), .headerSearchPath("headers")], + cxxSettings: [.headerSearchPath(".."), .headerSearchPath("headers")], + linkerSettings: [ + .linkedFramework("Foundation") + ] + ), + // The per-app generated-headers farm (built by spm sync), vended as a + // normal SPM headers target. + .target( + name: "ReactAppHeaders", + path: "ReactAppHeaders", + publicHeadersPath: "." + ), + ], + // React Native headers require C++20 (concepts, std::optional, etc.) + cxxLanguageStandard: .cxx20 +) diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/README.md b/packages/react-native/scripts/ios-prebuild/__docs__/README.md index 4d2786314714..c411934c1742 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/README.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/README.md @@ -111,123 +111,48 @@ The build process uses specific `xcodebuild` flags: - Build times vary depending on the target platform and configuration - XCFrameworks support multiple architectures in a single bundle -## Known Issues - -The generated XCFrameworks currently use CocoaPods-style header structures -rather than standard framework header conventions. This may cause modularity -issues when: - -- Consuming the XCFrameworks in projects that expect standard framework headers -- Building dependent frameworks that rely on proper module boundaries -- Integrating with Swift Package Manager projects expecting modular headers - -## VFS Overlay System - -The prebuilt XCFrameworks use Clang's Virtual File System (VFS) overlay -mechanism to enable header imports without modifying the actual header file -structure. This is necessary because React Native's headers are organized -differently than standard framework conventions. - -### Overview - -The VFS overlay creates a virtual mapping between the import paths used in code -(e.g., `#import `) and the actual physical -locations of headers within the XCFramework. This allows the prebuilt frameworks -to work seamlessly while maintaining the original import syntax. - -### Build-Time VFS Generation (`vfs.js`) - -The `vfs.js` script creates a VFS overlay template during the prebuild process: - -1. **Header Collection** (`headers.js`): Scans all podspec files in the React - Native package to discover header files and their target import paths. - -2. **VFS Structure Building**: The `buildVFSStructure()` function creates a - hierarchical directory tree representation from the header mappings. Clang's - VFS overlay requires directories to contain their children in a tree - structure. - -3. **YAML Generation**: The `generateVFSOverlayYAML()` function converts the VFS - structure into Clang's expected YAML format. - -4. **Template Creation**: The generated overlay uses `${ROOT_PATH}` as a - placeholder for the actual installation path. This template is included in - the XCFramework as `React-VFS-template.yaml`. - -#### Key Functions - -- `createVFSOverlay(rootFolder)`: Main entry point that generates the complete - VFS overlay YAML string -- `createVFSOverlayContents(rootFolder)`: Creates the VFS overlay object - structure -- `buildVFSStructure(mappings)`: Builds the hierarchical directory tree from - flat mappings -- `resolveVFSOverlay(vfsTemplate, rootPath)`: Replaces `${ROOT_PATH}` with the - actual path - -### Runtime VFS Processing (CocoaPods) - -When consuming prebuilt frameworks via CocoaPods, the VFS overlay is processed -at pod install time by `rncore.rb`: - -#### `process_vfs_overlay()` - -Called during `react_native_post_install`, this method: - -1. Reads the `React-VFS-template.yaml` from the XCFramework -2. Resolves the `${ROOT_PATH}` placeholder with the actual XCFramework path -3. Writes the resolved overlay to - `$(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml` - -#### `add_rncore_dependency(s)` - -Adds VFS overlay compiler flags to podspecs that depend on React Native: - -```ruby -# For C/C++ compilation -OTHER_CFLAGS += "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" -OTHER_CPLUSPLUSFLAGS += "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - -# For Swift compilation (flags passed to underlying Clang) -OTHER_SWIFT_FLAGS += "-Xcc -ivfsoverlay -Xcc $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" -``` - -#### `configure_aggregate_xcconfig(installer)` - -Configures VFS overlay flags for: - -- **Aggregate targets**: Main app targets that don't go through podspec - processing -- **All pod targets**: Third-party pods that don't explicitly call - `add_rncore_dependency` - -This ensures all compilation units in the project can resolve React Native -headers through the VFS overlay. - -### VFS Overlay Format - -The VFS overlay uses Clang's hierarchical YAML format: - -```yaml -version: 0 -case-sensitive: false -roots: - - name: '${ROOT_PATH}/Headers' - type: 'directory' - contents: - - name: 'react' - type: 'directory' - contents: - - name: 'renderer' - type: 'directory' - contents: - - name: 'Size.h' - type: 'file' - external-contents: '${ROOT_PATH}/Headers/React/react/renderer/Size.h' -``` - -The structure maps virtual paths (what the compiler sees) to physical paths -(where the files actually exist in the XCFramework). +## Header Resolution (headers-spec layout) + +The prebuilt XCFrameworks ship a **headers-spec layout** so that header imports +resolve through plain header/framework search paths — there is **no clang VFS +overlay**. The layout contract is defined and validated in code: + +- `headers-spec.js`: the executable layout contract (rules R1–R8) — which + namespaces are hoisted, which carry module maps, and how collisions are + rejected. +- `headers-inventory.js`: scans the source tree to build the live header + inventory that feeds the spec. +- `headers-compose.js`: emits the layout. `emitReactFrameworkHeaders()` writes + the `React/` and bare-aliased headers into every slice's + `React.framework/Headers`, and `buildReactNativeHeadersXcframework()` + assembles the headers-only `ReactNativeHeaders.xcframework` carrying every + other namespace (incl. `react/`) plus the third-party dependency namespaces + (`folly`, `glog`, `boost`, `fmt`, `double-conversion`, `fast_float`). The + Hermes public headers (``) are folded in only on the SwiftPM + consumer side (`ensureHeadersLayout`); the published prebuild artifact does + not yet carry them (TODO in `xcframework.js`). + +### Artifacts + +The prebuild (`xcframework.js`) always produces: + +- `React.xcframework` — the compiled React core. Each slice's `React.framework` + carries the headers-spec layout (every `` header + the framework + module map), which is what both CocoaPods and SwiftPM consume. +- `ReactNativeHeaders.xcframework` — headers-only; carries every other + namespace. Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via the + `React-Core-prebuilt` pod (headers flattened onto the header search path). + +### CocoaPods consumption + +The `React-Core-prebuilt` pod vends `React.xcframework` (so `` and +`@import React;` resolve through the framework module via +`FRAMEWORK_SEARCH_PATHS`) and flattens `ReactNativeHeaders.xcframework`'s +headers into a top-level `Headers/` exposed on the pod header search path (so +``, ``, `` resolve). `rncore.rb` adds the +`HEADER_SEARCH_PATHS` entry to `React-Core-prebuilt/Headers` for podspec, +aggregate (main app), and third-party pod targets. No `-ivfsoverlay` flags are +added. ## Integrating in your project with Cocoapods diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js new file mode 100644 index 000000000000..3f99028a32df --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js @@ -0,0 +1,215 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + buildI18nStringsBundle, + buildReactPrivacyManifest, + collectLprojDirs, + collectReactPrivacyManifestPaths, + i18nBundleInfoPlist, + mergePrivacyManifests, +} = require('../framework-resources'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// react-native package root (…/scripts/ios-prebuild/__tests__ -> …) +const RN_PATH = path.resolve(__dirname, '..', '..', '..'); + +// Apple privacy manifest fixtures mirroring the real ones shipped by the pods +// baked into React.framework. +const reactCore = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp', + NSPrivacyAccessedAPITypeReasons: ['C617.1'], + }, + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', + NSPrivacyAccessedAPITypeReasons: ['CA92.1'], + }, + ], + NSPrivacyCollectedDataTypes: [], + NSPrivacyTracking: false, +}; + +const cxxreact = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp', + NSPrivacyAccessedAPITypeReasons: ['C617.1'], + }, + ], + NSPrivacyCollectedDataTypes: [], + NSPrivacyTracking: false, +}; + +describe('mergePrivacyManifests', () => { + it('returns a valid empty manifest for no inputs', () => { + expect(mergePrivacyManifests([])).toEqual({ + NSPrivacyAccessedAPITypes: [], + NSPrivacyCollectedDataTypes: [], + NSPrivacyTracking: false, + }); + }); + + it('passes a single manifest through unchanged (by value)', () => { + expect(mergePrivacyManifests([reactCore])).toEqual(reactCore); + }); + + it('unions accessed-API categories, deduping reasons per category', () => { + const merged = mergePrivacyManifests([reactCore, cxxreact]); + const byType = Object.fromEntries( + merged.NSPrivacyAccessedAPITypes.map(e => [ + e.NSPrivacyAccessedAPIType, + e.NSPrivacyAccessedAPITypeReasons, + ]), + ); + // FileTimestamp appears in both -> single entry, reason deduped. + expect(merged.NSPrivacyAccessedAPITypes).toHaveLength(2); + expect(byType.NSPrivacyAccessedAPICategoryFileTimestamp).toEqual([ + 'C617.1', + ]); + expect(byType.NSPrivacyAccessedAPICategoryUserDefaults).toEqual(['CA92.1']); + }); + + it('unions reasons across manifests for the same category', () => { + const a = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', + NSPrivacyAccessedAPITypeReasons: ['CA92.1'], + }, + ], + }; + const b = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', + NSPrivacyAccessedAPITypeReasons: ['1C8F.1'], + }, + ], + }; + const merged = mergePrivacyManifests([a, b]); + expect(merged.NSPrivacyAccessedAPITypes).toHaveLength(1); + expect( + merged.NSPrivacyAccessedAPITypes[0].NSPrivacyAccessedAPITypeReasons.sort(), + ).toEqual(['1C8F.1', 'CA92.1']); + }); + + it('ORs NSPrivacyTracking and unions tracking domains', () => { + const a = {NSPrivacyTracking: false, NSPrivacyTrackingDomains: ['a.com']}; + const b = { + NSPrivacyTracking: true, + NSPrivacyTrackingDomains: ['a.com', 'b.com'], + }; + const merged = mergePrivacyManifests([a, b]); + expect(merged.NSPrivacyTracking).toBe(true); + expect(merged.NSPrivacyTrackingDomains.sort()).toEqual(['a.com', 'b.com']); + }); + + it('unions collected data types, deduping structurally-equal entries', () => { + const entry = { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeCrashData', + NSPrivacyCollectedDataTypeLinked: false, + }; + const merged = mergePrivacyManifests([ + {NSPrivacyCollectedDataTypes: [entry]}, + {NSPrivacyCollectedDataTypes: [{...entry}]}, + ]); + expect(merged.NSPrivacyCollectedDataTypes).toHaveLength(1); + }); +}); + +describe('buildReactPrivacyManifest (against the real source tree)', () => { + it('discovers React-core PrivacyInfo.xcprivacy files (not third-party deps)', () => { + const paths = collectReactPrivacyManifestPaths(RN_PATH); + expect(paths.length).toBeGreaterThan(0); + // third-party-podspecs manifests belong to ReactNativeDependencies, not React.framework + expect(paths.some(p => p.includes('third-party-podspecs'))).toBe(false); + // React-Core's manifest is the canonical one that must be present + expect( + paths.some(p => p.endsWith('React/Resources/PrivacyInfo.xcprivacy')), + ).toBe(true); + }); + + it('merges them into one manifest covering the known React-core API usages', () => { + const merged = buildReactPrivacyManifest(RN_PATH); + expect(merged).not.toBeNull(); + const categories = (merged?.NSPrivacyAccessedAPITypes ?? []).map( + e => e.NSPrivacyAccessedAPIType, + ); + // FileTimestamp + UserDefaults are declared by React-Core; both must survive the merge. + expect(categories).toContain('NSPrivacyAccessedAPICategoryFileTimestamp'); + expect(categories).toContain('NSPrivacyAccessedAPICategoryUserDefaults'); + // No category should be duplicated after merging. + expect(new Set(categories).size).toBe(categories.length); + }); +}); + +describe('i18nBundleInfoPlist', () => { + it('is a valid resource-bundle Info.plist dict', () => { + const info = i18nBundleInfoPlist(); + expect(info.CFBundlePackageType).toBe('BNDL'); + expect(info.CFBundleName).toBe('RCTI18nStrings'); + expect(typeof info.CFBundleIdentifier).toBe('string'); + expect(info.CFBundleIdentifier.length).toBeGreaterThan(0); + expect(typeof info.CFBundleDevelopmentRegion).toBe('string'); + }); +}); + +describe('collectLprojDirs (against the real source tree)', () => { + it('finds the React i18n .lproj locale dirs', () => { + const dirs = collectLprojDirs(RN_PATH); + expect(dirs.length).toBeGreaterThan(0); + expect(dirs.every(d => d.endsWith('.lproj'))).toBe(true); + // English is the canonical base locale and must be present. + expect(dirs.some(d => path.basename(d) === 'en.lproj')).toBe(true); + }); +}); + +describe('buildI18nStringsBundle', () => { + let tmp; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-bundle-')); + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + it('builds RCTI18nStrings.bundle with the .lproj dirs and an Info.plist', () => { + const out = path.join(tmp, 'RCTI18nStrings.bundle'); + const count = buildI18nStringsBundle(RN_PATH, out); + + expect(count).toBeGreaterThan(0); + expect(fs.existsSync(path.join(out, 'Info.plist'))).toBe(true); + expect(fs.existsSync(path.join(out, 'en.lproj'))).toBe(true); + // the copied locale carries its actual strings file(s) + expect(fs.readdirSync(path.join(out, 'en.lproj')).length).toBeGreaterThan( + 0, + ); + // count matches the number of .lproj dirs copied + const copied = fs.readdirSync(out).filter(e => e.endsWith('.lproj')); + expect(copied.length).toBe(count); + }); + + it('returns 0 and writes nothing when there are no .lproj dirs', () => { + const emptyRn = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-rn-')); + const out = path.join(tmp, 'RCTI18nStrings.bundle'); + const count = buildI18nStringsBundle(emptyRn, out); + expect(count).toBe(0); + expect(fs.existsSync(out)).toBe(false); + fs.rmSync(emptyRn, {recursive: true, force: true}); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js new file mode 100644 index 000000000000..73b6337552bd --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js @@ -0,0 +1,142 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const { + planFromInventory, + renderNamespaceModuleMap, + renderReactModuleMap, +} = require('../headers-spec'); +const fs = require('fs'); + +// isUmbrellaSafe reads each header's source to reject extern-inline defs. Stub +// it to empty so synthetic objc-modular-candidate headers count as umbrella-safe +// (and thus land in namespaceModules), making these tests deterministic. +jest.spyOn(fs, 'readFileSync').mockReturnValue(''); + +const entry = (naturalPath /*: string */, bucket /*: string */) => ({ + naturalPath, + bucket, + lang: 'objc', + identities: [{source: `does/not/exist/${naturalPath}`}], +}); + +// A manifest satisfying both the R9 private-header allowlist and the R10 +// umbrella-namespace allowlist (React_RCTAppDelegate). +const validManifest = () => ({ + headers: [ + entry('React/RCTBridge+Private.h', 'objc-modular-candidate'), + entry('React/RCTComponentViewFactory.h', 'objc-blocked'), + entry('React/RCTComponentViewProtocol.h', 'objc-blocked'), + entry('React/RCTComponentViewRegistry.h', 'objc-blocked'), + entry('React/RCTMountingManager.h', 'objc-blocked'), + entry('React/RCTSurfacePresenter.h', 'objc-blocked'), + entry('React/RCTViewComponentView.h', 'objc-blocked'), + entry( + 'React_RCTAppDelegate/RCTReactNativeFactory.h', + 'objc-modular-candidate', + ), + entry( + 'React_RCTAppDelegate/RCTRootViewFactory.h', + 'objc-modular-candidate', + ), + entry('React_RCTAppDelegate/RCTAppDelegate.h', 'objc-modular-candidate'), + ], +}); + +describe('renderReactModuleMap (R9 private headers)', () => { + test('appends modular allowlist as `header` and objc-blocked as `textual header`', () => { + const out = renderReactModuleMap({ + modular: ['RCTBridge+Private.h'], + textual: ['RCTMountingManager.h'], + }); + expect(out).toContain('umbrella header "React-umbrella.h"'); + expect(out).toContain(' header "RCTBridge+Private.h"'); + expect(out).toContain(' textual header "RCTMountingManager.h"'); + // A textual header must NOT also appear as a plain modular `header`. + expect(out).not.toMatch(/^\s*header "RCTMountingManager\.h"/m); + }); + + test('with no private headers renders just the umbrella (backwards compatible)', () => { + const out = renderReactModuleMap(); + expect(out).toContain('umbrella header "React-umbrella.h"'); + expect(out).not.toContain('textual header'); + }); +}); + +describe('planFromInventory R9 validation', () => { + test('passes for a valid allowlist and exposes privateReactHeaders', () => { + const plan = planFromInventory(validManifest()); + expect(plan.privateReactHeaders.modular).toContain('RCTBridge+Private.h'); + expect(plan.privateReactHeaders.textual).toContain('RCTMountingManager.h'); + }); + + test('throws when an allowlisted header is absent from the inventory', () => { + const m = validManifest(); + m.headers = m.headers.filter( + x => x.naturalPath !== 'React/RCTBridge+Private.h', + ); + expect(() => planFromInventory(m)).toThrow( + /RCTBridge\+Private\.h is absent/, + ); + }); + + test('throws when a modular allowlist header is no longer objc-modular-candidate', () => { + const m = validManifest(); + const h = m.headers.find( + x => x.naturalPath === 'React/RCTBridge+Private.h', + ); + if (h == null) { + throw new Error('fixture missing RCTBridge+Private.h'); + } + h.bucket = 'objc-blocked'; + expect(() => planFromInventory(m)).toThrow(/not 'objc-modular-candidate'/); + }); +}); + +describe('R10 per-namespace umbrella (React_RCTAppDelegate)', () => { + test('emits a derived umbrella for the namespace', () => { + const plan = planFromInventory(validManifest()); + const u = plan.namespaceUmbrellas.find( + x => x.relPath === 'React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h', + ); + expect(u).toBeDefined(); + if (u == null) { + return; + } + // Imports are relative to the namespace dir, derived from the live set. + expect(u.content).toContain('#import "RCTReactNativeFactory.h"'); + expect(u.content).toContain('#import "RCTRootViewFactory.h"'); + expect(u.content).toContain('#import "RCTAppDelegate.h"'); + expect(u.content).toContain('#ifdef __OBJC__'); + // No CocoaPods version boilerplate. + expect(u.content).not.toContain('FOUNDATION_EXPORT'); + }); + + test('module map lists the umbrella so the import stays modular', () => { + const plan = planFromInventory(validManifest()); + const mm = renderNamespaceModuleMap(plan.namespaceModules); + expect(mm).toContain('module React_RCTAppDelegate {'); + expect(mm).toContain( + 'header "React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h"', + ); + }); + + test('fails closed when the umbrella namespace lost its modular headers', () => { + const m = validManifest(); + m.headers = m.headers.filter( + x => !x.naturalPath.startsWith('React_RCTAppDelegate/'), + ); + expect(() => planFromInventory(m)).toThrow( + /umbrella namespace 'React_RCTAppDelegate'/, + ); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/framework-resources.js b/packages/react-native/scripts/ios-prebuild/framework-resources.js new file mode 100644 index 000000000000..c12a56c8d639 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/framework-resources.js @@ -0,0 +1,239 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Non-header resources that the prebuild embeds into React.framework so the + * prebuilt artifact is self-describing for both CocoaPods-prebuilt and SwiftPM. + * + * In source builds each pod ships these via its podspec `resource_bundles`. In + * the prebuilt path the source pods aren't installed (CocoaPods facades) / not + * present (SwiftPM), so we reproduce them from the source tree at compose time: + * + * - Privacy manifest: the pods baked into React.framework each ship a + * PrivacyInfo.xcprivacy; we merge them into ONE manifest at the framework + * root, where Xcode's privacy-report aggregation picks it up (no runtime). + * - RCTI18nStrings: React-Core's localized strings (React/I18n/strings/*.lproj) + * rebuilt as RCTI18nStrings.bundle inside the framework, where the + * framework-aware RCTLocalizedString loader resolves them via bundleForClass:. + */ + +const fs = require('fs'); +const path = require('path'); +const plist = require('plist'); + +// Source roots whose pods compile into React.framework. third-party-podspecs is +// intentionally excluded — those (boost/glog/RCT-Folly) live in +// ReactNativeDependencies.xcframework and are aggregated there. +const REACT_PRIVACY_ROOTS = ['React', 'ReactCommon', 'Libraries', 'ReactApple']; + +// Where React-Core's localized strings live, relative to the package root. +const STRINGS_REL = path.join('React', 'I18n', 'strings'); + +/*:: +type AccessedAPIType = { + NSPrivacyAccessedAPIType: string, + NSPrivacyAccessedAPITypeReasons?: Array, + ... +}; +type PrivacyManifest = { + NSPrivacyAccessedAPITypes?: Array, + NSPrivacyCollectedDataTypes?: Array, + NSPrivacyTracking?: boolean, + NSPrivacyTrackingDomains?: Array, + ... +}; +*/ + +// --------------------------------------------------------------------------- +// Privacy manifest +// --------------------------------------------------------------------------- + +/** + * Merges Apple privacy manifests into one. Pure; operates on parsed plist + * objects. Semantics: + * - NSPrivacyAccessedAPITypes: keyed by category; reasons unioned (deduped). + * - NSPrivacyCollectedDataTypes: unioned, deduped structurally. + * - NSPrivacyTrackingDomains: unioned (deduped); omitted when empty. + * - NSPrivacyTracking: logical OR. + */ +function mergePrivacyManifests( + manifests /*: Array */, +) /*: PrivacyManifest */ { + const reasonsByType /*: Map> */ = new Map(); + const typeOrder /*: Array */ = []; + const trackingDomains /*: Set */ = new Set(); + const collected /*: Array */ = []; + const collectedSeen /*: Set */ = new Set(); + let tracking = false; + + for (const manifest of manifests) { + if (manifest == null) { + continue; + } + for (const entry of manifest.NSPrivacyAccessedAPITypes ?? []) { + const category = entry.NSPrivacyAccessedAPIType; + if (!reasonsByType.has(category)) { + reasonsByType.set(category, []); + typeOrder.push(category); + } + const reasons = reasonsByType.get(category); + if (reasons != null) { + for (const reason of entry.NSPrivacyAccessedAPITypeReasons ?? []) { + if (!reasons.includes(reason)) { + reasons.push(reason); + } + } + } + } + for (const domain of manifest.NSPrivacyTrackingDomains ?? []) { + trackingDomains.add(domain); + } + for (const dataType of manifest.NSPrivacyCollectedDataTypes ?? []) { + const key = JSON.stringify(dataType) ?? ''; + if (!collectedSeen.has(key)) { + collectedSeen.add(key); + collected.push(dataType); + } + } + if (manifest.NSPrivacyTracking === true) { + tracking = true; + } + } + + const merged /*: PrivacyManifest */ = { + NSPrivacyAccessedAPITypes: typeOrder.map(category => ({ + NSPrivacyAccessedAPIType: category, + NSPrivacyAccessedAPITypeReasons: reasonsByType.get(category) ?? [], + })), + NSPrivacyCollectedDataTypes: collected, + NSPrivacyTracking: tracking, + }; + if (trackingDomains.size > 0) { + merged.NSPrivacyTrackingDomains = Array.from(trackingDomains); + } + return merged; +} + +/** Parses a single `PrivacyInfo.xcprivacy` (plist) file into an object. */ +function readPrivacyManifest(filePath /*: string */) /*: PrivacyManifest */ { + // $FlowFixMe[incompatible-return] plist.parse returns a loose PlistValue. + return plist.parse(fs.readFileSync(filePath, 'utf8')); +} + +/** + * Finds every `PrivacyInfo.xcprivacy` under the React-core source roots of + * `reactNativePath` (excluding third-party deps). Sorted for deterministic output. + */ +function collectReactPrivacyManifestPaths( + reactNativePath /*: string */, +) /*: Array */ { + const found /*: Array */ = []; + for (const root of REACT_PRIVACY_ROOTS) { + const dir = path.join(reactNativePath, root); + if (!fs.existsSync(dir)) { + continue; + } + for (const rel of fs.readdirSync(dir, {recursive: true})) { + if (path.basename(String(rel)) === 'PrivacyInfo.xcprivacy') { + found.push(path.join(dir, String(rel))); + } + } + } + return found.sort(); +} + +/** + * Builds the aggregated React.framework privacy manifest from the source pods, + * or null when there are none. + */ +function buildReactPrivacyManifest( + reactNativePath /*: string */, +) /*: ?PrivacyManifest */ { + const paths = collectReactPrivacyManifestPaths(reactNativePath); + if (paths.length === 0) { + return null; + } + return mergePrivacyManifests(paths.map(readPrivacyManifest)); +} + +/** Serializes a manifest object back to a plist XML string. */ +function serializePrivacyManifest( + manifest /*: PrivacyManifest */, +) /*: string */ { + return plist.build(manifest); +} + +// --------------------------------------------------------------------------- +// RCTI18nStrings bundle +// --------------------------------------------------------------------------- + +/** The Info.plist contents that make the copied .lproj dirs load as an NSBundle. */ +function i18nBundleInfoPlist() /*: {[string]: string} */ { + return { + CFBundleDevelopmentRegion: 'en', + CFBundleIdentifier: 'org.reactnative.RCTI18nStrings', + CFBundleInfoDictionaryVersion: '6.0', + CFBundleName: 'RCTI18nStrings', + CFBundlePackageType: 'BNDL', + }; +} + +/** Absolute paths of the React i18n `.lproj` locale dirs, sorted. */ +function collectLprojDirs(reactNativePath /*: string */) /*: Array */ { + const stringsDir = path.join(reactNativePath, STRINGS_REL); + if (!fs.existsSync(stringsDir)) { + return []; + } + return fs + .readdirSync(stringsDir, {withFileTypes: true}) + .filter(e => e.isDirectory() && String(e.name).endsWith('.lproj')) + .map(e => path.join(stringsDir, String(e.name))) + .sort(); +} + +/** + * Builds `RCTI18nStrings.bundle` at `outBundlePath` from the React i18n .lproj + * dirs + an Info.plist. Returns the number of locales copied (0 when there are + * none, in which case nothing is written). + */ +function buildI18nStringsBundle( + reactNativePath /*: string */, + outBundlePath /*: string */, +) /*: number */ { + const lprojDirs = collectLprojDirs(reactNativePath); + if (lprojDirs.length === 0) { + return 0; + } + fs.rmSync(outBundlePath, {recursive: true, force: true}); + fs.mkdirSync(outBundlePath, {recursive: true}); + for (const lproj of lprojDirs) { + fs.cpSync(lproj, path.join(outBundlePath, path.basename(lproj)), { + recursive: true, + }); + } + fs.writeFileSync( + path.join(outBundlePath, 'Info.plist'), + plist.build(i18nBundleInfoPlist()), + ); + return lprojDirs.length; +} + +module.exports = { + // privacy manifest + mergePrivacyManifests, + readPrivacyManifest, + collectReactPrivacyManifestPaths, + buildReactPrivacyManifest, + serializePrivacyManifest, + // RCTI18nStrings bundle + i18nBundleInfoPlist, + collectLprojDirs, + buildI18nStringsBundle, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-compose.js b/packages/react-native/scripts/ios-prebuild/headers-compose.js new file mode 100644 index 000000000000..e3140131030c --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -0,0 +1,371 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Headers compose — emits the headers-spec layout (rules R1–R8 in + * headers-spec.js) into a React.xcframework and builds the headers-only + * ReactNativeHeaders.xcframework beside it. The prebuild path (xcframework.js) + * composes before signing (R7); `ensureHeadersLayout()` applies the same + * emission to an already-cached artifact. One projector, spec-driven, + * byte-identical output either way. + */ + +const { + buildI18nStringsBundle, + buildReactPrivacyManifest, + serializePrivacyManifest, +} = require('./framework-resources'); +const {computeInventory} = require('./headers-inventory'); +const { + DEPS_NAMESPACES, + planFromInventory, + renderNamespaceModuleMap, + renderReactModuleMap, + renderUmbrellaHeader, +} = require('./headers-spec'); +const {execSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/*:: import type {HeadersSpecPlan, SpecEntry} from './headers-spec'; */ + +/** + * Computes the spec plan from the live source tree. Throws on collisions + * (R8) — a collision means the spec and the source tree disagree and the + * artifact must not be produced. + */ +function computeSpecPlan(rnRoot /*: string */) /*: HeadersSpecPlan */ { + const plan = planFromInventory(computeInventory(rnRoot)); + if (plan.collisions.length > 0) { + throw new Error( + `headers-spec collisions (R8):\n ${plan.collisions.join('\n ')}`, + ); + } + return plan; +} + +/** + * Copies spec entries (each `{relPath, source}`) into a staging dir, creating + * parent dirs. Shared by the React.framework and ReactNativeHeaders emission. + */ +function stageEntries( + stage /*: string */, + entries /*: Array */, + rnRoot /*: string */, +) /*: void */ { + for (const e of entries) { + const dest = path.join(stage, e.relPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + fs.copyFileSync(path.join(rnRoot, e.source), dest); + } +} + +/** + * Emits the React.framework side of the spec (R1, R4, R6) into every slice + * of an xcframework: Headers root = React/ hoisted to root + bare aliases, + * generated umbrella + framework module map. Replaces each slice's Headers + * and Modules. The xcframework's ROOT Headers/ (the CocoaPods header surface) + * is left untouched. + */ +function emitReactFrameworkHeaders( + xcfwPath /*: string */, + plan /*: HeadersSpecPlan */, + rnRoot /*: string */, +) /*: void */ { + const stage = fs.mkdtempSync( + path.join(path.dirname(xcfwPath), '.react-stage-'), + ); + stageEntries(stage, plan.react, rnRoot); + fs.writeFileSync( + path.join(stage, 'React-umbrella.h'), + renderUmbrellaHeader(plan.umbrella), + ); + + // A slice is any entry carrying a React.framework. The framework as built by + // xcodebuild -create-xcframework ships no Headers/ dir of its own — this + // emission creates it (and replaces Modules), so detect by the framework, not + // by a pre-existing Headers/. + const slices = fs + .readdirSync(xcfwPath) + .filter(d => fs.existsSync(path.join(xcfwPath, d, 'React.framework'))); + + // Aggregate the privacy manifests of the pods baked into React.framework into + // one root-level PrivacyInfo.xcprivacy, so the prebuilt artifact carries them + // for both CocoaPods-prebuilt and SwiftPM (source builds get them from the + // podspecs instead). Built once, embedded per slice. + const privacyManifest = buildReactPrivacyManifest(rnRoot); + let i18nLocales = 0; + + for (const slice of slices) { + const fwk = path.join(xcfwPath, slice, 'React.framework'); + fs.rmSync(path.join(fwk, 'Headers'), {recursive: true, force: true}); + execSync(`/bin/cp -Rc "${stage}" "${path.join(fwk, 'Headers')}"`); + fs.rmSync(path.join(fwk, 'Modules'), {recursive: true, force: true}); + fs.mkdirSync(path.join(fwk, 'Modules'), {recursive: true}); + fs.writeFileSync( + path.join(fwk, 'Modules', 'module.modulemap'), + renderReactModuleMap(plan.privateReactHeaders), + ); + if (privacyManifest != null) { + fs.writeFileSync( + path.join(fwk, 'PrivacyInfo.xcprivacy'), + serializePrivacyManifest(privacyManifest), + ); + } + // Embed React-Core's localized strings as RCTI18nStrings.bundle so the + // framework-aware RCTLocalizedString loader resolves them in prebuilt/SPM. + i18nLocales = buildI18nStringsBundle( + rnRoot, + path.join(fwk, 'RCTI18nStrings.bundle'), + ); + } + fs.rmSync(stage, {recursive: true, force: true}); + console.log( + `headers-compose: React.framework spec layout -> ${slices.join(', ')} ` + + `(${plan.react.length} headers, umbrella ${plan.umbrella.length}` + + `${privacyManifest != null ? ', +PrivacyInfo.xcprivacy' : ''}` + + `${i18nLocales > 0 ? `, +RCTI18nStrings.bundle (${i18nLocales} locales)` : ''})`, + ); +} + +/*:: +type StubSlice = { + name: string, // human label + sdk: string, // xcrun --sdk name + targets: Array, // clang -target triples (lipo'd when > 1) +}; +*/ + +const DEFAULT_STUB_SLICES /*: Array */ = [ + {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, + { + name: 'ios-simulator', + sdk: 'iphonesimulator', + targets: [ + 'arm64-apple-ios15.0-simulator', + 'x86_64-apple-ios15.0-simulator', + ], + }, +]; + +// Mac Catalyst slice — used by the real compose (the cached-artifact +// repackage path skips it to stay fast; React.xcframework carries it). +const CATALYST_STUB_SLICE /*: StubSlice */ = { + name: 'mac-catalyst', + sdk: 'macosx', + targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], +}; + +/** + * Builds ReactNativeHeaders.xcframework (R2, R5): a headers-only LIBRARY + * xcframework (stub static archives — nothing embeds in apps) whose Headers + * root carries every non-React namespace incl. the third-party deps + * namespaces, plus module.modulemap with the plain per-namespace modules. + * SPM serves its Headers automatically to dependents — no flags. + */ +function buildReactNativeHeadersXcframework( + outDir /*: string */, + plan /*: HeadersSpecPlan */, + depsHeaders /*: string */, + rnRoot /*: string */, + includeCatalyst /*: boolean */ = false, + // Optional dir containing a `hermes/` namespace (Hermes public headers from + // the hermes-ios tarball's destroot/include). Folded in as a textual + // namespace like folly/glog so `` resolves without per-library + // wiring. null when unstaged — then `` stays unavailable. + hermesHeaders /*: ?string */ = null, +) /*: string */ { + // ---- stage headers ---- + const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-')); + stageEntries(stage, plan.reactNativeHeaders, rnRoot); + for (const ns of plan.depsNamespaces) { + const src = path.join(depsHeaders, ns); + // Fail closed: a declared deps namespace (folly/glog/boost/...) missing + // from the staged ReactNativeDependencies headers means the artifact would + // ship WITHOUT those ``-style headers — a silently-broken + // ReactNativeHeaders.xcframework (consumers lose third-party header + // resolution). Refuse rather than emit it. Stage + // third-party/ReactNativeDependencies.xcframework/Headers (full prebuild or + // cache slot) before composing. + if (!fs.existsSync(src)) { + throw new Error( + `headers-compose: deps namespace '${ns}' missing under ${depsHeaders}. ` + + `ReactNativeDependencies headers are not staged — refusing to ship an ` + + `incomplete ReactNativeHeaders.xcframework.`, + ); + } + execSync(`/bin/cp -Rc "${src}" "${path.join(stage, ns)}"`); + } + // Hermes public headers (separate source from the deps namespaces — they + // come from the hermes-ios tarball, not ReactNativeDependencies). Vend only + // the `hermes/` namespace; `jsi/` is already provided elsewhere, so copying + // it here would double-vend. + let hermesFolded = false; + if (hermesHeaders != null) { + const src = path.join(hermesHeaders, 'hermes'); + if (fs.existsSync(src)) { + execSync(`/bin/cp -Rc "${src}" "${path.join(stage, 'hermes')}"`); + hermesFolded = true; + } else { + console.warn(`headers-compose: hermes headers missing at ${src}`); + } + } + // R10: per-namespace umbrella headers (e.g. React_RCTAppDelegate-umbrella.h) + // that consumers like Expo probe via __has_include. Must be staged before the + // module map references them. + for (const u of plan.namespaceUmbrellas) { + const dest = path.join(stage, u.relPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + fs.writeFileSync(dest, u.content); + } + fs.writeFileSync( + path.join(stage, 'module.modulemap'), + renderNamespaceModuleMap(plan.namespaceModules), + ); + + // ---- stub static archives per slice ---- + const work = fs.mkdtempSync(path.join(outDir, '.stub-work-')); + fs.writeFileSync( + path.join(work, 'stub.c'), + '// ReactNativeHeaders is headers-only; this stub satisfies xcframework tooling.\nstatic int RNHeadersStub __attribute__((unused)) = 0;\n', + ); + const slices = includeCatalyst + ? [...DEFAULT_STUB_SLICES, CATALYST_STUB_SLICE] + : DEFAULT_STUB_SLICES; + const libs = slices.map(slice => { + const sdkPath = execSync(`xcrun --sdk ${slice.sdk} --show-sdk-path`) + .toString() + .trim(); + const thins = slice.targets.map((t, i) => { + const obj = path.join(work, `stub-${slice.name}-${i}.o`); + execSync( + `xcrun clang -c -target ${t} -isysroot "${sdkPath}" "${path.join(work, 'stub.c')}" -o "${obj}"`, + ); + const lib = path.join(work, `stub-${slice.name}-${i}.a`); + execSync(`xcrun libtool -static -o "${lib}" "${obj}" 2>/dev/null`); + return lib; + }); + const outLib = path.join(work, `libReactNativeHeaders-${slice.name}.a`); + if (thins.length === 1) { + fs.copyFileSync(thins[0], outLib); + } else { + execSync( + `xcrun lipo -create ${thins.map(l => `"${l}"`).join(' ')} -output "${outLib}"`, + ); + } + return outLib; + }); + + // ---- compose ---- + const outXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework'); + fs.rmSync(outXcfw, {recursive: true, force: true}); + execSync( + `xcodebuild -create-xcframework ` + + libs.map(l => `-library "${l}" -headers "${stage}"`).join(' ') + + ` -output "${outXcfw}"`, + {stdio: 'pipe'}, + ); + fs.rmSync(stage, {recursive: true, force: true}); + fs.rmSync(work, {recursive: true, force: true}); + console.log( + `headers-compose: ReactNativeHeaders.xcframework (${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` + + `(${plan.reactNativeHeaders.length} RN headers + deps ${plan.depsNamespaces.join(', ')}` + + `${hermesFolded ? ', hermes' : ''}; ` + + `${Object.keys(plan.namespaceModules).length} namespace modules)`, + ); + return outXcfw; +} + +/** + * Ensures the headers-spec layout exists at `outDir`, composed from the cache + * slot's artifacts: clones React.xcframework (APFS clonefile), strips the + * stale signature (R7 — production signs after compose), emits the spec + * layout into every slice, and builds ReactNativeHeaders.xcframework from + * the plan + the slot's deps headers. + * + * Skips when the freshness marker matches the source artifact (same + * realpath + Info.plist mtime) unless `force`. Any consumer with a cache slot + * gets composed artifacts automatically — no published ReactNativeHeaders + * required. + */ +function ensureHeadersLayout( + artifactsDir /*: string */, + rnRoot /*: string */, + outDir /*: string */, + force /*: boolean */ = false, +) /*: {reactXcfw: string, headersXcfw: string} */ { + const sourceXcfw = fs.realpathSync( + path.join(artifactsDir, 'React.xcframework'), + ); + const depsHeaders = path.join( + artifactsDir, + 'ReactNativeDependencies.xcframework', + 'Headers', + ); + // Hermes public headers staged into the slot by download-spm-artifacts + // (the hermes-ios tarball ships them in destroot/include, which the + // xcframework extraction otherwise discards). null when absent — then + // ReactNativeHeaders composes without the hermes namespace. + const hermesHeadersDir = path.join(artifactsDir, 'hermes-headers'); + const hermesHeaders = fs.existsSync(path.join(hermesHeadersDir, 'hermes')) + ? hermesHeadersDir + : null; + const reactXcfw = path.join(outDir, 'React.xcframework'); + const headersXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework'); + const markerPath = path.join(outDir, '.composed-from'); + + const sourceStat = fs.statSync(path.join(sourceXcfw, 'Info.plist')); + // Fold the hermes-headers presence into the marker so a slot that gains + // staged hermes headers (e.g. after a tooling upgrade re-downloads them) + // recomposes instead of reusing a hermes-less ReactNativeHeaders. + const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\n`; + if ( + !force && + fs.existsSync(reactXcfw) && + fs.existsSync(headersXcfw) && + fs.existsSync(markerPath) && + fs.readFileSync(markerPath, 'utf8') === marker + ) { + return {reactXcfw, headersXcfw}; + } + + console.log( + `headers-compose: composing layout from ${path.basename(artifactsDir)} slot...`, + ); + fs.rmSync(reactXcfw, {recursive: true, force: true}); + fs.rmSync(markerPath, {force: true}); + fs.mkdirSync(outDir, {recursive: true}); + execSync(`/bin/cp -Rc "${sourceXcfw}" "${reactXcfw}"`); + fs.rmSync(path.join(reactXcfw, '_CodeSignature'), { + recursive: true, + force: true, + }); + + const plan = computeSpecPlan(rnRoot); + emitReactFrameworkHeaders(reactXcfw, plan, rnRoot); + buildReactNativeHeadersXcframework( + outDir, + plan, + depsHeaders, + rnRoot, + false, + hermesHeaders, + ); + fs.writeFileSync(markerPath, marker); + return {reactXcfw, headersXcfw}; +} + +module.exports = { + computeSpecPlan, + emitReactFrameworkHeaders, + buildReactNativeHeadersXcframework, + ensureHeadersLayout, + DEPS_NAMESPACES, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-inventory.js b/packages/react-native/scripts/ios-prebuild/headers-inventory.js new file mode 100644 index 000000000000..7e4dfdf87aa5 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-inventory.js @@ -0,0 +1,586 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Inventory and classify every header the React xcframework ships — the input + * to the headers spec (headers-spec.js). + * + * Enumerates headers through the SAME podspec-driven discovery the prebuild + * uses (headers.js), so the inventory cannot drift from the shipped set. For + * each header it records: + * + * - both identities: the pod-namespaced layout path (`Headers//`) + * and the natural path (the include path consumers write) + * - language surface: objc | objcxx | cxx | c (with `#ifdef __cplusplus` + * guard awareness, so ObjC headers that only reach C++ behind guards are + * not misclassified) + * - a modularizability bucket (can this header live in a Clang module?) + * + * `computeInventory()` returns the classified set in-memory for the prebuild + * compose step; the CLI (`node scripts/ios-prebuild/headers-inventory.js`) + * writes the same set as a JSON manifest. Read-only: never touches the trees + * it describes. + */ + +const {getHeaderFilesFromPodspecs} = require('./headers'); +const fs = require('fs'); +const path = require('path'); + +/*:: +type Identity = { + pod: string, // pod folder name in Headers/ (specName with '-' -> '_') + spec: string, // (sub)spec name the header came from + namespacedPath: string, // path inside the xcframework Headers/ dir + source: string, // repo-relative path to the physical file + bareAlias?: boolean, // synthetic root-level alias (React_RCTAppDelegate rule) +}; + +type IncludeRef = { + token: string, // text between <> or "" + cxxGuarded: boolean, // true when only reachable under #ifdef __cplusplus +}; + +type HeaderEntry = { + naturalPath: string, + identities: Array, + lang: 'objc' | 'objcxx' | 'cxx' | 'c', + bucket: 'objc-modular-candidate' | 'objc-blocked' | 'objcxx' | 'cxx', + includes: { + internal: Array<{naturalPath: string, cxxGuarded: boolean}>, + thirdParty: Array<{lib: string, token: string, cxxGuarded: boolean}>, + hermes: Array, + system: Array, + std: Array<{token: string, cxxGuarded: boolean}>, + metaInternal: Array, + otherPlatform: Array, + notShipped: Array, + unresolved: Array, + }, +}; +*/ + +// Third-party C++ libraries that RN's public headers re-expose (Tier 3 of the +// modularization doc). Keyed by the first include-path segment. +const THIRD_PARTY_LIBS /*: Set */ = new Set([ + 'folly', + 'boost', + 'fmt', + 'glog', + 'double-conversion', + 'fast_float', +]); + +// Apple SDK / platform include roots (first path segment). Includes resolving +// here are "system": always modular or always available, never our problem. +const SDK_PREFIXES = new Set([ + 'Accelerate', + 'Accessibility', + 'AVFoundation', + 'AVKit', + 'CommonCrypto', + 'CoreFoundation', + 'CoreGraphics', + 'CoreLocation', + 'CoreMedia', + 'CoreServices', + 'CoreText', + 'CoreVideo', + 'Foundation', + 'ImageIO', + 'JavaScriptCore', + 'MachO', + 'Metal', + 'MetalKit', + 'MobileCoreServices', + 'Network', + 'PhotosUI', + 'QuartzCore', + 'SafariServices', + 'Security', + 'SwiftUI', + 'TargetConditionals.h', + 'UIKit', + 'UserNotifications', + 'WebKit', + 'XCTest', + 'arm', + 'dispatch', + 'libkern', + 'mach', + 'mach-o', + 'malloc', + 'objc', + 'os', + 'simd', + 'sys', +]); + +/** + * Scans a header's text line by line, tracking the preprocessor-conditional + * stack just enough to know whether a line is only compiled under + * `__cplusplus`. Returns the include list and language-marker observations. + * Heuristic by design: nested #if logic beyond __cplusplus is treated as + * "other" and ignored. + */ +function scanHeader(text /*: string */) /*: { + includes: Array, + hasObjC: boolean, + hasUnguardedCxx: boolean, + hasGuardedCxx: boolean, +} */ { + const includes /*: Array */ = []; + let hasObjC = false; + let hasUnguardedCxx = false; + let hasGuardedCxx = false; + + // Stack frames: 'cpp' (only under __cplusplus), 'notcpp', 'other'. + const stack /*: Array<'cpp' | 'notcpp' | 'other'> */ = []; + const inCxxOnly = () => stack.includes('cpp'); + + const includeRe = /^\s*#\s*(?:include|import)\s+(?:<([^>]+)>|"([^"]+)")/; + const objcRe = + /^\s*(@(interface|protocol|implementation|class\s|end)|NS_ASSUME_NONNULL_BEGIN)/; + const cxxRe = + /^\s*(namespace\s+[A-Za-z_]|template\s*<|extern\s+"C\+\+"|enum\s+class\b|constexpr\b|using\s+(namespace\s|[A-Za-z_]\w*\s*=))/; + + for (const rawLine of text.split('\n')) { + const line = rawLine.replace(/\/\/.*$/, ''); + const cond = line.match(/^\s*#\s*(if|ifdef|ifndef|elif|else|endif)\b(.*)$/); + if (cond) { + const [, directive, rest] = cond; + const mentionsCpp = /__cplusplus/.test(rest); + if (directive === 'ifdef' || directive === 'if') { + stack.push( + mentionsCpp && + !/!\s*defined|defined\s*\(\s*__cplusplus\s*\)\s*==\s*0/.test(rest) + ? 'cpp' + : 'other', + ); + } else if (directive === 'ifndef') { + stack.push(mentionsCpp ? 'notcpp' : 'other'); + } else if (directive === 'else') { + const top = stack.pop() ?? 'other'; + stack.push( + top === 'cpp' ? 'notcpp' : top === 'notcpp' ? 'cpp' : 'other', + ); + } else if (directive === 'elif') { + stack.pop(); + stack.push(mentionsCpp ? 'cpp' : 'other'); + } else if (directive === 'endif') { + stack.pop(); + } + continue; + } + + const inc = line.match(includeRe); + if (inc) { + includes.push({ + token: inc[1] != null ? inc[1] : `"${inc[2]}"`, + cxxGuarded: inCxxOnly(), + }); + } + if (objcRe.test(line)) { + hasObjC = true; + } + if (cxxRe.test(line)) { + if (inCxxOnly()) { + hasGuardedCxx = true; + } else { + hasUnguardedCxx = true; + } + } + } + + // C++ default member initializer inside an aggregate, e.g. + // struct RCTFontProperties { NSString *family = nil; CGFloat size = NAN; }; + // Illegal in C/ObjC, so the header is really ObjC++ and cannot compile in a + // plain ObjC module. The keyword scan above misses it (no namespace/template/ + // class keyword). Detect a `struct`/`class` body that contains a member + // declaration carrying an `=` initializer. Whole-text (not per-line) so the + // aggregate context is required, avoiding false positives on file-scope + // definitions. Unguarded by construction (definitions can't sit under a + // pure `#ifdef __cplusplus` and still be the ObjC surface). + const aggregateMemberInitRe = + /\b(?:struct|class)\s+[A-Za-z_]\w*[^;{}]*\{[^{}]*?\b[A-Za-z_][\w\s:<>,]*\**\s+\*?[A-Za-z_]\w*\s*=\s*[^;{}]+;/s; + if (aggregateMemberInitRe.test(text)) { + hasUnguardedCxx = true; + } + + return {includes, hasObjC, hasUnguardedCxx, hasGuardedCxx}; +} + +// Meta-internal headers referenced behind RN_DISABLE_OSS_PLUGIN_HEADER (the +// FB*Plugins pattern) or fbjni/FBI18n — never resolvable in OSS, by design. +const META_INTERNAL_RE /*: RegExp */ = + /^(fbjni|FBI18n)\/|^React\/FB\w+Plugins\.h$/; +// Non-Apple platform headers (Android-only branches in shared headers). +const OTHER_PLATFORM_PREFIXES = new Set(['android', 'jni']); + +// C++ standard library headers have no slash and no extension (); +// C standard headers have no slash and a .h (). +function classifyExternal( + token /*: string */, + ownNamespaces /*: Set */, + rootFolder /*: string */, +) /*: string */ { + const first = token.split('/')[0]; + if (THIRD_PARTY_LIBS.has(first)) { + return 'thirdParty'; + } + if (first === 'hermes') { + return 'hermes'; + } + if (META_INTERNAL_RE.test(token)) { + return 'metaInternal'; + } + if (OTHER_PLATFORM_PREFIXES.has(first)) { + return 'otherPlatform'; + } + if (!token.includes('/')) { + return token.endsWith('.h') ? 'system' : 'std'; + } + if (SDK_PREFIXES.has(first)) { + return 'system'; + } + // RN's own include namespace but absent from the shipped set: either a + // genuinely unshipped header or a header_dir-flattening mismatch (headers.js + // ships /, dropping inner subdirs like mounting/stubs/). + if ( + ownNamespaces.has(first) || + fs.existsSync(path.join(rootFolder, 'ReactCommon', token)) + ) { + return 'notShipped'; + } + return 'unresolved'; +} + +function buildInventory(rootFolder /*: string */) /*: { + entries: Map, + sourceToNatural: Map>, + collisions: Array<{naturalPath: string, sources: Array}>, +} */ { + const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); + + // naturalPath -> entry skeleton; absolute source -> naturalPaths it serves. + const entries /*: Map */ = new Map(); + const sourceToNatural /*: Map> */ = new Map(); + const naturalToSources /*: Map> */ = new Map(); + + const addIdentity = ( + naturalPath /*: string */, + identity /*: Identity */, + absSource /*: string */, + ) => { + let entry = entries.get(naturalPath); + if (!entry) { + entry = { + naturalPath, + identities: [], + lang: 'c', + bucket: 'cxx', + includes: { + internal: [], + thirdParty: [], + hermes: [], + system: [], + std: [], + metaInternal: [], + otherPlatform: [], + notShipped: [], + unresolved: [], + }, + }; + entries.set(naturalPath, entry); + } + entry.identities.push(identity); + + const naturals = sourceToNatural.get(absSource) ?? []; + if (!naturals.includes(naturalPath)) { + naturals.push(naturalPath); + } + sourceToNatural.set(absSource, naturals); + + const sources = naturalToSources.get(naturalPath) ?? new Set(); + sources.add(absSource); + naturalToSources.set(naturalPath, sources); + }; + + for (const podspecPath of Object.keys(podSpecsWithHeaderFiles)) { + const headerMaps = podSpecsWithHeaderFiles[podspecPath]; + // xcframework.js and vfs.js both use the ROOT spec's name (first map) as + // the pod folder, with the same first-occurrence '-' -> '_' replacement. + const podName = headerMaps[0].specName.replace('-', '_'); + + for (const headerMap of headerMaps) { + for (const header of headerMap.headers) { + // Some header patterns are written as *.{m,mm,cpp,h}; only headers ship. + if (!/\.(h|hpp)$/.test(header.source)) { + continue; + } + // Natural path = the VFS key: the podspec target, with root-level + // targets of header_dir-less pods prefixed by the pod name (vfs.js rule). + let naturalPath = header.target; + if ( + !naturalPath.includes('/') && + (!headerMap.headerDir || headerMap.headerDir === '') + ) { + naturalPath = `${podName}/${naturalPath}`; + } + const identity /*: Identity */ = { + pod: podName, + spec: headerMap.specName, + namespacedPath: path.join(podName, header.target), + source: path.relative(rootFolder, header.source), + }; + addIdentity(naturalPath, identity, header.source); + + // The merged ReactCoreHeaders tree ALSO exposes React_RCTAppDelegate + // headers bare at the root (hosts write #import ). + // Model that second identity explicitly. + if (podName === 'React_RCTAppDelegate') { + addIdentity( + path.basename(header.target), + { + ...identity, + bareAlias: true, + }, + header.source, + ); + } + } + } + } + + const collisions = []; + for (const [naturalPath, sources] of naturalToSources) { + if (sources.size > 1) { + collisions.push({ + naturalPath, + sources: Array.from(sources) + .map(s => path.relative(rootFolder, s)) + .sort(), + }); + } + } + collisions.sort((a, b) => a.naturalPath.localeCompare(b.naturalPath)); + + return {entries, sourceToNatural, collisions}; +} + +function classifyEntries( + entries /*: Map */, + sourceToNatural /*: Map> */, + rootFolder /*: string */, +) /*: void */ { + // RN's own top-level include namespaces, derived from the shipped set, so + // "in our namespace but not shipped" is detectable. + const ownNamespaces = new Set( + Array.from(entries.keys()) + .map(p => p.split('/')[0]) + .filter(p => p.includes('.') === false), + ); + + // Scan each entry's primary source once. + for (const entry of entries.values()) { + const absSource = path.join(rootFolder, entry.identities[0].source); + let text; + try { + text = fs.readFileSync(absSource, 'utf8'); + } catch { + entry.includes.unresolved.push(''); + continue; + } + const scan = scanHeader(text); + const isHpp = absSource.endsWith('.hpp'); + if (scan.hasObjC && scan.hasUnguardedCxx) { + entry.lang = 'objcxx'; + } else if (scan.hasObjC) { + entry.lang = 'objc'; + } else if (scan.hasUnguardedCxx || isHpp) { + entry.lang = 'cxx'; + } else { + entry.lang = 'c'; + } + + for (const inc of scan.includes) { + let token = inc.token; + // Quoted include: resolve against the source dir and map back to a + // natural path if the resolved file is itself a shipped header. + if (token.startsWith('"')) { + const resolved = path.resolve( + path.dirname(absSource), + token.slice(1, -1), + ); + const naturals = sourceToNatural.get(resolved); + if (naturals && naturals.length > 0) { + entry.includes.internal.push({ + naturalPath: naturals[0], + cxxGuarded: inc.cxxGuarded, + }); + } + // Quoted includes that don't land on a shipped header are + // pod-internal/private — not part of the public surface contract. + continue; + } + if (entries.has(token)) { + entry.includes.internal.push({ + naturalPath: token, + cxxGuarded: inc.cxxGuarded, + }); + continue; + } + const kind = classifyExternal(token, ownNamespaces, rootFolder); + if (kind === 'thirdParty') { + entry.includes.thirdParty.push({ + lib: token.split('/')[0], + token, + cxxGuarded: inc.cxxGuarded, + }); + } else if (kind === 'hermes') { + entry.includes.hermes.push(token); + } else if (kind === 'system') { + entry.includes.system.push(token); + } else if (kind === 'std') { + entry.includes.std.push({token, cxxGuarded: inc.cxxGuarded}); + } else if (kind === 'metaInternal') { + entry.includes.metaInternal.push(token); + } else if (kind === 'otherPlatform') { + entry.includes.otherPlatform.push(token); + } else if (kind === 'notShipped') { + entry.includes.notShipped.push(token); + } else { + entry.includes.unresolved.push(token); + } + } + } + + // Fixpoint over UNGUARDED edges only: what an Obj-C (non-C++) consumer of + // this header actually pulls in. Decides modularizability of the ObjC surface. + const reachesCxx /*: Map */ = new Map(); + const reachesTp /*: Map> */ = new Map(); + for (const [naturalPath, entry] of entries) { + reachesCxx.set( + naturalPath, + entry.lang === 'cxx' || + entry.lang === 'objcxx' || + entry.includes.std.some(s => !s.cxxGuarded), + ); + reachesTp.set( + naturalPath, + new Set( + entry.includes.thirdParty.filter(t => !t.cxxGuarded).map(t => t.lib), + ), + ); + } + let changed = true; + while (changed) { + changed = false; + for (const [naturalPath, entry] of entries) { + let cxx = reachesCxx.get(naturalPath) ?? false; + const tp = reachesTp.get(naturalPath) ?? new Set(); + const beforeCxx = cxx; + const beforeTp = tp.size; + for (const dep of entry.includes.internal) { + if (dep.cxxGuarded) { + continue; + } + cxx = cxx || (reachesCxx.get(dep.naturalPath) ?? false); + for (const lib of reachesTp.get(dep.naturalPath) ?? []) { + tp.add(lib); + } + } + if (cxx !== beforeCxx || tp.size !== beforeTp) { + reachesCxx.set(naturalPath, cxx); + reachesTp.set(naturalPath, tp); + changed = true; + } + } + } + + for (const [naturalPath, entry] of entries) { + if (entry.lang === 'cxx') { + entry.bucket = 'cxx'; + } else if (entry.lang === 'objcxx') { + entry.bucket = 'objcxx'; + } else { + const cxx = reachesCxx.get(naturalPath) ?? false; + const tp = Array.from(reachesTp.get(naturalPath) ?? []).sort(); + if (!cxx && tp.length === 0) { + entry.bucket = 'objc-modular-candidate'; + } else { + entry.bucket = 'objc-blocked'; + } + } + } +} + +function main() /*: void */ { + const argv = process.argv.slice(2); + const getFlag = (name /*: string */) /*: ?string */ => { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; + }; + const rootFolder = path.resolve( + getFlag('--root') ?? path.join(__dirname, '..', '..'), + ); + const outPath = path.resolve( + getFlag('--out') ?? path.join(rootFolder, 'build', 'header-inventory.json'), + ); + + const {entries, sourceToNatural, collisions} = buildInventory(rootFolder); + classifyEntries(entries, sourceToNatural, rootFolder); + const headers = Array.from(entries.values()).sort((a, b) => + a.naturalPath.localeCompare(b.naturalPath), + ); + + const manifest = { + formatVersion: 1, + generatedBy: 'scripts/ios-prebuild/headers-inventory.js', + root: rootFolder, + totals: {headers: headers.length}, + collisions, + headers, + }; + + fs.mkdirSync(path.dirname(outPath), {recursive: true}); + fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); + + console.log(`Wrote ${headers.length} headers to ${outPath}`); +} + +if (require.main === module) { + main(); +} + +/** + * In-memory inventory for tooling that needs the classified header set + * without going through the JSON manifest on disk (e.g. the prebuild compose + * step feeding headers-spec.planFromInventory). + */ +function computeInventory( + rootFolder /*: string */, +) /*: {headers: Array} */ { + const {entries, sourceToNatural} = buildInventory(rootFolder); + classifyEntries(entries, sourceToNatural, rootFolder); + return { + headers: Array.from(entries.values()).sort((a, b) => + a.naturalPath.localeCompare(b.naturalPath), + ), + }; +} + +module.exports = { + buildInventory, + classifyEntries, + computeInventory, + scanHeader, + THIRD_PARTY_LIBS, + META_INTERNAL_RE, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-spec.js b/packages/react-native/scripts/ios-prebuild/headers-spec.js new file mode 100644 index 000000000000..24eefacb82d2 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-spec.js @@ -0,0 +1,375 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * THE HEADERS SPEC — executable contract for the packaged header layout. + * + * One source of truth: the prebuild compose step (headers-compose.js) EMITS + * artifacts from it, and the SPM tooling derives what consumers need from it + * (nothing extra, by design). + * + * The rules: + * + * R1. React.framework/Headers ROOT serves the `React/` namespace (contents + * hoisted to root) plus the bare root aliases. The framework name supplies + * the `React/` prefix, so `` resolves verbatim through + * FRAMEWORK_SEARCH_PATHS. The `react/` (lowercase) namespace is NOT here — + * it ships in ReactNativeHeaders (R2). Resolving it through React.framework + * would require case-folding `react.framework` → `React.framework`, which + * only works on case-insensitive filesystems; the header-search-path route + * is exact and works everywhere. + * R2. Every other namespace (incl. `react/`) ships in ONE headers-only library + * xcframework ("ReactNativeHeaders"), namespace dirs at its Headers root, + * INCLUDING the third-party deps namespaces (folly/glog/boost/fmt/ + * double-conversion/fast_float, sourced from the deps artifact) — making + * ReactNativeDependencies binary-only. Served by exact header-search-path + * lookup, so resolution is filesystem-case-independent. + * R3. NO include rewriting anywhere — source headers are byte-identical to + * the repo (content authority = source files; layout authority = this + * spec). Consumers compile unchanged except bare-form angle includes + * (R6). + * R4. React.framework gets a framework module map with an umbrella over the + * ObjC modular surface: objc-modular-candidate ∧ React/-namespace ∧ no + * '+'-category header ∧ no C extern-inline definition (C99 extern inline + * emits a STRONG symbol per importing .m TU → duplicate symbols; + * RCTTextInputNativeCommands.h found empirically). + * R5. Every namespace with objc-modular-candidates gets a module declaring + * exactly those candidates (framework modules may not textually include + * non-modular framework headers; yoga + RCTDeprecation found + * empirically). Namespaces whose name is not a valid module identifier + * (e.g. jsinspector-modern) are exempt — they have no candidates today; + * the verifier asserts that stays true. `react/` is also exempt: its few + * objc-modular-candidates stay textual (as they already were inside + * React.framework) so no `react` module aliases the `React` framework + * module. + * R6. Bare root aliases are servable only as `` — bare angle forms + * (`#import `) have no framework spelling. This is the + * accepted, measured consumer migration (~4 lines ecosystem-wide). + * R7. Artifacts are code-signed AFTER header composition (signature pins the + * header manifest). + * R8. Collisions are ERRORS: two different source files may never project to + * the same destination path. + */ + +const fs = require('fs'); +const path = require('path'); + +const RN_ROOT = path.join(__dirname, '..', '..'); + +/*:: +export type SpecEntry = { + relPath: string, // destination under the artifact's Headers root + source: string, // repo-relative source file + naturalPath: string, // canonical include identity (inventory key) +}; + +export type HeadersSpecPlan = { + // React.xcframework -> React.framework/Headers (R1) + react: Array, + // ReactNativeHeaders.xcframework -> Headers (R2); deps namespaces are + // added by the emitter from the deps artifact (not per-file here). + reactNativeHeaders: Array, + depsNamespaces: Array, + // R4: umbrella header list (React/-relative paths) + umbrella: Array, + // R5: plain modules for ReactNativeHeaders' module.modulemap + namespaceModules: {[ns: string]: Array}, + // R10: per-namespace umbrella headers emitted into ReactNativeHeaders. + namespaceUmbrellas: Array<{relPath: string, content: string}>, + // R9: private headers added to the React module map (allowlist). + privateReactHeaders: {modular: Array, textual: Array}, + collisions: Array, +}; +*/ + +// R2: third-party namespaces relocated from the deps artifact. +const DEPS_NAMESPACES = [ + 'folly', + 'glog', + 'boost', + 'fmt', + 'double-conversion', + 'fast_float', +]; + +// R4/R5 umbrella exclusion: C extern-inline definitions. +const EXTERN_INLINE_RE /*: RegExp */ = + /\b(RCT_EXTERN\s+inline|extern\s+inline)\b/; + +const MODULE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +// R9: Private React headers — a curated allowlist of `` headers that +// privileged framework consumers (e.g. Expo) import, but which the public +// umbrella (R4) excludes (they are `+`-suffixed and/or objc-blocked). They are +// already shipped in React.framework/Headers; adding them to the React module +// map keeps the existing `#import ` sites MODULAR under explicit +// modules — backwards-compatible, no consumer import (or Swift) changes. Split +// by inventory bucket: +// - modular: objc-modular-candidate (reach no C++) -> real `header`. +// - textual: objc-blocked (reach C++ via ``) -> `textual header` +// (a real member would re-trip -Wnon-modular-include; the C++ includes +// resolve at the consumer's use site, exactly as under the old VFS overlay). +// Privacy is by convention (the `+Private`/internal naming): a single binary +// artifact cannot hard-gate apps from headers a framework legitimately needs. +const PRIVATE_REACT_HEADERS /*: {modular: Array, textual: Array} */ = + { + modular: ['RCTBridge+Private.h'], + textual: [ + 'RCTComponentViewFactory.h', + 'RCTComponentViewProtocol.h', + 'RCTComponentViewRegistry.h', + 'RCTMountingManager.h', + 'RCTSurfacePresenter.h', + 'RCTViewComponentView.h', + ], + }; + +// Fail closed if an allowlisted private header drifts: it must exist in the +// inventory (else it was removed/renamed in source — e.g. RCTUIKit.h / +// RCTRootContentView.h, which need restoration, NOT this allowlist), and a +// `modular` entry must really be objc-modular-candidate (else it now reaches +// C++/third-party and must move to `textual`). +function validatePrivateReactHeaders(manifest /*: any */) /*: void */ { + const byNatural = new Map(manifest.headers.map(h => [h.naturalPath, h])); + const requireShipped = (name /*: string */) => { + const e = byNatural.get(`React/${name}`); + if (e == null) { + throw new Error( + `Private React header allowlist: React/${name} is absent from the ` + + `inventory (removed/renamed in source?). Restore the header or remove ` + + `it from PRIVATE_REACT_HEADERS.`, + ); + } + return e; + }; + for (const name of PRIVATE_REACT_HEADERS.modular) { + const e = requireShipped(name); + if (e.bucket !== 'objc-modular-candidate') { + throw new Error( + `Private React header React/${name} is bucket '${e.bucket}', not ` + + `'objc-modular-candidate' — it now reaches C++/third-party. Move it ` + + `to PRIVATE_REACT_HEADERS.textual.`, + ); + } + } + for (const name of PRIVATE_REACT_HEADERS.textual) { + requireShipped(name); + } +} + +function isUmbrellaSafe(h /*: any */) /*: boolean */ { + if (h.bucket !== 'objc-modular-candidate' || h.naturalPath.includes('+')) { + return false; + } + try { + return !EXTERN_INLINE_RE.test( + fs.readFileSync(path.join(RN_ROOT, h.identities[0].source), 'utf8'), + ); + } catch { + return false; + } +} + +// R10: per-namespace umbrella headers. Some consumers (e.g. Expo's +// RCTAppDelegateUmbrella.h) probe +// `` via __has_include. +// The flattened ReactNativeHeaders layout (R2/R5) ships the individual +// namespace headers but no umbrella, so the probe fails and e.g. +// RCTReactNativeFactory / RCTRootViewFactory are never declared. Re-emit a +// per-namespace umbrella for the namespaces consumers probe — content DERIVED +// from namespaceModules (R5) so it can't drift — and add it to that +// namespace's module so the import stays modular under explicit modules. +// Targeted (not all namespaces): only those a consumer imports as +// ``. Extend as the ecosystem surfaces more. +const UMBRELLA_NAMESPACES /*: Array */ = ['React_RCTAppDelegate']; + +// Renders a per-namespace umbrella that re-imports the namespace's modular +// headers. Paths are relative to the namespace dir (where the umbrella lives), +// so the first `/` segment is stripped. +function renderNamespaceUmbrella( + ns /*: string */, + headers /*: Array */, +) /*: string */ { + const imports = headers + .map(np => `#import "${np.slice(ns.length + 1)}"`) + .join('\n'); + return `#ifdef __OBJC__\n#import \n#endif\n\n${imports}\n`; +} + +/** + * Computes the full layout plan from the header inventory manifest + * (build/header-inventory.json — regenerate with header-inventory.js). + */ +function planFromInventory(manifest /*: any */) /*: HeadersSpecPlan */ { + validatePrivateReactHeaders(manifest); // R9: fail closed on allowlist drift + const react /*: Array */ = []; + const reactNativeHeaders /*: Array */ = []; + const umbrella /*: Array */ = []; + const namespaceModules /*: {[string]: Array} */ = {}; + const collisions /*: Array */ = []; + const seen /*: Map */ = new Map(); + + for (const h of manifest.headers) { + const np = h.naturalPath; + const source = h.identities[0].source; + let bucketKey; + let entryList; + let relPath; + if (np.startsWith('React/')) { + relPath = np.slice(6); // R1: hoist React/ to the framework Headers root + bucketKey = `React.framework/${relPath}`; + entryList = react; + } else if (!np.includes('/')) { + relPath = np; // R1/R6: bare alias at root + bucketKey = `React.framework/${relPath}`; + entryList = react; + } else { + // R2: every other namespace (incl. react/) keeps its prefix and is + // served from ReactNativeHeaders via the header search path. + relPath = np; + bucketKey = `ReactNativeHeaders/${relPath}`; + entryList = reactNativeHeaders; + } + const prev = seen.get(bucketKey); + if (prev != null) { + if (prev !== source) { + collisions.push(`${bucketKey}: ${prev} vs ${source}`); // R8 + } + continue; + } + seen.set(bucketKey, source); + entryList.push({relPath, source, naturalPath: np}); + + // R4: React umbrella membership. + if (np.startsWith('React/') && isUmbrellaSafe(h)) { + umbrella.push(np); + } + // R5: namespace modules (only for ReactNativeHeaders namespaces). Every + // namespace with modular candidates gets a module so that React.framework's + // modular headers can `#import ` as a MODULAR include (otherwise + // clang's -Wnon-modular-include-in-framework-module rejects it). `react/` is + // included here too — its module is renamed in renderNamespaceModuleMap so a + // `react` module never aliases the `React` framework module on a + // case-insensitive filesystem. + if (entryList === reactNativeHeaders) { + const ns = np.split('/')[0]; + if (MODULE_IDENT_RE.test(ns) && isUmbrellaSafe(h)) { + if (!namespaceModules[ns]) { + namespaceModules[ns] = []; + } + namespaceModules[ns].push(np); + } + } + } + + umbrella.sort(); + for (const ns of Object.keys(namespaceModules)) { + namespaceModules[ns].sort(); + } + + // R10: fail closed if a probed umbrella namespace lost all its modular + // headers (removed/renamed) — the umbrella would silently vanish and + // re-break consumers like Expo. + const namespaceUmbrellas = UMBRELLA_NAMESPACES.map(ns => { + const headers = namespaceModules[ns]; + if (headers == null || headers.length === 0) { + throw new Error( + `R10: umbrella namespace '${ns}' has no modular headers in the ` + + `inventory (removed/renamed?). Update UMBRELLA_NAMESPACES.`, + ); + } + return { + relPath: `${ns}/${ns}-umbrella.h`, + content: renderNamespaceUmbrella(ns, headers), + }; + }); + + return { + react, + reactNativeHeaders, + depsNamespaces: DEPS_NAMESPACES, + umbrella, + namespaceModules, + namespaceUmbrellas, + privateReactHeaders: PRIVATE_REACT_HEADERS, + collisions, + }; +} + +/** + * Renders React.framework's module map (R4 + R9). The umbrella covers the + * public modular surface; the allowlisted private headers (R9) are appended as + * explicit `header` (modular) / `textual header` (objc-blocked) entries so + * `#import ` of them stays modular without polluting the umbrella. + */ +function renderReactModuleMap( + privateReactHeaders /*:: ?: {modular: Array, textual: Array} */, +) /*: string */ { + const pv = privateReactHeaders ?? {modular: [], textual: []}; + const extra = [ + ...pv.modular.map(h => ` header "${h}"`), + ...pv.textual.map(h => ` textual header "${h}"`), + ]; + const extraBlock = extra.length > 0 ? '\n' + extra.join('\n') : ''; + return `framework module React { + umbrella header "React-umbrella.h"${extraBlock} + export * + module * { export * } +} +`; +} + +/** Renders the umbrella header content (R4). */ +function renderUmbrellaHeader(umbrella /*: Array */) /*: string */ { + return umbrella.map(u => `#import <${u}>`).join('\n') + '\n'; +} + +/** + * Renders ReactNativeHeaders' module.modulemap (R5): PLAIN (non-framework) + * modules, one per namespace with modular candidates — discovered implicitly + * by clang via the auto-added header search path. Headers are referenced by + * their path relative to the Headers root (= the modulemap's directory). + */ +function renderNamespaceModuleMap( + namespaceModules /*: {[string]: Array} */, +) /*: string */ { + // The module NAME is internal to clang's module graph (consumers never + // `@import` these; they `#import ` and clang maps the header to its + // module). It only has to be unique and must not alias the `React` framework + // module on a case-insensitive filesystem — so the lowercase `react` + // namespace is given a distinct module name. Header paths are unchanged, so + // `` still resolves and is now a modular include. + const moduleNameFor = (ns /*: string */) /*: string */ => + ns === 'react' ? 'ReactNativeHeaders_react' : ns; + const blocks = []; + for (const ns of Object.keys(namespaceModules).sort()) { + const headerLines = namespaceModules[ns].map(hh => ` header "${hh}"`); + // R10: the per-namespace umbrella is itself a module member, so importing + // it stays modular (otherwise it re-trips -Wnon-modular-include inside the + // consumer's framework module). + if (UMBRELLA_NAMESPACES.includes(ns)) { + headerLines.push(` header "${ns}/${ns}-umbrella.h"`); + } + blocks.push( + `module ${moduleNameFor(ns)} {\n` + + headerLines.join('\n') + + `\n export *\n}`, + ); + } + return blocks.join('\n\n') + '\n'; +} + +module.exports = { + planFromInventory, + renderReactModuleMap, + renderUmbrellaHeader, + renderNamespaceModuleMap, + DEPS_NAMESPACES, +}; diff --git a/packages/react-native/scripts/ios-prebuild/types.js b/packages/react-native/scripts/ios-prebuild/types.js index 56cad1f9ab18..663ebfa77a91 100644 --- a/packages/react-native/scripts/ios-prebuild/types.js +++ b/packages/react-native/scripts/ios-prebuild/types.js @@ -22,24 +22,6 @@ export type Destination = export type BuildFlavor = 'Debug' | 'Release'; export type MavenSubGroup = 'hermes' | 'react'; - -export type VFSEntry = { - name: string, - type: 'file' | 'directory', - 'external-contents'?: string, - contents?: Array, -}; - -export type VFSOverlay = { - version: number, - 'case-sensitive': boolean, - roots: Array, -}; - -export type HeaderMapping = { - key: string, - path: string, -}; */ module.exports = {}; diff --git a/packages/react-native/scripts/ios-prebuild/vfs.js b/packages/react-native/scripts/ios-prebuild/vfs.js deleted file mode 100644 index 13e2cb233d5f..000000000000 --- a/packages/react-native/scripts/ios-prebuild/vfs.js +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - * @format - */ - -/*:: import type {HeaderMapping, VFSEntry, VFSOverlay} from './types'; */ - -const headers = require('./headers'); - -const {getHeaderFilesFromPodspecs} = headers; - -const ROOT_PATH_PLACEHOLDER = '${ROOT_PATH}'; - -/** - * Builds a hierarchical VFS directory structure from a list of header mappings. - * Clang's VFS overlay requires a tree structure where directories contain their children. - */ -function buildVFSStructure( - mappings /*: Array */, -) /*: Array */ { - // Group files by their directory structure - const dirTree /*: Map> */ = new Map(); - - for (const mapping of mappings) { - const parts = mapping.key.split('/'); - const fileName = parts[parts.length - 1]; - const dirPath = parts.slice(0, -1).join('/'); - - if (!dirTree.has(dirPath)) { - dirTree.set(dirPath, new Map()); - } - const filesMap = dirTree.get(dirPath); - if (filesMap) { - filesMap.set(fileName, mapping.path); - } - } - - // Build the root-level entries (files at root + top-level directories) - const rootDirs /*: Set */ = new Set(); - for (const dirPath of dirTree.keys()) { - const topLevel = dirPath.split('/')[0]; - if (topLevel) { - rootDirs.add(topLevel); - } - } - - const roots /*: Array */ = []; - - // Add files that live at the root (e.g. key === 'RCTAppDelegate.h') - const rootFiles = dirTree.get(''); - if (rootFiles) { - for (const [fileName, sourcePath] of Array.from( - rootFiles.entries(), - ).sort()) { - roots.push({ - name: fileName, - type: 'file', - 'external-contents': sourcePath, - }); - } - } - - for (const rootDir of Array.from(rootDirs).sort()) { - const dirEntry = buildDirectoryEntry(rootDir, '', dirTree); - roots.push(dirEntry); - } - - return roots; -} - -/** - * Recursively builds a directory entry for the VFS - */ -function buildDirectoryEntry( - dirName /*: string */, - parentPath /*: string */, - dirTree /*: Map> */, -) /*: VFSEntry */ { - const currentPath = parentPath ? `${parentPath}/${dirName}` : dirName; - const contents /*: Array */ = []; - - // Add files in this directory - const filesInDir = dirTree.get(currentPath); - if (filesInDir) { - for (const [fileName, sourcePath] of Array.from( - filesInDir.entries(), - ).sort()) { - contents.push({ - name: fileName, - type: 'file', - 'external-contents': sourcePath, - }); - } - } - - // Add subdirectories - const subdirs /*: Set */ = new Set(); - for (const dirPath of dirTree.keys()) { - if (dirPath.startsWith(currentPath + '/')) { - const remainder = dirPath.slice(currentPath.length + 1); - const nextDir = remainder.split('/')[0]; - if (nextDir) { - subdirs.add(nextDir); - } - } - } - - for (const subdir of Array.from(subdirs).sort()) { - contents.push(buildDirectoryEntry(subdir, currentPath, dirTree)); - } - - return { - name: dirName, - type: 'directory', - contents, - }; -} - -/** - * Simple YAML generator for VFS overlay structure (hierarchical format) - */ -function generateVFSOverlayYAML(overlay /*: VFSOverlay */) /*: string */ { - let yaml = ''; - - yaml += `version: ${String(overlay.version)}\n`; - yaml += `case-sensitive: ${String(overlay['case-sensitive'])}\n`; - yaml += `roots:\n`; - - for (const root of overlay.roots) { - yaml += generateEntryYAML(root, 1); - } - - return yaml; -} - -/** - * Recursively generates YAML for a VFS entry - */ -function generateEntryYAML( - entry /*: VFSEntry */, - indent /*: number */, -) /*: string */ { - const spaces = ' '.repeat(indent); - let yaml = ''; - - yaml += `${spaces}- name: '${entry.name}'\n`; - yaml += `${spaces} type: '${entry.type}'\n`; - - if (entry['external-contents']) { - yaml += `${spaces} external-contents: '${entry['external-contents']}'\n`; - } - - if (entry.contents && entry.contents.length > 0) { - yaml += `${spaces} contents:\n`; - for (const child of entry.contents) { - yaml += generateEntryYAML(child, indent + 2); - } - } - - return yaml; -} - -/** - * Creates a VFS overlay object from the header files in podspecs. - * The source paths use ${ROOT_PATH} as a placeholder for later replacement - * with the actual root path on the end user's machine. - * - * The VFS overlay wraps all header mappings under a single root at - * ${ROOT_PATH}/Headers, which matches the HEADER_SEARCH_PATHS configured - * in rncore.rb. This allows the compiler to find headers like - * by looking up ${ROOT_PATH}/Headers/yoga/style/Style.h - * which the VFS redirects to the flat location in the xcframework. - * - * @param rootFolder The root folder of the React Native package - * @returns A VFS overlay object that can be serialized to YAML - */ -function createVFSOverlayContents(rootFolder /*: string */) /*: VFSOverlay */ { - // Get header files from podspecs (disable testing since we just need the mappings) - const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); - - const mappings /*: Array */ = []; - - // Process each podspec and its header files - Object.keys(podSpecsWithHeaderFiles).forEach(podspecPath => { - const headerMaps = podSpecsWithHeaderFiles[podspecPath]; - - // Use the first podspec spec name as the podspec name (this is the root spec) - const podSpecName = headerMaps[0].specName.replace('-', '_'); - - headerMaps.forEach(headerMap => { - headerMap.headers.forEach(header => { - // The key is just the target path (the import path) - // e.g., 'react/renderer/graphics/Size.h' for #import - let key = header.target; - - // If the podspec doesn't specify a header_dir, CocoaPods exposes public headers under - // (and umbrella headers typically use quoted imports resolved relative - // to the pod's public headers directory). To mirror that layout and avoid collisions - // between pods, prefix root-level header targets with the pod spec name. - if ( - !key.includes('/') && - (!headerMap.headerDir || headerMap.headerDir === '') - ) { - key = `${podSpecName}/${key}`; - } - - // The external-contents path is always podSpecName + header.target because - // xcframework.js copies headers to: outputHeadersPath/podSpecName/headerFile.target - // So the VFS must point to that same location. - const sourcePath = `${ROOT_PATH_PLACEHOLDER}/Headers/${podSpecName}/${header.target}`; - - mappings.push({ - key, - path: sourcePath, - }); - }); - }); - }); - - // Build the hierarchical VFS structure from mappings - const innerRoots = buildVFSStructure(mappings); - - // Wrap all roots under a single ${ROOT_PATH}/Headers root. - // This is required because Clang's VFS overlay needs absolute paths for root entries. - // The compiler will have -I${ROOT_PATH}/Headers in its include paths, so when it - // searches for , it looks for ${ROOT_PATH}/Headers/yoga/style/Style.h. - // The VFS overlay intercepts this and maps it to the actual flat location. - const wrappedRoot /*: VFSEntry */ = { - name: `${ROOT_PATH_PLACEHOLDER}/Headers`, - type: 'directory', - contents: innerRoots, - }; - - return { - version: 0, - 'case-sensitive': false, - roots: [wrappedRoot], - }; -} - -/** - * Creates a VFS overlay YAML file from the header files in podspecs. - * This is a convenience function that combines createVFSOverlayContents and - * generateVFSOverlayYAML into a single call. - * - * @param rootFolder The root folder of the React Native package - * @returns The VFS overlay as a YAML string ready to be written to a file - */ -function createVFSOverlay(rootFolder /*: string */) /*: string */ { - const overlay = createVFSOverlayContents(rootFolder); - return generateVFSOverlayYAML(overlay); -} - -/** - * Resolves a VFS overlay template by replacing the ${ROOT_PATH} placeholder - * with the actual root path. This is the equivalent of the Ruby create_vfs_overlay - * function in rncore.rb. - * - * The VFS overlay template contains ${ROOT_PATH} placeholders that need to be - * replaced with the actual path to the xcframework on the end user's machine - * (e.g., the path to React.xcframework in the Pods folder). - * - * @param vfsTemplate The VFS overlay template content (YAML string with ${ROOT_PATH} placeholders) - * @param rootPath The actual root path to substitute for ${ROOT_PATH} - * @returns The resolved VFS overlay YAML string with absolute paths - */ -function resolveVFSOverlay( - vfsTemplate /*: string */, - rootPath /*: string */, -) /*: string */ { - return vfsTemplate.split(ROOT_PATH_PLACEHOLDER).join(rootPath); -} - -module.exports = { - createVFSOverlay, - resolveVFSOverlay, -}; diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index 1f7d473046e5..f021edbf68cc 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -13,46 +13,16 @@ const { generateFBReactNativeSpecIOS, } = require('../codegen/generate-artifacts-executor/generateFBReactNativeSpecIOS'); -const headers = require('./headers'); const utils = require('./utils'); -const vfs = require('./vfs'); const childProcess = require('child_process'); const fs = require('fs'); const path = require('path'); const {execSync} = childProcess; -const {getHeaderFilesFromPodspecs} = headers; -const {createFolderIfNotExists, createLogger} = utils; -const {createVFSOverlay} = vfs; +const {createLogger} = utils; const frameworkLog = createLogger('XCFramework'); -/** - * Path to the React umbrella header file. - * This umbrella header contains ONLY the list of headers that are accessible by Swift, so no C++ construct are allowed in the headers. - */ -const REACT_CORE_UMBRELLA_HEADER_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'React-umbrella.h', -); - -/** - * Path to the React umbrella header file. - * This umbrella header contains ONLY the list of headers that are accessible by Swift, so no C++ construct are allowed in the headers. - */ -const RCT_APP_DELEGATE_UMBRELLA_HEADER_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'React_RCTAppDelegate-umbrella.h', -); - -const RN_MODULEMAP_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'module.modulemap', -); - function buildXCFrameworks( rootFolder /*: string */, buildFolder /*: string */, @@ -70,7 +40,7 @@ function buildXCFrameworks( buildType, 'React.xcframework', ); - // Delete all target platform folders (everything but the Headers and Modules folders) + // Delete any previous output try { fs.rmSync(outputPath, {recursive: true, force: true}); } catch (error) { @@ -104,98 +74,41 @@ function buildXCFrameworks( return; } - // Use the header files from podspecs - const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); - - // Delete header files to the output path - const outputHeadersPath = path.join(outputPath, 'Headers'); - - // Store umbrella headers keyed on podspec names - const umbrellaHeaders /*: {[key: string]: string} */ = {}; - const copiedHeaderFilesWithPodspecNames /*: {[key: string]: string[]} */ = {}; - - // Enumerate podspecs and copy headers, create umbrella headers and module map file - Object.keys(podSpecsWithHeaderFiles).forEach(podspec => { - const headerFiles = podSpecsWithHeaderFiles[podspec] - .map(h => h.headers) - .flat(); - - // Use the first podspec spec name as the podspec name (this is the root spec in the podspec file) - const podSpecName = podSpecsWithHeaderFiles[podspec][0].specName.replace( - '-', - '_', - ); - - if (headerFiles.length > 0) { - // Create a folder for the podspec in the output headers path - const podSpecTargetFolder = path.join(outputHeadersPath, podSpecName); - - // Copy each header file to the podspec folder - copiedHeaderFilesWithPodspecNames[podSpecName] = headerFiles.map( - headerFile => { - const headerFileTargetPath = path.join( - podSpecTargetFolder, - headerFile.target, - ); - createFolderIfNotExists(path.dirname(headerFileTargetPath)); - fs.copyFileSync(headerFile.source, headerFileTargetPath); - return headerFileTargetPath; - }, - ); - - // Create umbrella header file for the podspec - const umbrellaHeaderFilename = path.join( - podSpecTargetFolder, - podSpecName + '-umbrella.h', - ); - - if ( - podSpecName === 'React_Core' || - podSpecName === 'React_RCTAppDelegate' - ) { - if (podSpecName === 'React_Core') { - // Copy the React-umbrella.h file to the umbrella header filename - fs.copyFileSync( - REACT_CORE_UMBRELLA_HEADER_PATH, - umbrellaHeaderFilename, - ); - } else { - fs.copyFileSync( - RCT_APP_DELEGATE_UMBRELLA_HEADER_PATH, - umbrellaHeaderFilename, - ); - } - - // Store the umbrella header filename in the umbrellaHeaders object - umbrellaHeaders[podSpecName] = umbrellaHeaderFilename; - } - } - }); - - // Create the module map file using the header files in podSpecsWithHeaderFiles - const moduleMapFile = createModuleMapFile(outputPath); - if (!moduleMapFile) { - frameworkLog( - 'Failed to create module map file. The XCFramework may not work correctly. Stopping.', - 'error', - ); - return; - } + // Copy Symbols to symbols folder + copySymbols(outputPath, frameworkFolders); - // Copy header files and module map file to each platform slice in the XCFramework - copyHeaderFilesToSlices( + // Emit the headers-spec layout into every slice's React.framework and build + // the ReactNativeHeaders headers-only xcframework beside it. This is the only + // header surface consumers compile against — no root Headers/, no clang VFS + // overlay. MUST run before signing (spec R7: the signature pins the manifest). + const { + buildReactNativeHeadersXcframework, + computeSpecPlan, + emitReactFrameworkHeaders, + } = require('./headers-compose'); + const depsHeaders = path.join( rootFolder, - outputPath, - moduleMapFile, - umbrellaHeaders, - copiedHeaderFilesWithPodspecNames, + 'third-party', + 'ReactNativeDependencies.xcframework', + 'Headers', + ); + const plan = computeSpecPlan(rootFolder); + emitReactFrameworkHeaders(outputPath, plan, rootFolder); + // NOTE: Hermes public headers (``) are folded into + // ReactNativeHeaders on the consumer side by ensureHeadersLayout. When this + // publish path is productionized, pass the prebuild's hermes destroot/include + // as the 6th arg so the PUBLISHED ReactNativeHeaders carries hermes too. + const headersXcfw = buildReactNativeHeadersXcframework( + path.dirname(outputPath), + plan, + depsHeaders, + rootFolder, + true, // include the mac-catalyst slice in the real compose ); - - // Copy Symbols to symbols folder - copySymbols(outputPath, frameworkFolders); if (identity) { signXCFramework(identity, outputPath); + signXCFramework(identity, headersXcfw); } // Tar the output folder to a .tar.gz file @@ -208,8 +121,12 @@ function buildXCFrameworks( ); frameworkLog('Creating tar file: ' + tarFilePath); try { + // Ship ReactNativeHeaders.xcframework alongside React.xcframework in the + // reactnative-core artifact so the React-Core-prebuilt pod can vend both + // (React.framework -> , ReactNativeHeaders -> every other + // namespace). The headers-only xcframework is a sibling of React.xcframework. execSync( - `tar -czf ${tarFilePath} -C ${path.dirname(outputPath)} React.xcframework`, + `tar -czf ${tarFilePath} -C ${path.dirname(outputPath)} React.xcframework ${path.basename(headersXcfw)}`, { stdio: 'inherit', }, @@ -220,6 +137,27 @@ function buildXCFrameworks( 'warning', ); } + + // Publish ReactNativeHeaders alongside React. + const headersTarPath = path.join( + buildFolder, + 'output', + 'xcframeworks', + buildType, + 'ReactNativeHeaders.xcframework.tar.gz', + ); + frameworkLog('Creating tar file: ' + headersTarPath); + try { + execSync( + `tar -czf ${headersTarPath} -C ${path.dirname(headersXcfw)} ReactNativeHeaders.xcframework`, + {stdio: 'inherit'}, + ); + } catch (error) { + frameworkLog( + `Error creating ReactNativeHeaders tar: ${error.message}`, + 'warning', + ); + } } function copySymbols( @@ -277,134 +215,6 @@ function copySymbols( }); } -// Copy header files and module map file to each platform slice in the XCFramework. -function copyHeaderFilesToSlices( - rootFolder /*:string*/, - outputPath /*:string*/, - moduleMapFile /*:string*/, - umbrellaHeaderFiles /*:{[key: string]: string}*/, - outputHeaderFiles /*: {[key: string]: string[]} */, -) { - frameworkLog('Linking modules and headers to platform folders for slice...'); - - // Enumerate all platform folders in the output path - const platformFolders = fs - .readdirSync(outputPath) - .map(folder => path.join(outputPath, folder)) - .filter(folder => { - return ( - fs.statSync(folder).isDirectory() && - !folder.endsWith('Headers') && - !folder.endsWith('Modules') - ); - }); - - platformFolders.forEach(platformFolder => { - // Link the Modules folder into the platform folder - const targetModulesFolder = path.join( - platformFolder, - 'React.Framework', - 'Modules', - ); - createFolderIfNotExists(targetModulesFolder); - - try { - fs.linkSync( - moduleMapFile, - path.join(targetModulesFolder, path.basename(moduleMapFile)), - ); - } catch (error) { - frameworkLog( - `Error copying module map file: ${error.message}. Check if the file exists at ${moduleMapFile}.`, - 'error', - ); - } - // Copy headers folder into the platform folder - const targetHeadersFolder = path.join( - platformFolder, - 'React.Framework', - 'Headers', - ); - - // Copy umbrella / header files into the platform folder - Object.keys(umbrellaHeaderFiles).forEach(podSpecName => { - const umbrellaHeaderFile = umbrellaHeaderFiles[podSpecName]; - - // Create the target folder for the umbrella header file - const targetPodSpecFolder = path.join(targetHeadersFolder, podSpecName); - createFolderIfNotExists(targetPodSpecFolder); - // Copy the umbrella header file to the target folder - try { - fs.copyFileSync( - umbrellaHeaderFile, - path.join(targetPodSpecFolder, path.basename(umbrellaHeaderFile)), - ); - } catch (error) { - frameworkLog( - `Error copying umbrella header file: ${umbrellaHeaderFile}\nError: ${error.message}. Check if the file exists.`, - 'error', - ); - } - }); - - Object.keys(outputHeaderFiles).forEach(podSpecName => { - outputHeaderFiles[podSpecName].forEach(headerFile => { - // Get the relative path from the root Headers folder to preserve directory structure - // headerFile is like /path/to/Headers/Yoga/yoga/style/Style.h - // We need to extract Yoga/yoga/style/Style.h and copy to the same structure in the slice - const rootHeadersFolder = path.join(outputPath, 'Headers'); - const relativeHeaderPath = path.relative(rootHeadersFolder, headerFile); - const targetHeaderFile = path.join( - targetHeadersFolder, - relativeHeaderPath, - ); - createFolderIfNotExists(path.dirname(targetHeaderFile)); - if (!fs.existsSync(targetHeaderFile)) { - try { - fs.copyFileSync(headerFile, targetHeaderFile); - } catch (error) { - frameworkLog( - `Error copying header file: ${error.message}. Check if the file exists.`, - 'error', - ); - } - } - }); - }); - }); - - // Create VFS overlay file at the XCFramework root (same for all platforms) - const vfsFilePath = path.join(outputPath, 'React-VFS-template.yaml'); - try { - fs.writeFileSync(vfsFilePath, createVFSOverlay(rootFolder), 'utf8'); - frameworkLog(`Created VFS overlay: ${path.basename(vfsFilePath)}`); - } catch (error) { - frameworkLog(`Error creating VFS overlay file: ${error.message}.`, 'error'); - } -} - -function createModuleMapFile(outputPath /*: string */) { - // Create/get the module map folder - const moduleMapFolder = path.join(outputPath, 'Modules'); - createFolderIfNotExists(moduleMapFolder); - - // Create the module map file - const moduleMapFile = path.join(moduleMapFolder, 'module.modulemap'); - - frameworkLog('Creating module map file: ' + moduleMapFile); - - try { - fs.copyFileSync(RN_MODULEMAP_PATH, moduleMapFile); - return moduleMapFile; - } catch (error) { - frameworkLog( - `Error creating module map file: ${error.message}. Check if the file exists.`, - 'error', - ); - return null; - } -} - function getArchsFromFramework(frameworkPath /*:string*/) { try { return execSync(`vtool -show-build ${frameworkPath}|grep platform`) diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index f9dac08218be..2f1b769a96fa 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -21,6 +21,7 @@ require_relative './cocoapods/privacy_manifest_utils.rb' require_relative './cocoapods/spm.rb' require_relative './cocoapods/rncore.rb' +require_relative './cocoapods/rncore_facades.rb' # Importing to expose use_native_modules! require_relative './cocoapods/autolinking.rb' @@ -52,6 +53,28 @@ def prepare_react_native_project! ReactNativePodsUtils.create_xcode_env_if_missing end +# Declares a React core pod, choosing source vs prebuilt facade. In prebuilt +# mode, pods in the RNCoreFacades manifest are installed as dependency-only +# facades (no source/headers) so they can't shadow the prebuilt artifact; their +# code + headers come from React-Core-prebuilt. Everything else (and the whole +# source build) is unaffected. See cocoapods/rncore_facades.rb. +def rncore_pod(name, **opts) + base = name.split('/').first + if !ReactNativeCoreUtils.build_rncore_from_source() && RNCoreFacades.facade?(base) + # Install as a LOCAL pod (`:path`) from the generated facade directory, so + # CocoaPods never fetches the placeholder git source (a `:podspec` external + # source would). Both the pod and any subspec declaration point at the SAME + # directory, so CocoaPods sees one consistent source for the name (a bare + # subspec declaration would otherwise default to the spec repo and conflict). + # Preserve the caller's options (e.g. :modular_headers) but replace :path with + # the facade directory. + facade_opts = opts.reject { |k, _| k == :path } + pod name, **facade_opts, :path => RNCoreFacades.facade_path(base) + else + pod name, **opts + end +end + # Function that setup all the react native dependencies #  # Parameters @@ -123,19 +146,25 @@ def use_react_native! ( # Update ReactNativeCoreUtils so that we can easily switch between source and prebuilt ReactNativeCoreUtils.setup_rncore(prefix, react_native_version) + # In prebuilt mode, generate the facade podspecs the core pods are installed as + # (instead of their source podspecs) so they don't ship shadowing headers. + unless ReactNativeCoreUtils.build_rncore_from_source() + RNCoreFacades.generate(react_native_path, Pod::Config.instance.installation_root, react_native_version, min_ios_version_supported) + end + Pod::UI.puts "Configuring the target with the New Architecture\n" # The Pods which should be included in all projects - pod 'FBLazyVector', :path => "#{prefix}/Libraries/FBLazyVector" - pod 'RCTRequired', :path => "#{prefix}/Libraries/Required" + rncore_pod 'FBLazyVector', :path => "#{prefix}/Libraries/FBLazyVector" + rncore_pod 'RCTRequired', :path => "#{prefix}/Libraries/Required" pod 'RCTTypeSafety', :path => "#{prefix}/Libraries/TypeSafety", :modular_headers => true pod 'React', :path => "#{prefix}/" if !ReactNativeCoreUtils.build_rncore_from_source() pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true end - pod 'React-Core', :path => "#{prefix}/" + rncore_pod 'React-Core', :path => "#{prefix}/" pod 'React-CoreModules', :path => "#{prefix}/React/CoreModules" - pod 'React-RCTRuntime', :path => "#{prefix}/React/Runtime" + rncore_pod 'React-RCTRuntime', :path => "#{prefix}/React/Runtime" pod 'React-RCTAppDelegate', :path => "#{prefix}/Libraries/AppDelegate" pod 'React-RCTActionSheet', :path => "#{prefix}/Libraries/ActionSheetIOS" pod 'React-RCTAnimation', :path => "#{prefix}/Libraries/NativeAnimation" @@ -146,7 +175,7 @@ def use_react_native! ( pod 'React-RCTSettings', :path => "#{prefix}/Libraries/Settings" pod 'React-RCTText', :path => "#{prefix}/Libraries/Text" pod 'React-RCTVibration', :path => "#{prefix}/Libraries/Vibration" - pod 'React-Core/RCTWebSocket', :path => "#{prefix}/" + rncore_pod 'React-Core/RCTWebSocket', :path => "#{prefix}/" pod 'React-cxxreact', :path => "#{prefix}/ReactCommon/cxxreact" pod 'React-debug', :path => "#{prefix}/ReactCommon/react/debug" pod 'React-utils', :path => "#{prefix}/ReactCommon/react/utils" @@ -162,7 +191,7 @@ def use_react_native! ( pod 'React-defaultsnativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/defaults" pod 'React-Mapbuffer', :path => "#{prefix}/ReactCommon" pod 'React-jserrorhandler', :path => "#{prefix}/ReactCommon/jserrorhandler" - pod 'RCTDeprecation', :path => "#{prefix}/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + rncore_pod 'RCTDeprecation', :path => "#{prefix}/ReactApple/Libraries/RCTFoundation/RCTDeprecation" pod 'React-RCTFBReactNativeSpec', :path => "#{prefix}/React" pod 'React-jsi', :path => "#{prefix}/ReactCommon/jsi" pod 'RCTSwiftUI', :path => "#{prefix}/ReactApple/RCTSwiftUI" @@ -195,7 +224,7 @@ def use_react_native! ( pod 'React-logger', :path => "#{prefix}/ReactCommon/logger" pod 'ReactCommon/turbomodule/core', :path => "#{prefix}/ReactCommon", :modular_headers => true pod 'React-NativeModulesApple', :path => "#{prefix}/ReactCommon/react/nativemodule/core/platform/ios", :modular_headers => true - pod 'Yoga', :path => "#{prefix}/ReactCommon/yoga", :modular_headers => true + rncore_pod 'Yoga', :path => "#{prefix}/ReactCommon/yoga", :modular_headers => true setup_fabric!(:react_native_path => prefix) setup_bridgeless!(:react_native_path => prefix, :use_hermes => hermes_enabled) @@ -567,15 +596,14 @@ def react_native_post_install( ReactNativePodsUtils.add_ndebug_flag_to_pods_in_release(installer) if !ReactNativeCoreUtils.build_rncore_from_source() - # In XCode 26 we need to revert the new setting SWIFT_ENABLE_EXPLICIT_MODULES when building - # with precompiled binaries. - ReactNativePodsUtils.set_build_setting(installer, build_setting: "SWIFT_ENABLE_EXPLICIT_MODULES", value: "NO") - - # Process the VFS overlay for prebuilt React Native Core - this is done as part of the post install so - # that we can update paths based on the final location of the Pods installation. - ReactNativeCoreUtils.process_vfs_overlay() - - # Configure xcconfig for prebuilt usage (VFS overlay, header paths, cleanup redundant paths) + # The Xcode-26 SWIFT_ENABLE_EXPLICIT_MODULES=NO workaround (#53457) is removed: + # the modular ReactNativeHeaders layout + React-Core-prebuilt module-map + # activation + header-less facades let the React module precompile cleanly with + # explicit modules ON (verified cold-DD green), so the override is unnecessary. + + # Make the prebuilt React.xcframework headers resolvable from aggregate (main app) + # and third-party pod targets that don't go through add_rncore_dependency. The headers + # are served directly from the xcframework's headers-spec layout — no clang VFS overlay. ReactNativeCoreUtils.configure_aggregate_xcconfig(installer) end diff --git a/packages/react-native/scripts/replace-rncore-version.js b/packages/react-native/scripts/replace-rncore-version.js index 2684d3250b5d..99ba1b0f72f9 100644 --- a/packages/react-native/scripts/replace-rncore-version.js +++ b/packages/react-native/scripts/replace-rncore-version.js @@ -99,17 +99,19 @@ function replaceRNCoreConfiguration( throw new Error(`tar extraction failed with exit code ${result.status}`); } - // Verify extraction produced the expected xcframework structure + // Verify extraction produced the expected xcframework structure. The + // module map now lives per-slice inside React.framework, so check the + // xcframework's Info.plist instead of a root Modules/module.modulemap. const xcfwPath = path.join(tmpExtractDir, 'React.xcframework'); - const modulemapPath = path.join(xcfwPath, 'Modules', 'module.modulemap'); - if (!fs.existsSync(modulemapPath)) { + const infoPlistPath = path.join(xcfwPath, 'Info.plist'); + if (!fs.existsSync(infoPlistPath)) { throw new Error( - `Extraction verification failed: ${modulemapPath} not found`, + `Extraction verification failed: ${infoPlistPath} not found`, ); } - // Delete all directories in finalLocation - not files, since we want to - // keep the React-VFS.yaml file + // Delete only directories in finalLocation (e.g. the React.xcframework) - + // not files, so any sibling files written during pod install are preserved. const dirs = fs .readdirSync(finalLocation, {withFileTypes: true}) .filter(dirent => dirent.isDirectory()); @@ -144,6 +146,43 @@ function replaceRNCoreConfiguration( } } } + + // The podspec prepare_command flattens ReactNativeHeaders' headers into a + // top-level Headers/ dir, but it does not re-run on a config swap. Mirror + // it here: re-flatten the headers (identical across slices) and drop the + // now-redundant xcframework so $(PODS_ROOT)/React-Core-prebuilt/Headers + // keeps resolving , , etc. + const rnhXcfw = path.join(finalLocation, 'ReactNativeHeaders.xcframework'); + if (fs.existsSync(rnhXcfw)) { + const slice = fs + .readdirSync(rnhXcfw, {withFileTypes: true}) + .find( + dirent => + dirent.isDirectory() && + fs.existsSync( + path.join(rnhXcfw, dirent.name.toString(), 'Headers'), + ), + ); + if (slice) { + const headersDest = path.join(finalLocation, 'Headers'); + fs.rmSync(headersDest, {force: true, recursive: true}); + const cpHeaders = spawnSync( + 'cp', + [ + '-R', + path.join(rnhXcfw, slice.name.toString(), 'Headers'), + headersDest, + ], + {stdio: 'inherit'}, + ); + if (cpHeaders.status !== 0) { + throw new Error( + `Flattening ReactNativeHeaders failed with exit code ${cpHeaders.status}`, + ); + } + fs.rmSync(rnhXcfw, {force: true, recursive: true}); + } + } } finally { // Clean up temp directory fs.rmSync(tmpDir, {force: true, recursive: true}); diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js new file mode 100644 index 000000000000..2e42ef7b5c4e --- /dev/null +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -0,0 +1,1223 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: import type {CliConfigJson, SetupArgs} from './spm/spm-types'; */ + +/** + * setup-apple-spm.js – Entry point for setting up Swift Package Manager support + * in a React Native app using prebuilt XCFrameworks. + * + * Usage (from your app directory): + * node node_modules/react-native/scripts/setup-apple-spm.js [action] [options] + * (or `npx react-native spm [action]`) + * + * Actions: + * add Inject SPM packages (package refs, build + * 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). + * 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. + * scaffold Generate Package.swift for community deps that + * lack SPM support. + * sync / codegen / download Advanced/internal: `sync` is invoked by the + * generated Xcode build phase; `codegen` and + * `download` 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`). + * + * Options: + * --version React Native version (default: the resolved + * node_modules/react-native version). + * --flavor Artifact flavor (default: debug). + * --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. + * [advanced] --artifacts Local artifact source: a + * .xcframework → use it directly (no download); + * a directory → cache dir (read / download here). + * [advanced] --download Artifact policy (default: auto). + * [advanced] --skip-codegen Skip the react-native codegen step. + * + * Steps performed (add/update): + * 1. react-native codegen → build/generated/ios/ + install SPM codegen template + * 2. generate-spm-autolinking-config.js → build/generated/autolinking/autolinking.json + * 3. generate-spm-autolinking.js → build/generated/autolinking/Package.swift + * 4. download-spm-artifacts.js → cache dir (per --download policy) + * 5. generate-spm-package.js → build/xcframeworks/Package.swift + symlinks + * 6. inject SPM packages into the existing .xcodeproj (in place) + * + * The injection is committed with your project; its XCLocalSwiftPackageReference + * entries point at stable sub-package paths under build/ (xcframeworks, + * generated/autolinking, generated/ios), so adding/removing community deps + * changes those sub-packages (gitignored) and never re-injects. No app-level + * Package.swift is generated or required. + */ + +const { + main: downloadArtifacts, + resolveCacheSlotVersion, + validateArtifactsCache, +} = require('./spm/download-spm-artifacts'); +const { + MissingManifestError, + main: generateAutolinking, +} = require('./spm/generate-spm-autolinking'); +const { + generateAutolinkingConfig, +} = require('./spm/generate-spm-autolinking-config'); +const {main: generatePackage} = require('./spm/generate-spm-package'); +const {findSourcePath} = require('./spm/generate-spm-package'); +const { + SPM_INJECTED_MARKER, + cleanupLeftoverPodsGroup, + injectSpmIntoExistingXcodeproj, + removeSpmInjection, +} = require('./spm/generate-spm-xcodeproj'); +const {scaffoldAll} = require('./spm/scaffold-package-swift'); +const { + RemoteVersionError, + buildPerAppHeaderTree, + defaultCacheDir, + deriveAppName, + displayPath, + findProjectRoot, + installSpmCodegenTemplate, + makeLogger, + readPackageJson, + remotePackageConfig, + runCodegenAndInstallTemplate, +} = require('./spm/spm-utils'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); +const yargs = require('yargs'); + +const {log, warn: logError} = makeLogger('setup-apple-spm'); + +const VALID_ACTIONS = new Set([ + 'add', + 'update', + 'deinit', + 'sync', + 'codegen', + 'download', + 'scaffold', +]); + +/*:: +type AutolinkingConfigResult = { + config: CliConfigJson, + outputPath: string, + rawJson: string, +}; +*/ + +function parseArgs(argv /*: Array */) /*: SetupArgs */ { + const parsed = yargs(argv) + .version(false) + .command('$0 [action]', 'Set up Apple SPM support') + .positional('action', { + type: 'string', + choices: Array.from(VALID_ACTIONS), + describe: + 'Action to run: add, update, deinit, or scaffold. ' + + 'Defaults to add (or update if SPM is already set up).', + }) + .option('version', { + type: 'string', + describe: + 'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json', + }) + .option('flavor', { + type: 'string', + default: 'debug', + describe: 'Artifact flavor (debug or release)', + }) + .option('yes', { + type: 'boolean', + default: false, + describe: 'Skip the dirty-pbxproj confirmation prompt', + }) + .option('xcodeproj', { + type: 'string', + describe: + '[add] Path to the .xcodeproj to inject SPM packages into (disambiguates when several exist).', + }) + .option('product-name', { + type: 'string', + describe: + '[add] App target to inject into (disambiguates when several exist).', + }) + .option('deintegrate', { + type: 'boolean', + default: false, + describe: + '[add] Run `pod deintegrate` + strip React Native from the Podfile before injecting (CocoaPods → SPM migration).', + }) + .option('artifacts', { + type: 'string', + describe: + '[advanced] Local artifact source: a .xcframework file (used directly, no download) or a directory (cache dir to read/download into).', + }) + .option('download', { + type: 'string', + choices: ['auto', 'skip', 'force'], + default: 'auto', + describe: + '[advanced] Artifact download policy: auto (fetch if missing), skip (never fetch), force (clear cache + refetch).', + }) + .option('skip-codegen', { + type: 'boolean', + default: false, + describe: '[advanced] Skip the react-native codegen step', + }) + .usage( + 'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.', + ) + .strictOptions() + .help() + .parseSync(); + + const positional = parsed._.map(String); + const requestedAction = parsed.action ?? positional[0] ?? null; + if (positional.length > 1) { + throw new Error( + `Expected at most one action, got: ${positional.join(', ')}`, + ); + } + if (requestedAction != null && !VALID_ACTIONS.has(requestedAction)) { + throw new Error( + `Unknown action "${requestedAction}". Expected one of: ${Array.from( + VALID_ACTIONS, + ).join(', ')}`, + ); + } + + return { + action: requestedAction, + version: parsed.version ?? null, + artifacts: parsed.artifacts ?? null, + flavor: parsed.flavor, + skipCodegen: parsed['skip-codegen'], + downloadPolicy: parsed.download, + productName: parsed['product-name'] ?? null, + xcodeprojPath: parsed.xcodeproj ?? null, + deintegrate: parsed.deintegrate, + yes: parsed.yes, + }; +} + +const SPM_GITIGNORE_ENTRIES = [ + 'Package.resolved', + 'build/generated/', + 'build/xcframeworks/', + '.build/', +]; + +/** + * Ensure the project's .gitignore contains entries for SPM-generated + * directories. Called during init so that generated artifacts are not + * accidentally committed. + */ +function ensureGitignoreSpmEntries(appRoot /*: string */) { + const gitignorePath = path.join(appRoot, '.gitignore'); + let content = ''; + if (fs.existsSync(gitignorePath)) { + content = 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)); + + 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 + const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; + fs.writeFileSync(gitignorePath, content + separator + block + '\n', 'utf8'); + log(`Updated .gitignore with SPM entries: ${missing.join(', ')}`); +} + +/** + * Single TTY-gated Y/N prompt helper used by every interactive confirmation + * in this file. Non-TTY (CI / piped stdin) auto-confirms — every callsite + * either opted into the action explicitly or is downstream of an opt-in. + */ +function promptYesNo( + question /*: string */, + defaultYes /*: boolean */, +) /*: Promise */ { + // $FlowFixMe[prop-missing] process.stdin.isTTY not in Flow stubs + if (process.stdin.isTTY !== true) { + return Promise.resolve(true); + } + const suffix = defaultYes ? '[Y/n]' : '[y/N]'; + return new Promise(resolve => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(`${question} ${suffix} `, answer => { + rl.close(); + const a = answer.trim().toLowerCase(); + const yes = a === 'y' || a === 'yes'; + resolve(defaultYes ? a === '' || yes : yes); + }); + }); +} + +function resolveAction( + requestedAction /*: SetupArgs['action'] */, + appRoot /*: string */, +) /*: 'add' | 'update' | 'deinit' | 'sync' | 'codegen' | 'download' | 'scaffold' */ { + if (requestedAction != null) { + 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. + return findInjectedXcodeproj(appRoot) != null ? 'update' : 'add'; +} + +/** + * Detects the JS-root-vs-ios-dir mismatch that produces silently-broken + * builds for standard RN apps. The community CLI writes + * `autolinking.json` under `/build/generated/autolinking/` + * (i.e. `/ios/...`), while every SPM script anchors its + * inputs/outputs on `process.cwd()`. Running from the JS root therefore + * (a) writes outputs at `/build/...` — away from the iOS + * project, and (b) makes the autolinker miss `autolinking.json` and + * silently skip every npm native dep. The build "succeeds" but anything + * touching a native module crashes at runtime. + * + * Returns the absolute path to the redirected app root (`/ios`) + * when the redirect heuristic applies, else null. Pure: no side effects. + * The caller decides whether to auto-redirect (non-destructive actions) or + * refuse (destructive actions like `clean`). + */ +function detectStandardRnLayoutRedirect( + appRoot /*: string */, + projectRoot /*: string */, +) /*: string | null */ { + // Only relevant when cwd === projectRoot (i.e. user is at the JS root + // of their RN app). If they've already cd'd into a subdir, projectRoot + // walks up to find package.json and the two paths differ — leave alone. + if (path.resolve(appRoot) !== path.resolve(projectRoot)) { + return null; + } + // Standard RN layout has an `ios/` subdir holding the native project. + // Without it (e.g. rn-tester's flat layout), no mismatch to flag. + const iosSubdir = path.join(projectRoot, 'ios'); + try { + if (!fs.statSync(iosSubdir).isDirectory()) { + return null; + } + } catch { + return null; + } + return iosSubdir; +} + +function resolveReactNativeRoot( + autolinkingConfigResult /*: ?AutolinkingConfigResult */, + projectRoot /*: string */, +) /*: string */ { + // Prefer the React Native path resolved by the CLI config we already run for + // autolinking. Fall back to this script's package root for direct repo usage. + let reactNativeRoot = path.resolve(__dirname, '..'); + const cliConfig = autolinkingConfigResult?.config; + const cliReactNativePath = cliConfig?.reactNativePath; + const cliConfigRoot = cliConfig?.root; + if (typeof cliReactNativePath === 'string' && cliReactNativePath.length > 0) { + reactNativeRoot = path.resolve( + typeof cliConfigRoot === 'string' && cliConfigRoot.length > 0 + ? cliConfigRoot + : projectRoot, + cliReactNativePath, + ); + } + return reactNativeRoot; +} + +function determineVersion( + args /*: SetupArgs */, + reactNativeRoot /*: string */, +) /*: string */ { + let version = args.version; + if (version == null) { + // $FlowFixMe[incompatible-type] JSON.parse returns any + const pkgJson /*: {version: string} */ = JSON.parse( + fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'), + ); + version = pkgJson.version; + } + return version; +} + +function runCodegenStep( + projectRoot /*: string */, + appRoot /*: string */, + reactNativeRoot /*: string */, + skipCodegen /*: boolean */, +) /*: void */ { + if (skipCodegen) { + // Output dir may already exist from a previous run; still refresh the + // SPM template so cache-slot changes propagate. + log('Skipping codegen (--skip-codegen)'); + installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}); + return; + } + log('Running react-native codegen...'); + try { + runCodegenAndInstallTemplate(projectRoot, appRoot, reactNativeRoot, {log}); + } catch { + logError('Codegen failed. Continuing anyway...'); + } +} + +/** + * Walks autolinking.json and writes a Package.swift into each community RN + * package that ships a podspec but no SPM manifest. Reuses the dep's + * podspec (via `pod ipc spec` when available) so the scaffolded file + * captures the dep's actual sources, header search paths, frameworks, and + * dependencies. Files carrying the scaffolder's own marker are regenerated + * when the cache slot changes (manifest-hash bump); files without the + * marker are left alone (upstream-shipped or user-managed). + * + * Runs as part of `init` / `update` / `scaffold` actions. Each invocation + * is a no-op for deps already in a clean state. + */ +// Scaffolding is an EXPLICIT, manual step (`npx react-native spm scaffold`) and +// is NEVER run automatically by init/update/sync. A missing Package.swift is a +// real gap that must surface as a hard build error (see reportMissingManifests) +// so the user fixes it deliberately: scaffold, then persist with patch-package +// (node_modules is not committed), and ideally get it fixed upstream. There is +// intentionally no prompt and no auto-restore — auto-scaffolding would hide the +// error, and a wiped scaffold SHOULD re-surface it. +async function runScaffold( + args /*: SetupArgs */, + appRoot /*: string */, + projectRoot /*: string */, + reactNativeRoot /*: string */, +) /*: Promise */ { + // Resolve the cache slot identifier so the scaffolded files carry it as + // a comment — that's how SPM's manifest hash bumps on slot transitions. + let cacheSlotLabel /*: ?string */ = null; + try { + const rawVersion = args.version ?? determineVersion(args, reactNativeRoot); + const slotVersion = await resolveCacheSlotVersion(rawVersion); + cacheSlotLabel = `${slotVersion}/${args.flavor}`; + } catch { + // Without a slot label the scaffolder still works; the file just + // doesn't get the slot-bump comment. + } + + let results; + try { + results = scaffoldAll({ + appRoot, + projectRoot, + reactNativeRoot, + cacheSlotLabel, + // Always force a re-render so re-running after editing a podspec picks + // up the new content. + force: true, + }); + } catch (e) { + logError(`scaffold failed: ${e.message}.`); + process.exitCode = 1; + return; + } + + const written = results.filter(r => r.status === 'written'); + const errored = results.filter(r => r.status === 'error'); + const warned = results.filter( + r => r.status === 'written' && r.warnings && r.warnings.length > 0, + ); + + if (written.length > 0) { + log(`Scaffolded Package.swift for ${written.length} dep(s):`); + for (const r of written) { + log(` • ${r.depName}`); + } + log(''); + log( + 'node_modules is NOT committed and is wiped by `npm install`. To keep\n' + + 'these manifests, create and commit a patch with a tool like patch-package:\n' + + ' • `npx patch-package ` for each scaffolded dep, then commit the patch.\n' + + 'Also consider asking the maintainer to ship a Package.swift upstream.\n' + + 'Without a committed patch the build will hard-error again after a fresh install.', + ); + log(''); + } + + for (const r of warned) { + if (r.status !== 'written') continue; + for (const w of r.warnings) { + log(` ! ${r.depName}: ${w}`); + } + } + + for (const r of errored) { + if (r.status !== 'error') continue; + logError(` ! ${r.depName}: ${r.reason}`); + } +} + +// `--artifacts ` (advanced) is shape-detected: a `.xcframework` file is a +// local xcframework to use directly (no download); a directory is a cache-dir +// override (read if populated, download there if empty). +function localXcframeworkArg(args /*: SetupArgs */) /*: string | null */ { + return args.artifacts != null && args.artifacts.endsWith('.xcframework') + ? args.artifacts + : null; +} +function artifactsDirArg(args /*: SetupArgs */) /*: string | null */ { + return args.artifacts != null && !args.artifacts.endsWith('.xcframework') + ? args.artifacts + : null; +} + +function prepareLocalXcframeworkArtifacts( + args /*: SetupArgs */, + appRoot /*: string */, + version /*: string */, +) /*: string | null */ { + const localXcframework = localXcframeworkArg(args); + if (localXcframework == null) { + return artifactsDirArg(args); + } + + const localReactPath = path.resolve(localXcframework); + if ( + !localReactPath.endsWith('.xcframework') || + !fs.existsSync(localReactPath) + ) { + throw new Error( + `--artifacts path does not exist or is not an .xcframework: ${localReactPath}`, + ); + } + const localXcfwDir = path.resolve(localReactPath, '..'); + const xcfwLinksDir = path.join(appRoot, 'build', 'xcframeworks'); + fs.mkdirSync(xcfwLinksDir, {recursive: true}); + + // Build artifacts.json from the local xcframework + any siblings or cached deps + const artifacts /*: {[string]: {xcframeworkPath: string, url: string}} */ = + {}; + artifacts.React = {xcframeworkPath: localReactPath, url: ''}; + + // Look for ReactNativeDependencies and hermes-engine alongside or in cache + for (const name of ['ReactNativeDependencies', 'hermes-engine']) { + const siblingPath = path.join(localXcfwDir, `${name}.xcframework`); + const cachePath = path.join( + defaultCacheDir(args.version ?? version, args.flavor), + `${name}.xcframework`, + ); + if (fs.existsSync(siblingPath)) { + artifacts[name] = {xcframeworkPath: siblingPath, url: ''}; + } else if (fs.existsSync(cachePath)) { + artifacts[name] = {xcframeworkPath: cachePath, url: ''}; + } + } + + fs.writeFileSync( + path.join(xcfwLinksDir, 'artifacts.json'), + JSON.stringify(artifacts, null, 2), + 'utf8', + ); + log(`Using local xcframework: ${displayPath(localReactPath)}`); + return xcfwLinksDir; +} + +async function ensureArtifacts( + args /*: SetupArgs */, + version /*: string */, + artifactsDir /*: string | null */, +) /*: Promise */ { + // Resolve the cache-slot version before computing the cache dir. For dev / + // nightly labels this is the actual nightly hash, so each nightly gets its + // own slot and a new nightly invalidates the old slot automatically. Stable + // versions pass through unchanged. + const rawVersion = args.version ?? version; + const slotVersion = await resolveCacheSlotVersion(rawVersion); + const resolvedArtifactsDir = + artifactsDir != null + ? path.resolve(artifactsDir) + : defaultCacheDir(slotVersion, args.flavor); + + if (args.downloadPolicy === 'force' && resolvedArtifactsDir != null) { + log('Clearing cached artifacts (--download force)...'); + fs.rmSync(resolvedArtifactsDir, {recursive: true, force: true}); + } + + if (resolvedArtifactsDir == null) { + log('No artifacts directory resolved, skipping download step'); + return resolvedArtifactsDir; + } + if (args.downloadPolicy === 'skip') { + log('Skipping artifact download (--download skip)'); + return resolvedArtifactsDir; + } + + // Validate the cache before trusting it. A bare existsSync(artifacts.json) + // check would accept a partial write from a prior failed download (e.g. + // hermes-engine 404 on a not-yet-published nightly) and silently propagate + // the gap into the xcodeproj, surfacing only as "Missing package product" + // in Xcode. validateArtifactsCache reads the JSON and confirms every + // REQUIRED_ARTIFACT has a present xcframework on disk. + const cacheError = validateArtifactsCache(resolvedArtifactsDir); + if (cacheError == null) { + log(`Artifacts already present in ${displayPath(resolvedArtifactsDir)}`); + return resolvedArtifactsDir; + } + log(`Cache incomplete (${cacheError}); re-downloading...`); + log(`Downloading xcframework artifacts (slot: ${slotVersion})...`); + await downloadArtifacts([ + '--version', + rawVersion, + '--flavor', + args.flavor, + '--output', + resolvedArtifactsDir, + ]); + return resolvedArtifactsDir; +} + +function generateXcframeworksPackage( + args /*: SetupArgs */, + appRoot /*: string */, + reactNativeRoot /*: string */, + version /*: string */, + resolvedArtifactsDir /*: string | null */, +) { + log('Generating xcframeworks sub-package...'); + const packageArgs = [ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + '--version', + version, + ]; + const localXcframework = localXcframeworkArg(args); + if (localXcframework != null) { + packageArgs.push('--local-xcframework', localXcframework); + } + if (resolvedArtifactsDir != null) { + packageArgs.push('--artifacts-dir', resolvedArtifactsDir); + } + generatePackage(packageArgs); +} + +// True when the chosen pbxproj is still CocoaPods-integrated (its build configs +// layer a `Pods-*.xcconfig`) — the real blocker for SPM injection. +function pbxprojUsesCocoaPods(xcodeprojPath /*: string */) /*: boolean */ { + try { + const t = fs.readFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + 'utf8', + ); + return /\bPods[-/][^\n]*\.xcconfig\b/.test(t); + } catch { + return false; + } +} + +// 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'), + ); +} + +// True when the Podfile declares any explicit `pod '...'` (third-party pods). +function podfileHasThirdPartyPods(appRoot /*: string */) /*: boolean */ { + 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; + } + const podfilePath = path.join(appRoot, 'Podfile'); + if ( + fs.existsSync(podfilePath) && + gitTrackedAndClean(appRoot, podfilePath) !== true + ) { + return false; + } + return true; +} + +// 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. +function runDeintegrate(appRoot /*: string */) /*: void */ { + try { + execFileSync('pod', ['--version'], {stdio: 'ignore'}); + } catch { + logError( + '`--deintegrate` needs CocoaPods (`pod`) on PATH. Remove the React ' + + 'Native integration from your project manually, then run `spm add`.', + ); + process.exitCode = 1; + throw new Error('pod not found'); + } + 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 +// target (re-run) > the single .xcodeproj in appRoot. Returns an error string +// (ambiguous / none) so the caller fails loud. +function resolveInjectionTarget( + args /*: SetupArgs */, + appRoot /*: string */, +) /*: {path: string, error?: void} | {error: string, path?: void} */ { + if (args.xcodeprojPath != null) { + const p = path.resolve(appRoot, args.xcodeprojPath); + return fs.existsSync(p) + ? {path: p} + : {error: `--xcodeproj not found: ${p}`}; + } + const injected = findInjectedXcodeproj(appRoot); + if (injected != null) { + return {path: injected}; + } + const names /*: Array */ = []; + let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(appRoot, {withFileTypes: true}); + } catch {} + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs + const name /*: string */ = entry.name; + if (name.endsWith('.xcodeproj')) { + names.push(name); + } + } + if (names.length === 0) { + return { + error: + 'no .xcodeproj found. Create an app first (e.g. `npx ' + + '@react-native-community/cli init`) or make one in Xcode, then `spm add`.', + }; + } + if (names.length > 1) { + return { + error: `multiple .xcodeproj found (${names.join(', ')}); pass --xcodeproj to pick one.`, + }; + } + return {path: path.join(appRoot, names[0])}; +} + +/** + * Inject SPM packages into the user's existing .xcodeproj, in place — the only + * xcodeproj strategy (`add` and `update` both run this; there is no + * from-scratch generation). Fails loud rather than silently retargeting. With + * `--deintegrate`, removes CocoaPods first. + */ +async function setupXcodeproj( + args /*: SetupArgs */, + appRoot /*: string */, + reactNativeRoot /*: string */, +) /*: Promise */ { + const target = resolveInjectionTarget(args, appRoot); + if (target.error != null) { + logError(`Cannot set up SPM: ${target.error}`); + process.exitCode = 1; + throw new Error(target.error); + } + 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; + if (clean === false && !args.yes) { + const proceed = await promptYesNo( + `${path.basename(xcodeprojPath)} has uncommitted changes and no ` + + `backup is made (git is the only undo). Inject SPM packages anyway?`, + false, + ); + if (!proceed) { + log('Aborted. Commit or stash the project, then re-run `spm add`.'); + process.exitCode = 1; + throw new Error('In-place injection declined (dirty working tree)'); + } + } else if (clean === null) { + log( + `\x1b[33mNote: ${path.basename(xcodeprojPath)} is not in a git ` + + `repo — no backup is made before in-place injection.\x1b[0m`, + ); + } + + const result = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot, + xcodeprojPath, + appName: args.productName, + }); + if (result.status !== 'injected') { + logError(`SPM injection failed: ${result.reason}`); + process.exitCode = 1; + throw new Error(result.reason); + } +} + +// Returns the `*.xcodeproj` carrying a `.spm-injected.json` marker (the +// user-owned project SPM packages were injected into in place), else null. +function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { + let entries /*: Array<{name: string, isDirectory(): boolean}> */; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(appRoot, {withFileTypes: true}); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs + const name /*: string */ = entry.name; + if (!name.endsWith('.xcodeproj')) continue; + if (fs.existsSync(path.join(appRoot, name, SPM_INJECTED_MARKER))) { + return path.join(appRoot, name); + } + } + return null; +} + +// True when `git status --porcelain` reports the path dirty/untracked. Returns +// null when git is unavailable or the path is outside a repo (no safety net). +function gitTrackedAndClean( + appRoot /*: string */, + targetPath /*: string */, +) /*: boolean | null */ { + try { + const out = execFileSync( + 'git', + ['status', '--porcelain', '--', targetPath], + {cwd: appRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}, + ); + return out.trim() === ''; + } catch { + return null; // not a git repo / git missing + } +} + +function logNextSteps( + projectRoot /*: string */, + appRoot /*: string */, + productName /*: string | null */, +) { + const appPkgJson = readPackageJson(projectRoot); + const rawName = + (appPkgJson != null ? appPkgJson.name : null) ?? path.basename(projectRoot); + const sourcePath = findSourcePath(appRoot, rawName); + const appDisplayName = productName ?? deriveAppName(rawName, sourcePath); + + log(''); + log('SPM setup complete!'); + log(''); + log('Next steps:'); + log(` • Open ${appDisplayName}.xcodeproj in Xcode (or \`npm run ios\`)`); + log(' • Set your Development Team in Signing & Capabilities'); + log(' • Build and run on Simulator or device'); + log(''); + log('To remove SPM later: `npx react-native spm deinit`'); +} + +async function main(argv /*:: ?: Array */) /*: Promise */ { + let appRoot = process.cwd(); + const projectRoot = findProjectRoot(appRoot); + const args = parseArgs(argv ?? process.argv.slice(2)); + + // Standard-RN-layout redirect: if invoked from the JS root and there's an + // `ios/` subdir, route the run there. Runs BEFORE resolveAction so the + // first-run heuristic checks the correct directory. + const redirectTo = detectStandardRnLayoutRedirect(appRoot, projectRoot); + if (redirectTo != null) { + const redirectAction = args.action ?? 'add'; + log( + `\x1b[33mDetected standard RN layout — running ${redirectAction} in ${displayPath(redirectTo)} ` + + `instead of ${displayPath(appRoot)}.\x1b[0m`, + ); + appRoot = redirectTo; + } + + 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)}`); + } + + if (action === 'deinit') { + const xcodeprojPath = + args.xcodeprojPath != null + ? path.resolve(appRoot, args.xcodeprojPath) + : findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + 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.', + ); + return; + } + + // 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. + 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.', + ); + process.exitCode = 1; + return; + } + } + + const needsCliConfig = + action === 'add' || + action === 'update' || + action === 'sync' || + action === 'scaffold'; + let autolinkingConfigResult /*: ?AutolinkingConfigResult */ = null; + if (needsCliConfig) { + log('Generating autolinking.json (CLI config)...'); + try { + autolinkingConfigResult = generateAutolinkingConfig({projectRoot}); + log( + `Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`, + ); + } catch (e) { + logError( + `generate-spm-autolinking-config failed: ${e.message}. External native modules may not be discovered.`, + ); + } + } + const reactNativeRoot = resolveReactNativeRoot( + autolinkingConfigResult, + projectRoot, + ); + const version = determineVersion(args, reactNativeRoot); + log(`React Native version: ${version}`); + + // Resolve remote SPM mode ONCE up front. remotePackageConfig throws + // RemoteVersionError when remote mode is active but no usable RN version can + // be derived (e.g. the monorepo '1000.0.0' placeholder with no override). + // The downstream scaffold/autolinker/package steps all call it internally; + // surfacing it here gives a single, predictable failure point before any of + // them run. Exit 2 (same as a missing manifest) so the Xcode build phase + // turns it into a hard build error while staying lenient on transient sync + // failures. No-op in local mode (returns null). + try { + remotePackageConfig(appRoot); + } catch (e) { + if (e instanceof RemoteVersionError) { + logError(e.message); + process.exitCode = 2; + return; + } + throw e; + } + // The artifact cache directory is resolved later in ensureArtifacts so the + // nightly hash can be folded in for dev / nightly labels. That branch logs + // either "Downloading xcframework artifacts (slot: ...)" or + // "Artifacts already present in ...". + + if (action === 'codegen') { + runCodegenStep(projectRoot, appRoot, reactNativeRoot, false); + return; + } + + if (action === 'sync') { + const {main: runSync} = require('./spm/sync-spm-autolinking'); + try { + await runSync([ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + ]); + } catch (e) { + if (e instanceof MissingManifestError) { + // The per-dep `error:` lines were already printed by the autolinker. + // Exit 2 (distinct from generic failure) so the Xcode build phase can + // turn this into a hard build error while staying lenient on transient + // sync failures. + process.exitCode = 2; + } else { + logError(`SPM sync failed: ${e.message}`); + process.exitCode = 1; + } + } + return; + } + + let resolvedArtifactsDir = null; + if (action === 'download') { + try { + const artifactsDir = prepareLocalXcframeworkArtifacts( + args, + appRoot, + version, + ); + await ensureArtifacts(args, version, artifactsDir); + } catch (e) { + logError(`Artifact setup failed: ${e.message}`); + process.exitCode = 1; + } + return; + } + + // Scaffold Package.swift for community RN packages that don't ship SPM + // support — ONLY for the explicit `scaffold` action. init/update never + // auto-scaffold: a missing manifest must surface as a hard error (the + // autolinker below throws MissingManifestError → exit 2) so the gap is + // visible and fixed deliberately (scaffold + patch-package, or upstream). + // Auto-scaffolding would silently hide that real error. + if (action === 'scaffold') { + await runScaffold(args, appRoot, projectRoot, reactNativeRoot); + } + + runCodegenStep(projectRoot, appRoot, reactNativeRoot, args.skipCodegen); + log('Generating build/generated/autolinking/Package.swift...'); + try { + generateAutolinking([ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + ]); + } catch (e) { + if (e instanceof MissingManifestError) { + // Per-dep `error:` lines already printed by the autolinker. This happens + // on init/update when the user declined the scaffold prompt — surface it + // as a hard failure (exit 2) directing them to scaffold. + process.exitCode = 2; + } else { + logError(`generate-spm-autolinking.js failed: ${e.message}`); + process.exitCode = 1; + } + return; + } + + // Remote SPM package mode: artifacts come from the remote package (SPM + // resolves + caches them) — skip Maven download + the local artifacts pkg. + const remote = remotePackageConfig(appRoot); + if (remote == null) { + try { + const artifactsDir = prepareLocalXcframeworkArtifacts( + args, + appRoot, + version, + ); + resolvedArtifactsDir = await ensureArtifacts(args, version, artifactsDir); + } catch (e) { + logError(`Artifact setup failed: ${e.message}`); + process.exitCode = 1; + return; + } + + try { + generateXcframeworksPackage( + args, + appRoot, + reactNativeRoot, + version, + resolvedArtifactsDir, + ); + } catch (e) { + logError(`generate-spm-package.js failed: ${e.message}`); + process.exitCode = 1; + return; + } + } else { + log(`Remote ReactNative package: ${remote.url} @ ${remote.version}`); + } + + // (Re)install the static codegen Package.swift template once build/generated/ios exists. + installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}); + + // Build the per-app generated-headers farm (vended as the ReactAppHeaders + // SPM target inside the codegen package). React core headers need no trees + // — they live inside the composed artifacts (generate-spm-package). The + // generated manifests are fully declarative (fixed-relative package paths), + // 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); + } + + // 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); + } catch (e) { + logError(`xcodeproj setup failed: ${e.message}`); + if (process.exitCode == null) { + process.exitCode = 1; + } + return; + } + + logNextSteps(projectRoot, appRoot, args.productName); +} + +if (require.main === module) { + void main(); +} + +module.exports = { + main, + detectStandardRnLayoutRedirect, + findInjectedXcodeproj, + resolveAction, + shouldAutoDeintegrate, +}; diff --git a/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md b/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md new file mode 100644 index 000000000000..86116637e3f7 --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md @@ -0,0 +1,707 @@ +--- +title: Swift Package Manager Support for React Native iOS +author: +- Christian Falch +date: 2026-03-17 +--- + +# RFC: Swift Package Manager Support for React Native iOS + +## Summary + +Add Swift Package Manager (SPM) as an officially supported build system for +React Native iOS apps, alongside CocoaPods. The approach uses **prebuilt +XCFrameworks** published to Maven, eliminating the need for source compilation +of React Native internals and enabling fast, reproducible builds. + +## Basic example + +### New project + +```bash +npx react-native init MyApp +cd MyApp +npx react-native spm # auto-detects first-run → init; prompts to rename legacy CocoaPods xcodeproj +npm run ios +``` + +A future CLI integration (e.g., an `--ios-build-system spm` flag on +`react-native init`) could run `react-native spm init` automatically as part +of project creation, eliminating the manual step. + +### Existing project + +```bash +cd MyApp +npx react-native spm +# Prompted: rename CocoaPods MyApp.xcodeproj → MyApp.xcodeproj.legacy? +# Accept (Y) — the SPM xcodeproj writes to the now-free MyApp.xcodeproj slot, +# `npm run ios` resolves to it unambiguously. The legacy stays on disk +# (git mv tracks the rename cleanly) for rollback via `spm clean --project`. +``` + +After initial setup, day-to-day development requires no extra commands. Adding +or removing JS dependencies that include native code is handled automatically +by a build-phase sync step (see [Auto-sync build phase](#auto-sync-build-phase)). + +## Motivation + +### Apple is moving away from CocoaPods + +SPM is Apple's endorsed dependency manager. Xcode's SPM integration improves +with every release — package resolution, build caching, and IDE features all +assume SPM as the primary workflow. CocoaPods is community-maintained and has +been officially sunsetted — the CocoaPods trunk will become permanently +read-only on **December 2, 2026**, after which no new pods or updates can be +published ([announcement](https://blog.cocoapods.org/CocoaPods-Specs-Repo/)). +Existing builds will continue to work, but the ecosystem is moving on. + +### Build speed + +Prebuilt XCFrameworks skip compilation of ~2000 C++/Objective-C files. A clean +SPM build of rn-tester compiles only app sources and codegen output. This is a +significant improvement for CI pipelines and developer iteration speed. + +### Reduced onboarding friction + +CocoaPods requires Ruby, Bundler, and a working gem environment — a frequent +source of setup issues, especially on new machines or in CI. SPM requires only +Xcode. Removing the Ruby toolchain dependency simplifies onboarding and reduces +the surface area for environment-related build failures. + +### Adoption barrier + +Many organizations mandate SPM for iOS dependencies. Teams in these +environments are currently blocked from adopting React Native, or must maintain +custom workarounds. First-class SPM support might help overcoming this barrier. + +### Compatibility + +The SPM workflow generates an `AppName.xcodeproj` that takes the same +filename slot as the legacy CocoaPods xcodeproj. On `init`, the script +prompts to rename the existing CocoaPods project to `AppName.xcodeproj.legacy` +— preserving it for rollback while letting the community CLI's +`findXcodeProject` resolve `npm run ios` to the SPM project unambiguously. +Teams can migrate at their own pace before CocoaPods trunk goes read-only +in December 2026, and `spm clean --project` reverses the migration when +needed. + +## Detailed design + +### Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Maven (artifacts) │ +│ ├── React.xcframework (~200 MB, debug) │ +│ ├── ReactNativeDependencies.xcframework │ +│ └── hermes-engine.xcframework │ +└──────────────────┬──────────────────────────────┘ + │ download + cache + ▼ +┌─────────────────────────────────────────────────┐ +│ ~/Library/Caches/com.facebook.ReactNative/ │ +│ └── spm-artifacts/{version}/{flavor}/ │ +└──────────────────┬──────────────────────────────┘ + │ symlink + ▼ +┌──────────────────────────────────────────────────┐ +│ App ios/ │ +│ ├── AppName.xcodeproj/ (committed) │ +│ │ └── .spm-managed (marker file) │ +│ ├── AppName.xcodeproj.legacy/ (committed if │ +│ │ rename was │ +│ │ accepted) │ +│ └── build/ │ +│ ├── generated/ │ +│ │ ├── autolinking/ (generated) │ +│ │ │ ├── Package.swift │ +│ │ │ ├── autolinking.json │ +│ │ │ ├── packages/ (synth wrappers) │ +│ │ │ └── libs/ (alias symlinks │ +│ │ │ for self-managed│ +│ │ │ deps; basename │ +│ │ │ = SwiftName) │ +│ │ └── ios/ (codegen) │ +│ └── xcframeworks/ (symlinks) │ +│ ├── Package.swift │ +│ ├── React.xcframework -> cache │ +│ ├── ReactNativeDependencies.xcframework │ +│ └── hermes-engine.xcframework │ +└──────────────────────────────────────────────────┘ +``` + +### Pipeline + +`react-native spm` orchestrates six steps (the underlying script is +`scripts/setup-apple-spm.js`): + +| # | Step | Script | Output | +|---|------|--------|--------| +| 1 | CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | +| 2 | Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | +| 3 | Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` + source symlinks | +| 4 | Download | `spm/download-spm-artifacts.js` | Cached xcframeworks | +| 5 | Package | `spm/generate-spm-package.js` | `build/xcframeworks/Package.swift` + symlinks | +| 6 | Xcodeproj | `spm/generate-spm-xcodeproj.js` | `AppName.xcodeproj` + `.spm-managed` marker (`init` only; create-if-missing on subsequent runs) | +| — | Sync (build-time) | `spm/sync-spm-autolinking.js` | Re-runs steps 1–5 when inputs change (downloads artifacts if missing) | + +The `init` action additionally (a) prompts to rename any existing +CocoaPods `.xcodeproj` to `.xcodeproj.legacy` before step 6, and +(b) appends SPM-specific entries to `.gitignore` +(`build/generated/`, `build/xcframeworks/`, `.build/`, `Package.resolved`). +Existing entries are not duplicated. + +### Auto-sync build phase + +After initial setup, developers shouldn't need to re-run `react-native spm` +manually when dependencies change. The generated `.xcodeproj` includes a +**Sync SPM Autolinking** pre-build phase (ordered first, before VFS overlay) +that: + +1. Checks whether xcframework artifacts are missing (`artifacts.json` or + `React.xcframework` absent). This covers fresh clones where no setup + script has been run yet. +2. Compares timestamps of `package.json`, `react-native.config.js`, and the + `node_modules` directory against `autolinked/.spm-sync-stamp`. In + monorepos where `node_modules` is hoisted, the parent directory is also + checked. +3. If any check triggers (or the stamp is missing): sources `with-environment.sh` + for node PATH, then runs `spm/sync-spm-autolinking.js` which re-executes + codegen, artifact download (if needed), autolinking, and package generation. +4. If all inputs are fresh: exits immediately (~1ms shell check). + +Failures emit `warning:` and exit 0 — the existing autolinking may still be +valid. The stamp file is written on successful sync. + +The sync step handles React Native version changes automatically: after +`npm install` pulls a new version, the `node_modules` mtime changes, the sync +step regenerates autolinking and recreates xcframework symlinks pointing to the +new version's cache directory. + +The sync step is **self-healing**: if xcframework artifacts are missing (e.g., +the local cache at `~/Library/Caches/com.facebook.ReactNative/` was deleted, +or the project was freshly cloned), it automatically downloads them before +proceeding with autolinking and package generation. This means `react-native spm` +is only strictly required for initial project scaffolding (`init`); subsequent +builds recover automatically. + +### Cleaning generated SPM state + +Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does +not touch the project's `build/` or `.build/` directories. Xcode provides no +hook to run custom scripts during GUI clean actions. + +`react-native spm clean` is scoped by opt-in flags. The default removes only +generated dirs under `appRoot`: + +```bash +react-native spm clean # build/xcframeworks/, build/generated/, .build/ +react-native spm clean --project # also: delete SPM xcodeproj, restore .legacy backup +react-native spm clean --derived-data # also: this app's Xcode DerivedData entries +react-native spm clean --cache # also: cached xcframework slot for current version +react-native spm clean --all # = --project --derived-data --cache +``` + +Destructive scopes (`--project`, `--derived-data`, `--cache`, `--all`) prompt +for confirmation (bypass with `--yes`). `--project` is the reverse of the +init-time rename migration — deleting the SPM xcodeproj and restoring +`.xcodeproj.legacy` to its original filename if a backup exists. + +After a plain `clean`, run `react-native spm update` (or open the checked-in +`.xcodeproj` and build) to regenerate state. SPM package resolution is locked +for the duration of a build — if only stubs were left in place, Xcode would +resolve stubs and never pick up the real packages generated by the sync build +phase. + +### Stub packages for fresh clones + +Xcode resolves SPM packages **before** any build phase runs. On a fresh clone, +the referenced package directories (`build/xcframeworks`, `autolinked`, +`build/generated/ios`) may not exist yet, causing package resolution to fail. + +To solve this, `generate-spm-xcodeproj.js` writes **stub `Package.swift` +files** into each referenced sub-package directory that doesn't already have +one. Each stub defines the expected library products backed by a minimal +placeholder target (`.stub/Stub.swift`). This lets Xcode resolve packages +successfully even before the first build. On the first build, the auto-sync +build phase overwrites the stubs with real Package.swift files generated from +downloaded artifacts and autolinking output. + +### Caching and CI + +Xcframeworks are cached at +`~/Library/Caches/com.facebook.ReactNative/spm-artifacts/{version}/{flavor}/` +by default. The download step accepts a `--output` flag to write xcframeworks +to an explicit directory. + +For CI pipelines (GitHub Actions, CircleCI, etc.), cache the default path +keyed by the React Native version and flavor to avoid re-downloading +xcframeworks on every build. + +The Maven base URL can be overridden via the `ENTERPRISE_REPOSITORY` +environment variable for teams that mirror artifacts to an internal registry. + +**Planned:** A `RN_SPM_CACHE_DIR` environment variable to override the default +cache directory. This is not yet implemented in the current POC but is needed +for CI environments where a specific path must be persisted across builds. + +### Package graph + +The generated `.xcodeproj` references three local packages directly +via `XCLocalSwiftPackageReference` — no app-level `Package.swift` is required: + +``` +AppName.xcodeproj + ├── XCLocalSwiftPackageReference → build/xcframeworks/Package.swift + │ ├── ReactNative (product, wraps React binaryTarget) + │ ├── ReactNativeDependencies (binaryTarget) + │ └── hermes-engine (binaryTarget) + ├── XCLocalSwiftPackageReference → build/generated/ios/Package.swift + │ ├── ReactCodegen (target — codegen output) + │ └── ReactAppDependencyProvider (target) + └── XCLocalSwiftPackageReference → build/generated/autolinking/Package.swift + └── ... (targets — symlinked sources) +``` + +These three sub-package paths are **stable**: adding or removing community +deps changes the contents of `build/generated/autolinking/Package.swift` +(gitignored) but never the xcodeproj's references. That's why the +`.xcodeproj` is committed once and not regenerated on subsequent runs. + +The xcodeproj generation is **create-if-missing** on `update` (use +`--force-xcodeproj` for an explicit overwrite). This protects user-side +Xcode edits — signing, capabilities, Build Phases, scheme settings — from +being clobbered. Teammates can clone the repo and open Xcode immediately: +stub `Package.swift` files in each sub-package directory let SPM resolution +succeed before the first build, and the auto-sync build phase downloads +artifacts and writes the real sub-packages on first compile. + +### Header resolution + +React Native uses CocoaPods-style imports (`#import `) that +SPM does not natively support. Two mechanisms solve this: + +1. **XCFramework `Headers/` layout.** The prebuild step organizes headers by + `header_dir` (e.g., `Headers/React/`, `Headers/react/renderer/core/`). + Adding `-I Headers` to search paths resolves most imports directly. + +2. **VFS overlay.** A Clang virtual filesystem overlay (`React-VFS.yaml`) + remaps remaining edge cases — headers that appear in multiple pods or have + platform variants. The overlay is generated as a template at prebuild time + and resolved with local paths at setup time. + +### Local native modules + +Modules not discovered via autolinking (e.g., app-specific native modules) are +declared in `react-native.config.js`: + +```js +// react-native.config.js +module.exports = { + spmModules: [ + { + name: 'MyNativeModule', // SPM target name + path: 'ios/MyNativeModule', // path to source files + exclude: ['*.podspec'], // files to exclude from the target + publicHeadersPath: '.', // header search path for consumers + }, + ], +}; +``` + +Each entry becomes a target in `autolinked/Package.swift`. Sources outside the +autolinked directory are mirrored with **file-level symlinks** (SPM rejects +directory symlinks that resolve outside the package root). + +### Self-managed deps and package identity + +A community library that ships its own `Package.swift` (instead of being +wrapped by the autolinker) is referenced directly. SPM derives the package +identity for a `.package(path:)` dependency from the path's basename — and +a common convention is to ship the manifest inside an `ios/` subdir +(`/ios/Package.swift`). Two libs following that convention would both +have identity `"ios"`, and SPM rejects with `Conflicting identity for ios`. + +To make every reference globally unique by construction, the autolinker +materializes each self-managed dep as a symlink at +`build/generated/autolinking/libs//` pointing at the dep's real +manifest dir. The aggregator `Package.swift` then references the symlink +(`path: "libs/"`), and SPM uses the symlink basename — the +library's Swift module name — as the package identity. Swift module names +are already unique per dep (deriving from the npm package name), so this +sidesteps the collision in all cases, including against the codegen +package at `build/generated/ios/`. + +The `libs/` directory is wiped and recreated on every autolinker run, so +stale aliases for uninstalled deps disappear automatically. + +### Third-party library support + +The current implementation handles React Native's own frameworks and app-local +native modules. The primary goal for third-party libraries is to **build using +SPM**. Shipping prebuilt xcframeworks is the recommended approach for faster +builds, but it is not a requirement — libraries can also be compiled from +source via SPM targets. This ensures that library authors with limited +resources can support SPM without needing to set up a prebuild CI pipeline. + +#### Library metadata in `react-native.config.js` + +`react-native.config.js` is the canonical place for library SPM metadata. The +autolinking pipeline already scans `node_modules` for this file to discover +iOS and Android native modules. Adding SPM config alongside the existing +`dependency.platforms.ios` keeps a single source of truth, requires no new +discovery mechanism, and can express things `Package.swift` cannot — such as +Maven URL templates with version and flavor placeholders for downloading +prebuilt xcframeworks. Libraries may still ship a `Package.swift` for direct +SPM consumers outside the React Native ecosystem, but React Native autolinking +reads `react-native.config.js`. + +#### Prebuilt xcframeworks (primary path) + +React Native already prebuilds its core into xcframeworks and publishes them to +Maven. This is the model we want every library to follow. Libraries declare SPM +metadata in `react-native.config.js`: + +```js +// react-native-maps/react-native.config.js +module.exports = { + dependency: { + platforms: { + ios: { /* existing autolinking config */ }, + }, + }, + spm: { + // Primary: prebuilt xcframework (downloaded at setup time) + xcframework: { + name: 'ReactNativeMaps', + // URL template — {version}, {rn-version}, {flavor} resolved at download time + url: 'https://maven.example.com/.../react-native-maps-{version}-xcframework-{flavor}.tar.gz', + }, + // Fallback: source compilation (used during local development or when + // xcframework is unavailable) + source: { + name: 'ReactNativeMaps', + path: 'ios', + publicHeadersPath: '.', + exclude: ['*.podspec', 'Tests/**'], + dependencies: ['MapKit'], + resources: ['ios/Resources/**'], + }, + }, +}; +``` + +**Planned (Phase 2):** When `react-native spm` gains third-party library +support, it will: +1. If `spm.xcframework` is declared, download the prebuilt binary (fast path). +2. If the download fails or the `--source` flag is passed, fall back to + `spm.source` and compile from symlinked sources. +3. If neither is declared, the library requires a manual `spmModules` entry. + +Currently, only `spmModules` entries (see [Local native modules](#local-native-modules)) +are supported. The `spm.xcframework` and `spm.source` config fields — including +the `dependencies` field shown above — are not yet implemented. + +#### Source compilation (fallback) + +Source-level autolinking (`spmModules` / `spm.source`) remains available for: +- **Local development** — library authors iterating on native code +- **Libraries without prebuilt xcframeworks** — transitional state +- **App-specific native modules** — code that lives in the app repo + +This reuses the existing `spmModules` mechanism: sources are mirrored with +file-level symlinks into `autolinked/`, compiled as SPM targets with +appropriate header search paths. + +#### `react-native-prebuild` CLI + +React Native already has a mature prebuild pipeline (`scripts/ios-prebuild/`) +that produces signed, packaged xcframeworks published to Maven. Rather than +asking library authors to reinvent this, we can expose the same tooling as a +reusable CLI: + +```bash +npx react-native-prebuild \ + --podspec ios/MyLibrary.podspec \ + --react-native-version 0.80.0 \ + --platforms ios,ios-simulator \ + --flavor release \ + --output dist/ + +# Output: +# dist/MyLibrary.xcframework.tar.gz +# dist/MyLibrary.framework.dSYM.tar.gz +``` + +The tool would: + +1. **Download React Native xcframeworks** for the specified version. +2. **Parse the library's podspec** to discover source files, headers, + `header_dir`, dependencies, and compiler flags. +3. **Generate a temporary Package.swift** declaring the library as a target + with dependencies on the RN xcframeworks. +4. **Build** using `xcodebuild` for each platform slice. +5. **Compose** the xcframework with organized headers, module map, and + optional VFS overlay. +6. **Sign** the xcframework with the developer's code signing identity. +7. **Package** as `.tar.gz` with dSYM symbols. + +The tool includes code signing as a built-in step. Library authors provide +their own signing identity (Apple Developer certificate); the tool handles +the `codesign` invocation. Unsigned xcframeworks trigger macOS Gatekeeper +warnings, so signing is strongly recommended for distributed artifacts. +Documentation will cover how to create and manage a signing identity for +this purpose. + +Library authors can integrate this into CI to publish prebuilt artifacts on +every release, targeting a matrix of React Native versions and build flavors. + +#### Version compatibility + +A library's xcframework must be built against a compatible React Native +version. The prebuild tool embeds metadata (React Native version, library +version, build flavor, minimum iOS version) inside the xcframework. The +download step verifies compatibility at setup time, warning if a library was +built against a different React Native version than the app is using. + +## Drawbacks + +### Transition period: supporting both CocoaPods and SPM + +With CocoaPods trunk going read-only in December 2026, the migration to SPM is +necessary rather than optional. During the transition period, both build +systems must be supported in parallel. Bug fixes, new features, and build-phase +changes need to be tested against both CocoaPods and SPM until CocoaPods +support is eventually removed. + +### Download size + +Prebuilt xcframeworks for React Native core are compressed as tar.gz archives. +Individual library xcframeworks are typically 1–15 MB in debug mode including +dSYM bundles. Both debug and release flavors are needed, which doubles the +total. While artifacts are cached locally after the first download, CI +environments without persistent caches will re-download on every build. + +### Ecosystem adoption takes time + +Third-party libraries must opt in to the prebuild workflow. During the +transition period, many libraries will only support CocoaPods. Apps that depend +on these libraries cannot fully migrate to SPM until the libraries catch up. +This creates a chicken-and-egg problem that may slow adoption. + +### SPM limitations require `.xcodeproj` generation + +SPM does not support build script phases, `post_install` hooks, or the kind of +build-time customization that CocoaPods provides via its Podfile DSL. The +current design works around this by generating an `.xcodeproj` with explicit +build phases for JS bundling, Hermes engine copying, VFS overlay setup, and +autolinking sync. This is a known limitation of the current approach. If Apple +expands SPM's plugin API to support arbitrary script execution with file I/O +and network access, the `.xcodeproj` could be eliminated in favor of a purely +SPM-native workflow — but this is a future direction that depends on Apple's +roadmap, not something this proposal can resolve. + +### Committed xcodeproj edits + +The generated `.xcodeproj` is committed and may carry user edits — +signing, capabilities, Build Phases, custom schemes. The `update` action is +**create-if-missing** to protect those edits, which means the script does +not propagate generator improvements into existing projects automatically. +Bug fixes that change the emitted pbxproj need an explicit +`--force-xcodeproj` run to take effect. A future improvement could +preserve user-side edits through a merge step rather than full overwrite — +see "Hardening `update --force-xcodeproj`" in unresolved questions. + +## Alternatives + +### Compile React Native from source as SPM targets + +Compiling React Native's C++/Objective-C sources from source as SPM targets is +not the default path due to the ~2000 source files and complex header layout, +which makes clean build times significantly longer. However, source +compilation support is a goal for specific use cases: + +- **Debugging React Native internals** — developers investigating bugs or + contributing fixes to React Native itself need to build from source with + debug symbols. +- **Apps requiring source patches** — projects like Expo Go that need to modify + React Native source code to build successfully, or apps that apply patches + via tools like `patch-package`. + +The source compilation path would reuse the same SPM package structure but +replace binary xcframework targets with source targets. This is planned as a +`--source` flag to `react-native spm`. + +### SPM build tool plugins + +SPM plugins were evaluated as a way to eliminate the `.xcodeproj` (see +[SPM Plugins Assessment](spm-plugins-assessment.md) for details). The key +findings: + +- **Post-build phases are impossible.** JS bundling and Hermes engine copying + run after linking to place artifacts in the `.app` bundle. SPM has no + post-build plugin capability — this is a deliberate design choice for build + reproducibility. +- **Sandbox restrictions.** Build tool plugins cannot write to the source tree, + run `node`, or access `node_modules`. Pre-build phases like autolinking sync + require all of these. +- **No Xcode build settings.** SPM plugins do not receive `CONFIGURATION`, + `BUILT_PRODUCTS_DIR`, or other settings that the JS bundling script relies on. + +A hybrid approach (some SPM plugins + some Xcode build phases) would be harder +to reason about than the current uniform approach of all Xcode build phases. +SPM plugins are not a viable alternative today. + +## Adoption strategy + +This proposal introduces SPM as an **additional** build system. It is not a +breaking change. CocoaPods continues to work exactly as before. The two +workflows coexist — an app can have both `Podfile` and `Package.swift` in the +same directory. + +### Phase 1: React Native core (current) + +SPM works for React Native core frameworks and app-local native modules +declared as `spmModules` in `react-native.config.js`. No third-party library +support. This phase validates the architecture and developer experience with +rn-tester and the helloworld template. + +### Phase 2: Library ecosystem tooling + +Ship the `react-native-prebuild` CLI. Library authors can prebuild and publish +xcframeworks for their libraries. The autolinking step reads `spm.xcframework` +from installed libraries and downloads artifacts automatically. Libraries +without xcframeworks fall back to `spm.source` (source compilation) or manual +`spmModules` entries. + +### Phase 3: Ecosystem-wide adoption + +Popular libraries ship prebuilt xcframeworks from CI. App developers get +near-zero-compilation iOS builds — only app code and codegen output are +compiled. React Native provides clear documentation and tooling +(`react-native-prebuild`) to help library authors build and publish +xcframeworks — for example, CI workflow templates and guidance on publishing to +Maven or GitHub Releases. Prebuilt xcframeworks are recommended but not +required; libraries that don't provide them fall back to source compilation. + +### Migration path for existing apps + +1. Run `npx react-native spm` from the project root (auto-redirects into + `ios/`). +2. Accept the rename prompt — your existing `AppName.xcodeproj` becomes + `AppName.xcodeproj.legacy` (preserved for rollback). +3. Commit the new `AppName.xcodeproj/` (SPM-managed) and the renamed + `AppName.xcodeproj.legacy/`. `git mv` tracks the rename cleanly. +4. Run `npm run ios` and verify the SPM build. +5. Once validated, optionally delete the `.legacy` backup, `Podfile`, + `Pods/`, and `.xcworkspace`. + +To roll back: `npx react-native spm clean --project` deletes the SPM +xcodeproj and renames `.legacy` back to the canonical filename. + +No changes to JavaScript code, Metro configuration, or Android setup are +required. + +### Upgrading React Native + +After upgrading `react-native` in `package.json` and running `npm install`, +the auto-sync build phase detects the `node_modules` mtime change on the next +Xcode build and re-runs the sync step automatically. This downloads the new +version's xcframeworks, regenerates the sub-packages, and updates autolinking. +No manual edits are needed — the xcodeproj's sub-package references are +stable, and those sub-packages are fully regenerated each run. Developers +can also run `react-native spm` manually to trigger the update before +building. + +## How we teach this + +### Documentation + +- Add a **"Building with SPM"** guide to the React Native docs, parallel to the + existing CocoaPods setup guide. +- Update the **"Getting Started"** guide to present SPM as an option alongside + CocoaPods, with SPM as the recommended path for new projects once Phase 2 is + stable. +- Add a **library author guide** explaining how to use `react-native-prebuild` + and publish xcframeworks. + +### CLI discoverability + +- `react-native spm --help` should provide clear usage instructions and + explain each step. +- Error messages should include actionable suggestions (e.g., "Run + `react-native spm init` for first-time setup"). +- The auto-sync build phase should surface warnings in Xcode's issue navigator + when autolinking state is stale. + +### Community template + +- The `react-native init` template should include SPM as an option (e.g., + `--pm spm` flag or interactive prompt). +- The template should generate the initial `Package.swift` and `.xcodeproj` so + that new projects work with SPM out of the box. + +### Naming and terminology + +- **"SPM build"** or **"Swift Package Manager build"** to distinguish from the + CocoaPods-based workflow. +- **"xcframeworks"** when referring to the prebuilt binary artifacts. +- Avoid the term "pods" when discussing the SPM workflow to prevent confusion. + +## Unresolved questions + +1. **How should version compatibility be enforced?** A library's xcframework + must be built against a compatible React Native version. Should the download + step enforce strict version matching, accept semver-compatible ranges, or + simply warn on mismatch? + +2. **Where should library xcframeworks be hosted?** Maven Central (consistent + with React Native core), GitHub Releases (simpler for library authors), or a + dedicated registry (better discovery and compatibility metadata). Each has + different trade-offs for discoverability, reliability, and maintenance + burden. + +3. **Debug symbol (dSYM) distribution.** The prebuild pipeline produces dSYM + bundles alongside xcframeworks, but the best way to distribute and consume + them is not yet defined. Open questions include: should dSYMs be downloaded + alongside xcframeworks automatically or on demand? How should they integrate + with crash reporting services (Sentry, Crashlytics) that need dSYM UUIDs + for symbolication? Should the download step place dSYMs in a location that + Xcode's archive workflow picks up automatically? + +4. **How should library authors validate SPM compatibility?** A validation + command (`react-native-prebuild --validate`) could verify that a library's + sources compile as an SPM target without producing a full release artifact. + This would be useful for CI checks on pull requests. + +5. **Hardening `update --force-xcodeproj`.** The default `update` is + create-if-missing, which preserves user edits but means generator + improvements don't propagate to existing projects automatically. Passing + `--force-xcodeproj` clobbers everything. A future improvement could read + the existing pbxproj, merge changes (signing, capabilities, custom Build + Phases), and write back — likely via a proper Xcode project + parser/generator (e.g., `@bacons/xcode`) rather than the current + template-based approach. Planned work for production readiness. + +6. **Auto-sync failure visibility.** The sync build phase currently emits + `warning:` and exits 0 on failure, which means a broken autolinking state + can persist silently across builds. Planned improvements include a strict + mode (e.g., `RN_SPM_STRICT_SYNC=1` that exits non-zero on failure) and + generating a `#warning` directive in a source file when sync fails, so + Xcode surfaces the issue in the issue navigator even when build log + warnings are missed. + +7. **Monorepo and package manager compatibility.** The auto-sync build phase + uses `node_modules` mtime to detect dependency changes. This has been + tested with npm in the React Native monorepo but not yet with Yarn + workspaces (hoisted or PnP), pnpm (symlinked `node_modules`), or Bun. + These package managers structure `node_modules` differently and may require + adjustments to the mtime detection logic. Validating and fixing + compatibility across package managers is planned work. + +## References + +- [RFC0508: Out-of-NPM Artifacts](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0508-out-of-npm-artifacts.md) — established the Maven-based artifact distribution pattern this proposal builds on +- [Apple: Creating Swift Packages](https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode) +- [SE-0272: Package Manager Binary Dependencies](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0272-swiftpm-binary-dependencies.md) diff --git a/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md new file mode 100644 index 000000000000..d1221bbbeec7 --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md @@ -0,0 +1,97 @@ +# SPM headers & package references — how they resolve + +React Native's SPM consumption is **zero-I**: no `-I` / `-F` header search +paths and no `unsafeFlags` in any generated manifest. Headers are served by +SPM products/binary targets, and every generated `Package.swift` references +the React Native + codegen packages with plain, fixed-relative paths computed +at generation time (no runtime discovery). This document is the single source +of truth for how that resolves. + +> History: earlier iterations materialized two header trees and fed them to +> consumers as `-I` flags read from `spm-paths.json` / `.react-native/paths.json` +> via an inlined Swift loader. That whole mechanism (the loader +> `renderRNPathsLoader`, the `writeAppPathsJson` / `writeSharedPathsJson` +> writers, and both JSON files) has been **deleted** — manifests are now +> declarative. If you find a reference to those files, it is stale. + +## How headers resolve (no search paths) + +| Namespace | Served by | Mechanism | +|-----------|-----------|-----------| +| ``, `` | `React.xcframework` (React binaryTarget) | Xcode auto-adds `-F` for the linked binary product; `React.framework/Headers` is the unified root. | +| Everything else: ``, ``, ``, ``, folly/glog/boost/fmt/double-conversion | `ReactNativeHeaders.xcframework` (headers-only library binaryTarget) | Xcode auto-adds the binary target's `Headers` dir as a search path; plain per-namespace module maps. | +| ``, `ReactAppDependencyProvider`, this app's generated specs | `ReactAppHeaders` SPM target in the codegen package | SPM `publicHeadersPath` propagation — a real target dependency, not a flag. | + +The one remaining materialized header tree is the per-app farm at +`/build/generated/ios/ReactAppHeaders` (built by +`buildPerAppHeaderTree` in `spm-utils.js`, called from the orchestrators). It +is vended as the `ReactAppHeaders` SPM target — consumers reach it through a +product dependency, never through `-I`. + +`autolinking.json` (the `@react-native-community/cli config` output) is an +INPUT used to generate the manifests; it is never read by a manifest. + +## How each manifest references the React + codegen packages + +Every generated manifest sits at a known depth inside the app and is +regenerated on every `react-native spm` run, so package references are plain +fixed-relative paths — no walk-up, no JSON, no `import Foundation`. + +| Manifest | Location | How it references the React + codegen packages | +|----------|----------|-------------------------------------------------| +| Autolinked aggregator | `build/generated/autolinking/Package.swift` | `.package(path: "../../xcframeworks")` + `"../ios"` (only when it has inline `spmModule` targets) | +| Per-dep synth wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` | +| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | +| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | +| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | + +## Remote-package mode + +Remote mode is gated by a **URL alone** — `RN_SPM_REMOTE_URL` (or the persisted +`url`). When set, the whole app graph flips to a single remote React Native +package identity: `.package(path: build/xcframeworks)` becomes +`.package(url:exact:)` everywhere (aggregator/synth/codegen template/pbxproj), +and the local artifact download + compose is skipped. SPM's +one-version-per-package rule then unifies app + every library on one resolved +React Native. The package identity is derived from the URL tail (swift-tools 6 +dropped `.package(name:url:)`) — nothing hardcodes a repo name. + +**Version is derived from npm, not pinned by hand.** The SPM-pinned RN version +is not a free parameter: the SPM graph must compile against the same React +Native the JS/native code uses, so the app (graph root) pins EXACT to the +*installed* RN version, read from `node_modules/react-native/package.json`. +`RN_SPM_REMOTE_VERSION` and the persisted `versionOverride` are **overrides**, +not the source of truth — they're only needed when the installed version isn't +publishable (e.g. the monorepo `1000.0.0` dev placeholder, which has no remote +tag). A *derived* version is never persisted, so an `npm install` that upgrades +RN auto-re-pins the SPM graph on the next `spm` run; an *override* is persisted +as `versionOverride` so it survives Xcode-phase re-syncs without the env. + +Persisted schema is `{url, versionOverride?}`. Legacy `{url, version}` is still +read, with `version` honored as an override (back-compat). If remote mode is on +but no usable version can be resolved — react-native isn't installed, or it's a +non-publishable dev placeholder and no override is set — the tooling errors +(exit 2, a hard Xcode build error) directing you to set `RN_SPM_REMOTE_VERSION` +or install a released react-native, rather than silently pinning an unpublished +tag. + +## Hand-authored community library contract + +A library that ships its own `Package.swift` (no scaffolder/autolinker marker) +is left untouched by the tooling. It needs only two things, and **no discovery +code**: + +1. Depend on the React Native SPM package and its products — in remote mode + `.package(url: "", exact: "")` + `.product(name: "ReactNative", …)` + and `.product(name: "ReactNativeHeaders", …)`. (Libraries should declare a + version RANGE in production; the consuming app pins EXACT.) +2. Ship its own generated code: set `codegenConfig.includesGeneratedCode: true` + and generate with `generate-codegen-artifacts.js --path . --targetPlatform + ios --source library`. Output lands at + `/build/generated/ios/ReactCodegen/`, reachable from the manifest + with one safe `.headerSearchPath(...)` into the library's own tree. The + app-side codegen then skips the lib's spec (no duplicate symbols). + +This makes the library self-contained — it carries no app-layout knowledge and +needs no per-app codegen headers from the consuming app. Proven with +`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule lib). diff --git a/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md b/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md new file mode 100644 index 000000000000..e20e4c93f672 --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md @@ -0,0 +1,128 @@ +# SPM Build Plugins Assessment + +An evaluation of whether Swift Package Manager plugins can replace the Xcode build +phase scripts currently injected by `generate-spm-xcodeproj.js`. + +## Current Build Phases (6 total) + +| # | Phase | Timing | SPM Plugin Feasible? | +|---|-------|--------|----------------------| +| 1 | Sync SPM Autolinking | Pre-build | Partially | +| 2 | Prepare VFS Overlay | Pre-build | Partially | +| 3 | Sources (compile) | Build | N/A (standard) | +| 4 | Frameworks (link) | Build | N/A (standard) | +| 5 | Resources (copy) | Build | N/A (standard) | +| 6 | Build JS Bundle | **Post-build** | **See below** | + +> **Removed:** The "Copy Hermes Framework" phase was removed — it was a no-op. +> The underlying `copy-hermes-xcode.sh` script has been empty since Dec 2022. +> Hermes is already properly linked as an xcframework SPM dependency. + +## SPM Plugin Types + +SPM offers two plugin types: + +1. **Build Tool Plugins** (`BuildToolPlugin`) — run pre-build, can generate source + files/resources via `prebuildCommands` or per-file `buildCommands`. +2. **Command Plugins** (`CommandPlugin`) — run on-demand via + `swift package `. + +## Key Constraints + +### Sandbox restrictions + +SPM plugins run sandboxed by default — no network access, limited filesystem access. +The current scripts need to: + +- Run `node` (not on the sandbox-allowed path) +- Write to the source tree (`autolinked/`, `build/`) +- Access `node_modules/` +- Read git state + +Command plugins can request `--allow-writing-to-package-directory`, but build tool +plugins can only write to a designated plugin work directory, not the source tree. + +### No Xcode build settings + +SPM plugins do not receive Xcode build settings such as `CONFIGURATION`, +`PLATFORM_NAME`, `BUILT_PRODUCTS_DIR`, or `DERIVED_FILE_DIR`. The JS bundling script +relies heavily on these to decide debug-vs-release behavior and output paths. + +## JS Bundle Phase — Could Move to Pre-build + +The JS bundle has no dependency on native compilation. It only needs JS source files, +Metro, and knowledge of debug vs release. The current post-build placement is +historical — the script writes directly into `BUILT_PRODUCTS_DIR`. + +A potential restructuring: + +1. **Generate the bundle pre-build** into a known location (e.g. `build/jsbundle/`) +2. **Declare it as an SPM resource** so it gets copied into the app automatically + +Challenges: +- **Debug builds skip bundling** (app loads from Metro dev server). The script checks + `CONFIGURATION == Debug`, which is unavailable to SPM plugins. +- **Hermes bytecode compilation** also happens in this phase for release builds. +- Making it a command plugin (`swift package bundle-js --configuration release`) would + lose the automatic behavior — developers would need to run it explicitly. + +## What Could Theoretically Work + +### Codegen as a Command Plugin + +A Swift command plugin could shell out to `node` to run codegen: + +```swift +@main struct CodegenPlugin: CommandPlugin { + func performCommand(context: PluginContext, arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["node", "scripts/codegen/generate-codegen-artifacts.js"] + try process.run() + process.waitUntilExit() + } +} +``` + +Invoked as `swift package codegen`. This is essentially wrapping a shell script in +Swift with no real benefit over the current approach. + +### Autolinking sync as a Prebuild Command + +A `prebuildCommand` runs before every build, similar to Phase 1. But: + +- Output can only go to the plugin work directory (not `autolinked/`) +- Would need to restructure the package graph to consume generated files from the + plugin work directory +- Still needs to shell out to `node` + +This is a significant architectural rework for marginal benefit. + +## Recommendation + +**Do not invest in SPM plugins for this use case.** Reasons: + +1. **Pre-build phases already work well** as Xcode build phase scripts. Moving them + to SPM plugins adds Swift boilerplate around `Process()` calls to `node`, while + losing access to Xcode build settings. + +2. **The ROI is poor** — a hybrid (some SPM plugins + some Xcode build phases) is + harder to reason about than the current uniform approach of all Xcode build phases. + +3. **SPM plugins shine for pure Swift source generation** (SwiftGen, SwiftProtobuf) + where the plugin generates `.swift` files that feed into compilation. React + Native's build steps are fundamentally different — they orchestrate a JS toolchain + and copy runtime artifacts. + +4. **The JS bundle phase could move pre-build** but would lose automatic + debug/release detection without Xcode build settings. Worth revisiting if SPM + gains access to build configuration in a future Swift version. + +## Alternatives Worth Exploring + +- **Xcode Build Tool Plug-ins** (the Xcode-specific variant, not SPM) have access to + build settings and can run post-build, but require a different packaging model. +- **Move auto-sync to a `prepare` script** in `package.json` so it runs at + `yarn install` time instead of every build, reducing build-time overhead. +- **Pre-build JS bundling** with the bundle declared as an SPM resource, removing the + need for a post-build phase entirely (release builds only). diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__doc__/spm-scripts.md new file mode 100644 index 000000000000..ef16e7a06ddd --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/spm-scripts.md @@ -0,0 +1,264 @@ +# SPM Scripts – React Native iOS via Swift Package Manager + +Build React Native iOS apps using **Swift Package Manager** with prebuilt +XCFrameworks, as an alternative to CocoaPods. + +## Quick Start + +```bash +cd ios + +# First-time setup: injects SPM 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: +npx react-native spm add --deintegrate + +# Open in Xcode (or `npm run ios`). Autolinking syncs automatically on build. +open MyApp.xcodeproj +``` + +After the initial run, the `.xcodeproj` includes an **auto-sync build phase** +that detects dependency changes and re-runs autolinking before compilation +(see [Auto-Sync](#auto-sync-build-phase) below) — you typically don't need to +re-invoke `react-native spm` manually. + +> **Note:** `react-native spm` is a thin wrapper over +> `node node_modules/react-native/scripts/setup-apple-spm.js`. If the CLI +> alias is unavailable in your environment, invoke the script directly with +> the same actions and the kebab-case flag equivalents (e.g. +> `--skip-codegen`). + +## 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 runs +`pod deintegrate` and strips React Native from the Podfile before injecting. +Non-RN pods can stay side-by-side (rebuild a Podfile without +`use_react_native!` and `pod install`). The migration is fully reversible: +`spm deinit` removes the injection, then `pod install` restores CocoaPods. +Expo-managed apps are not supported yet. + +To roll back the SPM injection: + +```bash +npx react-native spm deinit # surgically removes everything `add` injected +# then, to restore CocoaPods: +pod install +``` + +## Pipeline + +`react-native spm add` and `react-native spm update` orchestrate these steps: + +| Step | Script | Output | +|------|--------|--------| +| 1. CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | +| 2. Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | +| 3. Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` | +| 4. Download | `spm/download-spm-artifacts.js` | Cached xcframeworks | +| 5. Package | `spm/generate-spm-package.js` | `build/xcframeworks/Package.swift` + symlinks | +| 6. Inject | `spm/generate-spm-xcodeproj.js` | SPM packages injected into the existing `.xcodeproj` + `.spm-injected.json` marker | +| Auto-sync | `spm/sync-spm-autolinking.js` | Re-runs codegen/autolinking/package generation at Xcode build time | + +## Directory Layout + +``` +my-app/ios/ + MyApp.xcodeproj/ <-- committed (your project; SPM injected in place, carries .spm-injected.json) + Podfile <-- present until `pod deintegrate` (CocoaPods coexistence is best-effort) + build/ + generated/ + autolinking/ <-- gitignored (regenerated at build time) + Package.swift + autolinking.json + packages/ <-- synth wrappers for autolinker-managed deps + libs/ <-- symlinks to self-managed deps' Package.swift + dirs, named by Swift module so SPM + package identity stays unique + headers/ <-- generated header symlinks + ios/ <-- gitignored, codegen output + xcframeworks/ <-- gitignored, symlinks to cached artifacts + React.xcframework -> ~/Library/Caches/.../React.xcframework + ReactNativeDependencies.xcframework -> ... + hermes-engine.xcframework -> ... +``` + +### What to commit + +| Path | Commit? | Why | +|------|---------|-----| +| `MyApp.xcodeproj/` | Yes | Your project, with SPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SPM refs/settings, additively. | +| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. | +| `build/generated/` | No | Codegen/autolinking output; regenerated | +| `build/xcframeworks/` | No | Symlinks to local cache; machine-specific | +| `Package.resolved` | No | SPM resolution file; machine-specific | + +Injection is **purely additive** and **idempotent**: `add`/`update` insert only +SPM package refs, the React build settings, the Sync build phase, and a scheme +pre-action — every other byte (your signing / capabilities / Build Phases) +stays untouched, and a re-run is a no-op. The injected refs point at three +stable sub-package paths under `build/`; adding or removing community deps +changes the sub-package contents (gitignored) and never re-injects. `deinit` +removes exactly what was injected (using the marker), leaving the project +byte-identical to its pre-`add` state. + +## CLI Actions + +```bash +react-native spm [action] [options] +``` + +With no action, the command **auto-resolves**: if SPM 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 additionally implies `--deintegrate` (the safe-gate), so +`npx react-native spm` converts a brand-new app to SwiftPM in one command. + +When invoked from the JS root of a standard RN app (sibling `ios/` subdir), +the command auto-redirects into `ios/` with a banner. + +| Action | Description | +|---|---| +| `add` | Inject SPM packages (package refs, build settings, 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 exact inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. | +| `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 build phase. Regenerates autolinking + xcframeworks sub-packages. Not for humans. | +| `codegen` (advanced) | Run codegen and install the SPM codegen template only. | +| `download` (advanced) | Download/check xcframework artifacts only. | + +## CLI Options + +Flags below use the `react-native spm` (camelCase) form. The raw script +accepts kebab-case equivalents (e.g. `--skip-codegen`). + +| Option | Description | +|---|---| +| `--version ` | RN version (default: from package.json) | +| `--flavor ` | Artifact flavor (default: debug) | +| `--yes` | Skip the dirty-pbxproj confirmation prompt | +| `--xcodeproj ` | [add] Which `.xcodeproj` to inject into (when several exist) | +| `--productName ` | [add] Which app target to inject into (when several exist) | +| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | +| `--artifacts ` | [advanced] Local artifact source: a `.xcframework` (used directly) or a directory (cache dir to read/download into) | +| `--download ` | [advanced] Artifact download policy (default: auto) | +| `--skipCodegen` | [advanced] Skip the codegen step | + +## Local Native Modules + +Modules not discovered via autolinking can be declared in `react-native.config.js`: + +```js +module.exports = { + spm: { + modules: [ + { + name: 'MyNativeModule', + path: 'ios/MyNativeModule', // relative to app root + exclude: ['*.podspec'], // optional + publicHeadersPath: '.', // optional + }, + ], + }, +}; +``` + +Each entry becomes a target in `build/generated/autolinking/Package.swift`. +Sources outside `build/generated/autolinking/` are automatically mirrored with +file-level symlinks. + +## Self-managed community packages + +A community library that ships its own `Package.swift` is referenced +directly by the autolinker instead of being wrapped. To keep SPM's +package identity (which it derives from the path basename) unique across +deps — even when several libs put their manifest inside an `ios/` subdir +— each self-managed dep is exposed through a uniquely-named symlink at +`build/generated/autolinking/libs//`. The aggregator +`Package.swift` references that path, so two libs both shipping +`/ios/Package.swift` never collide on identity `"ios"`. + +The `libs/` directory is wiped and recreated on every autolinker run, +so deleting a dep via `npm uninstall` cleans up the alias automatically +on the next build. + +## Header Resolution + +React Native uses CocoaPods-style imports (`#import `) that +SPM doesn't natively support. Two mechanisms solve this: + +1. **XCFramework Headers/**: prebuild copies headers organized by import path, + so `-I Headers` resolves `#import ` directly. + +2. **VFS overlay** (`React-VFS.yaml`): maps remaining non-standard paths — headers + that appear in multiple locations or have platform variants. Generated as a + template at prebuild time, resolved with local paths at setup time. + +## Auto-Sync Build Phase + +The generated `.xcodeproj` includes a **Sync SPM Autolinking** shell script +build phase that runs before all other phases. It keeps +`build/generated/autolinking/Package.swift` up to date without requiring manual +re-runs of `react-native spm`. + +**How it works:** + +1. Compares timestamps of staleness inputs against `build/generated/autolinking/.spm-sync-stamp`: + - `package.json` — dependency declarations + - `react-native.config.js` — `spm.modules` config + - `node_modules/` directory mtime — updated by any package manager (npm, yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups +2. If any input is newer (or stamp is missing): runs `npx react-native spm sync`, + which re-executes autolinking + package generation + VFS overlay resolution + and writes the stamp file. +3. If all inputs are fresh: exits immediately (~1ms). + +**Build phase ordering:** + +| # | Phase | +|---|-------| +| 1 | Sync SPM Autolinking (new) | +| 2 | Prepare VFS Overlay | +| 3 | Sources (compile) | +| 4 | Frameworks (link) | +| 5 | Resources (copy) | +| 6 | Build JS Bundle | + +Failures are non-fatal — the phase emits `warning:` and exits 0, so the +existing autolinking may still produce a successful build. + +## Removing / resetting + +To remove SPM entirely, use `deinit` (the inverse of `add`): + +```bash +react-native spm deinit # surgically removes everything `add` injected +pod install # then, to restore CocoaPods +``` + +To reset the regenerable build state (without un-injecting), just delete the +gitignored dirs and re-run: + +```bash +rm -rf build/xcframeworks build/generated .build +react-native spm update +``` + +Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does +not touch SPM-generated directories. The cached xcframework slot is shared +across apps; refresh it with `react-native spm update --download force`. + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `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: "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 `--product-name ` if multiple app targets). | +| Missing headers | Re-run `react-native spm` | +| "not contained in target" | Re-run setup (regenerates file-level symlinks) | +| Codegen fails | Use `--skipCodegen` to iterate on other parts | +| "SPM autolinking sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | +| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | +| Stale SPM 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` | diff --git a/packages/react-native/scripts/spm/__tests__/__fixtures__/plain-app.pbxproj b/packages/react-native/scripts/spm/__tests__/__fixtures__/plain-app.pbxproj new file mode 100644 index 000000000000..ee9fbf60dcf8 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/__fixtures__/plain-app.pbxproj @@ -0,0 +1,171 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + AA00000000000000000000A1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000B1 /* AppDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AA00000000000000000000B1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AA00000000000000000000C1 /* MyApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MyApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AA00000000000000000000D1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AA00000000000000000000E1 = { + isa = PBXGroup; + children = ( + AA00000000000000000000B1 /* AppDelegate.swift */, + AA00000000000000000000F1 /* Products */, + ); + sourceTree = ""; + }; + AA00000000000000000000F1 /* Products */ = { + isa = PBXGroup; + children = ( + AA00000000000000000000C1 /* MyApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA0000000000000000000101 /* MyApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000201 /* Build configuration list for PBXNativeTarget "MyApp" */; + buildPhases = ( + AA0000000000000000000301 /* Sources */, + AA00000000000000000000D1 /* Frameworks */, + AA0000000000000000000401 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MyApp; + productName = MyApp; + productReference = AA00000000000000000000C1 /* MyApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA0000000000000000000501 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1600; + }; + buildConfigurationList = AA0000000000000000000601 /* Build configuration list for PBXProject "MyApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AA00000000000000000000E1; + productRefGroup = AA00000000000000000000F1 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA0000000000000000000101 /* MyApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AA0000000000000000000401 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AA0000000000000000000301 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA00000000000000000000A1 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AA0000000000000000000701 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + AA0000000000000000000801 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + AA0000000000000000000901 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + AA00000000000000000000A2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AA0000000000000000000601 /* Build configuration list for PBXProject "MyApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000701 /* Debug */, + AA0000000000000000000801 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA0000000000000000000201 /* Build configuration list for PBXNativeTarget "MyApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000901 /* Debug */, + AA00000000000000000000A2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AA0000000000000000000501 /* Project object */; +} diff --git a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js new file mode 100644 index 000000000000..8928a973c78b --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js @@ -0,0 +1,756 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + exists, + extractXCFramework, + findFirst, + formatBytes, + formatSpeed, + hermesReleaseUrl, + resolveCacheSlotVersion, + resolveHermesArtifact, + resolveLatestV1Version, + resolveNightlyVersion, + resolveRNCoreArtifact, + resolveRNDepsArtifact, + resolveSnapshotUrl, + rnCoreReleaseUrl, + rnDepsReleaseUrl, + validateArtifactsCache, +} = require('../download-spm-artifacts'); +const {execSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Shared fetch router used by the URL-resolution tests below. Each key is a +// URL substring; the matched value describes the response. Anything unmatched +// returns 404 (the "release not found, fall back to snapshot" path). +function routerFetch(routes /*: {[string]: any} */) { + return jest.fn(async (url, opts) => { + for (const [key, resp] of Object.entries(routes)) { + if (String(url).includes(key)) { + return { + ok: resp.ok ?? true, + status: resp.status ?? 200, + json: async () => resp.json, + text: async () => resp.text ?? '', + }; + } + } + return { + ok: false, + status: 404, + json: async () => ({}), + text: async () => '', + }; + }); +} + +// --------------------------------------------------------------------------- +// resolveHermesArtifact — hermes uses its own version space, decoupled from +// React Native's nightly cadence. The default behavior mirrors RN's +// CocoaPods prebuild (HERMES_VERSION='latest-v1'): resolve via the +// hermes-compiler npm dist-tag instead of trying to download a hermes-ios +// artifact at the RN nightly version (which won't exist on Maven). +// --------------------------------------------------------------------------- + +describe('resolveHermesArtifact', () => { + let origFetch; + let origHermesEnv; + + beforeEach(() => { + origFetch = globalThis.fetch; + origHermesEnv = process.env.HERMES_VERSION; + delete process.env.HERMES_VERSION; + }); + + afterEach(() => { + globalThis.fetch = origFetch; + if (origHermesEnv !== undefined) { + process.env.HERMES_VERSION = origHermesEnv; + } else { + delete process.env.HERMES_VERSION; + } + }); + + // Mock fetch with a router: each entry's key is a URL substring; the value + // describes the response. Anything not matched returns 404 (mimicking the + // "release not found, try snapshot" path). + function mockFetch(routes /*: {[string]: any} */) { + globalThis.fetch = jest.fn(async url => { + for (const [key, resp] of Object.entries(routes)) { + if (String(url).includes(key)) { + return { + ok: resp.ok ?? true, + status: resp.status ?? 200, + json: async () => resp.json, + text: async () => resp.text ?? '', + }; + } + } + return { + ok: false, + status: 404, + json: async () => ({}), + text: async () => '', + }; + }); + } + + describe('default behavior (no HERMES_VERSION set)', () => { + it('resolves to the latest-v1 hermes-compiler dist-tag, NOT the RN version', async () => { + mockFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + // Pretend the release URL exists once we ask for 0.13.0. + 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + null, + ); + expect(result.version).toBe('0.13.0'); + expect(result.url).toContain('/0.13.0/'); + // The RN nightly hash MUST NOT leak into the hermes URL. + expect(result.url).not.toContain('20260519'); + }); + + it('ignores rawVersion (the RN --version arg) when HERMES_VERSION is unset', async () => { + mockFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + }); + // Caller passes the original RN --version verbatim; hermes should + // still default to latest-v1 instead of using this. + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + '0.87.0-nightly-20260519-58cd1bf58', + ); + expect(result.version).toBe('0.13.0'); + expect(result.url).not.toContain('20260519'); + }); + }); + + describe('HERMES_VERSION escape hatches', () => { + it('HERMES_VERSION= uses it verbatim', async () => { + process.env.HERMES_VERSION = '0.13.5'; + mockFetch({ + 'hermes-ios/0.13.5/hermes-ios-0.13.5': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-anything', + 'debug', + null, + ); + expect(result.version).toBe('0.13.5'); + expect(result.url).toContain('/0.13.5/'); + }); + + it('HERMES_VERSION=latest-v1 resolves via npm dist-tag', async () => { + process.env.HERMES_VERSION = 'latest-v1'; + mockFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-anything', + 'debug', + null, + ); + expect(result.version).toBe('0.13.0'); + }); + + it('HERMES_VERSION=nightly resolves hermes-compiler@nightly from npm', async () => { + process.env.HERMES_VERSION = 'nightly'; + mockFetch({ + 'hermes-compiler/nightly': {json: {version: '0.14.0-nightly-abc'}}, + 'hermes-ios/0.14.0-nightly-abc/hermes-ios-0.14.0-nightly-abc': { + ok: true, + }, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-anything', + 'debug', + null, + ); + expect(result.version).toBe('0.14.0-nightly-abc'); + }); + + it('falls back to the hermes snapshot URL when the release is missing', async () => { + process.env.HERMES_VERSION = '0.13.5'; + globalThis.fetch = jest.fn(async (url, opts) => { + if (opts && opts.method === 'HEAD') { + return {status: 404}; + } + return { + ok: true, + status: 200, + text: async () => + '20260303.000000' + + '2', + }; + }); + const result = await resolveHermesArtifact('0.87.0', 'debug', null); + expect(result.url).toContain('maven-snapshots'); + expect(result.url).toContain('hermes-ios-debug.tar.gz'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Maven URL builders — pure string composition. +// --------------------------------------------------------------------------- + +describe('release URL builders', () => { + it('rnCoreReleaseUrl points at the reactnative-core classifier on Maven Central', () => { + const url = rnCoreReleaseUrl('0.85.0', 'debug'); + expect(url).toBe( + 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.85.0/' + + 'react-native-artifacts-0.85.0-reactnative-core-debug.tar.gz', + ); + }); + + it('rnDepsReleaseUrl points at the reactnative-dependencies classifier', () => { + const url = rnDepsReleaseUrl('0.85.0', 'release'); + expect(url).toContain('react-native-artifacts/0.85.0/'); + expect(url).toContain('reactnative-dependencies-release.tar.gz'); + }); + + it('hermesReleaseUrl points at the hermes-ios coordinate', () => { + const url = hermesReleaseUrl('0.13.0', 'debug'); + expect(url).toBe( + 'https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/0.13.0/' + + 'hermes-ios-0.13.0-hermes-ios-debug.tar.gz', + ); + }); + + it('honors ENTERPRISE_REPOSITORY for the release base URL', () => { + jest.isolateModules(() => { + const prev = process.env.ENTERPRISE_REPOSITORY; + process.env.ENTERPRISE_REPOSITORY = 'https://maven.internal.example'; + try { + const mod = require('../download-spm-artifacts'); + expect(mod.rnCoreReleaseUrl('0.85.0', 'debug')).toContain( + 'https://maven.internal.example/com/facebook/react/', + ); + } finally { + if (prev !== undefined) { + process.env.ENTERPRISE_REPOSITORY = prev; + } else { + delete process.env.ENTERPRISE_REPOSITORY; + } + } + }); + }); +}); + +// --------------------------------------------------------------------------- +// formatBytes / formatSpeed — pure formatting with a 1 MB unit boundary. +// --------------------------------------------------------------------------- + +describe('formatBytes', () => { + it('renders sub-megabyte sizes in KB', () => { + expect(formatBytes(512)).toBe('0.5 KB'); + expect(formatBytes(1024)).toBe('1.0 KB'); + }); + + it('renders megabyte-and-larger sizes in MB', () => { + expect(formatBytes(1024 * 1024)).toBe('1.0 MB'); + expect(formatBytes(3 * 1024 * 1024)).toBe('3.0 MB'); + }); +}); + +describe('formatSpeed', () => { + it('renders sub-megabyte rates in KB/s (no decimals)', () => { + expect(formatSpeed(2048)).toBe('2 KB/s'); + }); + + it('renders megabyte-and-larger rates in MB/s', () => { + expect(formatSpeed(5 * 1024 * 1024)).toBe('5.0 MB/s'); + }); +}); + +// --------------------------------------------------------------------------- +// exists — HEAD probe used to choose release vs. snapshot. +// --------------------------------------------------------------------------- + +describe('exists', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + it('returns true on a 200 HEAD response', async () => { + globalThis.fetch = jest.fn(async () => ({status: 200})); + expect(await exists('https://example/x.tar.gz')).toBe(true); + expect(globalThis.fetch).toHaveBeenCalledWith('https://example/x.tar.gz', { + method: 'HEAD', + }); + }); + + it('returns false on a non-200 response', async () => { + globalThis.fetch = jest.fn(async () => ({status: 404})); + expect(await exists('https://example/missing.tar.gz')).toBe(false); + }); + + it('returns false when fetch rejects (offline)', async () => { + globalThis.fetch = jest.fn(async () => { + throw new Error('network down'); + }); + expect(await exists('https://example/x.tar.gz')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// resolveSnapshotUrl — parses maven-metadata.xml into a timestamped URL. +// --------------------------------------------------------------------------- + +describe('resolveSnapshotUrl', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + const METADATA = ` + + + 20260101.123456 + 7 + + `; + + it('builds a fully-versioned snapshot URL from timestamp + buildNumber', async () => { + globalThis.fetch = routerFetch({'maven-metadata.xml': {text: METADATA}}); + const url = await resolveSnapshotUrl( + '0.85.0', + 'react', + 'react-native-artifacts', + 'reactnative-core-debug.tar.gz', + ); + expect(url).toContain('0.85.0-SNAPSHOT/'); + expect(url).toContain( + 'react-native-artifacts-0.85.0-20260101.123456-7-reactnative-core-debug.tar.gz', + ); + }); + + it('throws when the metadata request fails', async () => { + globalThis.fetch = routerFetch({ + 'maven-metadata.xml': {ok: false, status: 500}, + }); + await expect( + resolveSnapshotUrl( + '0.85.0', + 'react', + 'react-native-artifacts', + 'x.tar.gz', + ), + ).rejects.toThrow(/Failed to fetch snapshot metadata/); + }); + + it('throws when timestamp/buildNumber are absent', async () => { + globalThis.fetch = routerFetch({ + 'maven-metadata.xml': {text: ''}, + }); + await expect( + resolveSnapshotUrl( + '0.85.0', + 'react', + 'react-native-artifacts', + 'x.tar.gz', + ), + ).rejects.toThrow(/Could not parse timestamp\/buildNumber/); + }); +}); + +// --------------------------------------------------------------------------- +// resolveNightlyVersion / resolveLatestV1Version — npm dist-tag lookups. +// --------------------------------------------------------------------------- + +describe('npm dist-tag resolvers', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + it('resolveNightlyVersion returns the version from the npm registry', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {json: {version: '0.86.0-nightly-xyz'}}, + }); + expect(await resolveNightlyVersion('react-native')).toBe( + '0.86.0-nightly-xyz', + ); + }); + + it('resolveNightlyVersion throws on a failed npm lookup', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {ok: false, status: 404}, + }); + await expect(resolveNightlyVersion('react-native')).rejects.toThrow( + /npm lookup failed/, + ); + }); + + it('resolveLatestV1Version reads hermes-compiler/latest-v1', async () => { + globalThis.fetch = routerFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + }); + expect(await resolveLatestV1Version()).toBe('0.13.0'); + }); + + it('resolveLatestV1Version throws on a failed lookup', async () => { + globalThis.fetch = routerFetch({ + 'hermes-compiler/latest-v1': {ok: false, status: 500}, + }); + await expect(resolveLatestV1Version()).rejects.toThrow(/npm lookup failed/); + }); +}); + +// --------------------------------------------------------------------------- +// resolveRNCoreArtifact / resolveRNDepsArtifact — release-then-snapshot. +// --------------------------------------------------------------------------- + +describe('resolveRNCoreArtifact', () => { + let origFetch; + let tempDir; + beforeEach(() => { + origFetch = globalThis.fetch; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-core-')); + }); + afterEach(() => { + globalThis.fetch = origFetch; + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('uses the stable release URL when it exists', async () => { + globalThis.fetch = routerFetch({ + 'reactnative-core-debug.tar.gz': {status: 200}, + }); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', null); + expect(result.version).toBe('0.85.0'); + expect(result.url).toContain('repo1.maven.org'); + expect(result.url).toContain('reactnative-core-debug.tar.gz'); + }); + + it('falls back to the snapshot URL when the release is missing', async () => { + globalThis.fetch = jest.fn(async (url, opts) => { + // HEAD probe of the release URL → 404. + if (opts && opts.method === 'HEAD') { + return {status: 404}; + } + // GET of maven-metadata.xml → snapshot coordinates. + return { + ok: true, + status: 200, + text: async () => + '20260101.000000' + + '1', + }; + }); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', null); + expect(result.url).toContain('maven-snapshots'); + expect(result.url).toContain('20260101.000000-1'); + }); + + it('uses a local tarball override when the file exists', async () => { + const tarball = path.join(tempDir, 'core.tar.gz'); + fs.writeFileSync(tarball, 'x'); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', tarball); + expect(result.url).toBe(tarball); + expect(result.version).toBe('0.85.0-local'); + }); + + it('throws when the local tarball override is missing', async () => { + await expect( + resolveRNCoreArtifact( + '0.85.0', + 'debug', + path.join(tempDir, 'nope.tar.gz'), + ), + ).rejects.toThrow(/does not exist/); + }); +}); + +describe('resolveRNDepsArtifact', () => { + let origFetch; + let origDepEnv; + beforeEach(() => { + origFetch = globalThis.fetch; + origDepEnv = process.env.RN_DEP_VERSION; + delete process.env.RN_DEP_VERSION; + }); + afterEach(() => { + globalThis.fetch = origFetch; + if (origDepEnv !== undefined) { + process.env.RN_DEP_VERSION = origDepEnv; + } else { + delete process.env.RN_DEP_VERSION; + } + }); + + it('uses the RN version by default', async () => { + globalThis.fetch = routerFetch({ + 'reactnative-dependencies-debug.tar.gz': {status: 200}, + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.version).toBe('0.85.0'); + expect(result.url).toContain('react-native-artifacts/0.85.0/'); + }); + + it('honors RN_DEP_VERSION override', async () => { + process.env.RN_DEP_VERSION = '0.84.2'; + globalThis.fetch = routerFetch({ + 'reactnative-dependencies-debug.tar.gz': {status: 200}, + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.version).toBe('0.84.2'); + expect(result.url).toContain('react-native-artifacts/0.84.2/'); + }); + + it('resolves RN_DEP_VERSION=nightly via the npm registry', async () => { + process.env.RN_DEP_VERSION = 'nightly'; + globalThis.fetch = routerFetch({ + 'react-native/nightly': {json: {version: '0.86.0-nightly-dep'}}, + 'react-native-artifacts/0.86.0-nightly-dep/': {status: 200}, + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.version).toBe('0.86.0-nightly-dep'); + }); + + it('falls back to the deps snapshot URL when the release is missing', async () => { + globalThis.fetch = jest.fn(async (url, opts) => { + if (opts && opts.method === 'HEAD') { + return {status: 404}; + } + return { + ok: true, + status: 200, + text: async () => + '20260202.000000' + + '3', + }; + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.url).toContain('maven-snapshots'); + expect(result.url).toContain('reactnative-dependencies-debug.tar.gz'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveCacheSlotVersion — stable label passthrough vs. nightly resolution. +// --------------------------------------------------------------------------- + +describe('resolveCacheSlotVersion', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + it('returns a stable version label unchanged (no npm lookup)', async () => { + globalThis.fetch = jest.fn(async () => { + throw new Error('should not be called'); + }); + expect(await resolveCacheSlotVersion('0.85.0')).toBe('0.85.0'); + }); + + it('resolves the 1000.0.0 dev label to the current nightly slot', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {json: {version: '0.86.0-nightly-zzz'}}, + }); + expect(await resolveCacheSlotVersion('1000.0.0')).toBe( + '0.86.0-nightly-zzz', + ); + }); + + it('falls back to the raw label when the nightly lookup fails', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {ok: false, status: 503}, + }); + expect(await resolveCacheSlotVersion('nightly')).toBe('nightly'); + }); +}); + +// --------------------------------------------------------------------------- +// findFirst — bounded recursive search. +// --------------------------------------------------------------------------- + +describe('findFirst', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-findfirst-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('finds a nested entry matching the predicate', () => { + const nested = path.join(tempDir, 'a', 'b'); + fs.mkdirSync(nested, {recursive: true}); + fs.writeFileSync(path.join(nested, 'Target.xcframework'), ''); + const hit = findFirst(tempDir, n => n.endsWith('.xcframework'), 8); + expect(hit).toBe(path.join(nested, 'Target.xcframework')); + }); + + it('returns null when depth is exhausted before the match', () => { + const nested = path.join(tempDir, 'a', 'b', 'c'); + fs.mkdirSync(nested, {recursive: true}); + fs.writeFileSync(path.join(nested, 'Target.xcframework'), ''); + // depth 1 only inspects the immediate children of tempDir. + expect(findFirst(tempDir, n => n.endsWith('.xcframework'), 1)).toBeNull(); + }); + + it('returns null for a nonexistent directory', () => { + expect(findFirst(path.join(tempDir, 'ghost'), () => true, 4)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// extractXCFramework — untar + locate the .xcframework dir. +// --------------------------------------------------------------------------- + +describe('extractXCFramework', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-extract-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('extracts a tarball and returns the contained .xcframework path', () => { + // Build a tarball whose payload contains a Foo.xcframework directory. + const payload = path.join(tempDir, 'payload'); + const xcfw = path.join(payload, 'Foo.xcframework'); + fs.mkdirSync(xcfw, {recursive: true}); + fs.writeFileSync(path.join(xcfw, 'Info.plist'), ''); + const tarPath = path.join(tempDir, 'foo.tar.gz'); + execSync(`tar -czf "${tarPath}" -C "${payload}" Foo.xcframework`); + + const extractDir = path.join(tempDir, 'out'); + const found = extractXCFramework(tarPath, extractDir); + expect(found).toBe(path.join(extractDir, 'Foo.xcframework')); + expect(fs.existsSync(path.join(found, 'Info.plist'))).toBe(true); + }); + + it('throws when the tarball contains no .xcframework', () => { + const payload = path.join(tempDir, 'payload'); + fs.mkdirSync(payload, {recursive: true}); + fs.writeFileSync(path.join(payload, 'readme.txt'), 'hi'); + const tarPath = path.join(tempDir, 'plain.tar.gz'); + execSync(`tar -czf "${tarPath}" -C "${payload}" readme.txt`); + + expect(() => + extractXCFramework(tarPath, path.join(tempDir, 'out')), + ).toThrow(/No .xcframework found/); + }); +}); + +// --------------------------------------------------------------------------- +// validateArtifactsCache — guards against stale / partial cache slots. +// --------------------------------------------------------------------------- + +describe('validateArtifactsCache', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-validate-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + // Writes artifacts.json plus the on-disk xcframework dir for each entry. + function seedCache(entries /*: {[string]: boolean} */) { + const json = {}; + for (const [name, onDisk] of Object.entries(entries)) { + const xcfwPath = path.join(tempDir, `${name}.xcframework`); + if (onDisk) { + fs.mkdirSync(xcfwPath, {recursive: true}); + } + json[name] = {xcframeworkPath: xcfwPath, url: 'https://example'}; + } + fs.writeFileSync( + path.join(tempDir, 'artifacts.json'), + JSON.stringify(json), + 'utf8', + ); + } + + // Stages the hermes public headers that validateArtifactsCache also requires. + function seedHermesHeaders() { + fs.mkdirSync(path.join(tempDir, 'hermes-headers', 'hermes'), { + recursive: true, + }); + } + + it('returns null when the cache is complete and on disk', () => { + seedCache({ + React: true, + ReactNativeDependencies: true, + 'hermes-engine': true, + }); + seedHermesHeaders(); + expect(validateArtifactsCache(tempDir)).toBeNull(); + }); + + it('reports unstaged Hermes public headers', () => { + seedCache({ + React: true, + ReactNativeDependencies: true, + 'hermes-engine': true, + }); + // No hermes-headers/hermes dir. + expect(validateArtifactsCache(tempDir)).toMatch( + /Hermes public headers not staged/, + ); + }); + + it('reports a missing artifacts.json', () => { + expect(validateArtifactsCache(tempDir)).toMatch(/artifacts.json missing/); + }); + + it('reports unreadable JSON', () => { + fs.writeFileSync(path.join(tempDir, 'artifacts.json'), '{not json', 'utf8'); + expect(validateArtifactsCache(tempDir)).toMatch(/unreadable/); + }); + + it('reports a missing required entry', () => { + seedCache({React: true, ReactNativeDependencies: true}); + expect(validateArtifactsCache(tempDir)).toMatch( + /missing entry for "hermes-engine"/, + ); + }); + + it('reports an entry whose xcframework dir is gone', () => { + seedCache({ + React: true, + ReactNativeDependencies: true, + 'hermes-engine': false, + }); + expect(validateArtifactsCache(tempDir)).toMatch( + /xcframework for "hermes-engine" not found/, + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js new file mode 100644 index 000000000000..ca52588a0f10 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js @@ -0,0 +1,375 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +/** + * Red tests for the transitive dependency expander. + * + * Library authors declare transitive native deps in their react-native.config.js: + * + * module.exports = { + * dependency: { platforms: { ios: {} } }, + * spm: { dependencies: ['react-native-test-library-common'] }, + * }; + * + * The expander takes the directly-autolinked deps (from autolinking.json) and + * follows each one's spm.dependencies recursively, resolving names to package + * roots via Node module resolution. Behavior mirrors podspec `s.dependency`: + * + * - Transitive deps with iOS native code → added as autolinked targets + * - Transitive deps without iOS native code → silently skipped + * - Deduped by package name (first occurrence wins) + * - Cycles are detected (visited set keyed on name) + * - Unresolvable names throw with a clear message + * + * I/O is injected (readConfig, resolveDep) so the tests stay pure. + */ + +const { + expandSpmDependencies, + resolveSwiftName, +} = require('../expand-spm-dependencies'); +const {toSwiftName} = require('../spm-utils'); + +function makeReadConfig(configs /*: {[string]: ?Object} */) { + return (root /*: string */) => + Object.prototype.hasOwnProperty.call(configs, root) ? configs[root] : null; +} + +function makeResolveDep(resolutions /*: {[string]: ?string} */) { + return (name /*: string */) => + Object.prototype.hasOwnProperty.call(resolutions, name) + ? resolutions[name] + : null; +} + +// --------------------------------------------------------------------------- +// expandSpmDependencies +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies', () => { + it('returns direct deps with auto-derived swiftName when none declare spm.dependencies', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {}}), + resolveDep: makeResolveDep({}), + }); + expect(result).toEqual([ + {...direct[0], swiftName: toSwiftName('a'), spmDependencies: []}, + ]); + }); + + it('pulls in one transitive dep declared by a direct dep', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common']}}, + '/common': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({common: '/common'}), + }); + expect(result.map(d => d.name)).toEqual(['apple', 'common']); + expect(result[1].root).toBe('/common'); + expect(result[1].platforms.ios).toBeDefined(); + }); + + it('recurses through a chain (A → B → C)', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['b']}}, + '/b': { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['c']}, + }, + '/c': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({b: '/b', c: '/c'}), + }); + expect(result.map(d => d.name)).toEqual(['a', 'b', 'c']); + }); + + it('handles cycles without infinite recursion (A → B → A)', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['b']}, + }, + '/b': { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['a']}, + }, + }), + resolveDep: makeResolveDep({a: '/a', b: '/b'}), + }); + expect(result.map(d => d.name).sort()).toEqual(['a', 'b']); + }); + + it('dedups a diamond (A → X, B → X) — X appears exactly once', () => { + const direct = [ + {name: 'a', root: '/a', platforms: {ios: {}}}, + {name: 'b', root: '/b', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['x']}}, + '/b': {spm: {dependencies: ['x']}}, + '/x': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({x: '/x'}), + }); + expect(result.filter(d => d.name === 'x')).toHaveLength(1); + expect(result.map(d => d.name).sort()).toEqual(['a', 'b', 'x']); + }); + + it('throws with a clear message when a declared transitive cannot be resolved', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['ghost']}}, + }), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/ghost.*apple|apple.*ghost/i); + }); + + it('silently skips transitives that have no iOS native code (matches autolinkingDepToSpmTarget behavior)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['js-only']}}, + // js-only has no dependency.platforms.ios — pure JS package + '/js-only': {}, + }), + resolveDep: makeResolveDep({'js-only': '/js-only'}), + }); + expect(result.map(d => d.name)).toEqual(['apple']); + }); + + it('does not re-add a transitive that is already a direct dep (first occurrence wins)', () => { + const direct = [ + {name: 'apple', root: '/apple', platforms: {ios: {}}}, + {name: 'common', root: '/common-direct', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common']}}, + '/common-other': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({common: '/common-other'}), + }); + expect(result.filter(d => d.name === 'common')).toHaveLength(1); + // The direct-dep entry should be preserved, not overwritten by the transitive + expect(result.find(d => d.name === 'common').root).toBe('/common-direct'); + }); + + // ------------------------------------------------------------------------- + // spmDependencies field: each entry should carry the names of its iOS-native + // transitive deps, so the downstream emitter can wire SPM target-level deps + // (e.g. apple's .target(dependencies: [.target(name: "...Common")])). + // ------------------------------------------------------------------------- + + it('attaches spmDependencies: [] when the dep declares none', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const [a] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {}}), + resolveDep: makeResolveDep({}), + }); + expect(a.spmDependencies).toEqual([]); + }); + + it('attaches spmDependencies with the declared transitive names (preserving declaration order)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const [apple, common] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common', 'extra']}}, + '/common': {dependency: {platforms: {ios: {}}}}, + '/extra': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({common: '/common', extra: '/extra'}), + }); + expect(apple.spmDependencies).toEqual(['common', 'extra']); + expect(common.spmDependencies).toEqual([]); + }); + + it('omits JS-only transitives from spmDependencies (only iOS-native names appear)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const [apple] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['js-only', 'common']}}, + '/js-only': {}, + '/common': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'js-only': '/js-only', common: '/common'}), + }); + expect(apple.spmDependencies).toEqual(['common']); + }); + + it('records spmDependencies on both sides of a diamond (A→X, B→X)', () => { + const direct = [ + {name: 'a', root: '/a', platforms: {ios: {}}}, + {name: 'b', root: '/b', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['x']}}, + '/b': {spm: {dependencies: ['x']}}, + '/x': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({x: '/x'}), + }); + const a = result.find(d => d.name === 'a'); + const b = result.find(d => d.name === 'b'); + expect(a.spmDependencies).toEqual(['x']); + expect(b.spmDependencies).toEqual(['x']); + }); + + it('passes the declaring dep root as the second argument to resolveDep (for Node resolution paths)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + let receivedFromRoot /*: ?string */ = null; + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common']}}, + '/common': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: (name, fromRoot) => { + if (name === 'common') { + receivedFromRoot = fromRoot; + return '/common'; + } + return null; + }, + }); + expect(receivedFromRoot).toBe('/apple'); + }); + + // ------------------------------------------------------------------------- + // swiftName resolution: each dep gets a Swift target name on the way out. + // Default is toSwiftName(npmName); the dep's react-native.config.js + // `spm.name` overrides it. Required for libraries whose import prefix + // differs from the auto-derived name (e.g. `react-native-worklets` + // publishes headers under ``). + // ------------------------------------------------------------------------- + + it('populates swiftName via toSwiftName when no spm.name override is set', () => { + const direct = [ + {name: 'react-native-foo', root: '/foo', platforms: {ios: {}}}, + ]; + const [foo] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/foo': {}}), + resolveDep: makeResolveDep({}), + }); + expect(foo.swiftName).toBe(toSwiftName('react-native-foo')); + expect(foo.swiftName).toBe('ReactNativeFoo'); + }); + + it('uses spm.name as swiftName when the direct dep declares one', () => { + const direct = [ + {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, + ]; + const [w] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/w': {spm: {name: 'worklets'}}}), + resolveDep: makeResolveDep({}), + }); + expect(w.swiftName).toBe('worklets'); + }); + + it('applies spm.name override to transitive deps too', () => { + const direct = [ + {name: 'react-native-reanimated', root: '/r', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/r': { + dependency: {platforms: {ios: {}}}, + spm: {name: 'reanimated', dependencies: ['react-native-worklets']}, + }, + '/w': { + dependency: {platforms: {ios: {}}}, + spm: {name: 'worklets'}, + }, + }), + resolveDep: makeResolveDep({'react-native-worklets': '/w'}), + }); + const reanimated = result.find(d => d.name === 'react-native-reanimated'); + const worklets = result.find(d => d.name === 'react-native-worklets'); + expect(reanimated.swiftName).toBe('reanimated'); + expect(worklets.swiftName).toBe('worklets'); + }); + + it('throws on swiftName collision between two deps (override vs auto-derived)', () => { + // 'react-native-worklets' would auto-derive to 'ReactNativeWorklets', but + // here a second dep overrides its spm.name to that same value. + const direct = [ + {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, + {name: 'other-package', root: '/o', platforms: {ios: {}}}, + ]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/w': {}, + '/o': {spm: {name: 'ReactNativeWorklets'}}, + }), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/ReactNativeWorklets/); + }); + + it('rejects empty-string spm.name with a clear error citing the npm name', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {spm: {name: ''}}}), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/'a' has an invalid 'spm.name'/); + }); + + it('rejects non-string spm.name (e.g. number, object) with a clear error', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {spm: {name: 42}}}), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/invalid 'spm.name'/); + }); + + it('rejects spm.name with disallowed characters (spaces, slashes, dots)', () => { + expect(() => resolveSwiftName('a', {spm: {name: 'foo bar'}})).toThrow( + /invalid 'spm.name'/, + ); + expect(() => resolveSwiftName('a', {spm: {name: 'foo/bar'}})).toThrow( + /invalid 'spm.name'/, + ); + expect(() => resolveSwiftName('a', {spm: {name: 'foo.bar'}})).toThrow( + /invalid 'spm.name'/, + ); + }); + + it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { + expect(resolveSwiftName('a', {spm: {name: 'reanimated'}})).toBe( + 'reanimated', + ); + expect(resolveSwiftName('a', {spm: {name: 'hermes-engine'}})).toBe( + 'hermes-engine', + ); + expect(resolveSwiftName('a', {spm: {name: 'RNWorklets'}})).toBe( + 'RNWorklets', + ); + expect(resolveSwiftName('a', {spm: {name: 'react_native_foo'}})).toBe( + 'react_native_foo', + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js new file mode 100644 index 000000000000..c5133367a1a9 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js @@ -0,0 +1,246 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +/** + * Red tests for the SPM port of packages/react-native/scripts/cocoapods/autolinking.rb. + * + * The Ruby script (list_native_modules!) does the following: + * 1. Accepts a config_command (default-documented as ['npx', '@react-native-community/cli', 'config']) + * 2. Warns if config_command is empty / invalid (autolinking.rb:19-26) + * 3. Captures stdout + exit status of the command (autolinking.rb:28) + * 4. Warns if exit status is non-zero (autolinking.rb:30-37) + * 5. Parses the JSON output (autolinking.rb:38) + * 6. Derives output path = /build/generated/autolinking/autolinking.json (autolinking.rb:41-43) + * 7. Creates the output directory if missing (autolinking.rb:46) + * 8. Writes the RAW JSON string unchanged to that path (autolinking.rb:47) + * + * These tests assert the same surface for the JS port (generate-spm-autolinking-config.js), + * which does not yet exist — so they are red by construction. + */ + +const { + generateAutolinkingConfig, + resolveDefaultConfigCommand, +} = require('../generate-spm-autolinking-config'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function makeTmpProject() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-autolink-config-')); + const projectRoot = path.join(tmp, 'project'); + const iosDir = path.join(projectRoot, 'ios'); + fs.mkdirSync(iosDir, {recursive: true}); + return {tmp, projectRoot, iosDir}; +} + +function fakeCliConfig(iosSourceDir) { + return { + root: path.dirname(iosSourceDir), + reactNativePath: '../react-native', + project: { + ios: {sourceDir: iosSourceDir}, + android: {sourceDir: path.join(path.dirname(iosSourceDir), 'android')}, + }, + dependencies: { + 'react-native-test-library-apple': { + name: 'react-native-test-library-apple', + root: '/somewhere/react-native-test-library/apple', + platforms: { + ios: { + podspecPath: + '/somewhere/react-native-test-library/apple/TestLibraryApple.podspec', + configurations: [], + scriptPhases: [], + version: '0.87.0-main', + }, + android: null, + }, + }, + }, + }; +} + +function autolinkingJsonPath(iosSourceDir) { + return path.join( + iosSourceDir, + 'build', + 'generated', + 'autolinking', + 'autolinking.json', + ); +} + +// --------------------------------------------------------------------------- +// generateAutolinkingConfig +// --------------------------------------------------------------------------- + +describe('generateAutolinkingConfig', () => { + it('writes the CLI config JSON to /build/generated/autolinking/autolinking.json (autolinking.rb:41-47)', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + const outPath = autolinkingJsonPath(iosDir); + expect(fs.existsSync(outPath)).toBe(true); + expect(fs.readFileSync(outPath, 'utf8')).toBe(raw); + }); + + it('writes the raw JSON unchanged — no filtering or reshaping of the upstream config (autolinking.rb:47)', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const cfg = fakeCliConfig(iosDir); + const raw = JSON.stringify(cfg); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + const parsed = JSON.parse( + fs.readFileSync(autolinkingJsonPath(iosDir), 'utf8'), + ); + // iOS dep preserved + expect( + parsed.dependencies['react-native-test-library-apple'].platforms.ios + .podspecPath, + ).toBe( + cfg.dependencies['react-native-test-library-apple'].platforms.ios + .podspecPath, + ); + // Android section preserved (downstream consumer does its own iOS-only filtering) + expect(parsed.project.android).toBeDefined(); + expect( + parsed.dependencies['react-native-test-library-apple'].platforms.android, + ).toBeNull(); + }); + + it('creates the autolinking output directory if missing (autolinking.rb:46)', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + + expect(fs.existsSync(path.join(iosDir, 'build'))).toBe(false); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + expect( + fs.existsSync(path.join(iosDir, 'build', 'generated', 'autolinking')), + ).toBe(true); + }); + + it('uses a no-install npx fallback when the local CLI cannot be resolved', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand /*: ?Array */ = null; + + generateAutolinkingConfig({ + projectRoot, + cliRunner: cmd => { + receivedCommand = cmd; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual([ + 'npx', + '--no-install', + '@react-native-community/cli', + 'config', + ]); + }); + + it('prefers the locally resolved React Native CLI over npx', () => { + const {projectRoot} = makeTmpProject(); + const cliRoot = path.join( + projectRoot, + 'node_modules', + '@react-native-community', + 'cli', + ); + fs.mkdirSync(path.join(cliRoot, 'build'), {recursive: true}); + fs.writeFileSync( + path.join(cliRoot, 'package.json'), + JSON.stringify({bin: {'rnc-cli': 'build/bin.js'}}), + ); + fs.writeFileSync(path.join(cliRoot, 'build', 'bin.js'), ''); + + expect(resolveDefaultConfigCommand(projectRoot)).toEqual([ + process.execPath, + path.join(fs.realpathSync(cliRoot), 'build', 'bin.js'), + 'config', + ]); + }); + + it('passes projectRoot as the CWD to the cliRunner', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCwd /*: ?string */ = null; + + generateAutolinkingConfig({ + projectRoot, + cliRunner: (_cmd, opts) => { + receivedCwd = opts && opts.cwd; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCwd).toBe(projectRoot); + }); + + it('throws when configCommand is empty (autolinking.rb:19-26)', () => { + const {projectRoot} = makeTmpProject(); + expect(() => + generateAutolinkingConfig({ + projectRoot, + configCommand: [], + cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}), + }), + ).toThrow(/config command/i); + }); + + it('throws when the CLI exits with a non-zero status (autolinking.rb:30-37)', () => { + const {projectRoot} = makeTmpProject(); + expect(() => + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({ + stdout: '', + stderr: 'cli failed', + exitCode: 1, + }), + }), + ).toThrow(/exit|status|1/i); + }); + + it('returns the parsed config, raw JSON, and path it wrote to', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const cfg = fakeCliConfig(iosDir); + const raw = JSON.stringify(cfg); + + const returned = generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + expect(returned).toEqual({ + config: cfg, + outputPath: autolinkingJsonPath(iosDir), + rawJson: raw, + }); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js new file mode 100644 index 000000000000..72e4846a5837 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -0,0 +1,930 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + AUTOGEN_MARKER, + MissingManifestError, + collectSpmSources, + expandSpmSourceGlobs, + findSelfManagedPackageDir, + generateAutolinkedPackageSwift, + generateSynthPackageSwift, + hasMixedLanguageSources, + hasPodspec, + linkHeaderTree, + reportMissingManifests, +} = require('../generate-spm-autolinking'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// generateAutolinkedPackageSwift — top-level aggregator +// +// Post-refactor shape: autolinked/Package.swift is a thin meta-package that +// references each autolinked dep as its own sub-package via .package(path:), +// and re-exports them via a single AutolinkedAggregate target. Per-dep +// settings (cFlags, cxxFlags, header paths, link order) live in each synth +// sub-package — see generateSynthPackageSwift below. +// --------------------------------------------------------------------------- + +describe('generateAutolinkedPackageSwift (aggregator)', () => { + it('emits a valid swift-tools-version 6.0 package with an Autolinked product backed by AutolinkedAggregate', () => { + const result = generateAutolinkedPackageSwift({}); + expect(result).toContain('// swift-tools-version: 6.0'); + expect(result).toContain('import PackageDescription'); + expect(result).toContain( + '.library(name: "Autolinked", targets: ["AutolinkedAggregate"])', + ); + expect(result).toContain('name: "AutolinkedAggregate"'); + }); + + it('references each npm dep as .package(path: "packages/") and depends on its product', () => { + const result = generateAutolinkedPackageSwift({ + npmDeps: [{swiftName: 'A'}, {swiftName: 'B'}], + }); + expect(result).toContain('.package(name: "A", path: "packages/A")'); + expect(result).toContain('.package(name: "B", path: "packages/B")'); + expect(result).toContain('.product(name: "A", package: "A")'); + expect(result).toContain('.product(name: "B", package: "B")'); + }); + + it('emits an eval-time missing-manifest guard naming each lib by npm name', () => { + const result = generateAutolinkedPackageSwift({ + npmDeps: [ + { + swiftName: 'ReactNativeSafeAreaContext', + packagePath: 'libs/ReactNativeSafeAreaContext', + npmName: 'react-native-safe-area-context', + }, + ], + }); + // The guard runs at resolution (manifest eval) — before the Xcode sync + // build phase — so a wiped library manifest surfaces an actionable message + // instead of SwiftPM's opaque "manifest cannot be accessed". + expect(result).toContain('let __rnAutolinkedLibs'); + expect(result).toContain( + '(path: "libs/ReactNativeSafeAreaContext", npm: "react-native-safe-area-context")', + ); + expect(result).toContain('FileManager.default.fileExists'); + expect(result).toContain('npx react-native spm scaffold'); + expect(result).toContain('npx patch-package'); + expect(result).toContain('fatalError('); + // The guard reads its own location to resolve lib paths. + expect(result).toContain('#filePath'); + }); + + it('omits the guard entirely when there are no npm deps', () => { + const result = generateAutolinkedPackageSwift({}); + expect(result).not.toContain('__rnAutolinkedLibs'); + }); + + it('emits inline .target() blocks for each inlineTarget alongside AutolinkedAggregate', () => { + const result = generateAutolinkedPackageSwift({ + inlineTargets: [ + { + name: 'ScreenshotManager', + path: 'sources/ScreenshotManager', + exclude: [], + publicHeadersPath: '.', + }, + ], + xcframeworksRelPath: '../build/xcframeworks', + hasReactDep: true, + hasXcfwHeaders: true, + hasDepsHeaders: true, + }); + // ReactNative dep is needed because inline targets reference it + expect(result).toContain( + '.package(name: "ReactNative", path: "../build/xcframeworks")', + ); + // Aggregator depends on inline targets via .target(name: ...) + expect(result).toContain('.target(name: "ScreenshotManager")'); + // Inline target's own declaration appears in the targets array + expect(result).toMatch( + /name: "ScreenshotManager",[\s\S]*?path: "sources\/ScreenshotManager"/, + ); + // Inline target depends on the ReactNative product + expect(result).toMatch( + /name: "ScreenshotManager",[\s\S]*?\.product\(name: "ReactNative", package: "ReactNative"\)/, + ); + // Inline targets resolve headers via product deps — no -I flags, no VFS. + expect(result).toContain( + '.product(name: "ReactNativeHeaders", package: "ReactNative")', + ); + expect(result).toContain( + '.product(name: "ReactAppHeaders", package: "React-GeneratedCode")', + ); + expect(result).not.toContain('rnCoreHeaders'); + expect(result).not.toContain('-ivfsoverlay'); + expect(result).toContain('.linkedFramework("CoreGraphics")'); + }); + + it('mixes npm sub-package deps and inline targets in a single aggregator', () => { + const result = generateAutolinkedPackageSwift({ + npmDeps: [{swiftName: 'NpmA'}], + inlineTargets: [ + { + name: 'LocalA', + path: 'sources/LocalA', + exclude: [], + publicHeadersPath: '.', + }, + ], + xcframeworksRelPath: '../build/xcframeworks', + hasReactDep: true, + hasXcfwHeaders: true, + }); + // Both forms of dep on AutolinkedAggregate + expect(result).toContain('.product(name: "NpmA", package: "NpmA")'); + expect(result).toContain('.target(name: "LocalA")'); + }); + + it('emits a stub aggregator when neither npmDeps nor inlineTargets are provided', () => { + const result = generateAutolinkedPackageSwift({}); + expect(result).toContain('name: "AutolinkedAggregate"'); + expect(result).not.toContain('.package(name:'); + }); +}); + +// --------------------------------------------------------------------------- +// generateSynthPackageSwift — per-dep synthesized Package.swift +// +// Each autolinked dep gets its own SPM package, written under +// autolinked/packages//Package.swift. Sources are mirrored into +// /Sources// so SPM's path-containment check passes. +// +// The synth Package.swift embeds the dep's settings (cFlags, cxxFlags, header +// paths) and declares cross-package dependencies for its transitive +// spmDependencies as sibling synth packages at path "../". +// --------------------------------------------------------------------------- + +describe('generateSynthPackageSwift', () => { + function baseSpec(overrides /*: ?Object */) { + return { + swiftName: 'MyDep', + exclude: [], + publicHeadersPath: '.', + spmDependencies: [], + hasReactDep: true, + hasXcfwHeaders: true, + hasDepsHeaders: false, + codegenHeadersIncluded: false, + ...overrides, + }; + } + + it('emits a valid Package with name/product matching swiftName', () => { + const result = generateSynthPackageSwift(baseSpec()); + expect(result).toContain('// swift-tools-version: 6.0'); + expect(result).toContain('name: "MyDep"'); + expect(result).toContain('.library('); + expect(result).toContain('name: "MyDep"'); + expect(result).toContain('targets: ["MyDep"]'); + // Target path points at the mirrored sources sub-dir + expect(result).toContain('path: "Sources/MyDep"'); + }); + + it('declares the library product as type: .dynamic so SPM framework-wraps it (enables includes)', () => { + const result = generateSynthPackageSwift(baseSpec()); + expect(result).toContain( + '.library(name: "MyDep", type: .dynamic, targets: ["MyDep"])', + ); + }); + + it('depends on ReactNative via a fixed relative path (default synth depth)', () => { + const result = generateSynthPackageSwift(baseSpec({hasReactDep: true})); + expect(result).toContain( + '.package(name: "ReactNative", path: "../../../../xcframeworks")', + ); + expect(result).toContain( + '.package(name: "React-GeneratedCode", path: "../../../ios")', + ); + expect(result).toContain( + '.product(name: "ReactNative", package: "ReactNative")', + ); + // Fully declarative — no runtime discovery, no Foundation import. + expect(result).not.toContain('import Foundation'); + expect(result).not.toContain('spm-paths.json'); + expect(result).not.toContain('#filePath'); + }); + + it('honors caller-supplied reactNativePackagePath / codegenPackagePath', () => { + const result = generateSynthPackageSwift( + baseSpec({ + hasReactDep: true, + reactNativePackagePath: '../../rel/xcframeworks', + codegenPackagePath: '../../rel/ios', + }), + ); + expect(result).toContain( + '.package(name: "ReactNative", path: "../../rel/xcframeworks")', + ); + expect(result).toContain( + '.package(name: "React-GeneratedCode", path: "../../rel/ios")', + ); + }); + + it('declares sibling synth packages at path "../" for each spmDependencies entry', () => { + const result = generateSynthPackageSwift( + baseSpec({spmDependencies: [{swiftName: 'CommonDep'}]}), + ); + expect(result).toContain( + '.package(name: "CommonDep", path: "../CommonDep")', + ); + expect(result).toContain( + '.product(name: "CommonDep", package: "CommonDep")', + ); + }); + + it('serves React headers via product deps (binaryTargets + ReactAppHeaders)', () => { + const result = generateSynthPackageSwift(baseSpec({hasReactDep: true})); + expect(result).toContain( + '.product(name: "ReactNativeHeaders", package: "ReactNative")', + ); + expect(result).toContain( + '.product(name: "ReactAppHeaders", package: "React-GeneratedCode")', + ); + // No header-search-path vars, no flags, no legacy VFS machinery. + expect(result).not.toContain('rnCoreHeaders'); + expect(result).not.toContain('unsafeFlags(["-I"'); + expect(result).not.toContain('ReactHeadersAll'); + expect(result).not.toContain('-ivfsoverlay'); + expect(result).not.toContain('let xcfwHeaders'); + expect(result).not.toContain('let vfsOverlay'); + expect(result).not.toContain('let depsHeaders'); + }); + + it('emits exclude list when given', () => { + const result = generateSynthPackageSwift( + baseSpec({exclude: ['tests/', 'broken.m']}), + ); + expect(result).toContain('exclude: ["tests/", "broken.m"]'); + }); + + it('omits publicHeadersPath when null (not all targets expose headers)', () => { + const result = generateSynthPackageSwift( + baseSpec({publicHeadersPath: null}), + ); + expect(result).not.toContain('publicHeadersPath:'); + }); + + it('links UIKit and Foundation frameworks by default', () => { + const result = generateSynthPackageSwift(baseSpec()); + expect(result).toContain('.linkedFramework("UIKit")'); + expect(result).toContain('.linkedFramework("Foundation")'); + }); + + // ------------------------------------------------------------------------- + // In-place mode: synth Package.swift lives in the dep's real source dir + // (target.path = ".") with an absolute appRoot. Used by the production + // emitter so Xcode can save source files normally (atomic-save through a + // symlink in autolinked/ fails with NSFileNoSuchFileError). + // ------------------------------------------------------------------------- + + it('emits no runtime discovery — fully declarative manifest', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + publicHeadersPath: 'include', + hasReactDep: true, + targetPath: '.', + }); + expect(result).not.toContain('rnSpmPaths'); + expect(result).not.toContain('spm-paths.json'); + expect(result).not.toContain('import Foundation'); + expect(result).toContain('path: "."'); + }); + + it('in-place mode: ReactNative dep path uses the default fixed relative path', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + targetPath: '.', + }); + expect(result).toContain( + '.package(name: "ReactNative", path: "../../../../xcframeworks")', + ); + }); + + it('in-place mode: sibling synth refs use absolute paths from siblingSynthAbsolutePaths', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + targetPath: '.', + spmDependencies: [{swiftName: 'CommonDep'}], + siblingSynthAbsolutePaths: {CommonDep: '/abs/path/to/common'}, + }); + expect(result).toContain( + '.package(name: "CommonDep", path: "/abs/path/to/common")', + ); + expect(result).toContain( + '.product(name: "CommonDep", package: "CommonDep")', + ); + }); + + it('falls back to a relative sibling path when no absolute synth path is provided', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + targetPath: '.', + spmDependencies: [{swiftName: 'Missing'}], + siblingSynthAbsolutePaths: {}, + }); + expect(result).toContain('.package(name: "Missing", path: "../Missing")'); + }); + + it('wrapper-dir mode: target.path = "root" (a dir symlink) so Xcode atomic-save works on real files', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app/root', + autogenHeadersAbsolute: + '/abs/app/root/build/generated/autolinking/headers', + }); + expect(result).toContain('path: "root"'); + }); + + it('wrapper-dir mode: routes all includes through the single merged tree (autolinking headers folded in)', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/build/generated/autolinking/headers', + }); + // The autolinking headers dir is folded into the per-app farm (served by + // the ReactAppHeaders product) — never a separate -I. + expect(result).not.toContain( + '"-I", "/abs/app/build/generated/autolinking/headers"', + ); + }); + + it('wrapper-dir mode: omits publicHeadersPath (headers route through -I instead)', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs', + autogenHeadersAbsolute: '/abs/headers', + // publicHeadersPath intentionally not set + }); + expect(result).not.toContain('publicHeadersPath:'); + }); +}); + +// --------------------------------------------------------------------------- +// linkHeaderTree +// +// Mirrors header files from srcDir into a separate destDir via relative +// symlinks. Used for the centralized cross-package headers tree at +// /headers//. +// --------------------------------------------------------------------------- + +describe('linkHeaderTree', () => { + function makeTmpDirs() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-headers-')); + const src = path.join(root, 'src'); + const dest = path.join(root, 'headers', 'MyDep'); + fs.mkdirSync(src, {recursive: true}); + return {root, src, dest}; + } + + it('symlinks each header from srcDir into destDir with a relative target', () => { + const {src, dest} = makeTmpDirs(); + fs.writeFileSync(path.join(src, 'Foo.h'), '// foo\n'); + fs.writeFileSync(path.join(src, 'Foo.mm'), '// not a header — skip\n'); + + linkHeaderTree(src, dest); + + const link = path.join(dest, 'Foo.h'); + expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); + // dest sits under root/headers/MyDep, src under root/src — relative target + // walks up two levels then back down into src/. + expect(fs.readlinkSync(link)).toBe('../../src/Foo.h'); + expect(fs.existsSync(path.join(dest, 'Foo.mm'))).toBe(false); + }); + + it('preserves nested header subdirs so resolves (ReactCommonSamples case)', () => { + const {src, dest} = makeTmpDirs(); + fs.mkdirSync(path.join(src, 'ReactCommon')); + fs.writeFileSync(path.join(src, 'ReactCommon', 'Nested.h'), '// nested\n'); + + linkHeaderTree(src, dest); + + const link = path.join(dest, 'ReactCommon', 'Nested.h'); + expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(link)).toBe('../../../src/ReactCommon/Nested.h'); + }); + + it('is idempotent: re-running with the same headers preserves symlink inodes', () => { + const {src, dest} = makeTmpDirs(); + fs.writeFileSync(path.join(src, 'Stable.h'), '// h\n'); + + linkHeaderTree(src, dest); + const link = path.join(dest, 'Stable.h'); + const inoBefore = fs.lstatSync(link).ino; + + linkHeaderTree(src, dest); + expect(fs.lstatSync(link).ino).toBe(inoBefore); + }); + + it('prunes symlinks for headers that no longer exist in srcDir', () => { + const {src, dest} = makeTmpDirs(); + fs.writeFileSync(path.join(src, 'A.h'), '// a\n'); + fs.writeFileSync(path.join(src, 'B.h'), '// b\n'); + linkHeaderTree(src, dest); + + expect(fs.existsSync(path.join(dest, 'B.h'))).toBe(true); + + // Remove B.h from src and re-run; the stale symlink should be gone. + fs.unlinkSync(path.join(src, 'B.h')); + linkHeaderTree(src, dest); + + expect(fs.existsSync(path.join(dest, 'A.h'))).toBe(true); + expect(fs.existsSync(path.join(dest, 'B.h'))).toBe(false); + }); + + it('removes destDir entirely when srcDir has no headers', () => { + const {src, dest} = makeTmpDirs(); + // No header files in src — just a non-header. + fs.writeFileSync(path.join(src, 'thing.mm'), '// impl\n'); + fs.mkdirSync(dest, {recursive: true}); + fs.writeFileSync(path.join(dest, 'Stale.h'), '// stale\n'); + + linkHeaderTree(src, dest); + + expect(fs.existsSync(dest)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// collectSpmSources — recursive auto-discovery used as the default `sources:` +// allowlist. Skip-dirs (tests/, __tests__/, android/, …) are pruned at every +// depth. Anything not matching ALL_SOURCE_EXTENSIONS is left out (no .js, +// .podspec, .md, package.json, CMakeLists.txt). +// --------------------------------------------------------------------------- + +describe('collectSpmSources', () => { + function makeTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sources-')); + } + + it('returns every source file under sourcePath, sorted, forward-slash-separated', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'A.h'), ''); + fs.writeFileSync(path.join(dir, 'A.mm'), ''); + fs.mkdirSync(path.join(dir, 'Sub')); + fs.writeFileSync(path.join(dir, 'Sub', 'B.cpp'), ''); + fs.writeFileSync(path.join(dir, 'Sub', 'B.hpp'), ''); + + expect(collectSpmSources(dir)).toEqual([ + 'A.h', + 'A.mm', + 'Sub/B.cpp', + 'Sub/B.hpp', + ]); + }); + + it('ignores non-source files like .js, .ts, .podspec, .md, CMakeLists.txt, package.json', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'Module.mm'), ''); + fs.writeFileSync(path.join(dir, 'module.js'), ''); + fs.writeFileSync(path.join(dir, 'index.ts'), ''); + fs.writeFileSync(path.join(dir, 'My.podspec'), ''); + fs.writeFileSync(path.join(dir, 'README.md'), ''); + fs.writeFileSync(path.join(dir, 'CMakeLists.txt'), ''); + fs.writeFileSync(path.join(dir, 'package.json'), '{}'); + + expect(collectSpmSources(dir)).toEqual(['Module.mm']); + }); + + it('skips test/__tests__/__mocks__/jest/android/node_modules directories at the top level', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'Real.mm'), ''); + for (const skip of [ + 'tests', + '__tests__', + '__mocks__', + 'test', + 'jest', + 'android', + 'node_modules', + ]) { + fs.mkdirSync(path.join(dir, skip)); + fs.writeFileSync(path.join(dir, skip, 'Hidden.mm'), ''); + } + + expect(collectSpmSources(dir)).toEqual(['Real.mm']); + }); + + it('skips skip-dirs at any nesting depth (the regression that motivated the switch)', () => { + // NativeCxxModuleExample/tests/NativeCxxModuleExampleTests.cpp shape: + // the test dir lives under a nested subdir, not at the source root. + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'NativeCxxModuleExample', 'tests'), { + recursive: true, + }); + fs.writeFileSync( + path.join(dir, 'NativeCxxModuleExample', 'NativeCxxModuleExample.mm'), + '', + ); + fs.writeFileSync( + path.join( + dir, + 'NativeCxxModuleExample', + 'tests', + 'NativeCxxModuleExampleTests.cpp', + ), + '', + ); + + expect(collectSpmSources(dir)).toEqual([ + 'NativeCxxModuleExample/NativeCxxModuleExample.mm', + ]); + }); + + it('returns an empty list when sourcePath does not exist', () => { + expect(collectSpmSources('/no/such/dir/spm-test')).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// expandSpmSourceGlobs — translates CocoaPods-style globs (e.g. +// 'ios/**/*.{h,m,mm}') into a sorted list of matching file paths. Skip-dir +// filtering still applies even when the pattern would otherwise match. +// --------------------------------------------------------------------------- + +describe('expandSpmSourceGlobs', () => { + function makeTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'spm-globs-')); + } + + it('** matches any depth, with brace alternation expanding extensions', () => { + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'ios', 'Sub'), {recursive: true}); + fs.writeFileSync(path.join(dir, 'ios', 'Root.h'), ''); + fs.writeFileSync(path.join(dir, 'ios', 'Root.m'), ''); + fs.writeFileSync(path.join(dir, 'ios', 'Sub', 'Deep.mm'), ''); + fs.writeFileSync(path.join(dir, 'ios', 'ignored.txt'), ''); + + expect(expandSpmSourceGlobs(dir, ['ios/**/*.{h,m,mm}'])).toEqual([ + 'ios/Root.h', + 'ios/Root.m', + 'ios/Sub/Deep.mm', + ]); + }); + + it('single * stays within one segment', () => { + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'a', 'b'), {recursive: true}); + fs.writeFileSync(path.join(dir, 'a', 'Foo.mm'), ''); + fs.writeFileSync(path.join(dir, 'a', 'b', 'Bar.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['a/*.mm'])).toEqual(['a/Foo.mm']); + }); + + it('still skips SKIP_DIRS_DEFAULT even when the glob would match inside them', () => { + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'tests')); + fs.writeFileSync(path.join(dir, 'Real.mm'), ''); + fs.writeFileSync(path.join(dir, 'tests', 'Hidden.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['**/*.mm'])).toEqual(['Real.mm']); + }); + + it('multiple patterns are unioned and deduplicated', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'A.h'), ''); + fs.writeFileSync(path.join(dir, 'A.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['*.h', '*.mm', '*.{h,mm}'])).toEqual([ + 'A.h', + 'A.mm', + ]); + }); + + it('returns an empty list when no pattern matches', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'A.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['nope/*.swift'])).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// generateSynthPackageSwift — sources: line rendering +// --------------------------------------------------------------------------- + +describe('generateSynthPackageSwift (sources: allowlist)', () => { + it('emits a multi-line sources: array when spec.sources is non-empty', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: '.', + sources: ['root/A.mm', 'root/Sub/B.cpp'], + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/headers', + }); + expect(result).toContain('sources: ['); + expect(result).toContain('"root/A.mm"'); + expect(result).toContain('"root/Sub/B.cpp"'); + // Order matters for diff readability: sources: comes after path: and + // before publicHeadersPath:. + const sourcesIdx = result.indexOf('sources: ['); + const pathIdx = result.indexOf('path: "."'); + const publicHeadersIdx = result.indexOf('publicHeadersPath:'); + expect(pathIdx).toBeLessThan(sourcesIdx); + if (publicHeadersIdx !== -1) { + expect(sourcesIdx).toBeLessThan(publicHeadersIdx); + } + }); + + it('omits sources: line when spec.sources is null or empty (falls back to SPM auto-scan)', () => { + const a = generateSynthPackageSwift({ + swiftName: 'A', + targetPath: '.', + appRootAbsolute: '/abs', + }); + const b = generateSynthPackageSwift({ + swiftName: 'B', + targetPath: '.', + sources: [], + appRootAbsolute: '/abs', + }); + expect(a).not.toContain('sources: ['); + expect(b).not.toContain('sources: ['); + }); +}); + +// --------------------------------------------------------------------------- +// spm.name override — verifies that a non-default Swift name (set by a +// library author via react-native.config.js `spm.name`) flows verbatim into +// the synth Package.swift: target name, library name, product references, +// and sibling package paths all use the override. +// --------------------------------------------------------------------------- + +describe('generateSynthPackageSwift (spm.name override)', () => { + it('uses the override Swift name for the target, library, and product', () => { + const result = generateSynthPackageSwift({ + swiftName: 'worklets', // override from spm.name (default would be "ReactNativeWorklets") + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/headers', + }); + expect(result).toContain('name: "worklets"'); + expect(result).toContain('.library(name: "worklets"'); + expect(result).toContain('targets: ["worklets"]'); + // The auto-derived name must not appear anywhere. + expect(result).not.toContain('ReactNativeWorklets'); + }); + + it('emits the override name in sibling .package(...) and .product(...) refs when a transitive dep was overridden', () => { + // Simulates the case where reanimated declares spm.dependencies on + // react-native-worklets, and worklets has set spm.name: "worklets". + // The autolinker's swiftNameByNpm map resolves the transitive to + // "worklets" before passing it to generateSynthPackageSwift. + const result = generateSynthPackageSwift({ + swiftName: 'reanimated', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/headers', + spmDependencies: [{swiftName: 'worklets'}], + siblingSynthAbsolutePaths: {worklets: '/abs/app/packages/worklets'}, + }); + expect(result).toContain( + '.package(name: "worklets", path: "/abs/app/packages/worklets")', + ); + expect(result).toContain('.product(name: "worklets", package: "worklets")'); + expect(result).not.toContain('ReactNativeWorklets'); + }); +}); + +// --------------------------------------------------------------------------- +// findSelfManagedPackageDir — detects hand-authored Package.swift at either +// the dep root or under ios/. The nested layout lets community libraries +// keep their npm-package root free of SPM artifacts (.build/, .swiftpm/). +// --------------------------------------------------------------------------- + +describe('findSelfManagedPackageDir', () => { + let depRoot; + + beforeEach(() => { + depRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-selfmgd-')); + }); + + afterEach(() => { + fs.rmSync(depRoot, {recursive: true, force: true}); + }); + + it('returns null when no Package.swift exists at any candidate location', () => { + expect(findSelfManagedPackageDir(depRoot)).toBe(null); + }); + + it('returns the dep root when /Package.swift exists without the AUTOGEN marker', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// swift-tools-version: 6.0\n// Hand-authored.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(depRoot); + }); + + it('returns null when /Package.swift carries the AUTOGEN marker', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + AUTOGEN_MARKER + '\n// synth wrapper content\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(null); + }); + + it('returns /ios when only the nested manifest exists and lacks the AUTOGEN marker', () => { + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync( + path.join(depRoot, 'ios', 'Package.swift'), + '// swift-tools-version: 6.0\n// Hand-authored nested manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios')); + }); + + it('prefers the root manifest when both root and nested manifests exist', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// Root manifest.\n', + ); + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync( + path.join(depRoot, 'ios', 'Package.swift'), + '// Nested manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(depRoot); + }); + + it('falls back to the nested manifest when the root one is autolinker-generated', () => { + // Models the transition state: dep was previously autolinker-wrapped and + // recently shipped its own ios/Package.swift. The root file (a leftover + // synth manifest from a prior run) shouldn't shadow the hand-authored one. + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + AUTOGEN_MARKER + '\n// stale synth\n', + ); + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync( + path.join(depRoot, 'ios', 'Package.swift'), + '// Hand-authored nested manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios')); + }); +}); + +// --------------------------------------------------------------------------- +// hasPodspec — does the dep ship a podspec (auto-scaffoldable)? +// --------------------------------------------------------------------------- +describe('hasPodspec', () => { + let depRoot; + + beforeEach(() => { + depRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-podspec-')); + }); + + afterEach(() => { + fs.rmSync(depRoot, {recursive: true, force: true}); + }); + + it('returns false when no .podspec exists at the root or under ios/', () => { + expect(hasPodspec(depRoot)).toBe(false); + }); + + it('returns true for a .podspec at the dep root', () => { + fs.writeFileSync(path.join(depRoot, 'Foo.podspec'), '# podspec'); + expect(hasPodspec(depRoot)).toBe(true); + }); + + it('returns true for a .podspec under ios/', () => { + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.podspec'), '# podspec'); + expect(hasPodspec(depRoot)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Missing-manifest error — the gate that replaced the silent synth wrapper +// for community npm deps. The `error:` line prefix is the load-bearing +// contract: Xcode parses it to render a build error. +// --------------------------------------------------------------------------- +describe('MissingManifestError + reportMissingManifests', () => { + let errSpy; + + beforeEach(() => { + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + errSpy.mockRestore(); + }); + + it('carries the dep list and a scaffold instruction on the error', () => { + const deps = [{name: 'Foo', npmName: 'react-native-foo', hasPodspec: true}]; + const err = new MissingManifestError(deps); + expect(err).toBeInstanceOf(Error); + expect(err.missingManifests).toEqual(deps); + expect(err.message).toContain('react-native spm scaffold'); + }); + + it('emits one `error:`-prefixed line per dep naming the npm package + fix', () => { + const err = reportMissingManifests([ + {name: 'Foo', npmName: 'react-native-foo', hasPodspec: true}, + {name: 'Bar', npmName: 'react-native-bar', hasPodspec: true}, + ]); + expect(err).toBeInstanceOf(MissingManifestError); + expect(errSpy).toHaveBeenCalledTimes(2); + const lines = errSpy.mock.calls.map(c => c[0]); + // Xcode only renders the `error: ` headline; each dep is one such message. + expect(lines.every(l => l.startsWith('error: '))).toBe(true); + expect(lines[0]).toContain('react-native-foo'); + expect(lines[0]).toContain('npx react-native spm scaffold'); + // The pressure mechanics: persist via patch-package, and the error returns + // on a fresh node_modules if you don't (no auto-scaffold/auto-restore). + expect(lines[0]).toContain('patch-package'); + expect(lines[0]).toContain('node_modules is reset'); + }); + + it('tells the user a podspec-less dep cannot be auto-scaffolded', () => { + reportMissingManifests([ + {name: 'Baz', npmName: 'react-native-baz', hasPodspec: false}, + ]); + const line = errSpy.mock.calls[0][0]; + expect(line.startsWith('error: ')).toBe(true); + expect(line).toContain('no podspec'); + expect(line).toContain('react-native-baz'); + }); + + it('gives a mixed-language dep a DISTINCT error (not "run scaffold") with an opt-out + binary path', () => { + reportMissingManifests([ + { + name: 'Screens', + npmName: 'react-native-screens', + hasPodspec: true, + mixed: true, + }, + ]); + const line = errSpy.mock.calls[0][0]; + expect(line.startsWith('error: ')).toBe(true); + expect(line).toContain('mixed Swift'); + // Must NOT tell them to scaffold — scaffolding can't fix mixed-language. + expect(line).not.toContain('react-native spm scaffold'); + // The two real escape hatches: + expect(line).toContain('react-native.config.js'); // opt out of autolinking + expect(line).toContain('platforms: { ios: null }'); + expect(line).toContain('xcframework'); // or consume as a prebuilt binary + }); +}); + +describe('hasMixedLanguageSources', () => { + let root; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-mixed-')); + }); + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}); + }); + + it('is true when both .swift and .mm exist under the source dir (screens shape)', () => { + fs.mkdirSync(path.join(root, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(root, 'ios', 'RNSScreen.swift'), ''); + fs.writeFileSync(path.join(root, 'ios', 'RNSScreen.mm'), ''); + expect(hasMixedLanguageSources(root)).toBe(true); + }); + + it('is false for a pure-ObjC++ lib (svg/skia shape)', () => { + fs.mkdirSync(path.join(root, 'apple'), {recursive: true}); + fs.writeFileSync(path.join(root, 'apple', 'A.mm'), ''); + fs.writeFileSync(path.join(root, 'apple', 'B.h'), ''); + expect(hasMixedLanguageSources(root)).toBe(false); + }); + + it('ignores .swift that lives only under example/ or __tests__ (not real sources)', () => { + fs.mkdirSync(path.join(root, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(root, 'ios', 'A.mm'), ''); + fs.mkdirSync(path.join(root, 'example', 'ios'), {recursive: true}); + fs.writeFileSync(path.join(root, 'example', 'ios', 'App.swift'), ''); + expect(hasMixedLanguageSources(root)).toBe(false); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js new file mode 100644 index 000000000000..b7333b15d763 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js @@ -0,0 +1,273 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + findSourcePath, + generateXCFrameworksPackageSwift, + main, +} = require('../generate-spm-package'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// generateXCFrameworksPackageSwift +// --------------------------------------------------------------------------- + +describe('generateXCFrameworksPackageSwift', () => { + it('renames React product to ReactNative', () => { + const result = generateXCFrameworksPackageSwift([ + 'React', + 'ReactNativeDependencies', + 'hermes-engine', + ]); + expect(result).toContain( + '.library(name: "ReactNative", targets: ["React"])', + ); + expect(result).toContain( + '.library(name: "ReactNativeDependencies", targets: ["ReactNativeDependencies"])', + ); + expect(result).toContain( + '.library(name: "hermes-engine", targets: ["hermes-engine"])', + ); + }); + + it('lists binary targets', () => { + const result = generateXCFrameworksPackageSwift([ + 'React', + 'ReactNativeDependencies', + ]); + expect(result).toContain( + '.binaryTarget(name: "React", path: "React.xcframework")', + ); + expect(result).toContain( + '.binaryTarget(name: "ReactNativeDependencies", path: "ReactNativeDependencies.xcframework")', + ); + }); + + it('includes auto-generated header comment', () => { + const result = generateXCFrameworksPackageSwift(['React']); + expect(result).toContain('AUTO-GENERATED'); + expect(result).toContain('swift-tools-version: 6.0'); + expect(result).toContain('name: "ReactNative"'); + }); +}); + +// --------------------------------------------------------------------------- +// findSourcePath +// --------------------------------------------------------------------------- + +describe('findSourcePath', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-find-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('finds directory matching derived name', () => { + fs.mkdirSync(path.join(tempDir, 'MyApp')); + expect(findSourcePath(tempDir, 'my-app')).toBe('MyApp'); + }); + + it('falls back to ios directory', () => { + fs.mkdirSync(path.join(tempDir, 'ios')); + expect(findSourcePath(tempDir, 'unknown-pkg')).toBe('ios'); + }); + + it('scans for directory with native sources', () => { + fs.mkdirSync(path.join(tempDir, 'CustomDir')); + fs.writeFileSync(path.join(tempDir, 'CustomDir', 'main.m'), ''); + expect(findSourcePath(tempDir, 'unrelated-name')).toBe('CustomDir'); + }); + + it('returns derived name when nothing found', () => { + expect(findSourcePath(tempDir, 'my-app')).toBe('MyApp'); + }); +}); + +// --------------------------------------------------------------------------- +// main — end-to-end generation of build/xcframeworks/{Package.swift,symlinks} +// from a local artifacts.json. The headers composer is injected so the +// happy paths stay inside a tempdir with no cross-package side effects. +// --------------------------------------------------------------------------- + +describe('main', () => { + let appRoot; + let rnRoot; + let origExitCode; + let logSpy; + let errSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pkg-app-')); + rnRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pkg-rn-')); + origExitCode = process.exitCode; + process.exitCode = undefined; + // main() is chatty via makeLogger/console.error — silence to keep output + // readable; assertions target the filesystem, not the logs. + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errSpy.mockRestore(); + process.exitCode = origExitCode; + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(rnRoot, {recursive: true, force: true}); + }); + + // Writes the app package.json so findProjectRoot/readPackageJson resolve. + function writeAppPkg(name /*: string */ = 'my-app') { + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name, version: '1.0.0'}), + 'utf8', + ); + } + + // Builds an artifacts dir with artifacts.json + a target dir per entry. + // Each value's `present` flag controls whether the entry is written at all. + function writeArtifacts(entries /*: Array */) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pkg-art-')); + const json = {}; + for (const name of entries) { + const xcfwPath = path.join(dir, `${name}.xcframework`); + fs.mkdirSync(xcfwPath, {recursive: true}); + json[name] = {xcframeworkPath: xcfwPath, url: 'https://example'}; + } + fs.writeFileSync( + path.join(dir, 'artifacts.json'), + JSON.stringify(json), + 'utf8', + ); + return dir; + } + + function run(artifactsDir /*:: ?: ?string */) { + const argv = [ + '--app-root', + appRoot, + '--react-native-root', + rnRoot, + '--version', + '0.85.0', + ]; + if (artifactsDir != null) { + argv.push('--artifacts-dir', artifactsDir); + } + main(argv); + } + + it('generates Package.swift + symlinks when headers ship in the slot', () => { + writeAppPkg(); + const artifactsDir = writeArtifacts([ + 'React', + 'ReactNativeDependencies', + 'hermes-engine', + 'ReactNativeHeaders', + ]); + try { + run(artifactsDir); + + expect(process.exitCode).toBeUndefined(); + + const pkgSwift = path.join( + appRoot, + 'build', + 'xcframeworks', + 'Package.swift', + ); + expect(fs.existsSync(pkgSwift)).toBe(true); + const contents = fs.readFileSync(pkgSwift, 'utf8'); + expect(contents).toContain('.binaryTarget(name: "React"'); + // The slot comment is derived from the artifacts dir's trailing path + // segments (version/flavor), not the --version flag. + expect(contents).toContain('Cache slot:'); + + const reactLink = path.join( + appRoot, + 'build', + 'xcframeworks', + 'React.xcframework', + ); + expect(fs.lstatSync(reactLink).isSymbolicLink()).toBe(true); + } finally { + fs.rmSync(artifactsDir, {recursive: true, force: true}); + } + }); + + it('throws when ReactNativeHeaders is absent (no consumer-side compose)', () => { + writeAppPkg(); + // Artifacts WITHOUT ReactNativeHeaders: the consumer does not compose the + // layout locally, it fails with a clear error instead. + const artifactsDir = writeArtifacts([ + 'React', + 'ReactNativeDependencies', + 'hermes-engine', + ]); + try { + expect(() => run(artifactsDir)).toThrow(/ReactNativeHeaders/); + // No package is generated when the artifacts are incomplete. + expect( + fs.existsSync( + path.join(appRoot, 'build', 'xcframeworks', 'Package.swift'), + ), + ).toBe(false); + } finally { + fs.rmSync(artifactsDir, {recursive: true, force: true}); + } + }); + + it('throws when no package.json is found', () => { + // No app package.json written. + expect(() => run(null)).toThrow(/No package\.json/); + }); + + it('throws when --artifacts-dir has no artifacts.json', () => { + writeAppPkg(); + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pkg-empty-')); + try { + expect(() => run(emptyDir)).toThrow(/artifacts\.json not found/); + } finally { + fs.rmSync(emptyDir, {recursive: true, force: true}); + } + }); + + it('throws when artifacts.json is missing a required entry', () => { + writeAppPkg(); + // Missing hermes-engine. + const artifactsDir = writeArtifacts(['React', 'ReactNativeDependencies']); + try { + expect(() => run(artifactsDir)).toThrow(/missing required entries/); + } finally { + fs.rmSync(artifactsDir, {recursive: true, force: true}); + } + }); + + it('auto-detects an existing build/xcframeworks without --artifacts-dir', () => { + writeAppPkg(); + const xcfwDir = path.join(appRoot, 'build', 'xcframeworks'); + fs.mkdirSync(xcfwDir, {recursive: true}); + fs.writeFileSync(path.join(xcfwDir, 'Package.swift'), '// existing'); + run(null); + // No artifacts-dir: it should leave the existing manifest untouched. + expect(process.exitCode).toBeUndefined(); + expect(fs.readFileSync(path.join(xcfwDir, 'Package.swift'), 'utf8')).toBe( + '// existing', + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js new file mode 100644 index 000000000000..588834a3b369 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -0,0 +1,83 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {generateXcscheme} = require('../generate-spm-xcodeproj'); + +// --------------------------------------------------------------------------- +// generateXcscheme — pre-action for SPM autolinking sync +// +// Without the pre-action, our sync ran as a build phase AFTER Xcode's +// "Resolve Package Dependencies" step. Adding a dep then required two +// builds to take effect — the first build re-resolved against the old +// graph, the second saw the just-regenerated Package.swift. Moving the +// sync to a scheme PreAction makes it run BEFORE resolution. +// --------------------------------------------------------------------------- + +describe('generateXcscheme', () => { + const SYNC_SENTINEL = 'SYNC_SCRIPT_SENTINEL_MARKER'; + + it('emits a PreActions block containing the sync script', () => { + const result = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + SYNC_SENTINEL, + ); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain('Sync SPM Autolinking'); + expect(result).toContain(SYNC_SENTINEL); + }); + + it('pre-action references the target via EnvironmentBuildable so env vars inherit', () => { + const result = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + SYNC_SENTINEL, + ); + expect(result).toContain(''); + // The buildable inside EnvironmentBuildable must point at the same target + // as the main BuildableReference, so SRCROOT / PROJECT_DIR / etc. resolve. + const envBlock = result.slice( + result.indexOf(''), + result.indexOf(''), + ); + expect(envBlock).toContain('BlueprintIdentifier = "TARGET_UUID"'); + expect(envBlock).toContain('BuildableName = "MyApp.app"'); + expect(envBlock).toContain('BlueprintName = "MyApp"'); + }); + + it('XML-escapes shell-meta characters inside scriptText', () => { + // Shell scripts have `>` (redirection), `&` (bg/and), `<` (heredoc); all + // are XML special chars. Without escaping, the scheme XML is malformed + // and Xcode silently ignores the pre-action — which would mask the very + // bug we're fixing. + const script = + 'echo "x" > /tmp/foo 2>&1; while read L; do :; done < /tmp/in'; + const result = generateXcscheme('MyApp', 'TARGET_UUID', 'MyApp', script); + expect(result).toContain('>'); + expect(result).toContain('&'); + expect(result).toContain('<'); + // Raw `>` inside the scriptText attribute breaks the XML parser. + // (Outside attributes, > is technically legal, so just assert the + // problematic substring doesn't appear: the actual script text after + // scriptText=" must not contain raw >, &, < before its closing quote.) + const attrStart = result.indexOf('scriptText = "'); + const attrEnd = result.indexOf('"', attrStart + 'scriptText = "'.length); + const attrValue = result.slice( + attrStart + 'scriptText = "'.length, + attrEnd, + ); + expect(attrValue).not.toMatch(/[<>&](?!(amp|lt|gt|quot|apos);)/); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js new file mode 100644 index 000000000000..6b927d80c639 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -0,0 +1,212 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + injectSpmIntoPbxproj, + planInjection, +} = require('../generate-spm-xcodeproj'); +const fs = require('fs'); +const path = require('path'); + +const PLAIN = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +// Derive a CocoaPods-integrated variant by layering a Pods xcconfig onto the +// app target's Debug config (what makes in-place injection refuse). +const PODS = PLAIN.replace( + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {', + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = BB0000000000000000000001 /* Pods-MyApp.debug.xcconfig */;\n\t\t\tbuildSettings = {', +); + +const RN_PATH = '../node_modules/react-native'; + +function inject(text, remote = null) { + const plan = planInjection(text, {}); + expect(plan.ok).toBe(true); + return injectSpmIntoPbxproj( + text, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + }, + RN_PATH, + remote, + ); +} + +// A simple balanced-delimiter check (the injected file must stay well-formed). +function isBalanced(text) { + let depth = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '"') { + i++; + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\') i++; + i++; + } + } else if (c === '{' || c === '(') { + depth++; + } else if (c === '}' || c === ')') { + depth--; + } + } + return depth === 0; +} + +describe('planInjection', () => { + it('accepts a plain SPM-only app and resolves its anchors', () => { + const plan = planInjection(PLAIN, {}); + expect(plan.ok).toBe(true); + expect(plan.target.name).toBe('MyApp'); + expect(plan.configUuids).toHaveLength(2); // Debug + Release + expect(plan.frameworksPhaseUuid).toMatch(/^[0-9A-Fa-f]{24}$/); + }); + + it('refuses a CocoaPods-integrated target (fail-closed for fallback)', () => { + const plan = planInjection(PODS, {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toMatch(/CocoaPods/); + }); + + it('refuses when there is no application target', () => { + const noApp = PLAIN.replace( + '"com.apple.product-type.application"', + '"com.apple.product-type.framework"', + ); + const plan = planInjection(noApp, {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toMatch(/no application target/); + }); +}); + +describe('injectSpmIntoPbxproj — Tier 1 (SPM graph)', () => { + it('adds the local package references and product dependencies', () => { + const {text} = inject(PLAIN); + expect(text).toContain('/* Begin XCLocalSwiftPackageReference section */'); + expect(text).toContain('relativePath = build/xcframeworks'); + expect(text).toContain('relativePath = build/generated/autolinking'); + expect(text).toContain('relativePath = build/generated/ios'); + // One XCSwiftPackageProductDependency per product (6). + expect(text.match(/isa = XCSwiftPackageProductDependency;/g)).toHaveLength( + 6, + ); + expect(text).toContain('productName = ReactNative'); + expect(text).toContain('productName = Autolinked'); + expect(text).toContain('productName = ReactCodegen'); + }); + + it('wires packageReferences onto the project and product deps onto the target', () => { + const {text} = inject(PLAIN); + expect(text).toMatch(/packageReferences = \(/); + expect(text).toMatch(/packageProductDependencies = \(/); + // Product build files land in the Frameworks phase. + expect(text).toContain('ReactNative in Frameworks'); + }); + + it('uses remote package references in remote mode', () => { + const remote = { + url: 'https://github.com/facebook/react-native', + version: '0.87.0', + identity: 'react-native', + }; + const {text} = inject(PLAIN, remote); + expect(text).toContain('/* Begin XCRemoteSwiftPackageReference section */'); + expect(text).toContain( + 'repositoryURL = "https://github.com/facebook/react-native"', + ); + // build/xcframeworks is NOT referenced locally in remote mode. + expect(text).not.toContain('relativePath = build/xcframeworks'); + // The app's generated-code packages stay local. + expect(text).toContain('relativePath = build/generated/ios'); + }); +}); + +describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { + it('merges React build settings into BOTH build configurations', () => { + const {text} = inject(PLAIN); + expect(text.match(/-ObjC/g)).toHaveLength(2); + expect(text.match(/REACT_NATIVE_PATH = /g)).toHaveLength(2); + expect(text).toContain( + 'fmodule-map-file=$(BUILT_PRODUCTS_DIR)/React.framework', + ); + expect(text).toContain('build/generated/autolinking/headers'); + expect(text.match(/CLANG_CXX_LANGUAGE_STANDARD = "c\+\+20"/g)).toHaveLength( + 2, + ); + }); + + it('prepends the Sync SPM Autolinking build phase', () => { + const {text} = inject(PLAIN); + expect(text).toContain('Sync SPM Autolinking'); + expect(text).toContain('npx react-native spm sync'); + // It runs before Sources. + const syncIdx = text.indexOf('Sync SPM Autolinking */,'); + const sourcesIdx = text.indexOf('Sources */,'); + expect(syncIdx).toBeGreaterThan(-1); + expect(syncIdx).toBeLessThan(sourcesIdx); + }); +}); + +describe('injectSpmIntoPbxproj — invariants', () => { + it('produces a balanced (well-formed) pbxproj', () => { + const {text} = inject(PLAIN); + expect(isBalanced(PLAIN)).toBe(true); + expect(isBalanced(text)).toBe(true); + }); + + it('is idempotent — a second injection is a byte-for-byte no-op', () => { + const first = inject(PLAIN).text; + const plan = planInjection(first, {}); + const second = injectSpmIntoPbxproj( + first, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + }, + RN_PATH, + null, + ).text; + expect(second).toBe(first); + }); + + it('keeps the diff small — only adds lines, never removes original ones', () => { + const {text} = inject(PLAIN); + // Every original line is preserved verbatim (purely additive splice). + for (const line of PLAIN.split('\n')) { + if (line.trim() === '') continue; + expect(text).toContain(line); + } + const added = text.split('\n').length - PLAIN.split('\n').length; + // Sanity bound: the SPM graph + settings + phase is well under 120 lines. + expect(added).toBeGreaterThan(0); + expect(added).toBeLessThan(120); + }); + + it('namespaces injected UUIDs by the host project root (collision-safe, stable)', () => { + const {injectedUuids} = inject(PLAIN); + // All injected UUIDs are valid 24-hex and none collide with the originals. + const originalUuids = new Set(PLAIN.match(/[0-9A-Fa-f]{24}/g)); + for (const u of injectedUuids) { + expect(u).toMatch(/^[0-9A-F]{24}$/); + expect(originalUuids.has(u)).toBe(false); + } + // Deterministic across runs. + expect(inject(PLAIN).injectedUuids).toEqual(injectedUuids); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/read-podspec-test.js b/packages/react-native/scripts/spm/__tests__/read-podspec-test.js new file mode 100644 index 000000000000..135667fa729e --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/read-podspec-test.js @@ -0,0 +1,487 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {flattenSubspecs, readPodspec, regexPodspec} = require('../read-podspec'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Real-world podspec fixtures inlined as strings. The regex parser is tested +// against these directly; flattenSubspecs is tested against pod-ipc-style +// JSON objects that match what `pod ipc spec` actually emits. + +const SAFE_AREA_PODSPEC = ` +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "react-native-safe-area-context" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + s.platforms = { :ios => "12.4", :tvos => "12.4", :osx => "10.15" } + s.source = { :git => package["repository"]["url"], :tag => "#{s.version}" } + s.source_files = "ios/**/*.{h,m,mm}" + s.dependency "React-Core" +end +`; + +const SIMPLE_LIB_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "react-native-foo" + s.version = "1.2.3" + s.source_files = "ios/**/*.{h,m,mm}" + s.public_header_files = "ios/**/*.h" + s.framework = "UIKit" + s.frameworks = ["Foundation", "CoreGraphics"] + s.dependency "React-Core" + s.dependency "React-jsi" +end +`; + +const REANIMATED_LIKE_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "RNReanimated" + s.version = "1.0.0" + s.dependency "RNWorklets" + install_modules_dependencies(s) + s.subspec "common" do |ss| + ss.source_files = "Common/cpp/reanimated/**/*.{cpp,h}" + ss.header_mappings_dir = "Common/cpp/reanimated" + ss.header_dir = "reanimated" + end + s.subspec "apple" do |ss| + ss.source_files = "apple/reanimated/**/*.{mm,h,m}" + ss.header_mappings_dir = "apple/reanimated" + end +end +`; + +const HEADER_SEARCH_PATHS_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "react-native-thing" + s.version = "1.0" + s.source_files = "ios/**/*.{h,m,mm}" + s.pod_target_xcconfig = { + "HEADER_SEARCH_PATHS" => "\\"$(PODS_TARGET_SRCROOT)/common/cpp\\"" + } +end +`; + +// Helper: write a fixture to a temp file and return its path. +function writeFixture(name, content) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-podspec-')); + const file = path.join(dir, name); + fs.writeFileSync(file, content); + return {file, dir}; +} + +// --------------------------------------------------------------------------- +// regexPodspec — best-effort fallback when CocoaPods isn't available. +// Should handle simple RN libs cleanly and degrade gracefully on subspecs / +// install_modules_dependencies() (warns + partial = true). +// --------------------------------------------------------------------------- + +describe('regexPodspec', () => { + it('extracts name, version, source_files, dependency from a real-world simple podspec', () => { + const {file, dir} = writeFixture( + 'react-native-safe-area-context.podspec', + SAFE_AREA_PODSPEC, + ); + try { + const raw = regexPodspec(file); + expect(raw.name).toBe('react-native-safe-area-context'); + expect(raw.source_files).toEqual(['ios/**/*.{h,m,mm}']); + expect(raw.dependencies).toEqual(['React-Core']); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('handles s.framework (singular method call) and s.frameworks (array assignment) together', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + const raw = regexPodspec(file); + expect(raw.frameworks.sort()).toEqual([ + 'CoreGraphics', + 'Foundation', + 'UIKit', + ]); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('collects multiple s.dependency lines in declaration order', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + const raw = regexPodspec(file); + expect(raw.dependencies).toEqual(['React-Core', 'React-jsi']); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('extracts pod_target_xcconfig HEADER_SEARCH_PATHS (string form), preserving the $(PODS_TARGET_SRCROOT) token', () => { + const {file, dir} = writeFixture( + 'hsp.podspec', + HEADER_SEARCH_PATHS_PODSPEC, + ); + try { + const raw = regexPodspec(file); + const hsp = raw.pod_target_xcconfig.HEADER_SEARCH_PATHS; + expect(hsp).toContain('$(PODS_TARGET_SRCROOT)/common/cpp'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('warns on subspec blocks and install_modules_dependencies() so callers know coverage is partial', () => { + const {file, dir} = writeFixture( + 'subspec.podspec', + REANIMATED_LIKE_PODSPEC, + ); + try { + const raw = regexPodspec(file); + expect(raw.__warnings__.some(w => /Subspecs detected/.test(w))).toBe( + true, + ); + expect( + raw.__warnings__.some(w => /install_modules_dependencies/.test(w)), + ).toBe(true); + expect(raw.__regex_partial__).toBe(true); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('marks output as partial so flattenSubspecs can propagate to the PodspecModel', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + const raw = regexPodspec(file); + expect(raw.__regex_partial__).toBe(true); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); +}); + +// --------------------------------------------------------------------------- +// flattenSubspecs — merges default_subspecs (or all subspecs) into a single +// logical PodspecModel. Tested with pod-ipc-shaped objects directly, since +// that's the shape that exercises the merging logic (regex doesn't extract +// subspec bodies). +// --------------------------------------------------------------------------- + +describe('flattenSubspecs', () => { + it('returns a model from a top-level-only spec without touching subspecs', () => { + const raw = { + name: 'react-native-foo', + version: '1.0', + source_files: 'ios/**/*.{h,m,mm}', + dependencies: {'React-Core': []}, + }; + const model = flattenSubspecs(raw); + expect(model.name).toBe('react-native-foo'); + expect(model.version).toBe('1.0'); + expect(model.sourceFiles).toEqual(['ios/**/*.{h,m,mm}']); + expect(model.dependencies).toEqual(['React-Core']); + expect(model.partial).toBe(false); + }); + + it('lifts preprocessor defines from OTHER_CFLAGS + GCC_PREPROCESSOR_DEFINITIONS (worklets shape)', () => { + const raw = { + name: 'RNWorklets', + version: '0.9.2', + pod_target_xcconfig: { + OTHER_CFLAGS: + '$(inherited) -DWORKLETS_FEATURE_FLAGS="[A:false][B:true]" -DWORKLETS_VERSION=0.9.2 ', + 'GCC_PREPROCESSOR_DEFINITIONS[config=*Debug*]': + '$(inherited) HERMES_ENABLE_DEBUGGER=1', + 'GCC_PREPROCESSOR_DEFINITIONS[config=*Release*]': '$(inherited)', + }, + }; + const model = flattenSubspecs(raw); + const byName = Object.fromEntries( + model.preprocessorDefines.map(d => [d.name, d]), + ); + // Quoted string-literal value kept intact (incl. its quotes). + expect(byName.WORKLETS_FEATURE_FLAGS).toEqual({ + name: 'WORKLETS_FEATURE_FLAGS', + value: '"[A:false][B:true]"', + config: null, + }); + expect(byName.WORKLETS_VERSION).toEqual({ + name: 'WORKLETS_VERSION', + value: '0.9.2', + config: null, + }); + // Per-config define scoped to debug; $(inherited) dropped. + expect(byName.HERMES_ENABLE_DEBUGGER).toEqual({ + name: 'HERMES_ENABLE_DEBUGGER', + value: '1', + config: 'debug', + }); + expect(model.preprocessorDefines).toHaveLength(3); + }); + + it('parses a multi-path HEADER_SEARCH_PATHS string with embedded quotes + recursive /** (skia shape)', () => { + const raw = { + name: 'react-native-skia', + version: '1.0', + pod_target_xcconfig: { + HEADER_SEARCH_PATHS: + '"$(PODS_TARGET_SRCROOT)/cpp/"/** "$(PODS_TARGET_SRCROOT)/cpp" "$(PODS_TARGET_SRCROOT)/cpp/skia" "$(PODS_TARGET_SRCROOT)/cpp/dawn/include"', + }, + }; + const model = flattenSubspecs(raw); + // Each space-separated, individually-quoted path becomes its own entry + // (quotes stripped); the `/**` recursive marker is preserved for translate. + expect(model.headerSearchPaths).toEqual([ + '$(PODS_TARGET_SRCROOT)/cpp//**', + '$(PODS_TARGET_SRCROOT)/cpp', + '$(PODS_TARGET_SRCROOT)/cpp/skia', + '$(PODS_TARGET_SRCROOT)/cpp/dawn/include', + ]); + }); + + it('lifts defines from s.xcconfig too, not just pod_target_xcconfig (reanimated shape)', () => { + const raw = { + name: 'RNReanimated', + version: '4.4.1', + // reanimated declares its version define in `s.xcconfig`, not + // pod_target_xcconfig (where worklets puts it). + xcconfig: { + OTHER_CFLAGS: '$(inherited) -DREANIMATED_VERSION=4.4.1', + }, + pod_target_xcconfig: { + 'GCC_PREPROCESSOR_DEFINITIONS[config=*Debug*]': + '$(inherited) HERMES_ENABLE_DEBUGGER=1', + }, + }; + const model = flattenSubspecs(raw); + const byName = Object.fromEntries( + model.preprocessorDefines.map(d => [d.name, d]), + ); + expect(byName.REANIMATED_VERSION).toEqual({ + name: 'REANIMATED_VERSION', + value: '4.4.1', + config: null, + }); + expect(byName.HERMES_ENABLE_DEBUGGER.config).toBe('debug'); + }); + + it('drops non-define flags and unresolved tokens from OTHER_CFLAGS', () => { + const raw = { + name: 'foo', + version: '1', + pod_target_xcconfig: { + OTHER_CFLAGS: + '-Wno-comma -gen-cdb-fragment-path build/cdb -DGOOD=1 -D$(BAD_TOKEN)=x -DALSO_GOOD', + }, + }; + const model = flattenSubspecs(raw); + const names = model.preprocessorDefines.map(d => d.name).sort(); + // Only the two valid -D defines survive; -W / -gen-cdb-fragment-path and + // the unresolved $(...) token are dropped. + expect(names).toEqual(['ALSO_GOOD', 'GOOD']); + expect( + model.preprocessorDefines.find(d => d.name === 'ALSO_GOOD').value, + ).toBe(null); + }); + + it('unions source_files across selected subspecs', () => { + const raw = { + name: 'foo', + version: '1', + source_files: 'top/**/*.h', + subspecs: [ + {name: 'common', source_files: 'Common/cpp/**/*.cpp'}, + {name: 'apple', source_files: 'apple/**/*.mm'}, + ], + default_subspecs: ['common', 'apple'], + }; + const model = flattenSubspecs(raw); + expect(model.sourceFiles.sort()).toEqual([ + 'Common/cpp/**/*.cpp', + 'apple/**/*.mm', + 'top/**/*.h', + ]); + }); + + it('selects ALL subspecs when default_subspecs is unset (matches CocoaPods behavior)', () => { + const raw = { + name: 'foo', + version: '1', + subspecs: [ + {name: 'a', source_files: 'a/**/*.h'}, + {name: 'b', source_files: 'b/**/*.h'}, + ], + }; + const model = flattenSubspecs(raw); + expect(model.sourceFiles.sort()).toEqual(['a/**/*.h', 'b/**/*.h']); + }); + + it('honors default_subspecs by name — non-default subspecs are excluded', () => { + const raw = { + name: 'foo', + version: '1', + subspecs: [ + {name: 'core', source_files: 'core/**/*.h'}, + {name: 'optional', source_files: 'optional/**/*.h'}, + ], + default_subspecs: ['core'], + }; + const model = flattenSubspecs(raw); + expect(model.sourceFiles).toEqual(['core/**/*.h']); + expect(model.sourceFiles).not.toContain('optional/**/*.h'); + }); + + it('merges pod_target_xcconfig HEADER_SEARCH_PATHS across subspecs and dedupes', () => { + const raw = { + name: 'foo', + version: '1', + subspecs: [ + { + name: 'a', + pod_target_xcconfig: { + HEADER_SEARCH_PATHS: + '"$(PODS_TARGET_SRCROOT)/a/cpp" "$(PODS_TARGET_SRCROOT)/shared"', + }, + }, + { + name: 'b', + pod_target_xcconfig: { + HEADER_SEARCH_PATHS: ['"$(PODS_TARGET_SRCROOT)/shared"'], + }, + }, + ], + }; + const model = flattenSubspecs(raw); + expect(model.headerSearchPaths).toEqual( + expect.arrayContaining([ + '$(PODS_TARGET_SRCROOT)/a/cpp', + '$(PODS_TARGET_SRCROOT)/shared', + ]), + ); + // dedup + const sharedCount = model.headerSearchPaths.filter( + p => p === '$(PODS_TARGET_SRCROOT)/shared', + ).length; + expect(sharedCount).toBe(1); + }); + + it('takes the first non-null header_mappings_dir (subspec layer-walk order)', () => { + const raw = { + name: 'foo', + version: '1', + // top-level has no mappings_dir + subspecs: [ + {name: 'common', header_mappings_dir: 'Common/cpp/foo'}, + {name: 'apple', header_mappings_dir: 'apple/foo'}, + ], + }; + const model = flattenSubspecs(raw); + expect(model.headerMappingsDir).toBe('Common/cpp/foo'); + }); + + it('accepts dependencies as a pod-ipc hash {name: [version]} OR as an array (regex fallback shape)', () => { + const fromIpc = flattenSubspecs({ + name: 'foo', + version: '1', + dependencies: {'React-Core': [], 'React-jsi': ['1.0']}, + }); + expect(fromIpc.dependencies.sort()).toEqual(['React-Core', 'React-jsi']); + + const fromRegex = flattenSubspecs({ + name: 'foo', + version: '1', + dependencies: ['React-Core', 'React-jsi'], + }); + expect(fromRegex.dependencies.sort()).toEqual(['React-Core', 'React-jsi']); + }); + + it('tokenizes compiler_flags from either string ("a b c") or array form', () => { + const a = flattenSubspecs({ + name: 'foo', + version: '1', + compiler_flags: '-Wno-documentation -fno-rtti', + }); + expect(a.compilerFlags).toEqual(['-Wno-documentation', '-fno-rtti']); + + const b = flattenSubspecs({ + name: 'foo', + version: '1', + compiler_flags: ['-Wno-documentation', '-fno-rtti'], + }); + expect(b.compilerFlags).toEqual(['-Wno-documentation', '-fno-rtti']); + }); + + it('propagates __regex_partial__ + __warnings__ from the regex fallback into the PodspecModel', () => { + const raw = { + name: 'foo', + version: '1', + __regex_partial__: true, + __warnings__: [ + 'Subspecs detected', + 'install_modules_dependencies detected', + ], + }; + const model = flattenSubspecs(raw); + expect(model.partial).toBe(true); + expect(model.warnings.length).toBe(2); + }); + + it('defaults requires_arc to true; explicit false is honored', () => { + expect(flattenSubspecs({name: 'a', version: '1'}).requiresArc).toBe(true); + expect( + flattenSubspecs({name: 'a', version: '1', requires_arc: false}) + .requiresArc, + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// readPodspec — orchestrator. We can't easily test the pod-ipc branch (would +// require either CocoaPods on the test runner or invasive child-process +// mocking), so we exercise the fallback path: when `pod` isn't on PATH, the +// regex parser kicks in transparently. +// --------------------------------------------------------------------------- + +describe('readPodspec', () => { + it('throws a clear error when the file does not exist', () => { + expect(() => readPodspec('/no/such/file.podspec')).toThrow( + /does not exist/, + ); + }); + + it('returns a flattened PodspecModel for a simple podspec (regex fallback path)', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + // We can't force pod-ipc to fail without mocking, but the regex + // parser produces a complete enough model that the test assertions + // hold regardless of which branch ran. + const model = readPodspec(file); + expect(model.name).toBe('react-native-foo'); + expect(model.version).toBe('1.2.3'); + expect(model.sourceFiles).toContain('ios/**/*.{h,m,mm}'); + expect(model.dependencies).toEqual( + expect.arrayContaining(['React-Core', 'React-jsi']), + ); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); +}); 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 new file mode 100644 index 000000000000..6831f9ff6ae4 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -0,0 +1,110 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + SPM_INJECTED_MARKER, + injectSpmIntoExistingXcodeproj, + removeSpmInjection, +} = require('../generate-spm-xcodeproj'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const PLAIN = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +// Build a throwaway app dir: /MyApp.xcodeproj/project.pbxproj seeded with +// the plain (SPM-only) fixture, and a node_modules/react-native sibling so the +// relative reactNativePath resolves. +function scaffoldApp() { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-deinit-')); + const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeprojPath, {recursive: true}); + fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN, 'utf8'); + const rnRoot = path.join(appRoot, 'node_modules', 'react-native'); + fs.mkdirSync(rnRoot, {recursive: true}); + return {appRoot, xcodeprojPath, rnRoot}; +} + +function pbxprojOf(xcodeprojPath) { + return fs.readFileSync(path.join(xcodeprojPath, 'project.pbxproj'), 'utf8'); +} + +describe('removeSpmInjection — the surgical inverse of add', () => { + it('round-trips: add then deinit restores the pbxproj byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + + const injected = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(injected.status).toBe('injected'); + // It actually changed something + wrote the marker. + expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + true, + ); + + const removed = removeSpmInjection({appRoot, xcodeprojPath}); + expect(removed.status).toBe('removed'); + // Byte-identical to the pre-add pbxproj. + expect(pbxprojOf(xcodeprojPath)).toBe(before); + // Marker is gone. + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + }); + + it('preserves an unrelated edit made to the pbxproj after add', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + // Simulate a user edit AFTER injection: flip the deployment target. + const edited = pbxprojOf(xcodeprojPath).replace( + /IPHONEOS_DEPLOYMENT_TARGET = [0-9.]+;/g, + 'IPHONEOS_DEPLOYMENT_TARGET = 18.0;', + ); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + edited, + 'utf8', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + + const after = pbxprojOf(xcodeprojPath); + // The user's edit survives… + expect(after).toContain('IPHONEOS_DEPLOYMENT_TARGET = 18.0;'); + // …and all SPM injection is gone. + expect(after).not.toContain('Sync SPM Autolinking'); + expect(after).not.toContain('build/generated/autolinking/headers'); + expect(after).not.toContain('REACT_NATIVE_PATH'); + expect(after).not.toMatch(/relativePath = build\/xcframeworks/); + }); + + it('is a no-op (status: absent) when the project was never injected', () => { + const {appRoot, xcodeprojPath} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const result = removeSpmInjection({appRoot, xcodeprojPath}); + expect(result.status).toBe('absent'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js new file mode 100644 index 000000000000..da85f06e386c --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -0,0 +1,1110 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + SCAFFOLDER_MARKER, + SCAFFOLDER_VERSION, + emitScaffoldedPackageSwift, + scaffoldAll, + scaffoldPackageSwiftForDep, + translatePodspecToSpmTarget, +} = require('../scaffold-package-swift'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Minimal PodspecModel fixture builder so each test stays focused on the +// field it exercises. +function podspec(overrides /*: Object */ = {}) { + return { + name: 'react-native-foo', + version: '1.0', + sourceFiles: [], + publicHeaderFiles: [], + privateHeaderFiles: [], + excludeFiles: [], + headerMappingsDir: null, + headerMappingsDirs: [], + headerDir: null, + frameworks: [], + weakFrameworks: [], + libraries: [], + dependencies: [], + compilerFlags: [], + headerSearchPaths: [], + preprocessorDefines: [], + resources: [], + requiresArc: true, + warnings: [], + partial: false, + ...overrides, + }; +} + +function autolinkedDep(overrides = {}) { + return { + name: 'react-native-foo', + root: '/fake/node_modules/react-native-foo', + platforms: { + ios: { + podspecPath: + '/fake/node_modules/react-native-foo/react-native-foo.podspec', + }, + }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// translatePodspecToSpmTarget — pure: PodspecModel + AutolinkedDep → +// SpmScaffoldSpec. Buckets deps, substitutes Xcode tokens, validates names. +// --------------------------------------------------------------------------- + +describe('translatePodspecToSpmTarget', () => { + it('always uses toSwiftName(npm-name) as the SPM target name — header_dir does NOT change the target name', () => { + // The autolinker registers every autolinked dep under toSwiftName(npmName) + // in its aggregator. The scaffolded Package.swift's product MUST match + // that or SPM resolution fails on the .product(name:, package:) lookup. + // header_dir flows through headerSearchPaths instead. + const model = podspec({ + headerDir: 'react/renderer/components/safeareacontext', + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-safe-area-context'}), + ); + expect(spec.swiftName).toBe('ReactNativeSafeAreaContext'); + }); + + it('adds dirname(header_mappings_dir) as a header search path so namespaced includes resolve (reanimated/worklets pattern)', () => { + // reanimated/worklets ship headers at `apple/reanimated/...` and + // `Common/cpp/reanimated/...` with per-subspec header_mappings_dir, and + // include them as ``. SPM has no header_mappings_dir copy + // step, so the parent of each mappings dir must be on the search path. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'apple', 'reanimated'), {recursive: true}); + fs.mkdirSync(path.join(root, 'Common', 'cpp', 'reanimated'), { + recursive: true, + }); + const model = podspec({ + headerMappingsDirs: ['Common/cpp/reanimated', 'apple/reanimated'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated', root}), + ); + expect(spec.headerSearchPaths).toContain('apple'); + expect(spec.headerSearchPaths).toContain('Common/cpp'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('skips a header_mappings_dir whose parent dir does not exist on disk', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-scaffold-')); + try { + const model = podspec({headerMappingsDirs: ['nope/reanimated']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.headerSearchPaths).not.toContain('nope'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('wires a pod-style dependency (RNWorklets) to its npm sibling via the podToNpm index', () => { + const model = podspec({dependencies: ['RNWorklets', 'React-jsi']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated'}), + new Map([ + ['RNWorklets', 'react-native-worklets'], + ['RNReanimated', 'react-native-reanimated'], + ]), + ); + // RNWorklets → sibling; React-jsi → collapses into ReactNative core. + expect(spec.siblingNames).toContain('react-native-worklets'); + expect(spec.coreReactNative).toBe(true); + }); + + it('does not self-wire when a pod dependency maps back to the dep itself', () => { + const model = podspec({dependencies: ['RNReanimated']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated'}), + new Map([['RNReanimated', 'react-native-reanimated']]), + ); + expect(spec.siblingNames).not.toContain('react-native-reanimated'); + }); + + it('derives publicHeadersPath from header_mappings_dir, preferring the cross-platform (Common) namespace root', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wk-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'Common', 'cpp', 'worklets'), { + recursive: true, + }); + fs.mkdirSync(path.join(root, 'apple', 'worklets'), {recursive: true}); + const model = podspec({ + headerMappingsDirs: ['Common/cpp/worklets', 'apple/worklets'], + publicHeaderFiles: ['Common/cpp/worklets/**/*.h'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-worklets', root}), + ); + // Common/cpp (parent of Common/cpp/worklets) is what dependents need to + // resolve ; the apple/ root is not preferred. + expect(spec.publicHeadersPath).toBe('Common/cpp'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('header-map emulation: adds every header-containing subdir to the search path (flat-include libs like svg)', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hmap-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'apple', 'Elements'), {recursive: true}); + fs.mkdirSync(path.join(root, 'apple', 'Text'), {recursive: true}); + fs.writeFileSync(path.join(root, 'apple', 'Elements', 'A.h'), ''); + fs.writeFileSync(path.join(root, 'apple', 'Text', 'B.h'), ''); + fs.writeFileSync(path.join(root, 'apple', 'C.mm'), ''); + const model = podspec({ + sourceFiles: ['apple/Elements/A.h', 'apple/Text/B.h', 'apple/C.mm'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-svg', root}), + ); + expect(spec.headerSearchPaths).toContain('apple/Elements'); + expect(spec.headerSearchPaths).toContain('apple/Text'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('expands a recursive `/**` HEADER_SEARCH_PATH into the base dir + all subdirs (skia shape)', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rec-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'cpp', 'skia', 'include', 'core'), { + recursive: true, + }); + const model = podspec({ + headerSearchPaths: ['$(PODS_TARGET_SRCROOT)/cpp//**'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-skia', root}), + ); + expect(spec.headerSearchPaths).toContain('cpp'); // base + expect(spec.headerSearchPaths).toContain('cpp/skia'); // makes resolve + expect(spec.headerSearchPaths).toContain('cpp/skia/include/core'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('flags needsObjCPrefix (and adds "." to the search path) when the target has ObjC(++) sources', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'objc-scaffold-')); + try { + fs.writeFileSync(path.join(root, 'A.mm'), ''); + const model = podspec({sourceFiles: ['A.mm', 'B.cpp']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.needsObjCPrefix).toBe(true); + expect(spec.headerSearchPaths).toContain('.'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('does not flag needsObjCPrefix for a C++-only target', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cpp-scaffold-')); + try { + fs.writeFileSync(path.join(root, 'A.cpp'), ''); + const model = podspec({sourceFiles: ['A.cpp']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.needsObjCPrefix).toBe(false); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('does not add "." for a single-segment header_mappings_dir', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'ios'), {recursive: true}); + const model = podspec({headerMappingsDirs: ['ios']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.headerSearchPaths).not.toContain('.'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('still uses toSwiftName(npm-name) even when header_dir is a plain identifier (matches autolinker registration)', () => { + const model = podspec({headerDir: 'reanimated'}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated'}), + ); + expect(spec.swiftName).toBe('ReactNativeReanimated'); + }); + + it('falls back cleanly when header_dir is absent', () => { + const model = podspec({headerDir: null}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo-bar'}), + ); + expect(spec.swiftName).toBe('ReactNativeFooBar'); + }); + + it('substitutes $(PODS_TARGET_SRCROOT) in HEADER_SEARCH_PATHS with the target-relative form', () => { + const model = podspec({ + headerSearchPaths: ['$(PODS_TARGET_SRCROOT)/common/cpp'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.headerSearchPaths).toEqual(['common/cpp']); + }); + + it('drops HEADER_SEARCH_PATHS entries with unresolved Xcode tokens and warns', () => { + const model = podspec({ + headerSearchPaths: [ + '$(PODS_TARGET_SRCROOT)/ok', + '$(SOMETHING_UNKNOWN)/foo', + ], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.headerSearchPaths).toEqual(['ok']); + expect(spec.warnings.some(w => /SOMETHING_UNKNOWN/.test(w))).toBe(true); + }); + + it('buckets React-Core / React-jsi / RCT-Folly / glog into the single ReactNative product', () => { + const model = podspec({ + dependencies: ['React-Core', 'React-jsi', 'RCT-Folly', 'glog'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.coreReactNative).toBe(true); + expect(spec.siblingNames).toEqual([]); + }); + + it('routes sibling RN deps (react-native-*) into siblingNames', () => { + const model = podspec({ + dependencies: ['React-Core', 'react-native-worklets'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.coreReactNative).toBe(true); + expect(spec.siblingNames).toEqual(['react-native-worklets']); + }); + + it('treats a package.json codegenConfig as an implicit React-core dep (New-Arch libs strip install_modules_dependencies — svg shape)', () => { + // svg declares its React-core dep only via install_modules_dependencies(s), + // which we strip — so model.dependencies has NO React-Core. The codegenConfig + // marker is what tells us it still needs the React-GeneratedCode package. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegen-dep-')); + try { + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'react-native-svg', + codegenConfig: {name: 'rnsvg'}, + }), + ); + const model = podspec({dependencies: []}); // nothing explicit + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-svg', root}), + ); + expect(spec.coreReactNative).toBe(true); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('does NOT force coreReactNative for a non-codegen dep with no React deps', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'no-codegen-dep-')); + try { + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({name: 'react-native-foo'}), // no codegenConfig + ); + const model = podspec({dependencies: []}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.coreReactNative).toBe(false); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('warns + drops unknown non-RN dependencies (MMKV, AFNetworking)', () => { + const model = podspec({dependencies: ['MMKV', 'AFNetworking']}); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.coreReactNative).toBe(false); + expect(spec.siblingNames).toEqual([]); + expect(spec.warnings.some(w => /MMKV/.test(w))).toBe(true); + expect(spec.warnings.some(w => /AFNetworking/.test(w))).toBe(true); + }); + + it('silently drops cross-subspec refs like "react-native-foo/common" from the same podspec', () => { + // CocoaPods uses this for one subspec depending on another from the + // SAME spec — after flattenSubspecs merges everything into one SPM + // target the ref is meaningless. Must not be treated as a sibling. + const model = podspec({ + name: 'react-native-safe-area-context', + dependencies: ['React-Core', 'react-native-safe-area-context/common'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-safe-area-context'}), + ); + expect(spec.siblingNames).toEqual([]); + expect(spec.coreReactNative).toBe(true); + }); + + it('strips subspec suffix from sibling RN deps ("react-native-worklets/foo" → "react-native-worklets")', () => { + const model = podspec({ + dependencies: ['react-native-worklets/foo', 'react-native-worklets/bar'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.siblingNames).toEqual(['react-native-worklets']); + }); + + it('passes through frameworks, weak frameworks, compiler flags, resources', () => { + const model = podspec({ + frameworks: ['UIKit', 'CoreMotion'], + weakFrameworks: ['SafariServices'], + compilerFlags: ['-Wno-documentation'], + resources: ['Foo.png'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.extraFrameworks).toEqual(['UIKit', 'CoreMotion']); + expect(spec.weakFrameworks).toEqual(['SafariServices']); + expect(spec.compilerFlags).toEqual(['-Wno-documentation']); + expect(spec.resources).toEqual(['Foo.png']); + }); + + it('expands podspec source globs into explicit file paths against the dep root, and infers publicHeadersPath', () => { + // Fake a dep on disk so glob expansion can find real files. + const depDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-translate-')); + try { + fs.mkdirSync(path.join(depDir, 'ios', 'Sub'), {recursive: true}); + fs.writeFileSync(path.join(depDir, 'ios', 'Foo.h'), ''); + fs.writeFileSync(path.join(depDir, 'ios', 'Foo.mm'), ''); + fs.writeFileSync(path.join(depDir, 'ios', 'Sub', 'Bar.h'), ''); + const model = podspec({sourceFiles: ['ios/**/*.{h,m,mm}']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({root: depDir}), + ); + // SPM rejects globs — these must be explicit relative paths now. + expect(spec.sources).toEqual( + expect.arrayContaining(['ios/Foo.h', 'ios/Foo.mm', 'ios/Sub/Bar.h']), + ); + // publicHeadersPath is inferred from the first existing prefix dir + // (so SPM's "publicHeadersPath defaults to non-existent include/" + // error doesn't fire). + expect(spec.publicHeadersPath).toBe('ios'); + } finally { + fs.rmSync(depDir, {recursive: true, force: true}); + } + }); + + it('filters out files matching exclude_files globs after expansion', () => { + const depDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-translate-')); + try { + fs.mkdirSync(path.join(depDir, 'ios', 'Fabric'), {recursive: true}); + fs.writeFileSync(path.join(depDir, 'ios', 'KeepMe.h'), ''); + fs.writeFileSync(path.join(depDir, 'ios', 'Fabric', 'SkipMe.h'), ''); + const model = podspec({ + sourceFiles: ['ios/**/*.h'], + excludeFiles: ['ios/Fabric/**'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({root: depDir}), + ); + expect(spec.sources).toContain('ios/KeepMe.h'); + expect(spec.sources).not.toContain('ios/Fabric/SkipMe.h'); + } finally { + fs.rmSync(depDir, {recursive: true, force: true}); + } + }); +}); + +// --------------------------------------------------------------------------- +// emitScaffoldedPackageSwift — pure: SpmScaffoldSpec → Swift string. +// Snapshot-style "contains" assertions on the key emitted lines. +// --------------------------------------------------------------------------- + +describe('emitScaffoldedPackageSwift', () => { + function baseSpec(overrides = {}) { + return { + swiftName: 'foo', + sources: [], + headerSearchPaths: [], + preprocessorDefines: [], + needsObjCPrefix: false, + coreReactNative: false, + siblingNames: [], + extraFrameworks: [], + weakFrameworks: [], + compilerFlags: [], + publicHeadersPath: null, + resources: [], + warnings: [], + ...overrides, + }; + } + + it('contains the SCAFFOLDER marker (after the line-1 swift-tools-version directive) and NOT the autolinker AUTOGEN marker', () => { + const out = emitScaffoldedPackageSwift(baseSpec()); + // Line 1 is reserved for the swift-tools-version directive — SPM ignores + // it elsewhere. The scaffolder marker lives on a subsequent line. + expect(out.split('\n', 1)[0]).toMatch(/^\/\/ swift-tools-version: /); + expect(out).toContain(SCAFFOLDER_MARKER); + // The autolinker's marker — must be absent so isSelfManagedPackage + // treats this file as self-managed. + expect(out).not.toContain( + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js', + ); + }); + + it('includes a cache-slot label comment when provided (bumps SPM manifest hash on slot change)', () => { + const out = emitScaffoldedPackageSwift(baseSpec(), { + cacheSlotLabel: '0.87.0-nightly-20260513-abc/debug', + }); + expect(out).toContain('// Cache slot: 0.87.0-nightly-20260513-abc/debug'); + }); + + it('is fully declarative — no runtime discovery code, no Foundation import', () => { + const out = emitScaffoldedPackageSwift(baseSpec()); + expect(out).not.toContain('import Foundation'); + expect(out).not.toContain('#filePath'); + expect(out).not.toContain('FileManager'); + }); + + it('emits a header-search-path directive per podspec entry (.headerSearchPath("common/cpp"))', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({headerSearchPaths: ['common/cpp']}), + ); + expect(out).toContain('.headerSearchPath("common/cpp")'); + }); + + it('declares the ReactNative package + product via scaffold-time relative paths when coreReactNative is true', () => { + const out = emitScaffoldedPackageSwift(baseSpec({coreReactNative: true}), { + cacheSlotLabel: null, + remote: null, + codegenPackageDir: '../../ios/build/generated/ios', + localXcfwPackageDir: '../../ios/build/xcframeworks', + }); + expect(out).toContain( + '.package(name: "ReactNative", path: "../../ios/build/xcframeworks")', + ); + expect(out).toContain( + '.package(name: "React-GeneratedCode", path: "../../ios/build/generated/ios")', + ); + expect(out).toContain( + '.product(name: "ReactNative", package: "ReactNative")', + ); + }); + + it('throws when coreReactNative is set but no codegenPackageDir was provided', () => { + expect(() => + emitScaffoldedPackageSwift(baseSpec({coreReactNative: true})), + ).toThrow(/codegenPackageDir is required/); + }); + + it('remote mode: declares .package(url:exact:) and needs no local xcframeworks path', () => { + const out = emitScaffoldedPackageSwift(baseSpec({coreReactNative: true}), { + cacheSlotLabel: null, + remote: { + url: 'https://github.com/facebook/react-native-apple', + version: '0.87.0', + identity: 'react-native-apple', + }, + codegenPackageDir: '../../ios/build/generated/ios', + localXcfwPackageDir: null, + }); + expect(out).toContain( + '.package(url: "https://github.com/facebook/react-native-apple", exact: "0.87.0")', + ); + expect(out).toContain( + '.product(name: "ReactNative", package: "react-native-apple")', + ); + expect(out).not.toContain('build/xcframeworks'); + }); + + it('emits sibling .package(path: "../") + .product entries for sibling RN deps', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({siblingNames: ['react-native-worklets']}), + ); + // Path uses the libs/ symlink name (where the autolinker places + // the sibling), NOT the npm name — `../react-native-worklets` would be + // `libs/react-native-worklets`, which does not exist. + expect(out).toContain( + '.package(name: "ReactNativeWorklets", path: "../ReactNativeWorklets")', + ); + expect(out).toContain( + '.product(name: "ReactNativeWorklets", package: "ReactNativeWorklets")', + ); + }); + + it('-includes the ObjC prefix header in c/cxx settings when needsObjCPrefix is set', () => { + const withPrefix = emitScaffoldedPackageSwift( + baseSpec({needsObjCPrefix: true}), + ); + expect( + ( + withPrefix.match( + /\.unsafeFlags\(\["-include", "react-native-spm-prefix\.h"\]\)/g, + ) ?? [] + ).length, + ).toBe(2); // cSettings + cxxSettings + // Not emitted for a C/C++-only target. + const noPrefix = emitScaffoldedPackageSwift( + baseSpec({needsObjCPrefix: false}), + ); + expect(noPrefix).not.toContain('-include'); + }); + + it('emits preprocessor defines as .define(...) in c/cxx settings, escaping quoted values and honoring config', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({ + preprocessorDefines: [ + {name: 'WORKLETS_VERSION', value: '0.9.2', config: null}, + { + name: 'WORKLETS_FEATURE_FLAGS', + value: '"[A:false][B:true]"', + config: null, + }, + {name: 'HERMES_ENABLE_DEBUGGER', value: '1', config: 'debug'}, + {name: 'NDEBUG', value: null, config: 'release'}, + ], + }), + ); + expect(out).toContain('.define("WORKLETS_VERSION", to: "0.9.2")'); + // Embedded quotes escaped for the Swift string literal. + expect(out).toContain( + '.define("WORKLETS_FEATURE_FLAGS", to: "\\"[A:false][B:true]\\"")', + ); + expect(out).toContain( + '.define("HERMES_ENABLE_DEBUGGER", to: "1", .when(configuration: .debug))', + ); + // Valueless define + release config. + expect(out).toContain('.define("NDEBUG", .when(configuration: .release))'); + }); + + it('emits sources: array when podspec declared globs', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({sources: ['ios/**/*.{h,m,mm}', 'common/cpp/**/*.{cpp,h}']}), + ); + expect(out).toContain('sources: ['); + expect(out).toContain('"ios/**/*.{h,m,mm}"'); + expect(out).toContain('"common/cpp/**/*.{cpp,h}"'); + }); + + it('omits sources: line when no globs (SPM auto-scans target dir)', () => { + const out = emitScaffoldedPackageSwift(baseSpec({sources: []})); + expect(out).not.toContain('sources: ['); + }); + + it('emits publicHeadersPath when header_mappings_dir set', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({publicHeadersPath: 'common/cpp/foo'}), + ); + expect(out).toContain('publicHeadersPath: "common/cpp/foo"'); + }); + + it('dedups linker frameworks (default + extras = no UIKit twice)', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({extraFrameworks: ['UIKit', 'CoreMotion']}), + ); + const uikitCount = (out.match(/\.linkedFramework\("UIKit"\)/g) || []) + .length; + expect(uikitCount).toBe(1); + expect(out).toContain('.linkedFramework("CoreMotion")'); + }); + + it('embeds podspec compiler_flags into cxxSettings unsafeFlags', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({compilerFlags: ['-Wno-documentation']}), + ); + expect(out).toContain('"-Wno-documentation"'); + }); +}); + +// --------------------------------------------------------------------------- +// scaffoldPackageSwiftForDep — orchestrator with I/O. Tested via temp dirs; +// covers each skip rule + the happy path. +// --------------------------------------------------------------------------- + +describe('scaffoldPackageSwiftForDep', () => { + let appRoot; + let depRoot; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-app-')); + depRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-dep-')); + }); + + afterEach(() => { + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(depRoot, {recursive: true, force: true}); + }); + + function makePodspec() { + // Minimal valid podspec — exercises the regex-parser fallback path. + const podspecPath = path.join(depRoot, 'react-native-foo.podspec'); + fs.writeFileSync( + podspecPath, + ` +Pod::Spec.new do |s| + s.name = "react-native-foo" + s.version = "1.0" + s.source_files = "ios/**/*.{h,m,mm}" + s.dependency "React-Core" +end +`, + ); + return podspecPath; + } + + function makeCtx(overrides = {}) { + return { + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + force: false, + dryRun: false, + cacheSlotLabel: null, + ...overrides, + }; + } + + function makeDep(overrides = {}) { + return { + name: 'react-native-foo', + root: depRoot, + platforms: {ios: {}}, + ...overrides, + }; + } + + it('writes Package.swift into the dep root on the happy path', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(true); + const content = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + // Line 1 is the swift-tools-version directive; the scaffolder marker + // appears immediately after (still detectable by `isScaffolded` checks + // that scan the whole file). + expect(content.split('\n', 1)[0]).toMatch(/^\/\/ swift-tools-version: /); + expect(content).toContain(SCAFFOLDER_MARKER); + }); + + it('skips (does not write) a mixed-language dep — Swift + ObjC(++) cannot share one SPM target', () => { + // react-native-screens shape: a single source glob mixing .swift and .mm. + const podspecPath = path.join(depRoot, 'react-native-foo.podspec'); + fs.writeFileSync( + podspecPath, + ` +Pod::Spec.new do |s| + s.name = "react-native-foo" + s.version = "1.0" + s.source_files = "ios/**/*.{h,m,mm,swift}" + s.dependency "React-Core" +end +`, + ); + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.swift'), ''); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.mm'), ''); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-mixed-language'); + // Fail-closed: no half-baked manifest left behind. + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false); + }); + + it('computes app paths relative to the libs/ symlink, not dep.root (fresh-resolve correctness)', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + const content = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + // swiftName = ReactNativeFoo; the autolinker references the dep via + // build/generated/autolinking/libs/ReactNativeFoo. SwiftPM resolves the + // manifest's relative paths against THAT location, so: + // build/generated/ios -> ../../../ios + // build/xcframeworks -> ../../../../xcframeworks + expect(content).toContain( + '.package(name: "React-GeneratedCode", path: "../../../ios")', + ); + expect(content).toContain( + '.package(name: "ReactNative", path: "../../../../xcframeworks")', + ); + // The old dep.root-relative form (doubled to …/autolinking/ios/build/... + // through the symlink) must NOT be emitted. + expect(content).not.toContain('../../ios/build/generated/ios'); + }); + + it('reports previouslyExisted=false for first-time scaffolds (so the CLI can prompt)', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + if (result.status === 'written') { + expect(result.previouslyExisted).toBe(false); + } + }); + + it('reports previouslyExisted=true when regenerating an existing scaffolder-marker file (slot change)', () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// Cache slot: OLD\n`, + ); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: 'NEW'}), + ); + expect(result.status).toBe('written'); + if (result.status === 'written') { + expect(result.previouslyExisted).toBe(true); + } + }); + + it('skips with skipped-no-ios when autolinking.json has no ios platform', () => { + const result = scaffoldPackageSwiftForDep( + makeDep({platforms: {ios: null}}), + makeCtx(), + ); + expect(result.status).toBe('skipped-no-ios'); + }); + + it('skips with skipped-no-podspec when no .podspec exists in dep root', () => { + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-no-podspec'); + }); + + it('refuses to touch a Package.swift that lacks the scaffolder marker (user/upstream-managed)', () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// Hand-written. Do not touch.', + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-self-managed'); + // File unchanged + expect(fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8')).toBe( + '// Hand-written. Do not touch.', + ); + }); + + it('refuses to scaffold when a nested ios/Package.swift exists without markers', () => { + makePodspec(); + // Library ships its manifest under ios/ to keep the npm-package root + // free of SPM artifacts. The scaffolder should NOT write a stray root + // Package.swift — that would shadow the nested one (the autolinker + // checks the root first). + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + const nestedContent = + '// swift-tools-version: 6.0\n// Hand-written nested manifest.\n'; + fs.writeFileSync(path.join(depRoot, 'ios', 'Package.swift'), nestedContent); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-self-managed'); + // Root stayed clean + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false); + // Nested file untouched + expect( + fs.readFileSync(path.join(depRoot, 'ios', 'Package.swift'), 'utf8'), + ).toBe(nestedContent); + }); + + it('refuses to overwrite a Package.swift carrying the autolinker AUTOGEN_MARKER', () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit.\n', + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-autogen'); + }); + + it('skips re-scaffolding when the existing file carries the scaffolder marker AND the same cache slot', () => { + makePodspec(); + // Pre-existing scaffold from same slot AND current generator version + // — otherwise the version-bump skip-bypass kicks in. + const prior = + SCAFFOLDER_MARKER + + `\n// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}` + + '\n// Cache slot: 0.87.0-X/debug\n// rest unchanged'; + fs.writeFileSync(path.join(depRoot, 'Package.swift'), prior); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: '0.87.0-X/debug'}), + ); + expect(result.status).toBe('skipped-scaffolder-marker'); + expect(fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8')).toBe( + prior, + ); + }); + + it('REGENERATES when the existing scaffolder file is from a different cache slot (manifest hash bump)', () => { + makePodspec(); + const prior = + SCAFFOLDER_MARKER + '\n// Cache slot: OLD-slot/debug\n// rest'; + fs.writeFileSync(path.join(depRoot, 'Package.swift'), prior); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: 'NEW-slot/debug'}), + ); + expect(result.status).toBe('written'); + expect( + fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'), + ).toContain('// Cache slot: NEW-slot/debug'); + }); + + it('--force re-overwrites a scaffolder-marker file even when the slot is unchanged', () => { + makePodspec(); + const prior = + SCAFFOLDER_MARKER + + '\n// Cache slot: SLOT-A/debug\n// hand edits here will be lost'; + fs.writeFileSync(path.join(depRoot, 'Package.swift'), prior); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: 'SLOT-A/debug', force: true}), + ); + expect(result.status).toBe('written'); + expect( + fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'), + ).not.toContain('hand edits here will be lost'); + }); + + it('--dry-run produces a ScaffoldResult but writes nothing', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({dryRun: true}), + ); + expect(result.status).toBe('written'); + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false); + }); + + it("honors a dep's spm: { scaffold: false } opt-out in its react-native.config.js", () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + 'module.exports = { spm: { scaffold: false } };', + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-opt-out'); + }); + + it('returns skipped-is-react-native for `react-native` itself (handled by the xcframework path)', () => { + const result = scaffoldPackageSwiftForDep( + makeDep({name: 'react-native'}), + makeCtx(), + ); + expect(result.status).toBe('skipped-is-react-native'); + }); +}); + +// --------------------------------------------------------------------------- +// scaffoldAll — minimal smoke test. The orchestrator delegates everything +// to scaffoldPackageSwiftForDep (already covered above); here we just +// verify it reads autolinking.json and produces one result per dep. +// --------------------------------------------------------------------------- + +describe('scaffoldAll', () => { + let appRoot; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-all-')); + }); + + afterEach(() => { + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + it('returns [] and logs when autolinking.json is absent', () => { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results).toEqual([]); + }); + + it('walks dependencies in autolinking.json and produces one result per entry', () => { + const autolinkingDir = path.join(appRoot, 'build/generated/autolinking'); + fs.mkdirSync(autolinkingDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkingDir, 'autolinking.json'), + JSON.stringify({ + dependencies: { + 'react-native-a': {root: '/no/such/a', platforms: {ios: {}}}, + 'react-native-b': {root: '/no/such/b', platforms: {ios: null}}, + }, + }), + ); + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.length).toBe(2); + expect(results.find(r => r.depName === 'react-native-b').status).toBe( + 'skipped-no-ios', + ); + // react-native-a's root doesn't exist → skipped-no-podspec + expect(results.find(r => r.depName === 'react-native-a').status).toBe( + 'skipped-no-podspec', + ); + }); +}); + +// --------------------------------------------------------------------------- +// SCAFFOLDER_VERSION — auto-regen when the emitter's output format changes +// +// Without versioning, a Package.swift scaffolded by an older generator stays +// on disk indefinitely (skip-on-marker), even when our template has since +// been fixed. Bumping SCAFFOLDER_VERSION triggers a one-time regeneration +// on next scaffold. Edits are persisted via patch-package per the marker +// comment, so destructive regen here aligns with the documented workflow. +// --------------------------------------------------------------------------- + +describe('SCAFFOLDER_VERSION', () => { + it('is a positive integer', () => { + expect(Number.isInteger(SCAFFOLDER_VERSION)).toBe(true); + expect(SCAFFOLDER_VERSION).toBeGreaterThanOrEqual(1); + }); + + it('emitter writes the current version to the file', () => { + const out = emitScaffoldedPackageSwift({ + swiftName: 'foo', + sources: [], + headerSearchPaths: [], + preprocessorDefines: [], + needsObjCPrefix: false, + coreReactNative: false, + siblingNames: [], + extraFrameworks: [], + weakFrameworks: [], + compilerFlags: [], + publicHeadersPath: null, + resources: [], + warnings: [], + }); + expect(out).toMatch( + new RegExp(`^// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}$`, 'm'), + ); + }); +}); + +describe('scaffoldPackageSwiftForDep — version-based regen', () => { + let tempDir; + let depRoot; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-version-')); + depRoot = path.join(tempDir, 'node_modules', 'react-native-foo'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'package.json'), + JSON.stringify({name: 'react-native-foo', version: '1.0.0'}), + ); + fs.writeFileSync( + path.join(depRoot, 'react-native-foo.podspec'), + "Pod::Spec.new do |s|\n s.name = 'react-native-foo'\n s.version = '1.0'\n s.source_files = 'ios/**/*.{h,m,mm}'\nend\n", + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + function makeDep() { + return { + name: 'react-native-foo', + root: depRoot, + platforms: {ios: {}}, + }; + } + + function makeCtx(overrides = {}) { + return { + appRoot: tempDir, + reactNativeRoot: depRoot, + force: false, + dryRun: false, + cacheSlotLabel: 'SLOT-A/debug', + skipDeps: new Set(), + ...overrides, + }; + } + + it('regenerates a file scaffolded under an older version, even without --force', () => { + const olderVersion = Math.max(1, SCAFFOLDER_VERSION - 1); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// AUTO-SCAFFOLDED-VERSION: ${olderVersion}\n// Cache slot: SLOT-A/debug\n`, + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + const after = fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'); + expect(after).toContain( + `// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}`, + ); + }); + + it('regenerates a marker-tagged file with NO version line (treats as v1)', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// Cache slot: SLOT-A/debug\n// pre-versioning scaffold\n`, + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + const after = fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'); + expect(after).not.toContain('pre-versioning scaffold'); + expect(after).toContain( + `// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}`, + ); + }); + + it('skips when the existing file is already at the current version and slot', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}\n// Cache slot: SLOT-A/debug\n`, + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-scaffolder-marker'); + }); +}); 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 new file mode 100644 index 000000000000..35e1989690ea --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -0,0 +1,230 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + detectStandardRnLayoutRedirect, + findInjectedXcodeproj, + resolveAction, + shouldAutoDeintegrate, +} = require('../../setup-apple-spm'); +const {SPM_INJECTED_MARKER} = require('../generate-spm-xcodeproj'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Create an in-place-injected xcodeproj fixture: a directory carrying the +// `.spm-injected.json` marker (what injectSpmIntoExistingXcodeproj writes). +function mkInjectedXcodeproj(appRoot, name) { + const dir = path.join(appRoot, name); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, SPM_INJECTED_MARKER), + JSON.stringify({rootUuid: 'X', target: 'MyApp', injectedUuids: []}), + ); + return dir; +} + +// Create a (CocoaPods or plain) xcodeproj fixture with a minimal pbxproj. +function mkXcodeproj(appRoot, name, {cocoapods = false} = {}) { + const dir = path.join(appRoot, name); + fs.mkdirSync(dir, {recursive: true}); + const baseConfig = cocoapods + ? 'baseConfigurationReference = ABC /* Pods-MyApp.debug.xcconfig */;\n' + : ''; + fs.writeFileSync( + path.join(dir, 'project.pbxproj'), + `// !$*UTF8*$!\n{\n\tobjects = {\n${baseConfig}\t};\n}\n`, + ); + return dir; +} + +function gitInitAndCommit(dir) { + const opts = {cwd: dir, stdio: 'ignore'}; + execFileSync('git', ['init'], opts); + execFileSync('git', ['config', 'user.email', 'test@example.com'], opts); + execFileSync('git', ['config', 'user.name', 'Test'], opts); + execFileSync('git', ['add', '-A'], opts); + execFileSync('git', ['commit', '-m', 'init'], opts); +} + +// --------------------------------------------------------------------------- +// resolveAction — zero-arg default. Explicit action wins; otherwise `update` +// when an injection marker exists, else `add` (first run). +// --------------------------------------------------------------------------- + +describe('resolveAction', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-resolve-action-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns the requested action verbatim when one is given', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveAction('add', tempDir)).toBe('add'); + expect(resolveAction('update', tempDir)).toBe('update'); + expect(resolveAction('deinit', tempDir)).toBe('deinit'); + expect(resolveAction('scaffold', tempDir)).toBe('scaffold'); + }); + + it('defaults to `add` on first run (no injection marker)', () => { + expect(resolveAction(null, tempDir)).toBe('add'); + }); + + it('defaults to `add` even when a (non-injected) xcodeproj exists', () => { + mkXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveAction(null, tempDir)).toBe('add'); + }); + + it('defaults to `update` once an injection marker is present', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveAction(null, tempDir)).toBe('update'); + }); +}); + +// --------------------------------------------------------------------------- +// findInjectedXcodeproj — locates the `.xcodeproj` carrying the injection marker +// --------------------------------------------------------------------------- + +describe('findInjectedXcodeproj', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-find-injected-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns the injected project path when a marker is present', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(findInjectedXcodeproj(tempDir)).toBe( + path.join(tempDir, 'MyApp.xcodeproj'), + ); + }); + + it('returns null when no injected project exists', () => { + mkXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(findInjectedXcodeproj(tempDir)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// detectStandardRnLayoutRedirect — auto-redirect into ios/ when run from the JS +// root of a standard RN app. +// --------------------------------------------------------------------------- + +describe('detectStandardRnLayoutRedirect', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-redirect-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns the ios/ subdir when cwd === projectRoot AND ios/ exists', () => { + fs.mkdirSync(path.join(tempDir, 'ios')); + expect(detectStandardRnLayoutRedirect(tempDir, tempDir)).toBe( + path.join(tempDir, 'ios'), + ); + }); + + it('returns null when running from a subdirectory (already cd-ed)', () => { + fs.mkdirSync(path.join(tempDir, 'ios')); + expect( + detectStandardRnLayoutRedirect(path.join(tempDir, 'ios'), tempDir), + ).toBeNull(); + }); + + it('returns null for flat layouts (no ios/ subdir, e.g. rn-tester)', () => { + expect(detectStandardRnLayoutRedirect(tempDir, tempDir)).toBeNull(); + }); + + it('returns null when `ios` is a file, not a directory', () => { + fs.writeFileSync(path.join(tempDir, 'ios'), ''); + expect(detectStandardRnLayoutRedirect(tempDir, tempDir)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// 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). +// --------------------------------------------------------------------------- + +describe('shouldAutoDeintegrate', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-safegate-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('false when the project is not CocoaPods-integrated', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: false, + }); + expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); + }); + + it('false when there is no target project at all', () => { + expect(shouldAutoDeintegrate(tempDir, null)).toBe(false); + }); + + it('false for a CocoaPods project whose Podfile has third-party pods', () => { + const xcodeproj = mkXcodeproj(tempDir, '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); + }); + + it('false when the pbxproj has uncommitted edits (not revertible)', () => { + const xcodeproj = mkXcodeproj(tempDir, '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); + }); + + it('true despite an unrelated dirty file when pbxproj + Podfile are clean', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: true, + }); + 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/__tests__/spm-pbxproj-test.js b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js new file mode 100644 index 000000000000..2f2d352f313d --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js @@ -0,0 +1,353 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + addArrayMembers, + addArrayStringValues, + ensureScalarField, + findApplicationTargets, + findField, + findObjectByUuid, + findProjectObject, + generateUUID, + insertObjectsIntoSection, + namespacedUUID, + quoteIfNeeded, + removeArrayMembersByUuid, + removeArrayStringValues, + removeEmptyPodsGroup, + removeField, + removeObjectByUuid, + scanToClose, + serializeEntry, + uuidsInArray, +} = require('../spm-pbxproj'); +const fs = require('fs'); +const path = require('path'); + +const PLAIN_PBXPROJ = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +// --------------------------------------------------------------------------- +// generateUUID +// --------------------------------------------------------------------------- + +describe('generateUUID', () => { + it('produces a 24-character uppercase hex string', () => { + const result = generateUUID('test-seed'); + expect(result).toMatch(/^[0-9A-F]{24}$/); + }); + + it('is deterministic', () => { + expect(generateUUID('same')).toBe(generateUUID('same')); + }); + + it('produces different results for different seeds', () => { + expect(generateUUID('seed-a')).not.toBe(generateUUID('seed-b')); + }); +}); + +// --------------------------------------------------------------------------- +// quoteIfNeeded +// --------------------------------------------------------------------------- + +describe('quoteIfNeeded', () => { + it.each([ + ['foo.bar/baz', 'foo.bar/baz'], + ['foo bar', '"foo bar"'], + ['a\\b', '"a\\\\b"'], + ['a"b', '"a\\"b"'], + ['', '""'], + ])('quoteIfNeeded(%j) => %j', (input, expected) => { + expect(quoteIfNeeded(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Surgical-edit toolkit (in-place injection primitives) +// --------------------------------------------------------------------------- + +describe('namespacedUUID', () => { + it('is deterministic and 24-hex', () => { + const a = namespacedUUID('ROOT', 'sec', 'id'); + expect(a).toMatch(/^[0-9A-F]{24}$/); + expect(namespacedUUID('ROOT', 'sec', 'id')).toBe(a); + }); + + it('differs by root, section, id, and salt', () => { + const base = namespacedUUID('ROOT', 'sec', 'id'); + expect(namespacedUUID('OTHER', 'sec', 'id')).not.toBe(base); + expect(namespacedUUID('ROOT', 'other', 'id')).not.toBe(base); + expect(namespacedUUID('ROOT', 'sec', 'other')).not.toBe(base); + expect(namespacedUUID('ROOT', 'sec', 'id', '2')).not.toBe(base); + }); +}); + +describe('scanToClose', () => { + it('matches braces and parens, skipping quoted delimiters', () => { + const t = 'x = { a = ("a)b"); };'; + const open = t.indexOf('{'); + expect(t[scanToClose(t, open)]).toBe('}'); + const paren = t.indexOf('('); + // The ")" inside the quoted string must not close the paren early. + expect(scanToClose(t, paren)).toBe(t.indexOf(');') + 0); + }); +}); + +describe('findObjectByUuid / findField', () => { + it('locates an object body and reads scalar + array fields', () => { + const target = findApplicationTargets(PLAIN_PBXPROJ)[0]; + expect(target.name).toBe('MyApp'); + const obj = findObjectByUuid(PLAIN_PBXPROJ, target.uuid); + expect(obj).not.toBeNull(); + const productType = findField(PLAIN_PBXPROJ, obj, 'productType'); + expect(productType.value).toContain('application'); + const buildPhases = findField(PLAIN_PBXPROJ, obj, 'buildPhases'); + expect(uuidsInArray(buildPhases.value).size).toBe(3); + }); + + it('returns null for an absent field', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + expect(findField(PLAIN_PBXPROJ, project, 'packageReferences')).toBeNull(); + }); +}); + +describe('addArrayMembers', () => { + it('creates an absent array field after the body open', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const out = addArrayMembers(PLAIN_PBXPROJ, project, 'packageReferences', [ + {uuid: 'CAFE0000000000000000CAFE', comment: 'ref'}, + ]); + expect(out).toMatch(/packageReferences = \(/); + expect(out).toContain('CAFE0000000000000000CAFE /* ref */'); + }); + + it('appends to and dedupes an existing array', () => { + const target = findApplicationTargets(PLAIN_PBXPROJ)[0]; + const member = [{uuid: 'AA0000000000000000000301'}]; // already in buildPhases + const out = addArrayMembers(PLAIN_PBXPROJ, target, 'buildPhases', member); + // Dedup: no second occurrence added. + expect(out.match(/AA0000000000000000000301/g)).toHaveLength( + PLAIN_PBXPROJ.match(/AA0000000000000000000301/g).length, + ); + }); + + it('prepends when requested', () => { + const target = findApplicationTargets(PLAIN_PBXPROJ)[0]; + const out = addArrayMembers( + PLAIN_PBXPROJ, + target, + 'buildPhases', + [{uuid: 'BEEF0000000000000000BEEF', comment: 'First'}], + {prepend: true}, + ); + const firstIdx = out.indexOf('BEEF0000000000000000BEEF'); + const sourcesIdx = out.indexOf('AA0000000000000000000301 /* Sources */'); + expect(firstIdx).toBeLessThan(sourcesIdx); + }); +}); + +describe('addArrayStringValues', () => { + function targetDebugDict(text) { + const cfg = findObjectByUuid(text, 'AA0000000000000000000901'); + const bs = findField(text, cfg, 'buildSettings'); + return {uuid: 'x', bodyOpen: bs.valueStart, bodyClose: bs.tokenEnd - 1}; + } + + it('creates an array seeded with $(inherited)', () => { + const out = addArrayStringValues( + PLAIN_PBXPROJ, + targetDebugDict(PLAIN_PBXPROJ), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + expect(out).toMatch(/OTHER_LDFLAGS = \(/); + expect(out).toContain('"$(inherited)"'); + expect(out).toContain('"-ObjC"'); + }); + + it('promotes an existing scalar to an array, preserving the old value', () => { + const scalar = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + 'OTHER_LDFLAGS = "-lz"; PRODUCT_NAME = "$(TARGET_NAME)";', + ); + const out = addArrayStringValues( + scalar, + targetDebugDict(scalar), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + expect(out).toMatch(/OTHER_LDFLAGS = \(/); + expect(out).toContain('"-lz"'); + expect(out).toContain('"-ObjC"'); + }); +}); + +describe('ensureScalarField', () => { + it('adds a scalar only when absent', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const out = ensureScalarField( + PLAIN_PBXPROJ, + project, + 'ORGANIZATIONNAME', + 'Acme', + ); + expect(out).toContain('ORGANIZATIONNAME = Acme;'); + // Re-running is a no-op. + const project2 = findProjectObject(out); + expect(ensureScalarField(out, project2, 'ORGANIZATIONNAME', 'Other')).toBe( + out, + ); + }); +}); + +describe('insertObjectsIntoSection', () => { + it('creates a new section before the objects dict closes', () => { + const entry = serializeEntry({ + uuid: 'DEAD0000000000000000DEAD', + comment: 'XCLocalSwiftPackageReference "x"', + fields: {isa: 'XCLocalSwiftPackageReference', relativePath: 'x'}, + }); + const out = insertObjectsIntoSection( + PLAIN_PBXPROJ, + 'XCLocalSwiftPackageReference', + entry, + ); + expect(out).toContain('/* Begin XCLocalSwiftPackageReference section */'); + expect(out).toContain('DEAD0000000000000000DEAD'); + // Still inside the objects dict (before rootObject). + expect(out.indexOf('DEAD0000000000000000DEAD')).toBeLessThan( + out.indexOf('rootObject ='), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Surgical removal — inverses used by `deinit` +// --------------------------------------------------------------------------- + +describe('surgical removal (deinit inverse)', () => { + const FAKE = 'DEADBEEF0000000000001234'; + + it('removeObjectByUuid exactly inverts insertObjectsIntoSection', () => { + const inserted = insertObjectsIntoSection( + PLAIN_PBXPROJ, + 'PBXBuildFile', + serializeEntry({ + uuid: FAKE, + comment: 'Fake', + fields: {isa: 'PBXBuildFile'}, + }), + ); + expect(inserted).not.toBe(PLAIN_PBXPROJ); + expect(removeObjectByUuid(inserted, FAKE)).toBe(PLAIN_PBXPROJ); + }); + + it('removeObjectByUuid is a no-op when the uuid is absent', () => { + expect(removeObjectByUuid(PLAIN_PBXPROJ, FAKE)).toBe(PLAIN_PBXPROJ); + }); + + it('removeArrayMembersByUuid inverts addArrayMembers on an existing array', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + const added = addArrayMembers(PLAIN_PBXPROJ, target, 'buildPhases', [ + {uuid: FAKE, comment: 'Fake'}, + ]); + expect(added).not.toBe(PLAIN_PBXPROJ); + expect(removeArrayMembersByUuid(added, [FAKE])).toBe(PLAIN_PBXPROJ); + }); + + it('removeField inverts ensureScalarField', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + const added = ensureScalarField( + PLAIN_PBXPROJ, + target, + 'SPM_TEST_FLAG', + '"yes"', + ); + expect(added).not.toBe(PLAIN_PBXPROJ); + const [target2] = findApplicationTargets(added); + expect(removeField(added, target2, 'SPM_TEST_FLAG')).toBe(PLAIN_PBXPROJ); + }); + + it('removeArrayStringValues removes only the named values', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + const seeded = addArrayStringValues(PLAIN_PBXPROJ, target, 'SPM_TEST_ARR', [ + '"-A"', + ]); + const [t2] = findApplicationTargets(seeded); + const appended = addArrayStringValues(seeded, t2, 'SPM_TEST_ARR', ['"-B"']); + const [t3] = findApplicationTargets(appended); + // Removing the appended "-B" returns to the seeded (single-value) state. + expect( + removeArrayStringValues(appended, t3, 'SPM_TEST_ARR', ['"-B"']), + ).toBe(seeded); + }); +}); + +// --------------------------------------------------------------------------- +// removeEmptyPodsGroup — clean up the leftover empty `Pods` group after +// `pod deintegrate` (which `add --deintegrate` runs). +// --------------------------------------------------------------------------- + +describe('removeEmptyPodsGroup', () => { + // A main group referencing an empty `Pods` group (what pod deintegrate leaves). + const WITH_EMPTY_PODS = [ + '// !$*UTF8*$!', + '{', + '\tobjects = {', + '/* Begin PBXGroup section */', + '\t\tAA0000000000000000000001 = {', + '\t\t\tisa = PBXGroup;', + '\t\t\tchildren = (', + '\t\t\t\t13B07FAE1A68108700A75B9A /* App */,', + '\t\t\t\tBBD78D7AC51CEA395F1C20DB /* Pods */,', + '\t\t\t);', + '\t\t\tsourceTree = "";', + '\t\t};', + '\t\tBBD78D7AC51CEA395F1C20DB /* Pods */ = {', + '\t\t\tisa = PBXGroup;', + '\t\t\tchildren = (', + '\t\t\t);', + '\t\t\tpath = Pods;', + '\t\t\tsourceTree = "";', + '\t\t};', + '/* End PBXGroup section */', + '\t};', + '}', + '', + ].join('\n'); + + it('removes the empty Pods group object and its parent reference', () => { + const out = removeEmptyPodsGroup(WITH_EMPTY_PODS); + expect(out).not.toContain('/* Pods */'); + expect(out).not.toContain('path = Pods;'); + // The unrelated child survives. + expect(out).toContain('13B07FAE1A68108700A75B9A /* App */,'); + }); + + it('leaves a NON-empty Pods group untouched (still integrated)', () => { + const nonEmpty = WITH_EMPTY_PODS.replace( + 'children = (\n\t\t\t);\n\t\t\tpath = Pods;', + 'children = (\n\t\t\t\tDEADBEEF0000000000000001 /* libPods.a */,\n\t\t\t);\n\t\t\tpath = Pods;', + ); + expect(removeEmptyPodsGroup(nonEmpty)).toBe(nonEmpty); + }); + + it('is a no-op when there is no Pods group', () => { + const noPods = WITH_EMPTY_PODS.split('\n') + .filter(l => !l.includes('Pods')) + .join('\n'); + expect(removeEmptyPodsGroup(noPods)).toBe(noPods); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js new file mode 100644 index 000000000000..15b235784fd8 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js @@ -0,0 +1,607 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const { + RemoteVersionError, + buildPerAppHeaderTree, + defaultCacheDir, + displayPath, + isPublishableVersion, + makeLogger, + readPackageJson, + remotePackageConfig, + resolveInstalledRnVersion, + resolveReactNativeRoot, + runCodegenAndInstallTemplate, + sharedCacheDir, + toSwiftName, +} = require('../spm-utils'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// toSwiftName +// --------------------------------------------------------------------------- + +describe('toSwiftName', () => { + it.each([ + ['@react-native/tester', 'Tester'], + ['my-app', 'MyApp'], + ['@scope/foo-bar', 'FooBar'], + ['simple', 'Simple'], + ['a--b', 'AB'], + ['my_great_app', 'MyGreatApp'], + ])('toSwiftName(%j) => %j', (input, expected) => { + expect(toSwiftName(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// defaultCacheDir +// --------------------------------------------------------------------------- + +describe('sharedCacheDir', () => { + it('matches CocoaPods shared_cache_dir (~/Library/Caches/ReactNative)', () => { + expect(sharedCacheDir()).toBe( + path.join(os.homedir(), 'Library', 'Caches', 'ReactNative'), + ); + }); +}); + +describe('defaultCacheDir', () => { + it('nests SPM artifacts under the canonical ReactNative cache root', () => { + const result = defaultCacheDir('0.80.0', 'debug'); + expect(result).toBe( + path.join( + os.homedir(), + 'Library', + 'Caches', + 'ReactNative', + 'spm-artifacts', + '0.80.0', + 'debug', + ), + ); + // No bundle-id-named dir that other tools might also use. + expect(result).not.toContain('com.facebook.ReactNative'); + }); + + it('varies by flavor', () => { + const debug = defaultCacheDir('1.0.0', 'debug'); + const release = defaultCacheDir('1.0.0', 'release'); + expect(debug).not.toBe(release); + expect(debug).toContain('debug'); + expect(release).toContain('release'); + }); +}); + +// --------------------------------------------------------------------------- +// displayPath +// --------------------------------------------------------------------------- + +describe('displayPath', () => { + it('replaces homedir with ~', () => { + const home = os.homedir(); + expect(displayPath(path.join(home, 'projects', 'app'))).toBe( + '~/projects/app', + ); + }); + + it('returns ~ for exact homedir', () => { + expect(displayPath(os.homedir())).toBe('~'); + }); + + it('returns relative path when close to cwd and not under $HOME', () => { + // displayPath prefers ~/ for paths under $HOME, so use a non-home path + // to test the relative-path logic. On macOS cwd is typically under $HOME, + // so we verify the ~/... behavior instead. + const cwd = process.cwd(); + const child = path.join(cwd, 'sub', 'dir'); + const result = displayPath(child); + // Either relative (sub/dir) or ~/... depending on whether cwd is under $HOME + const home = os.homedir(); + if (cwd.startsWith(home + path.sep)) { + expect(result).toMatch(/^~\//); + } else { + expect(result).toBe(path.join('sub', 'dir')); + } + }); + + it('returns absolute path for deep relative', () => { + // Paths more than 2 levels above cwd should stay absolute + // (unless they fall under $HOME) + const home = os.homedir(); + const p = path.join(home, 'deep', 'nested', 'path'); + // This is under $HOME, so it should use ~/ + expect(displayPath(p)).toBe('~/deep/nested/path'); + }); +}); + +// --------------------------------------------------------------------------- +// makeLogger +// --------------------------------------------------------------------------- + +describe('makeLogger', () => { + let spies; + + afterEach(() => { + if (spies) { + spies.forEach(s => s.mockRestore()); + spies = null; + } + }); + + function mockConsole(...methods) { + spies = methods.map(m => + jest.spyOn(console, m).mockImplementation(() => {}), + ); + return spies; + } + + it('log writes to stdout with green prefix', () => { + const [spy] = mockConsole('log'); + const {log} = makeLogger('test'); + log('hello'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('[test]')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('hello')); + }); + + it('warn writes to stderr with yellow prefix', () => { + const [spy] = mockConsole('warn'); + const {warn} = makeLogger('test'); + warn('caution'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('[test]')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('caution')); + }); + + it('die throws, sets exitCode, writes to stderr', () => { + const [spy] = mockConsole('error'); + const origExitCode = process.exitCode; + const {die} = makeLogger('test'); + expect(() => die('fatal')).toThrow('fatal'); + expect(process.exitCode).toBe(1); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('fatal')); + process.exitCode = origExitCode; + }); +}); + +// --------------------------------------------------------------------------- +// readPackageJson +// --------------------------------------------------------------------------- + +describe('readPackageJson', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns parsed JSON for valid file', () => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({name: 'test-pkg', version: '1.0.0'}), + ); + const result = readPackageJson(tempDir); + expect(result).toEqual({name: 'test-pkg', version: '1.0.0'}); + }); + + it('returns null for missing file', () => { + expect(readPackageJson(tempDir)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveReactNativeRoot +// --------------------------------------------------------------------------- + +describe('resolveReactNativeRoot', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('finds react-native hoisted above the app package root', () => { + const workspaceRoot = path.join(tempDir, 'workspace'); + const appRoot = path.join(workspaceRoot, 'packages', 'app', 'ios'); + const rnRoot = path.join(workspaceRoot, 'node_modules', 'react-native'); + fs.mkdirSync(appRoot, {recursive: true}); + fs.mkdirSync(rnRoot, {recursive: true}); + + expect( + resolveReactNativeRoot( + appRoot, + path.join(workspaceRoot, 'packages', 'app'), + ), + ).toBe(rnRoot); + }); +}); + +// --------------------------------------------------------------------------- +// isPublishableVersion +// --------------------------------------------------------------------------- + +describe('isPublishableVersion', () => { + it.each([ + ['0.86.3', true], + ['0.87.0-nightly-20260608-2ff3b81dc', true], + ['1.2.3', true], + ['1000.0.0', false], + ['0.0.0', false], + ['0.0.0-canary', false], + [null, false], + ['', false], + ])('isPublishableVersion(%j) => %j', (input, expected) => { + expect(isPublishableVersion(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// resolveInstalledRnVersion +// --------------------------------------------------------------------------- + +describe('resolveInstalledRnVersion', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + function writeRn(dir /*: string */, version /*: string */) { + const rnDir = path.join(dir, 'node_modules', 'react-native'); + fs.mkdirSync(rnDir, {recursive: true}); + fs.writeFileSync( + path.join(rnDir, 'package.json'), + JSON.stringify({name: 'react-native', version}), + ); + } + + it('reads the version from appRoot/node_modules/react-native', () => { + writeRn(tempDir, '0.86.3'); + expect(resolveInstalledRnVersion(tempDir)).toBe('0.86.3'); + }); + + it('walks up to find a hoisted react-native', () => { + const appRoot = path.join(tempDir, 'packages', 'app', 'ios'); + fs.mkdirSync(appRoot, {recursive: true}); + writeRn(tempDir, '0.87.0'); + expect(resolveInstalledRnVersion(appRoot)).toBe('0.87.0'); + }); + + it('returns null when react-native is not installed', () => { + expect(resolveInstalledRnVersion(tempDir)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// remotePackageConfig +// --------------------------------------------------------------------------- + +describe('remotePackageConfig', () => { + const REMOTE_CONFIG_REL = 'build/generated/autolinking/spm-remote.json'; + let tempDir; + let savedEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + savedEnv = { + url: process.env.RN_SPM_REMOTE_URL, + version: process.env.RN_SPM_REMOTE_VERSION, + }; + delete process.env.RN_SPM_REMOTE_URL; + delete process.env.RN_SPM_REMOTE_VERSION; + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + if (savedEnv.url == null) { + delete process.env.RN_SPM_REMOTE_URL; + } else { + process.env.RN_SPM_REMOTE_URL = savedEnv.url; + } + if (savedEnv.version == null) { + delete process.env.RN_SPM_REMOTE_VERSION; + } else { + process.env.RN_SPM_REMOTE_VERSION = savedEnv.version; + } + }); + + function writeRn(version /*: string */) { + const rnDir = path.join(tempDir, 'node_modules', 'react-native'); + fs.mkdirSync(rnDir, {recursive: true}); + fs.writeFileSync( + path.join(rnDir, 'package.json'), + JSON.stringify({name: 'react-native', version}), + ); + } + + function writePersisted(obj /*: Object */) { + const cfgPath = path.join(tempDir, REMOTE_CONFIG_REL); + fs.mkdirSync(path.dirname(cfgPath), {recursive: true}); + fs.writeFileSync(cfgPath, JSON.stringify(obj)); + } + + function readPersisted() /*: Object */ { + return JSON.parse( + fs.readFileSync(path.join(tempDir, REMOTE_CONFIG_REL), 'utf8'), + ); + } + + it('returns null in local mode (no URL anywhere)', () => { + expect(remotePackageConfig(tempDir)).toBeNull(); + }); + + it('env override: activates remote mode and persists versionOverride', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + process.env.RN_SPM_REMOTE_VERSION = '0.86.0'; + writeRn('0.86.3'); // present but ignored — override wins + + const result = remotePackageConfig(tempDir); + expect(result).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.0', + identity: 'react-native-apple', + }); + expect(readPersisted()).toEqual({ + url: 'file:///tmp/react-native-apple', + versionOverride: '0.86.0', + }); + }); + + it('env URL only: derives version from npm and persists NO version', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + writeRn('0.86.3'); + + const result = remotePackageConfig(tempDir); + expect(result).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.3', + identity: 'react-native-apple', + }); + // Derived version is never frozen. + expect(readPersisted()).toEqual({url: 'file:///tmp/react-native-apple'}); + }); + + it('throws RemoteVersionError for a non-publishable derived version', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + writeRn('1000.0.0'); + expect(() => remotePackageConfig(tempDir)).toThrow(RemoteVersionError); + }); + + it('throws RemoteVersionError when react-native is not installed', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + expect(() => remotePackageConfig(tempDir)).toThrow(RemoteVersionError); + }); + + it('honors a persisted versionOverride with no env', () => { + writePersisted({ + url: 'file:///tmp/react-native-apple', + versionOverride: '0.86.0', + }); + writeRn('1000.0.0'); // dev placeholder — override still wins, no throw + + expect(remotePackageConfig(tempDir)).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.0', + identity: 'react-native-apple', + }); + }); + + it('reads a legacy persisted {url, version} as an override', () => { + writePersisted({ + url: 'file:///tmp/react-native-apple', + version: '0.85.1', + }); + writeRn('1000.0.0'); + + expect(remotePackageConfig(tempDir)).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.85.1', + identity: 'react-native-apple', + }); + }); + + it('persisted URL only: derives from npm without an env (the sync lever)', () => { + writePersisted({url: 'file:///tmp/react-native-apple'}); + writeRn('0.86.3'); + + expect(remotePackageConfig(tempDir)).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.3', + identity: 'react-native-apple', + }); + // No env → no re-write of the persisted file. + expect(readPersisted()).toEqual({url: 'file:///tmp/react-native-apple'}); + }); +}); + +// --------------------------------------------------------------------------- +// runCodegenAndInstallTemplate +// --------------------------------------------------------------------------- + +describe('runCodegenAndInstallTemplate', () => { + let tempDir; + let reactNativeRoot; + let appRoot; + let codegenPkgSwift; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-codegen-test-')); + reactNativeRoot = path.join(tempDir, 'react-native'); + appRoot = path.join(tempDir, 'app'); + + // Minimal fake codegen script (no-op) so the execSync call exits cleanly. + fs.mkdirSync(path.join(reactNativeRoot, 'scripts'), {recursive: true}); + fs.writeFileSync( + path.join(reactNativeRoot, 'scripts', 'generate-codegen-artifacts.js'), + '// no-op codegen for tests\n', + ); + // Codegen template that installSpmCodegenTemplate renders + writes. + fs.mkdirSync( + path.join(reactNativeRoot, 'scripts', 'codegen', 'templates'), + { + recursive: true, + }, + ); + fs.writeFileSync( + path.join( + reactNativeRoot, + 'scripts', + 'codegen', + 'templates', + 'Package.swift.spm-template', + ), + '// template\n', + ); + // build/generated/ios must exist for the template to be installed. + fs.mkdirSync(path.join(appRoot, 'build', 'generated', 'ios'), { + recursive: true, + }); + codegenPkgSwift = path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'Package.swift', + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('installs the codegen template by default', () => { + runCodegenAndInstallTemplate(appRoot, appRoot, reactNativeRoot); + expect(fs.existsSync(codegenPkgSwift)).toBe(true); + }); + + it('skips the template install when installTemplate is false', () => { + runCodegenAndInstallTemplate(appRoot, appRoot, reactNativeRoot, undefined, { + installTemplate: false, + }); + // The SPM sync re-points the xcframework symlinks and installs the template + // itself afterwards, so this in-codegen install must be suppressed. + expect(fs.existsSync(codegenPkgSwift)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// buildMergedHeaderTree +// --------------------------------------------------------------------------- +describe('per-app header farm (ReactAppHeaders SPM target)', () => { + let tempDir; + let appRoot; + let perAppDir; + + function writeFile(p, contents) { + fs.mkdirSync(path.dirname(p), {recursive: true}); + fs.writeFileSync(p, contents); + } + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-farm-test-')); + appRoot = path.join(tempDir, 'app'); + perAppDir = path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'ReactAppHeaders', + ); + // Autolinking header farm — a SYMLINK farm (leaf headers are symlinks to + // the dep's real source). foldDir must follow symlinks, not skip them. + const realProviderHeader = path.join(tempDir, 'src', 'Provider.h'); + writeFile(realProviderHeader, '#pragma once\n// provider\n'); + const farmHeader = path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + 'headers', + 'MyLib', + 'Provider.h', + ); + fs.mkdirSync(path.dirname(farmHeader), {recursive: true}); + fs.symlinkSync(realProviderHeader, farmHeader); + // Codegen output (folded both at generated/ios root and ReactCodegen/). + writeFile( + path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'ReactCodegen', + 'react', + 'renderer', + 'EventEmitters.h', + ), + '#pragma once\n// codegen\n', + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('folds codegen + autolinking headers and lives inside the codegen package', () => { + const result = buildPerAppHeaderTree(appRoot); + expect(result.path).toBe(perAppDir); + expect(fs.existsSync(path.join(perAppDir, 'MyLib', 'Provider.h'))).toBe( + true, + ); + expect( + fs.existsSync( + path.join(perAppDir, 'react', 'renderer', 'EventEmitters.h'), + ), + ).toBe(true); + // include form resolves via the farm root. + expect( + fs.existsSync( + path.join( + perAppDir, + 'ReactCodegen', + 'react', + 'renderer', + 'EventEmitters.h', + ), + ), + ).toBe(true); + }); + + it('carries the SPM stub source so the farm is a valid target', () => { + buildPerAppHeaderTree(appRoot); + expect(fs.existsSync(path.join(perAppDir, 'ReactAppHeadersStub.c'))).toBe( + true, + ); + }); + + it('rebuilds cleanly on re-run without folding its previous self', () => { + buildPerAppHeaderTree(appRoot); + const second = buildPerAppHeaderTree(appRoot); + // No nested ReactAppHeaders/ReactAppHeaders self-fold artifacts. + expect(fs.existsSync(path.join(perAppDir, 'ReactAppHeaders'))).toBe(false); + expect(second.virtualPaths.has('MyLib/Provider.h')).toBe(true); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/swift-tools-version-test.js b/packages/react-native/scripts/spm/__tests__/swift-tools-version-test.js new file mode 100644 index 000000000000..ed31a5115298 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/swift-tools-version-test.js @@ -0,0 +1,97 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +// --------------------------------------------------------------------------- +// SPM requires `// swift-tools-version: X.Y` on the FIRST LINE of Package.swift. +// When the directive isn't on line 1, SPM silently treats the manifest as +// tools-version 3.1.0 and recent Xcode rejects it outright: +// +// error: package 'package.swift' is using Swift tools version 3.1.0 which +// is no longer supported; consider using '// swift-tools-version: 6.3' +// +// This regression test pins every Package.swift our generators emit to that +// rule, and asserts the same for the static codegen template. +// --------------------------------------------------------------------------- + +const { + generateAutolinkedPackageSwift, + generateSynthPackageSwift, +} = require('../generate-spm-autolinking'); +const {generateXCFrameworksPackageSwift} = require('../generate-spm-package'); +const {emitScaffoldedPackageSwift} = require('../scaffold-package-swift'); +const fs = require('fs'); +const path = require('path'); + +const TOOLS_VERSION_RE = /^\/\/ swift-tools-version: \d+\.\d+/; + +function firstLineOf(s) { + return s.split('\n', 1)[0]; +} + +describe('swift-tools-version directive must be on line 1', () => { + it('generateXCFrameworksPackageSwift (xcframeworks sub-package)', () => { + const out = generateXCFrameworksPackageSwift( + ['React', 'ReactNativeDependencies', 'hermes-engine'], + '/tmp/cache/0.85.3/debug', + ); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('generateAutolinkedPackageSwift (autolinker aggregator)', () => { + const out = generateAutolinkedPackageSwift({}); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('generateSynthPackageSwift (per-dep synth wrapper)', () => { + const out = generateSynthPackageSwift({ + swiftName: 'MyDep', + exclude: [], + publicHeadersPath: '.', + spmDependencies: [], + hasReactDep: false, + hasXcfwHeaders: false, + hasDepsHeaders: false, + codegenHeadersIncluded: false, + }); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('emitScaffoldedPackageSwift (community-lib scaffold)', () => { + const out = emitScaffoldedPackageSwift({ + swiftName: 'foo', + sources: [], + headerSearchPaths: [], + coreReactNative: false, + siblingNames: [], + extraFrameworks: [], + weakFrameworks: [], + compilerFlags: [], + publicHeadersPath: null, + resources: [], + warnings: [], + }); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('codegen Package.swift.spm-template (static file)', () => { + const templatePath = path.resolve( + __dirname, + '..', + '..', + 'codegen', + 'templates', + 'Package.swift.spm-template', + ); + const out = fs.readFileSync(templatePath, 'utf8'); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js new file mode 100644 index 000000000000..93337ff3831c --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js @@ -0,0 +1,179 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {decideSyncPlan, main} = require('../sync-spm-autolinking'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// decideSyncPlan — the pure decision core extracted from main(). Encodes the +// remote-vs-local / cached-vs-uncached matrix that drives the side effects. +// --------------------------------------------------------------------------- + +describe('decideSyncPlan', () => { + it('local mode without a cache: download + generate the sub-package', () => { + expect(decideSyncPlan(null, false)).toEqual({ + isRemote: false, + shouldDownload: true, + shouldGeneratePackage: true, + }); + }); + + it('local mode with a populated cache: generate but do not download', () => { + expect(decideSyncPlan(null, true)).toEqual({ + isRemote: false, + shouldDownload: false, + shouldGeneratePackage: true, + }); + }); + + it('remote mode: never download, never generate the local sub-package', () => { + const remote = {url: 'https://example/rn.git', version: '0.85.0'}; + expect(decideSyncPlan(remote, false)).toEqual({ + isRemote: true, + shouldDownload: false, + shouldGeneratePackage: false, + }); + // A stray cache must not change the remote-mode decision. + expect(decideSyncPlan(remote, true).shouldGeneratePackage).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// main — orchestration. Collaborators are injected as recording fakes; the +// fs-backed steps (cache probe, stamp write) run for real against tempdirs. +// --------------------------------------------------------------------------- + +describe('main', () => { + let appRoot; + let rnRoot; + let cacheDir; + let logSpy; + let errSpy; + + // Builds a full set of injectable fakes with sensible local-mode defaults. + function makeDeps(over /*: Object */ = {}) { + return { + runCodegenAndInstallTemplate: jest.fn(), + readPackageJson: jest.fn(() => ({version: '0.85.0'})), + resolveCacheSlotVersion: jest.fn(async () => '0.85.0'), + defaultCacheDir: jest.fn(() => cacheDir), + remotePackageConfig: jest.fn(() => null), + downloadArtifacts: jest.fn(async () => {}), + generateAutolinking: jest.fn(), + generatePackage: jest.fn(), + installSpmCodegenTemplate: jest.fn(), + buildPerAppHeaderTree: jest.fn(), + findProjectRoot: jest.fn(p => p), + ...over, + }; + } + + function run(deps) { + return main(['--app-root', appRoot, '--react-native-root', rnRoot], deps); + } + + function stampPath() { + return path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-sync-stamp', + ); + } + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-app-')); + rnRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-rn-')); + cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-cache-')); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errSpy.mockRestore(); + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(rnRoot, {recursive: true, force: true}); + fs.rmSync(cacheDir, {recursive: true, force: true}); + }); + + it('local mode, empty cache: downloads, generates, and writes the stamp', async () => { + const deps = makeDeps(); + await run(deps); + + expect(deps.downloadArtifacts).toHaveBeenCalledWith([ + '--version', + '0.85.0', + '--flavor', + 'debug', + '--output', + cacheDir, + ]); + expect(deps.generateAutolinking).toHaveBeenCalledTimes(1); + expect(deps.generatePackage).toHaveBeenCalledTimes(1); + expect(deps.installSpmCodegenTemplate).toHaveBeenCalledTimes(1); + expect(deps.buildPerAppHeaderTree).toHaveBeenCalledTimes(1); + expect(fs.existsSync(stampPath())).toBe(true); + }); + + it('local mode, populated cache: skips download but still generates', async () => { + fs.writeFileSync(path.join(cacheDir, 'artifacts.json'), '{}'); + const deps = makeDeps(); + await run(deps); + + expect(deps.downloadArtifacts).not.toHaveBeenCalled(); + expect(deps.generatePackage).toHaveBeenCalledTimes(1); + expect(fs.existsSync(stampPath())).toBe(true); + }); + + it('remote mode: skips both download and sub-package generation', async () => { + const deps = makeDeps({ + remotePackageConfig: jest.fn(() => ({ + url: 'https://example/rn.git', + version: '0.85.0', + })), + }); + await run(deps); + + expect(deps.downloadArtifacts).not.toHaveBeenCalled(); + expect(deps.generatePackage).not.toHaveBeenCalled(); + // Autolinking + stamp still happen in remote mode. + expect(deps.generateAutolinking).toHaveBeenCalledTimes(1); + expect(fs.existsSync(stampPath())).toBe(true); + }); + + it('continues when codegen throws, completing the rest of the sync', async () => { + const deps = makeDeps({ + runCodegenAndInstallTemplate: jest.fn(() => { + throw new Error('codegen blew up'); + }), + }); + await expect(run(deps)).resolves.toBeUndefined(); + + expect(deps.generateAutolinking).toHaveBeenCalledTimes(1); + expect(fs.existsSync(stampPath())).toBe(true); + }); + + it('propagates a slot-resolution failure to the caller', async () => { + const deps = makeDeps({ + resolveCacheSlotVersion: jest.fn(async () => { + throw new Error('npm offline'); + }), + }); + await expect(run(deps)).rejects.toThrow(/npm offline/); + // The stamp is only written on a successful run. + expect(fs.existsSync(stampPath())).toBe(false); + }); +}); diff --git a/packages/react-native/scripts/spm/download-spm-artifacts.js b/packages/react-native/scripts/spm/download-spm-artifacts.js new file mode 100644 index 000000000000..67392e3047e0 --- /dev/null +++ b/packages/react-native/scripts/spm/download-spm-artifacts.js @@ -0,0 +1,1172 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * + */ + +'use strict'; + +/*:: import type {DownloadArgs, ResolvedArtifact, ProcessResult, ArtifactResultEntry} from './spm-types'; */ + +/** + * download-spm-artifacts.js + * + * Downloads the three React Native iOS xcframeworks from Maven, extracts + * them to a local cache directory, and writes artifacts.json for use by + * generate-spm-package.js. + * + * Supports stable releases, nightlies, and snapshot builds, matching + * the same resolution logic used by the existing CocoaPods scripts. + * + * Artifacts handled: + * React – react-native-core tarball from Maven + * ReactNativeDependencies – react-native-dependencies tarball from Maven + * hermes-engine – hermes-ios tarball from Maven + * + * Usage: + * node scripts/download-spm-artifacts.js [options] + * + * Options: + * --version RN version. Defaults to version in package.json. + * Use "nightly" to resolve the latest nightly. + * --flavor debug (default) or release. + * --output Where to write xcframeworks. + * Default: ~/Library/Caches/ReactNative/spm-artifacts/{version}/{flavor}/ + * (downloaded tarballs are shared with CocoaPods in + * ~/Library/Caches/ReactNative/; RCT_SKIP_CACHES=1 bypasses.) + * + * Per-artifact version overrides (mirrors existing env vars): + * HERMES_VERSION= + * RN_DEP_VERSION= + * ENTERPRISE_REPOSITORY= Custom Maven mirror (must match Maven structure) + * + * Output: + * /React.xcframework/ + * /ReactNativeDependencies.xcframework/ + * /hermes-engine.xcframework/ + * /artifacts.json ← maps target names to xcframework paths + */ + +const { + defaultCacheDir, + displayPath, + makeLogger, + sharedCacheDir, +} = require('./spm-utils'); +const {execSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const stream = require('stream'); +const yargs = require('yargs'); + +const {log, warn, die} = makeLogger('download-spm-artifacts'); + +function parseArgs(argv /*: Array */) /*: DownloadArgs */ { + const parsed = yargs(argv) + .version(false) + .option('version', { + alias: 'v', + type: 'string', + describe: + 'RN version. Defaults to version in package.json. Use "nightly" to resolve the latest nightly.', + }) + .option('flavor', { + type: 'string', + default: 'debug', + describe: 'debug or release', + }) + .option('output', { + alias: 'o', + type: 'string', + describe: + 'Where to write xcframeworks. Default: ~/Library/Caches/ReactNative/spm-artifacts/{version}/{flavor}/', + }) + .option('core-tarball', { + type: 'string', + describe: + 'Local React core tarball to use instead of downloading (e.g. the prebuild output). Env fallback: RN_CORE_TARBALL_PATH.', + }) + .option('headers-tarball', { + type: 'string', + describe: + 'Local ReactNativeHeaders tarball to use instead of downloading. Env fallback: RN_HEADERS_TARBALL_PATH.', + }) + .usage( + 'Usage: $0 [options]\n\nDownloads React Native iOS xcframeworks from Maven.', + ) + .help() + .parseSync(); + + return { + version: parsed.version ?? null, + flavor: parsed.flavor.toLowerCase(), + output: parsed.output ?? null, + coreTarball: + parsed['core-tarball'] ?? process.env.RN_CORE_TARBALL_PATH ?? null, + headersTarball: + parsed['headers-tarball'] ?? process.env.RN_HEADERS_TARBALL_PATH ?? null, + }; +} + +const MAVEN_RELEASE = + process.env.ENTERPRISE_REPOSITORY ?? 'https://repo1.maven.org/maven2'; +const MAVEN_SNAPSHOT = + 'https://central.sonatype.com/repository/maven-snapshots'; + +function rnCoreReleaseUrl( + version /*: string */, + flavor /*: string */, +) /*: string */ { + return ( + `${MAVEN_RELEASE}/com/facebook/react/react-native-artifacts/${version}/` + + `react-native-artifacts-${version}-reactnative-core-${flavor}.tar.gz` + ); +} +function rnDepsReleaseUrl( + version /*: string */, + flavor /*: string */, +) /*: string */ { + return ( + `${MAVEN_RELEASE}/com/facebook/react/react-native-artifacts/${version}/` + + `react-native-artifacts-${version}-reactnative-dependencies-${flavor}.tar.gz` + ); +} +function hermesReleaseUrl( + version /*: string */, + flavor /*: string */, +) /*: string */ { + return ( + `${MAVEN_RELEASE}/com/facebook/hermes/hermes-ios/${version}/` + + `hermes-ios-${version}-hermes-ios-${flavor}.tar.gz` + ); +} + +/** + * Resolves a Maven snapshot URL by fetching maven-metadata.xml and extracting + * the latest timestamp+buildNumber. Mirrors computeNightlyTarballURL() in utils.js. + * + * @param {string} version Base version without -SNAPSHOT suffix (e.g. "0.85.0") + * @param {string} subGroup com/facebook/ + * @param {string} coordinate Maven artifact coordinate (e.g. "react-native-artifacts") + * @param {string} artifactName Classifier part of the filename (e.g. "reactnative-core-debug.tar.gz") + */ +async function resolveSnapshotUrl( + version /*: string */, + subGroup /*: string */, + coordinate /*: string */, + artifactName /*: string */, +) /*: Promise */ { + const metadataUrl = + `${MAVEN_SNAPSHOT}/com/facebook/${subGroup}/${coordinate}/` + + `${version}-SNAPSHOT/maven-metadata.xml`; + + log(` Fetching snapshot metadata: ${metadataUrl}`); + const res = await fetch(metadataUrl); + if (!res.ok) { + throw new Error( + `Failed to fetch snapshot metadata (${res.status}): ${metadataUrl}`, + ); + } + const xml = await res.text(); + + const ts = (xml.match(/(.*?)<\/timestamp>/) ?? [])[1]; + const bn = (xml.match(/(.*?)<\/buildNumber>/) ?? [])[1]; + if (!ts || !bn) { + throw new Error( + `Could not parse timestamp/buildNumber from ${metadataUrl}`, + ); + } + + const fullVersion = `${version}-${ts}-${bn}`; + return ( + `${MAVEN_SNAPSHOT}/com/facebook/${subGroup}/${coordinate}/` + + `${version}-SNAPSHOT/${coordinate}-${fullVersion}-${artifactName}` + ); +} + +async function rnCoreSnapshotUrl( + version /*: string */, + flavor /*: string */, +) /*: Promise */ { + return resolveSnapshotUrl( + version, + 'react', + 'react-native-artifacts', + `reactnative-core-${flavor}.tar.gz`, + ); +} +async function rnDepsSnapshotUrl( + version /*: string */, + flavor /*: string */, +) /*: Promise */ { + return resolveSnapshotUrl( + version, + 'react', + 'react-native-artifacts', + `reactnative-dependencies-${flavor}.tar.gz`, + ); +} +async function hermesSnapshotUrl( + version /*: string */, + flavor /*: string */, +) /*: Promise */ { + return resolveSnapshotUrl( + version, + 'hermes', + 'hermes-ios', + `hermes-ios-${flavor}.tar.gz`, + ); +} + +async function resolveNightlyVersion( + npmPackage /*: string */, +) /*: Promise */ { + log(` Resolving nightly version from npm: ${npmPackage}`); + + const res = await fetch(`https://registry.npmjs.org/${npmPackage}/nightly`); + if (!res.ok) { + throw new Error(`npm lookup failed for ${npmPackage}: ${res.status}`); + } + const ver = (await res.json()).version; + log(` Resolved nightly: ${ver}`); + return ver; +} + +/** + * Returns the cache-slot key for a given raw version label. + * + * Stable versions ('0.80.0', '0.81.0', …) become their own slot. + * Dev / nightly labels ('1000.0.0', 'nightly') resolve to the current + * nightly version (e.g. '0.85.0-nightly-20260515-abc') so each published + * nightly is its own slot — a new nightly invalidates automatically + * instead of sticking on a stale `1000.0.0` cache forever. + * + * If the npm registry lookup fails (offline, transient error), falls back + * to the raw label so a previously-cached slot under that label can still + * be used. A subsequent download attempt would surface the real error. + */ +async function resolveCacheSlotVersion( + rawVersion /*: string */, +) /*: Promise */ { + if (rawVersion !== '1000.0.0' && rawVersion !== 'nightly') { + return rawVersion; + } + try { + return await resolveNightlyVersion('react-native'); + } catch { + return rawVersion; + } +} + +async function resolveLatestV1Version() /*: Promise */ { + log(' Resolving latest-v1 Hermes from npm...'); + // $FlowFixMe[incompatible-call] global fetch not in Flow stubs + const res = await fetch( + 'https://registry.npmjs.org/hermes-compiler/latest-v1', + ); + if (!res.ok) { + throw new Error(`npm lookup failed: ${res.status}`); + } + const ver = (await res.json()).version; + log(` Resolved latest-v1: ${ver}`); + return ver; +} + +async function exists(url /*: string */) /*: Promise */ { + try { + // $FlowFixMe[incompatible-call] global fetch not in Flow stubs + const res = await fetch(url, {method: 'HEAD'}); + return res.status === 200; + } catch { + return false; + } +} + +/** + * Returns {url, version} for the React Native core xcframework tarball. + * Resolution order: + * 1. Stable release on Maven Central + * 2. Snapshot build on Sonatype + */ +async function resolveRNCoreArtifact( + version /*: string */, + flavor /*: string */, + localTarball /*: ?string */, +) /*: Promise */ { + // Local-tarball override (--core-tarball / RN_CORE_TARBALL_PATH): use a + // locally built core tarball (e.g. the prebuild's output) instead of + // downloading. processArtifact() treats an existing local path as "already + // downloaded" and always re-extracts it. NOTE: distinct from CocoaPods' + // RCT_TESTONLY_RNCORE_TARBALL_PATH — that one belongs to pod install. + if (localTarball != null && localTarball !== '') { + if (!fs.existsSync(localTarball)) { + throw new Error( + `core tarball override is set to ${localTarball} but the file does not exist`, + ); + } + log(` Using LOCAL core tarball: ${localTarball}`); + return {url: localTarball, version: `${version}-local`}; + } + const releaseUrl = rnCoreReleaseUrl(version, flavor); + if (await exists(releaseUrl)) { + log(` Using stable release: ${releaseUrl}`); + return {url: releaseUrl, version}; + } + log(` Release not found, trying snapshot...`); + const snapshotUrl = await rnCoreSnapshotUrl(version, flavor); + return {url: snapshotUrl, version}; +} + +/** + * Returns {url, version} for ReactNativeDependencies. + * Respects RN_DEP_VERSION env var. + */ +async function resolveRNDepsArtifact( + rnVersion /*: string */, + flavor /*: string */, +) /*: Promise */ { + let version = process.env.RN_DEP_VERSION ?? rnVersion; + if (version === 'nightly') { + version = await resolveNightlyVersion('react-native'); + } + + const releaseUrl = rnDepsReleaseUrl(version, flavor); + if (await exists(releaseUrl)) { + log(` Using stable release: ${releaseUrl}`); + return {url: releaseUrl, version}; + } + log(` Release not found, trying snapshot...`); + const snapshotUrl = await rnDepsSnapshotUrl(version, flavor); + return {url: snapshotUrl, version}; +} + +/** + * Returns {url, version} for Hermes. Hermes uses its own version space + * decoupled from React Native's nightly cadence — RN's `hermes-compiler` + * npm package publishes a `latest-v1` dist-tag that always resolves to a + * binary that's been built and uploaded to Maven. Our default mirrors RN's + * CocoaPods prebuild path (see scripts/ios-prebuild/hermes.js): + * + * HERMES_VERSION unset → 'latest-v1' dist-tag + * HERMES_VERSION=latest-v1 → same (explicit) + * HERMES_VERSION=nightly → hermes-compiler@nightly dist-tag + * HERMES_VERSION= → use that version verbatim + * + * Note: rnVersion / rawVersion are intentionally not consulted. There is no + * guarantee a hermes-ios artifact exists for any given RN nightly hash — + * tying them together produces 404s like #(repro case from spikes/MyApp). + */ +async function resolveHermesArtifact( + rnVersion /*: string */, + flavor /*: string */, + rawVersion /*: string | null */, +) /*: Promise */ { + let version = process.env.HERMES_VERSION ?? 'latest-v1'; + + if (version === 'nightly') { + version = await resolveNightlyVersion('hermes-compiler'); + } else if (version === 'latest-v1') { + version = await resolveLatestV1Version(); + } + + const releaseUrl = hermesReleaseUrl(version, flavor); + if (await exists(releaseUrl)) { + log(` Using stable release: ${releaseUrl}`); + return {url: releaseUrl, version}; + } + log(` Release not found, trying snapshot...`); + const snapshotUrl = await hermesSnapshotUrl(version, flavor); + return {url: snapshotUrl, version}; +} + +function formatBytes(bytes /*: number */) /*: string */ { + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function formatSpeed(bytesPerSec /*: number */) /*: string */ { + if (bytesPerSec < 1024 * 1024) { + return `${(bytesPerSec / 1024).toFixed(0)} KB/s`; + } + return `${(bytesPerSec / 1024 / 1024).toFixed(1)} MB/s`; +} + +/** + * Creates a multi-line progress display that keeps N lines pinned at the + * bottom of the terminal. Each line is prefixed and truncated to the current + * terminal width — without truncation, a long line (e.g. a FAILED message + * carrying a URL) wraps to a second row and `\x1b[2K` only clears the first, + * leaving stray fragments after the next update. + */ +function createProgressDisplay( + lineCount /*: number */, + prefix /*: string */ = '', +) /*: {update: (index: number, text: string) => void} */ { + let initialized = false; + + function truncateToWidth(s /*: string */) /*: string */ { + // $FlowFixMe[prop-missing] columns lives on tty$WriteStream not stream$Writable + const cols = process.stdout.columns ?? 120; + const budget = Math.max(10, cols - 1); + let out = ''; + let visLen = 0; + let i = 0; + while (i < s.length) { + if (s[i] === '\x1b' && s[i + 1] === '[') { + // CSI escape: forward through the final letter without counting. + let j = i + 2; + while (j < s.length && !/[a-zA-Z]/.test(s[j])) j++; + out += s.slice(i, j + 1); + i = j + 1; + } else { + if (visLen >= budget - 1) return out + '…\x1b[0m'; + out += s[i]; + visLen++; + i++; + } + } + return out; + } + + function update(index /*: number */, text /*: string */) { + if (!initialized) { + for (let i = 0; i < lineCount; i++) { + process.stdout.write('\n'); + } + initialized = true; + } + const moveUp = lineCount - index; + const line = truncateToWidth(prefix + text); + process.stdout.write(`\x1b[${moveUp}A\x1b[2K\r${line}\x1b[${moveUp}B\r`); + } + + return {update}; +} + +/*:: +type ProgressCallback = (label: string, downloaded: number, total: number, speed: number, done: boolean, elapsed: number) => void; +*/ + +async function download( + url /*: string */, + destPath /*: string */, + onProgress /*:: ?: ProgressCallback */, +) /*: Promise */ { + if (fs.existsSync(destPath)) { + log(` Already cached: ${path.basename(destPath)}`); + return; + } + + const tmpPath = destPath + '.download'; + try { + // $FlowFixMe[incompatible-call] global fetch not in Flow stubs + const res = await fetch(url); + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText}: ${url}`); + } + + const totalBytes = parseInt(res.headers.get('content-length') ?? '0', 10); + let downloadedBytes = 0; + let lastPrintTime = Date.now(); + let lastPrintBytes = 0; + const startTime = Date.now(); + + const fileStream = fs.createWriteStream(tmpPath); + + const reportProgress = (final /*: boolean */ = false) => { + const now = Date.now(); + const elapsed = (now - startTime) / 1000; + const intervalMs = now - lastPrintTime; + const intervalBytes = downloadedBytes - lastPrintBytes; + const speed = intervalMs > 0 ? (intervalBytes / intervalMs) * 1000 : 0; + + if (onProgress) { + onProgress( + path.basename(destPath), + downloadedBytes, + totalBytes, + speed, + final, + elapsed, + ); + } else { + // Fallback: single-line progress (used when not in parallel mode) + let line = ` ${formatBytes(downloadedBytes)}`; + if (totalBytes > 0) { + const pct = ((downloadedBytes / totalBytes) * 100).toFixed(1); + line += ` / ${formatBytes(totalBytes)} (${pct}%)`; + } + line += ` @ ${formatSpeed(speed)}`; + + if (final) { + const totalMb = formatBytes(downloadedBytes); + const totalSec = elapsed.toFixed(1); + const avgSpeed = + elapsed > 0 ? formatSpeed(downloadedBytes / elapsed) : ''; + process.stdout.write( + `\r Done: ${totalMb} in ${totalSec}s (avg ${avgSpeed}) \n`, + ); + } else { + process.stdout.write(`\r${line} `); + } + } + + if (!final) { + lastPrintTime = now; + lastPrintBytes = downloadedBytes; + } + }; + + if (res.body) { + // fetch() returns a Web ReadableStream, not a Node.js Readable. + // Convert it so we can pipe to a file stream and track progress. + // $FlowFixMe[prop-missing] stream.Readable.fromWeb not in Flow stubs + const nodeReadable = stream.Readable.fromWeb(res.body); + + await new Promise((resolve, reject) => { + let progressInterval; + try { + progressInterval = setInterval(() => reportProgress(), 500); + + nodeReadable + .on('data', chunk => { + downloadedBytes += chunk.length; + }) + .on('error', err => { + clearInterval(progressInterval); + reject(err); + }) + .pipe(fileStream) + .on('finish', () => { + clearInterval(progressInterval); + reportProgress(true); + resolve(); + }) + .on('error', err => { + clearInterval(progressInterval); + reject(err); + }); + } catch (err) { + if (progressInterval != null) clearInterval(progressInterval); + reject(err); + } + }); + } else { + const buf = await res.arrayBuffer(); + downloadedBytes = buf.byteLength; + fs.writeFileSync(tmpPath, Buffer.from(buf)); + reportProgress(true); + } + + fs.renameSync(tmpPath, destPath); + } catch (err) { + // Clean up partial .download temp file on failure + try { + fs.unlinkSync(tmpPath); + } catch { + // temp file may not exist yet + } + throw err; + } +} + +/** + * Extracts a .tar.gz and returns the path to the first .xcframework found. + */ +function extractXCFramework( + tarPath /*: string */, + extractDir /*: string */, +) /*: string */ { + fs.mkdirSync(extractDir, {recursive: true}); + log(` Extracting ${path.basename(tarPath)}...`); + execSync(`tar -xzf "${tarPath}" -C "${extractDir}"`, {stdio: 'pipe'}); + + const found = findFirst(extractDir, name => name.endsWith('.xcframework'), 8); + if (found == null) { + throw new Error(`No .xcframework found after extracting ${tarPath}`); + } + log(` Found: ${path.relative(extractDir, found)}`); + return found; +} + +function findFirst( + dir /*: string */, + predicate /*: (name: string) => boolean */, + depth /*: number */, +) /*: string | null */ { + if (depth <= 0 || !fs.existsSync(dir)) { + return null; + } + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here + const full /*: string */ = path.join(dir, entry.name); + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here + if (predicate(entry.name)) { + return full; + } + if (entry.isDirectory()) { + const hit = findFirst(full, predicate, depth - 1); + if (hit != null) { + return hit; + } + } + } + return null; +} + +/** + * The hermes-ios tarball ships its public C++ API headers in + * `destroot/include/hermes` alongside the framework — which extractXCFramework + * discards (it keeps only the .xcframework). Stage the `hermes/` namespace into + * `/hermes-headers/hermes` so headers-compose can fold it into + * ReactNativeHeaders (making `` resolve for any RN-linking + * target). Only `hermes/` is staged — `jsi/` is already vended elsewhere. + * Best-effort: a tarball without these headers just leaves hermes unavailable. + */ +function stageHermesHeaders( + extractDir /*: string */, + outputDir /*: string */, +) /*: void */ { + let includeDir = path.join(extractDir, 'destroot', 'include'); + if (!fs.existsSync(path.join(includeDir, 'hermes', 'hermes.h'))) { + // Fall back to locating the include dir wherever it landed in the tarball. + const hit = findFirst(extractDir, name => name === 'include', 8); + if (hit != null) { + includeDir = hit; + } + } + const src = path.join(includeDir, 'hermes'); + if (!fs.existsSync(path.join(src, 'hermes.h'))) { + log(' Hermes public headers not found in tarball — skipping header stage'); + return; + } + const destRoot = path.join(outputDir, 'hermes-headers'); + const dest = path.join(destRoot, 'hermes'); + fs.rmSync(dest, {recursive: true, force: true}); + fs.mkdirSync(destRoot, {recursive: true}); + execSync(`/bin/cp -R "${src}" "${dest}"`, {stdio: 'pipe'}); + log(' Staged Hermes public headers → hermes-headers/hermes'); +} + +/** + * Self-heal: stage Hermes headers into an already-extracted slot (the fast + * path skips extraction, so the headers were never staged). Prefers a CACHED + * hermes tarball (no network); downloads only as a last resort so the slot + * can't get stuck "incomplete" forever. No-op only when the headers can't be + * obtained at all (e.g. a missing local-tarball override). + */ +async function ensureHermesHeadersStaged( + url /*: string */, + downloadDir /*: string */, + sharedTarballName /*: ?string */, + outputDir /*: string */, +) /*: Promise */ { + const candidates = [ + !/^https?:\/\//.test(url) ? url : null, // local-tarball override + path.join(downloadDir, url.split('/').pop() ?? ''), + sharedTarballName != null + ? path.join(sharedCacheDir(), sharedTarballName) + : null, + ].filter(Boolean); + let tarPath /*: ?string */ = candidates.find( + p => p != null && fs.existsSync(p), + ); + if (tarPath == null) { + if (!/^https?:\/\//.test(url)) { + return; // local override missing — nothing to recover from + } + const localPath = path.join( + downloadDir, + url.split('/').pop() ?? 'hermes.tar.gz', + ); + fs.mkdirSync(downloadDir, {recursive: true}); + await download(url, localPath); + tarPath = localPath; + } + const tmp = path.join(outputDir, '.hermes-hdr-tmp'); + fs.rmSync(tmp, {recursive: true, force: true}); + fs.mkdirSync(tmp, {recursive: true}); + try { + execSync(`tar -xzf "${tarPath}" -C "${tmp}"`, {stdio: 'pipe'}); + stageHermesHeaders(tmp, outputDir); + } finally { + fs.rmSync(tmp, {recursive: true, force: true}); + } +} + +/** + * Downloads a tarball, extracts the xcframework, and places it directly in + * the output directory as .xcframework/. + * + * SPM binaryTarget(path:) accepts a bare .xcframework directory — no zip or + * checksum needed for local path-based targets. + * + * @param {string} label Internal label (used for log messages) + * @param {string} xcframeworkName The SPM target name (e.g. "React", "hermes-engine") + * @param resolvedArtifact {url, version} from resolve*Artifact() + * @param {string} downloadDir Where to cache downloaded tarballs + * @param {string} outputDir Where to place the final .xcframework directory + * @param sharedTarballName Filename in the flat shared cache to reuse/populate + * (matches CocoaPods' convention), or null to not share. + */ +async function processArtifact( + label /*: string */, + xcframeworkName /*: string */, + resolvedArtifact /*: ResolvedArtifact */, + downloadDir /*: string */, + outputDir /*: string */, + onProgress /*:: ?: ProgressCallback */, + sharedTarballName /*:: ?: ?string */, +) /*: Promise */ { + const {url, version} = resolvedArtifact; + + const destXcfwPath = path.join(outputDir, `${xcframeworkName}.xcframework`); + // Local-tarball override: `url` is an existing local file. Always + // re-extract (a changed local tarball must win over a previous extraction) + // and never touch the shared cache. + const isLocalTarball = !/^https?:\/\//.test(url) && fs.existsSync(url); + if (isLocalTarball && fs.existsSync(destXcfwPath)) { + fs.rmSync(destXcfwPath, {recursive: true, force: true}); + } + if (fs.existsSync(destXcfwPath)) { + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, true, 0); + } else { + log(` Already extracted: ${xcframeworkName}.xcframework`); + } + // The xcframework is cached, but a slot from older tooling won't have the + // Hermes headers staged. Backfill them from a cached tarball (no network). + if ( + label === 'hermes' && + !fs.existsSync(path.join(outputDir, 'hermes-headers', 'hermes')) + ) { + try { + await ensureHermesHeadersStaged( + url, + downloadDir, + sharedTarballName, + outputDir, + ); + } catch (e) { + log(` Hermes header backfill failed (${e.message}) — continuing`); + } + } + return {label, version, xcframeworkPath: destXcfwPath, url}; + } + + // Tarball acquisition: prefer the flat shared cache (~/Library/Caches/ + // ReactNative/) that CocoaPods also populates, so SPM and `pod install` + // reuse the same download. RCT_SKIP_CACHES=1 bypasses it (mirrors CocoaPods). + const skipCaches = process.env.RCT_SKIP_CACHES === '1'; + const sharedPath = + !skipCaches && sharedTarballName != null + ? path.join(sharedCacheDir(), sharedTarballName) + : null; + + const downloadAndCache = async () /*: Promise */ => { + const tarName = url.split('/').pop() ?? ''; + const localPath = path.join(downloadDir, tarName); + await download( + url, + localPath, + onProgress + ? (name, downloaded, total, speed, done, elapsed) => + onProgress(xcframeworkName, downloaded, total, speed, done, elapsed) + : undefined, + ); + // Best-effort: save into the flat shared cache for future SPM/CocoaPods runs. + if (sharedPath != null) { + try { + fs.mkdirSync(sharedCacheDir(), {recursive: true}); + fs.copyFileSync(localPath, sharedPath); + } catch { + // ignore shared-cache write failures + } + } + return localPath; + }; + + let tarPath /*: string */; + let fromShared = false; + if (isLocalTarball) { + tarPath = url; + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, true, 0); + } else { + log(` Using local tarball: ${url}`); + } + } else if (sharedPath != null && fs.existsSync(sharedPath)) { + // Shared cache hit — skip the download entirely. + tarPath = sharedPath; + fromShared = true; + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, true, 0); + } else { + log(` Shared cache hit: ${path.basename(sharedPath)}`); + } + } else { + tarPath = await downloadAndCache(); + } + + // Extract to a temp dir, rename to the expected name, then move into outputDir + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, false, 0); + } + const tmpExtractDir = path.join(outputDir, '.extract-tmp', label); + let xcfwPath /*: string */; + try { + xcfwPath = extractXCFramework(tarPath, tmpExtractDir); + } catch (e) { + // A poisoned shared tarball must not permanently break SPM: drop it and + // re-download to the local dir once. + if (fromShared) { + try { + fs.rmSync(tarPath, {force: true}); + } catch {} + tarPath = await downloadAndCache(); + xcfwPath = extractXCFramework(tarPath, tmpExtractDir); + } else { + throw e; + } + } + + const actualBasename = path.basename(xcfwPath); + const expectedBasename = `${xcframeworkName}.xcframework`; + if (actualBasename !== expectedBasename) { + const renamed = path.join(tmpExtractDir, expectedBasename); + fs.renameSync(xcfwPath, renamed); + fs.renameSync(renamed, destXcfwPath); + } else { + fs.renameSync(xcfwPath, destXcfwPath); + } + + // Hermes ships its public headers in the same tarball; stage them next to + // the xcframeworks so headers-compose can fold `hermes/` into + // ReactNativeHeaders. (Other artifacts have no such headers — no-op.) + if (label === 'hermes') { + try { + stageHermesHeaders(tmpExtractDir, outputDir); + } catch (e) { + log(` Hermes header staging failed (${e.message}) — continuing`); + } + } + + fs.rmSync(tmpExtractDir, {recursive: true, force: true}); + + return {label, version, xcframeworkPath: destXcfwPath, url}; +} + +async function main(argv /*:: ?: Array */) /*: Promise */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + const rnRoot = path.resolve(__dirname, '../..'); + const flavor = args.flavor; + + // Resolve base RN version + // rawVersion preserves the original --version arg (e.g. 'nightly') before resolution. + // It is passed to Hermes resolution so it can independently resolve its nightly. + let rawVersion = args.version; + let rnVersion = args.version; + if (rnVersion == null) { + // $FlowFixMe[incompatible-type] JSON.parse returns any + const rnPkg /*: {version: string} */ = JSON.parse( + fs.readFileSync(path.join(rnRoot, 'package.json'), 'utf8'), + ); + rnVersion = rnPkg.version; + } + if (rnVersion === '1000.0.0') { + log('Detected dev version (1000.0.0), resolving as nightly...'); + rawVersion = 'nightly'; + } + if (rnVersion === 'nightly' || rnVersion === '1000.0.0') { + rnVersion = await resolveNightlyVersion('react-native'); + } + if (rnVersion == null) { + die('Could not determine RN version'); + } + // Re-bind to const so Flow keeps the non-null narrowing across the closures + // below (let-bound vars are widened across function boundaries). + const resolvedRnVersion /*: string */ = rnVersion; + + // Cache key: stable versions slot under their own number. Dev / nightly + // labels use the resolved nightly hash (e.g. "0.85.0-nightly-20260515-abc") + // so each published nightly is its own slot — picks up new specs and fixes + // automatically instead of sticking on a stale "1000.0.0" cache forever. + const cacheVersionKey = + rawVersion === 'nightly' || rawVersion === '1000.0.0' || rawVersion == null + ? resolvedRnVersion + : rawVersion; + const outputDir = + args.output != null + ? path.resolve(args.output) + : defaultCacheDir(cacheVersionKey, flavor); + // Tarballs are cached in a .downloads/ subdirectory to keep them separate + // from the extracted .xcframework directories. + const downloadDir = path.join(outputDir, '.downloads'); + + fs.mkdirSync(outputDir, {recursive: true}); + fs.mkdirSync(downloadDir, {recursive: true}); + + log(`RN version : ${resolvedRnVersion}`); + log(`Flavor : ${flavor}`); + log(`Output : ${displayPath(outputDir)}`); + log(''); + + // Download all three artifacts in parallel for faster setup + log('Downloading artifacts in parallel...'); + + // `sharedName` builds the flat shared-cache filename in the canonical + // ~/Library/Caches/ReactNative/ dir, matching the names other RN tooling uses + // (CocoaPods' rncore.rb / rndependencies.rb for core+deps, and the hermes + // prebuilt tarball name) so SPM and `pod install` reuse the same downloads. + // `v` is each artifact's resolved version (RN version for core/deps, the + // hermes-ios version for hermes). + const artifactSpecs = [ + { + label: 'react-core', + name: 'React', + resolve: () => + resolveRNCoreArtifact(resolvedRnVersion, flavor, args.coreTarball), + // Local overrides skip the shared cache (test artifacts must not + // poison the canonical downloads). + sharedName: + args.coreTarball != null + ? null + : (v /*: string */) => `reactnative-core-${v}-${flavor}.tar.gz`, + }, + { + label: 'rndeps', + name: 'ReactNativeDependencies', + resolve: () => resolveRNDepsArtifact(resolvedRnVersion, flavor), + sharedName: (v /*: string */) => + `reactnative-dependencies-${v}-${flavor}.tar.gz`, + }, + { + label: 'hermes', + name: 'hermes-engine', + resolve: () => + resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion), + sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`, + }, + ]; + + // ReactNativeHeaders (headers-only artifact): currently local-tarball only + // (--headers-tarball / RN_HEADERS_TARBALL_PATH, e.g. the prebuild output at + // .build/output/xcframeworks//ReactNativeHeaders.xcframework.tar.gz). + // The Maven resolve joins this list when nightlies publish the artifact. + const headersTarball = args.headersTarball; + if (headersTarball != null && headersTarball !== '') { + if (!fs.existsSync(headersTarball)) { + die( + `headers tarball override is set to ${headersTarball} but the file does not exist`, + ); + } + artifactSpecs.push({ + label: 'rnheaders', + name: 'ReactNativeHeaders', + resolve: () => + Promise.resolve({ + url: headersTarball, + version: `${resolvedRnVersion}-local`, + }), + sharedName: null, + }); + } + + const progress = createProgressDisplay( + artifactSpecs.length, + '\x1b[32m[download-spm-artifacts]\x1b[0m ', + ); + + const makeCallback = (index /*: number */) /*: ProgressCallback */ => + (name, downloaded, total, speed, done, elapsed) => { + if (done && downloaded === 0 && total === 0) { + progress.update(index, ` ${name}: already cached`); + } else if (done) { + const avg = elapsed > 0 ? formatSpeed(downloaded / elapsed) : ''; + progress.update( + index, + ` ${name}: done ${formatBytes(downloaded)} in ${elapsed.toFixed(1)}s (${avg})`, + ); + } else if (total > 0) { + const pct = ((downloaded / total) * 100).toFixed(1); + progress.update( + index, + ` ${name}: ${formatBytes(downloaded)} / ${formatBytes(total)} (${pct}%) @ ${formatSpeed(speed)}`, + ); + } else { + progress.update(index, ` ${name}: extracting...`); + } + }; + + const results /*: Array */ = await Promise.all( + artifactSpecs.map(async (spec, index) => { + try { + const artifact = await spec.resolve(); + progress.update(index, ` ${spec.name}: resolving...`); + const sharedTarballName = + spec.sharedName != null ? spec.sharedName(artifact.version) : null; + const r = await processArtifact( + spec.label, + spec.name, + artifact, + downloadDir, + outputDir, + makeCallback(index), + sharedTarballName, + ); + const ok /*: ArtifactResultEntry */ = { + name: spec.name, + error: undefined, + ...r, + }; + return ok; + } catch (e) { + progress.update(index, ` ${spec.name}: FAILED - ${e.message}`); + const failed /*: ArtifactResultEntry */ = { + name: spec.name, + error: e.message, + }; + return failed; + } + }), + ); + log(''); + + const succeeded = results.filter(r => r.error == null); + const failed = results.filter(r => r.error != null); + + log('='.repeat(60)); + if (succeeded.length > 0) { + log('Extracted xcframeworks:'); + log(''); + for (const r of succeeded) { + if (r.error == null) { + log(` ${r.name}`); + log(` path: ${displayPath(r.xcframeworkPath)}`); + log(''); + } + } + } + // Abort on ANY failure — the three artifacts (React, ReactNativeDependencies, + // hermes-engine) are all required; proceeding with a partial set would only + // surface as a confusing build error in Xcode. We also intentionally do NOT + // write artifacts.json when there are failures: the orchestrator uses its + // presence as the "already present" signal, so a partial write would mask + // the problem and prevent retries. + if (failed.length > 0) { + log('Failed:'); + for (const r of failed) { + warn(` ${r.name}: ${r.error ?? 'unknown error'}`); + } + die( + `Failed to download ${failed.length} of ${results.length} artifact(s): ` + + failed.map(r => r.name).join(', '), + ); + } + + // Write artifacts.json only on full success. + const artifactsJson /*: {[string]: {xcframeworkPath: string, url: string}} */ = + {}; + for (const r of succeeded) { + if (r.error == null) { + artifactsJson[r.name] = {xcframeworkPath: r.xcframeworkPath, url: r.url}; + } + } + const artifactsJsonPath = path.join(outputDir, 'artifacts.json'); + fs.writeFileSync( + artifactsJsonPath, + JSON.stringify(artifactsJson, null, 2) + '\n', + 'utf8', + ); + log(`Artifact index: ${displayPath(artifactsJsonPath)}`); +} + +// Canonical set of xcframework artifacts the SPM pipeline downloads. The +// xcodeproj references all three as package products; missing any one +// surfaces as "Missing package product" only at Xcode build time. Used by +// `setup-apple-spm.js` to validate the cache before skipping a re-download. +const REQUIRED_ARTIFACTS = [ + 'React', + 'ReactNativeDependencies', + 'hermes-engine', +]; + +/** + * Returns null if `artifacts.json` is present, complete (covers every entry + * in REQUIRED_ARTIFACTS), and each entry's xcframework dir exists on disk. + * Otherwise returns a string describing what's wrong — caller treats that as + * "needs re-download". Catches stale partial-write states from older runs + * that didn't fail loudly on download errors. + */ +function validateArtifactsCache( + artifactsDir /*: string */, +) /*: string | null */ { + const artifactsJsonPath = path.join(artifactsDir, 'artifacts.json'); + if (!fs.existsSync(artifactsJsonPath)) { + return `artifacts.json missing in ${artifactsDir}`; + } + let json /*: {[string]: {xcframeworkPath: string, url: string}} */; + try { + // $FlowFixMe[unclear-type] JSON.parse returns any + const parsed /*: any */ = JSON.parse( + fs.readFileSync(artifactsJsonPath, 'utf8'), + ); + json = parsed; + } catch (e) { + return `artifacts.json is unreadable: ${e.message}`; + } + for (const name of REQUIRED_ARTIFACTS) { + const entry = json[name]; + if (entry == null) { + return `artifacts.json missing entry for "${name}"`; + } + if (!fs.existsSync(entry.xcframeworkPath)) { + return `xcframework for "${name}" not found at ${entry.xcframeworkPath}`; + } + } + // The Hermes public headers must be staged for headers-compose to fold + // `` into ReactNativeHeaders. A slot from older tooling won't + // have them — report incomplete so ensureArtifacts re-runs the download + // (which, with the xcframeworks already present, only backfills the headers + // from the cached tarball — no network re-download). + if (!fs.existsSync(path.join(artifactsDir, 'hermes-headers', 'hermes'))) { + return 'Hermes public headers not staged (hermes-headers/hermes)'; + } + return null; +} + +if (require.main === module) { + main().catch(err => { + console.error(`\x1b[31m${err.message}\x1b[0m`); + process.exitCode = 1; + }); +} + +module.exports = { + main, + resolveCacheSlotVersion, + resolveHermesArtifact, + REQUIRED_ARTIFACTS, + validateArtifactsCache, + // Exposed for unit tests (pure / fetch-stubbable helpers). + rnCoreReleaseUrl, + rnDepsReleaseUrl, + hermesReleaseUrl, + resolveSnapshotUrl, + resolveNightlyVersion, + resolveLatestV1Version, + resolveRNCoreArtifact, + resolveRNDepsArtifact, + exists, + formatBytes, + formatSpeed, + findFirst, + extractXCFramework, +}; diff --git a/packages/react-native/scripts/spm/expand-spm-dependencies.js b/packages/react-native/scripts/spm/expand-spm-dependencies.js new file mode 100644 index 000000000000..a953b3b69ca6 --- /dev/null +++ b/packages/react-native/scripts/spm/expand-spm-dependencies.js @@ -0,0 +1,206 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const {toSwiftName} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); + +/** + * expand-spm-dependencies.js — Resolves transitive native deps declared via + * `spm.dependencies` in a library's react-native.config.js. + * + * SPM has no equivalent of CocoaPods' podspec `s.dependency`, so library + * authors declare the same relationships explicitly: + * + * // react-native-reanimated/react-native.config.js + * module.exports = { + * dependency: { platforms: { ios: {} } }, + * spm: { dependencies: ['react-native-worklets'] }, + * }; + * + * This module reads the directly-autolinked deps (from autolinking.json), + * follows each one's spm.dependencies recursively, and returns the deduped + * list with autolinking-shaped entries so the downstream pipeline can convert + * each to an SPM target without further branching. + * + * I/O is injected (readConfig, resolveDep) so the logic stays pure and + * testable. + */ + +/*:: +import type {AutolinkedDep} from './spm-types'; + +// react-native.config.js entries have a user-defined shape, so we use an +// inexact object type and access properties dynamically. +type RnConfig = {...}; +type ReadConfig = (root: string) => ?RnConfig; +type ResolveDep = (name: string, fromRoot: string) => ?string; +type Options = { + readConfig: ReadConfig, + resolveDep: ResolveDep, +}; +*/ + +// Validates and returns the Swift target name for a dep. Falls back to +// toSwiftName(npmName) when no override is set. The override is the dep's +// `react-native.config.js` `spm.name`, intended for libraries whose import +// prefix differs from the auto-derived name (e.g. `react-native-worklets` +// publishes headers under `` via the podspec `s.header_dir`, +// so the SPM target name should be `worklets`, not `ReactNativeWorklets`). +function resolveSwiftName( + npmName /*: string */, + config /*: ?RnConfig */, +) /*: string */ { + // $FlowFixMe[prop-missing] config has dynamic shape + const override = config?.spm?.name; + if (override == null) { + return toSwiftName(npmName); + } + if (typeof override !== 'string' || override.length === 0) { + throw new Error( + `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`, + ); + } + // Accept Swift-identifier style (TitleCase / snake_case) and header-dir + // style (lowercase, optional hyphens). Reject whitespace, slashes, and + // other characters that would break SPM target / module identifiers. + if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(override)) { + throw new Error( + `react-native autolinking: '${npmName}' has an invalid 'spm.name' override '${override}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, + ); + } + return override; +} + +function expandSpmDependencies( + directDeps /*: Array */, + options /*: Options */, +) /*: Array */ { + const {readConfig, resolveDep} = options; + const byName /*: Map */ = new Map(); + for (const dep of directDeps) { + byName.set(dep.name, {...dep, spmDependencies: []}); + } + + const queue /*: Array */ = directDeps.map(d => d.name); + while (queue.length > 0) { + const currentName = queue.shift(); + if (typeof currentName !== 'string') { + continue; + } + const current = byName.get(currentName); + if (current == null) { + continue; + } + const config = readConfig(current.root); + // Resolve swiftName lazily from the same config read we already need for + // spm.dependencies — saves a duplicate readConfig call per direct dep. + if (current.swiftName == null) { + current.swiftName = resolveSwiftName(currentName, config); + } + // $FlowFixMe[prop-missing] config has dynamic shape + const transitives /*: Array */ = config?.spm?.dependencies ?? []; + + const currentSpmDeps /*: Array */ = []; + for (const transitiveName of transitives) { + if (!byName.has(transitiveName)) { + const transitiveRoot = resolveDep(transitiveName, current.root); + if (transitiveRoot == null) { + throw new Error( + `react-native autolinking: '${currentName}' declares an unresolvable spm.dependency '${transitiveName}'. Ensure '${transitiveName}' is installed and visible via Node module resolution from ${current.root}.`, + ); + } + + const transitiveConfig = readConfig(transitiveRoot); + // $FlowFixMe[prop-missing] config has dynamic shape + const iosPlatform = transitiveConfig?.dependency?.platforms?.ios; + if (iosPlatform == null) { + // No iOS native code — nothing to autolink and nothing to declare + // as an SPM target dep; mirrors the silent skip in + // autolinkingDepToSpmTarget for android-only deps. + continue; + } + + byName.set(transitiveName, { + name: transitiveName, + root: transitiveRoot, + platforms: {ios: iosPlatform}, + swiftName: resolveSwiftName(transitiveName, transitiveConfig), + spmDependencies: [], + }); + queue.push(transitiveName); + } + currentSpmDeps.push(transitiveName); + } + current.spmDependencies = currentSpmDeps; + } + + // Collision check: two deps mapping to the same Swift name (whether via + // override or auto-derivation) would clobber each other in the synth + // package layout and the centralized headers tree. Surface it now with a + // clear message instead of letting SPM emit a confusing duplicate-target + // error later. + const seen /*: Map */ = new Map(); + for (const dep of byName.values()) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + const existing = seen.get(swiftName); + if (existing != null) { + throw new Error( + `react-native autolinking: SPM Swift name collision: '${existing}' and '${dep.name}' both resolve to '${swiftName}'. Set a distinct 'spm.name' in one of their react-native.config.js files.`, + ); + } + seen.set(swiftName, dep.name); + } + + return Array.from(byName.values()); +} + +// --------------------------------------------------------------------------- +// Default I/O implementations +// --------------------------------------------------------------------------- + +function defaultReadConfig(root /*: string */) /*: ?RnConfig */ { + const configPath = path.join(root, 'react-native.config.js'); + if (!fs.existsSync(configPath)) { + return null; + } + try { + // $FlowFixMe[unsupported-syntax] + return require(configPath); + } catch { + return null; + } +} + +function defaultResolveDep( + name /*: string */, + fromRoot /*: string */, +) /*: ?string */ { + try { + const pkgJsonPath = require.resolve(`${name}/package.json`, { + paths: [fromRoot], + }); + return path.dirname(pkgJsonPath); + } catch { + return null; + } +} + +module.exports = { + expandSpmDependencies, + resolveSwiftName, + defaultReadConfig, + defaultResolveDep, +}; diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking-config.js b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js new file mode 100644 index 000000000000..58ebb96b2f92 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js @@ -0,0 +1,161 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/** + * generate-spm-autolinking-config.js — JS port of the autolinking.json + * generation step in packages/react-native/scripts/cocoapods/autolinking.rb. + * + * Invokes the React Native community CLI to produce its config and writes the + * raw JSON to /build/generated/autolinking/autolinking.json. + * + * No filtering or reshaping happens here — the downstream consumer + * (generate-spm-autolinking.js) does its own iOS-only filtering when reading + * the file. + * + * Removes the implicit `pod install` dependency the SPM flow has today for + * external dep discovery. + */ + +const {spawnSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/*:: +import type {CliConfigJson} from './spm-types'; + +type CliRunnerResult = { + stdout: string, + stderr: string, + exitCode: number, +}; +type CliRunner = ( + command: Array, + opts: {cwd: string}, +) => CliRunnerResult; +type Options = { + projectRoot: string, + configCommand?: Array, + cliRunner?: CliRunner, +}; +type GenerateAutolinkingConfigResult = { + config: CliConfigJson, + outputPath: string, + rawJson: string, +}; +*/ + +const FALLBACK_CONFIG_COMMAND = [ + 'npx', + '--no-install', + '@react-native-community/cli', + 'config', +]; + +function resolveDefaultConfigCommand( + projectRoot /*: string */, +) /*: Array */ { + try { + const pkgJsonPath = require.resolve( + '@react-native-community/cli/package.json', + {paths: [projectRoot]}, + ); + // $FlowFixMe[unclear-type] package.json has dynamic shape + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + const bin = pkgJson.bin; + const binPath = + typeof bin === 'string' + ? bin + : typeof bin?.['rnc-cli'] === 'string' + ? bin['rnc-cli'] + : bin != null && typeof bin === 'object' + ? bin[Object.keys(bin)[0]] + : null; + + if (typeof binPath === 'string' && binPath.length > 0) { + return [ + process.execPath, + path.join(path.dirname(pkgJsonPath), binPath), + 'config', + ]; + } + } catch { + // Fall through to a no-install npx invocation for older layouts. + } + + return FALLBACK_CONFIG_COMMAND; +} + +function defaultCliRunner( + command /*: Array */, + opts /*: {cwd: string} */, +) /*: CliRunnerResult */ { + const result = spawnSync(command[0], command.slice(1), { + cwd: opts.cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024, + }); + return { + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + exitCode: typeof result.status === 'number' ? result.status : 1, + }; +} + +function generateAutolinkingConfig( + opts /*: Options */, +) /*: GenerateAutolinkingConfigResult */ { + const { + projectRoot, + configCommand = resolveDefaultConfigCommand(projectRoot), + cliRunner = defaultCliRunner, + } = opts; + + if (!Array.isArray(configCommand) || configCommand.length === 0) { + throw new Error( + 'generate-spm-autolinking-config: config command must be a non-empty array of strings', + ); + } + + const result = cliRunner(configCommand, {cwd: projectRoot}); + + if (result.exitCode !== 0) { + throw new Error( + `generate-spm-autolinking-config: '${configCommand.join(' ')}' exited with status ${result.exitCode}\n${result.stderr}`, + ); + } + + const rawJson = result.stdout; + const config /*: CliConfigJson */ = JSON.parse(rawJson); + + const iosSourceDir = config?.project?.ios?.sourceDir; + if (typeof iosSourceDir !== 'string' || iosSourceDir.length === 0) { + throw new Error( + 'generate-spm-autolinking-config: CLI config did not provide project.ios.sourceDir', + ); + } + + const outPath = path.join( + iosSourceDir, + 'build', + 'generated', + 'autolinking', + 'autolinking.json', + ); + + fs.mkdirSync(path.dirname(outPath), {recursive: true}); + fs.writeFileSync(outPath, rawJson); + + return {config, outputPath: outPath, rawJson}; +} + +module.exports = {generateAutolinkingConfig, resolveDefaultConfigCommand}; diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js new file mode 100644 index 000000000000..1304f7fe977c --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -0,0 +1,1620 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: import type { + AggregatorInput, + AutolinkedDep, + AutolinkingArgs, + NpmDepRef, + RawAutolinkingJson, + SpmModuleConfig, + SpmTarget, + SynthPackageSpec, + TargetEntry, +} from './spm-types'; */ + +/** + * generate-spm-autolinking.js – Generates autolinked/Package.swift, the SPM + * equivalent of CocoaPods' `use_native_modules!`. + * + * Usage: + * node generate-spm-autolinking.js [options] + * + * Options: + * --app-root Path to the app directory (default: cwd) + * --react-native-root Path to react-native package root + * --autolinking-json Path to autolinking.json (default: build/generated/autolinking/autolinking.json) + * --output Output dir (default: autolinked/) + * + * Reads: + * - build/generated/autolinking/autolinking.json (produced by react-native codegen) + * - react-native.config.js (for spm.modules extra modules, optional) + * + * Generates: + * - autolinked/Package.swift + * + * V1 behavior: + * - Processes npm-package native modules with platforms.ios != null from autolinking.json + * - Also processes any `spm.modules` entries from react-native.config.js for local modules + * - Targets resolve React headers via SPM product dependencies (no flags) + * + * V2 behavior (future): + * - npm packages with their own Package.swift use .package(url: ...) instead of inline targets + */ + +const { + defaultReadConfig, + defaultResolveDep, + expandSpmDependencies, +} = require('./expand-spm-dependencies'); +const {readPodspec} = require('./read-podspec'); +const { + RemoteVersionError, + makeLogger, + remotePackageConfig, + toSwiftName, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); +const yargs = require('yargs'); + +const {log} = makeLogger('generate-spm-autolinking'); + +// Targets compiling against React get all headers via SPM product +// dependencies — no search-path flags: React/react namespaces from the React +// binaryTarget, every other namespace from the ReactNativeHeaders +// binaryTarget, and the app's generated headers from the ReactAppHeaders +// target in the codegen package. +// +// Remote mode (remotePackageConfig): the ReactNative-family products come +// from the single remote package identity instead of the local path-based +// package, so app + every library unify on one SPM-resolved version. +let remoteCfg /*: ?{url: string, version: string, identity: string} */ = null; + +function reactNativePackageLabel() /*: string */ { + return remoteCfg != null ? remoteCfg.identity : 'ReactNative'; +} +function reactNativePackageDecl(localDecl /*: string */) /*: string */ { + return remoteCfg != null + ? `.package(url: "${remoteCfg.url}", exact: "${remoteCfg.version}")` + : localDecl; +} +function reactProductDeps() /*: string */ { + const rn = reactNativePackageLabel(); + return ( + `.product(name: "ReactNative", package: "${rn}")` + + `, .product(name: "ReactNativeHeaders", package: "${rn}")` + + ', .product(name: "ReactAppHeaders", package: "React-GeneratedCode")' + ); +} + +// Normalize a (possibly Windows) path to posix separators for embedding in +// a Package.swift `.package(path:)` literal — SPM expects forward slashes. +function toPosix(p /*: string */) /*: string */ { + return p.split(path.sep).join('/'); +} + +function parseArgs(argv /*: Array */) /*: AutolinkingArgs */ { + const parsed = yargs(argv) + .option('app-root', { + type: 'string', + default: process.cwd(), + describe: 'Path to the app directory', + }) + .option('react-native-root', { + type: 'string', + describe: 'Path to react-native package root', + }) + .option('autolinking-json', { + type: 'string', + describe: + 'Path to autolinking.json (default: build/generated/autolinking/autolinking.json)', + }) + .option('output', { + type: 'string', + describe: 'Output dir (default: autolinked/)', + }) + .option('xcframeworks-path', { + type: 'string', + describe: + 'Path to the xcframeworks sub-package (absolute or relative to appRoot)', + }) + .usage( + 'Usage: $0 [options]\n\nGenerates autolinked/Package.swift for SPM autolinking.', + ) + .help() + .parseSync(); + + return { + appRoot: parsed['app-root'], + reactNativeRoot: parsed['react-native-root'] ?? null, + autolinkingJson: parsed['autolinking-json'] ?? null, + output: parsed.output ?? null, + xcframeworksPath: parsed['xcframeworks-path'] ?? null, + }; +} + +/** + * Reads autolinking.json and returns dependencies with iOS platform support. + */ +function readAutolinkingJson( + filePath /*: string */, +) /*: RawAutolinkingJson | null */ { + if (!fs.existsSync(filePath)) { + return null; + } + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +/** + * Attempts to read react-native.config.js to find spm.modules entries. + * These are extra modules not discoverable via autolinking.json. + * + * Expected structure in react-native.config.js: + * module.exports = { + * ... + * spm: { + * modules: [ + * { + * name: "MyNativeModule", + * path: "ios/MyNativeModule", // relative to appRoot + * exclude: ["*.js", "*.podspec"], // optional + * publicHeadersPath: ".", // optional + * } + * ] + * } + * } + */ +function readSpmModulesFromConfig( + appRoot /*: string */, +) /*: Array */ { + const configPath = path.join(appRoot, 'react-native.config.js'); + if (!fs.existsSync(configPath)) { + return []; + } + try { + // $FlowFixMe[unsupported-syntax] dynamic require by computed path + const config = require(configPath); + return config.spm?.modules ?? []; + } catch (e) { + // Config might use Ruby interop or other patterns – skip + return []; + } +} + +/** + * Returns "." if the source directory has .h/.hpp files directly at its root + * AND no subdirectories exist at that root (adjacent subdirectories would cause + * Clang to reject the umbrella header). + * Returns null otherwise. + */ +function inferPublicHeadersPath(sourcePath /*: string */) /*: string | null */ { + if (!fs.existsSync(sourcePath)) return null; + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(sourcePath, {withFileTypes: true}); + const hasHeaders = entries.some( + e => + (e.isFile() || e.isSymbolicLink()) && + (e.name.endsWith('.h') || e.name.endsWith('.hpp')), + ); + const hasSubdirs = entries.some(e => e.isDirectory()); + // Only use "." if headers at root AND no adjacent subdirectories. + // If both headers and subdirectories exist, Clang rejects the module map + // (umbrella header + adjacent directories = error). + return hasHeaders && !hasSubdirs ? '.' : null; +} + +/*:: +type ExtensionFilter = ReadonlySet; +*/ + +const HEADER_EXTENSIONS /*: ExtensionFilter */ = new Set(['.h', '.hpp']); +const IMPL_EXTENSIONS /*: ExtensionFilter */ = new Set([ + '.m', + '.mm', + '.c', + '.cpp', + '.swift', +]); +const ALL_SOURCE_EXTENSIONS /*: ExtensionFilter */ = new Set([ + ...HEADER_EXTENSIONS, + ...IMPL_EXTENSIONS, +]); + +// Directory names whose contents should never be included in an SPM target — +// test fixtures, Android sources, vendored modules. Shared between the source +// walker and the header linker so they agree on what to skip. +const SKIP_DIRS_DEFAULT /*: ReadonlySet */ = new Set([ + 'android', + 'tests', + '__tests__', + '__mocks__', + 'test', + 'jest', + 'node_modules', +]); + +// Name of the dir-symlink inside each wrapper that points at the dep's real +// source dir. With target.path = "." (the wrapper), SPM resolves source paths +// like "/Foo.mm" by following this link. +const WRAPPER_ROOT_NAME = 'root'; + +// Marker the autolinker stamps onto every synth Package.swift it writes. +// Files lacking this marker are treated as user-managed (self-managed) and +// are referenced directly rather than wrapped — see findSelfManagedPackageDir. +const AUTOGEN_MARKER = + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js'; + +/** + * A dep is "self-managed" when it ships a hand-written Package.swift + * (i.e. one that lacks our AUTOGEN_MARKER). The autolinker skips wrapping + * it and references the manifest's directory directly — useful for + * libraries that want to ship a real SPM manifest and have full control + * over their target settings. + * + * Two layouts are recognized: + * 1. /Package.swift — manifest at the npm-package root + * 2. /ios/Package.swift — manifest co-located with ObjC + * sources, keeping the npm-package + * root free of SPM artifacts + * (.build/, .swiftpm/, Package.resolved) + * + * Returns the directory that contains the hand-authored manifest, or null + * when no candidate exists. That directory is what the aggregator hands to + * SPM as `.package(path:)` — for layout 2 that means `/ios`. + */ +function findSelfManagedPackageDir(absSource /*: string */) /*: ?string */ { + for (const sub of ['', 'ios']) { + const dir = sub === '' ? absSource : path.join(absSource, sub); + try { + const content = fs.readFileSync(path.join(dir, 'Package.swift'), 'utf8'); + if (!content.includes(AUTOGEN_MARKER)) { + return dir; + } + } catch { + // candidate does not exist; try the next one + } + } + return null; +} + +/** + * Does this dep ship a CocoaPods podspec? (Checked at the dep root and under + * ios/.) A missing manifest is auto-scaffoldable only when a podspec exists — + * the scaffolder translates the podspec into a Package.swift. + */ +function hasPodspec(absSource /*: string */) /*: boolean */ { + for (const sub of ['', 'ios']) { + const dir = sub === '' ? absSource : path.join(absSource, sub); + try { + if (fs.readdirSync(dir).some(e => e.endsWith('.podspec'))) { + return true; + } + } catch { + // dir does not exist; try the next candidate + } + } + return false; +} + +/** + * True when a dep has BOTH Swift and C-family (.m/.mm/.c/.cpp) sources. SPM + * cannot compile mixed-language sources in a single target, and RN libs that + * mix them are typically bidirectionally coupled (ObjC↔Swift) — which can't be + * split into two targets either (it would be a circular dependency). So such a + * dep is unsupportable by the scaffolder; we surface a clear, distinct error + * instead of emitting a manifest that fails with a cryptic SPM resolve error. + * Heuristic filesystem scan (bounded depth; skips examples/tests/build noise). + */ +function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ { + const SKIP /*: Set */ = new Set([ + 'node_modules', + 'Pods', + 'build', + '.git', + '__tests__', + 'example', + 'Example', + 'examples', + ]); + let hasSwift = false; + let hasClang = false; + const walk = (dir /*: string */, depth /*: number */) => { + if (depth > 6 || (hasSwift && hasClang)) return; + let entries /*: Array<{name: string, isDirectory(): boolean}> */; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(dir, {withFileTypes: true}); + } catch { + return; + } + for (const e of entries) { + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in stubs + const name /*: string */ = e.name; + if (e.isDirectory()) { + if (!name.startsWith('.') && !SKIP.has(name)) { + walk(path.join(dir, name), depth + 1); + } + } else if (/\.swift$/i.test(name)) { + hasSwift = true; + } else if (/\.(mm?|c|cc|cpp|cxx)$/i.test(name)) { + hasClang = true; + } + if (hasSwift && hasClang) return; + } + }; + walk(absSource, 0); + return hasSwift && hasClang; +} + +/** + * Error thrown when one or more autolinked community npm deps have no Swift + * Package Manager manifest (neither a shipped Package.swift nor a scaffolded + * one). The autolinker no longer silently synthesizes a manifest for these — + * that hid the gap and duplicated the scaffolder. Carries the dep list so the + * CLI can surface a precise, actionable message and set a distinct exit code + * (the Xcode build phase keys off it to fail the build). + */ +class MissingManifestError extends Error { + /*:: missingManifests: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}>; */ + constructor( + deps /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */, + ) { + super( + `${deps.length} autolinked native module(s) have no Package.swift. ` + + 'Run `npx react-native spm scaffold` to generate them.', + ); + this.name = 'MissingManifestError'; + this.missingManifests = deps; + } +} + +/** + * Prints one `error:`-prefixed line per missing-manifest dep so Xcode surfaces + * each as a build error (Xcode parses lines beginning with `error: `), then + * returns the MissingManifestError to throw. Kept together so the message and + * the thrown error never drift. + */ +function reportMissingManifests( + deps /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */, +) /*: MissingManifestError */ { + for (const d of deps) { + if (d.mixed === true) { + console.error( + `error: "${d.npmName}" has mixed Swift + Objective-C/C++ sources, which Swift Package Manager cannot compile in a single target (and its Swift↔ObjC interop typically can't be split into two targets without a circular dependency).\n` + + ` • Opt it out of SPM autolinking in your app's react-native.config.js:\n` + + ` module.exports = { dependencies: { '${d.npmName}': { platforms: { ios: null } } } };\n` + + ` • Or consume ${d.npmName} as a prebuilt binary (xcframework) instead.`, + ); + continue; + } + if (d.hasPodspec) { + console.error( + `error: Package.swift is missing for library "${d.npmName}" — it ships no Swift Package Manager support.\n` + + ` 1. Run \`npx react-native spm scaffold\` to generate a Package.swift for ${d.npmName}.\n` + + ` 2. Persist it with a patch: \`npx patch-package ${d.npmName}\`, and commit the patch (node_modules is not committed).\n` + + ` 3. Ask ${d.npmName}'s maintainer to ship a Package.swift upstream (or contribute one).\n` + + ' 4. Without a committed patch, this same error returns whenever node_modules is reset (fresh install / CI).', + ); + } else { + console.error( + `error: Package.swift is missing for library "${d.npmName}", and it ships no podspec so it cannot be scaffolded automatically.\n` + + ` • It needs Swift Package Manager support added manually — ask ${d.npmName}'s maintainer to ship a Package.swift upstream (or contribute one).`, + ); + } + } + return new MissingManifestError(deps); +} + +/** + * Mirrors every header file under `srcDir` as a relative symlink at the same + * relative location under `destDir`. Used for the centralized cross-package + * headers tree at `/headers//` so consumers can resolve + * `#import ` via a single `-I /headers` flag. + * + * Idempotent: existing symlinks pointing at the right target are left alone; + * stale entries are pruned. Header symlinks here are inert to Xcode (it + * doesn't navigate them as editable source — they're compiler-only). + */ +function linkHeaderTree( + srcDir /*: string */, + destDir /*: string */, + skipDirNames /*: Set */ = new Set(), +) /*: void */ { + if (!srcDir || !path.isAbsolute(srcDir)) { + throw new Error( + `linkHeaderTree: srcDir must be a non-empty absolute path, got: "${srcDir}"`, + ); + } + if (!destDir || !path.isAbsolute(destDir)) { + throw new Error( + `linkHeaderTree: destDir must be a non-empty absolute path, got: "${destDir}"`, + ); + } + if (!fs.existsSync(srcDir)) { + return; + } + + /*:: type HeaderEntry = {relSrc: string, absSrc: string}; */ + const headers /*: Array */ = []; + function collect(dir /*: string */, relBase /*: string */) /*: void */ { + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of entries) { + const {name} = entry; + if (entry.isDirectory()) { + if (SKIP_DIRS_DEFAULT.has(name) || skipDirNames.has(name)) continue; + collect(path.join(dir, name), path.join(relBase, name)); + } else if (entry.isFile() && HEADER_EXTENSIONS.has(path.extname(name))) { + headers.push({ + relSrc: path.join(relBase, name), + absSrc: path.join(dir, name), + }); + } + } + } + collect(srcDir, ''); + + if (headers.length === 0) { + try { + if (fs.lstatSync(destDir).isDirectory()) { + fs.rmSync(destDir, {recursive: true, force: true}); + } + } catch { + // destDir does not exist – fine + } + return; + } + + fs.mkdirSync(destDir, {recursive: true}); + + const expected /*: Set */ = new Set(); + for (const {relSrc, absSrc} of headers) { + const linkPath = path.join(destDir, relSrc); + expected.add(relSrc); + fs.mkdirSync(path.dirname(linkPath), {recursive: true}); + const desiredTarget = path.relative(path.dirname(linkPath), absSrc); + try { + const existing = fs.lstatSync(linkPath); + if ( + existing.isSymbolicLink() && + fs.readlinkSync(linkPath) === desiredTarget + ) { + continue; + } + fs.unlinkSync(linkPath); + } catch { + // nothing to remove + } + fs.symlinkSync(desiredTarget, linkPath); + } + + // Prune stale entries: walk destDir and delete anything not in `expected`. + function pruneWalk(dir /*: string */, relBase /*: string */) /*: void */ { + if (!fs.existsSync(dir)) return; + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of entries) { + const rel = path.join(relBase, entry.name); + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + pruneWalk(abs, rel); + if (fs.readdirSync(abs).length === 0) { + fs.rmdirSync(abs); + } + } else { + if (!expected.has(rel)) { + fs.unlinkSync(abs); + } + } + } + } + pruneWalk(destDir, ''); +} + +/** + * Searches sourcePath for a PrivacyInfo.xcprivacy file (at root or one level deep). + * Returns the relative path from sourcePath if found, null otherwise. + */ +function findPrivacyManifest(sourcePath /*: string */) /*: string | null */ { + if (!fs.existsSync(sourcePath)) return null; + // Check root level + if (fs.existsSync(path.join(sourcePath, 'PrivacyInfo.xcprivacy'))) { + return 'PrivacyInfo.xcprivacy'; + } + // Check one level deep (e.g. ios/PrivacyInfo.xcprivacy) + const entries /*: Array<{name: string, isDirectory(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(sourcePath, {withFileTypes: true}); + for (const entry of entries) { + if (entry.isDirectory()) { + const nested = path.join(sourcePath, entry.name, 'PrivacyInfo.xcprivacy'); + if (fs.existsSync(nested)) { + return path.join(entry.name, 'PrivacyInfo.xcprivacy'); + } + } + } + return null; +} + +/** + * Recursively yields forward-slash paths (relative to sourcePath) for every + * regular file under sourcePath, skipping directories whose name is in + * SKIP_DIRS_DEFAULT. Used as the building block for both the auto-discovery + * (collectSpmSources) and explicit-glob (expandSpmSourceGlobs) paths so they + * agree on what's a candidate before extension/glob filtering applies. + */ +function walkSourceFiles(sourcePath /*: string */) /*: Array */ { + const out /*: Array */ = []; + if (!fs.existsSync(sourcePath)) { + return out; + } + function walk(dir /*: string */, rel /*: string */) /*: void */ { + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of entries) { + const {name} = entry; + const childRel = rel === '' ? name : `${rel}/${name}`; + if (entry.isDirectory()) { + if (SKIP_DIRS_DEFAULT.has(name)) continue; + walk(path.join(dir, name), childRel); + } else if (entry.isFile() || entry.isSymbolicLink()) { + out.push(childRel); + } + } + } + walk(sourcePath, ''); + return out; +} + +/** + * Idempotent symlink: ensure `linkPath` is a symlink to `target`. If it + * already is, leave it untouched (preserves inode). If it points elsewhere + * or is a real file/directory, replace it. Returns true when the symlink + * was created or replaced, false when it was already correct. + */ +function ensureSymlink( + linkPath /*: string */, + target /*: string */, +) /*: boolean */ { + try { + const stat = fs.lstatSync(linkPath); + if (stat.isSymbolicLink() && fs.readlinkSync(linkPath) === target) { + return false; + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + fs.unlinkSync(linkPath); + } else { + fs.rmSync(linkPath, {recursive: true, force: true}); + } + } catch { + // linkPath does not exist – fine + } + fs.symlinkSync(target, linkPath); + return true; +} + +// Default sources allowlist when no explicit glob is provided — analog of +// CocoaPods' `s.source_files` auto-discovery. +function collectSpmSources(sourcePath /*: string */) /*: Array */ { + return walkSourceFiles(sourcePath) + .filter(p => ALL_SOURCE_EXTENSIONS.has(path.extname(p))) + .sort(); +} + +// Filters walkSourceFiles output through CocoaPods-style globs via micromatch. +// Skip-dir filtering applies before matching, so `**/*.{h,mm}` never returns +// paths under `tests/`, `android/`, etc. — even if the pattern would match. +function expandSpmSourceGlobs( + sourcePath /*: string */, + patterns /*: Array */, +) /*: Array */ { + if (patterns.length === 0) { + return []; + } + // $FlowFixMe[untyped-import] micromatch ships no types + const micromatch = require('micromatch'); + return micromatch(walkSourceFiles(sourcePath), patterns).sort(); +} + +/** + * Converts an autolinking.json dependency to an SPM target spec. + * Returns null if the dependency doesn't have iOS support. + * + * `swiftNameByNpm` maps each autolinked dep's npm name to its resolved Swift + * name (populated by expandSpmDependencies, possibly overridden via the dep's + * `spm.name` config). Optional for backwards compatibility with callers that + * don't have the map; falls back to `toSwiftName(name)` per entry. + */ +/** + * Read the dep's podspec (if any) and extract its declared + * `pod_target_xcconfig` HEADER_SEARCH_PATHS, substituted relative to the dep + * source dir. Returns paths suitable for an SPM `.headerSearchPath()` + * directive whose target.path is the dep root (entries are NOT yet prefixed + * with the synth wrapper's `root/` — the emission site adds that prefix + * because the wrapper's target.path is `.`, not the source dir). + * + * Without these, path-style angle includes like + * `` (used by + * react-native-safe-area-context, reanimated, screens, etc.) fail to resolve + * because the headers live under the dep's `common/cpp/` rather than under + * the framework-imported xcframework headers. + */ +function extractPodspecHeaderSearchPaths( + sourceDir /*: string */, +) /*: Array */ { + let podspecPath /*: ?string */ = null; + try { + const entries = fs.readdirSync(sourceDir); + const candidate = entries.find(e => e.endsWith('.podspec')); + if (candidate != null) { + podspecPath = path.join(sourceDir, candidate); + } + } catch { + return []; + } + if (podspecPath == null) return []; + + let model; + try { + model = readPodspec(podspecPath); + } catch { + return []; + } + + const out /*: Array */ = []; + for (const raw of model.headerSearchPaths) { + const substituted = raw + .replace(/\$\(PODS_TARGET_SRCROOT\)/g, '.') + .replace(/\$\{PODS_TARGET_SRCROOT\}/g, '.'); + // Drop entries still containing unresolved Xcode tokens — emitting them + // verbatim would surface as clang "no such file" failures. + if (/\$[({]/.test(substituted)) continue; + const cleaned = substituted.replace(/^\.\//, '').replace(/^\//, ''); + if (cleaned.length > 0 && !out.includes(cleaned)) { + out.push(cleaned); + } + } + return out; +} + +function autolinkingDepToSpmTarget( + depName /*: string */, + dep /*: AutolinkedDep */, + outputDir /*: string */, + swiftNameByNpm /*: ?Map */, +) /*: SpmTarget | null */ { + const iosPlatform = dep.platforms.ios; + const sourceDir = iosPlatform.sourceDir ?? dep.root; + if (sourceDir == null) { + return null; + } + + // target.path is stored relative to the autolinker's outputDir so main()'s + // `path.resolve(outputDir, target.path)` recovers the absolute source dir — + // same convention the spmModule branch in main() follows. + const relSourcePath = path.relative(outputDir, sourceDir); + + // Prefer the resolved Swift name (which honors `spm.name` overrides set in + // the dep's react-native.config.js). Fall back to toSwiftName(depName) when + // the caller didn't run expandSpmDependencies. + const targetName = dep.swiftName ?? toSwiftName(depName); + + // No exclude inference — main()'s emission loop emits `sources:` (an + // explicit allowlist). User-supplied excludes still work. + + // Detect PrivacyInfo.xcprivacy + const privacyManifest = findPrivacyManifest(sourceDir); + const resources = privacyManifest != null ? [privacyManifest] : undefined; + + // Map declared spm.dependencies (npm names) to Swift target names so the + // synth's .product(...) deps list reaches the consuming target. Each + // transitive npm name's Swift name comes from the map (honoring overrides); + // toSwiftName fallback handles entries the map doesn't know about. + const spmDeps /*: Array */ = dep.spmDependencies ?? []; + const spmTargetDependencies = + spmDeps.length > 0 + ? spmDeps.map(n => swiftNameByNpm?.get(n) ?? toSwiftName(n)) + : undefined; + + const headerSearchPaths = extractPodspecHeaderSearchPaths(sourceDir); + + return { + name: targetName, + path: relSourcePath, + exclude: [], + publicHeadersPath: inferPublicHeadersPath(sourceDir), + resources, + spmTargetDependencies, + headerSearchPaths: + headerSearchPaths.length > 0 ? headerSearchPaths : undefined, + }; +} + +/** + * Generates the full autolinked/Package.swift content. + * + * xcframeworksRelPath – path to the xcframeworks sub-package relative to the + * autolinked/ directory (e.g. "../build/xcframeworks"). When non-null a + * React dependency is declared. Headers need no search paths — React/react + * come from the React binaryTarget, every other namespace from + * ReactNativeHeaders, and the app's generated headers from the + * ReactAppHeaders product — so , , + * , folly/glog/boost, and all resolve. + */ +/** + * Top-level autolinked/Package.swift — a thin aggregator that references each + * autolinked dep as its own sub-package (under packages/) and + * re-exports them through a single AutolinkedAggregate target. Per-dep + * settings (header paths, cFlags, link order) live in each synth sub-package; + * see generateSynthPackageSwift below. + * + * input: { deps: Array<{swiftName: string}> } + */ +function generateAutolinkedPackageSwift( + input /*: AggregatorInput */, +) /*: string */ { + const npmDeps /*: ReadonlyArray */ = input.npmDeps ?? []; + const inlineTargets /*: ReadonlyArray */ = + input.inlineTargets ?? []; + const hasReactDep /*: boolean */ = input.hasReactDep !== false; + // Relative path from autolinked/ to build/xcframeworks/, e.g. "../build/xcframeworks". + const xcframeworksRelPath /*: ?string */ = input.xcframeworksRelPath; + + // Package-level dependencies: one .package(path:) per autolinked dep, + // plus ReactNative if any inline target needs to import React headers. + const packageDeps /*: Array */ = npmDeps.map(d => { + const pkgPath = d.packagePath ?? `packages/${d.swiftName}`; + return `.package(name: "${d.swiftName}", path: "${pkgPath}")`; + }); + if ( + inlineTargets.length > 0 && + hasReactDep && + typeof xcframeworksRelPath === 'string' + ) { + packageDeps.push( + reactNativePackageDecl( + `.package(name: "ReactNative", path: "${xcframeworksRelPath}")`, + ), + ); + // Per-app generated headers come from the ReactAppHeaders product in + // the codegen package (sibling of the autolinking dir). + packageDeps.push(`.package(name: "React-GeneratedCode", path: "../ios")`); + } + + // AutolinkedAggregate's target dependencies: .product(...) for npm sub-package + // products and .target(...) for inline spmModule targets in the same package. + const aggregateDeps /*: Array */ = [ + ...npmDeps.map( + d => `.product(name: "${d.swiftName}", package: "${d.swiftName}")`, + ), + ...inlineTargets.map(t => `.target(name: "${t.name}")`), + ]; + + const inlineDecls = inlineTargets.map(t => { + const excludeLine = + t.exclude && t.exclude.length > 0 + ? `\n exclude: [${t.exclude.map(e => `"${e}"`).join(', ')}],` + : ''; + const publicHeadersLine = + t.publicHeadersPath != null + ? `\n publicHeadersPath: "${t.publicHeadersPath}",` + : ''; + const resourcesLine = + t.resources && t.resources.length > 0 + ? `\n resources: [${t.resources.map(r => `.copy("${r}")`).join(', ')}],` + : ''; + return ` .target( + name: "${t.name}", + dependencies: [${reactProductDeps()}], + path: "${t.path}",${excludeLine}${publicHeadersLine}${resourcesLine} + linkerSettings: [.linkedFramework("UIKit"), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")] + )`; + }); + + const packageDepsBlock = + packageDeps.length > 0 + ? ` dependencies: [\n ${packageDeps.join(',\n ')},\n ],\n` + : ''; + const aggregateDepsLine = + aggregateDeps.length > 0 + ? `\n dependencies: [${aggregateDeps.join(', ')}],` + : ''; + + const inlineDeclsBlock = + inlineDecls.length > 0 ? `,\n${inlineDecls.join(',\n')}` : ''; + + // Eval-time missing-manifest guard. SwiftPM resolves the package graph BEFORE + // the Xcode "Sync SPM Autolinking" build phase runs, so a community library + // whose Package.swift is absent at resolution time (e.g. a scaffolded manifest + // wiped by a node_modules reset without a committed patch) fails resolution + // with an opaque "package manifest cannot be accessed" error — and the + // actionable sync-phase message never prints. Manifest evaluation may READ the + // filesystem (only writes are sandboxed), so the aggregator checks each + // referenced library here and explains the cause + fix at resolution time. + const guardEntries = npmDeps.map(d => { + const pkgPath = d.packagePath ?? `packages/${d.swiftName}`; + return ` (path: "${pkgPath}", npm: "${d.npmName ?? d.swiftName}")`; + }); + const guardBlock = + guardEntries.length > 0 + ? `// Eval-time guard: surface a wiped/absent library Package.swift here (at +// resolution) instead of the opaque SwiftPM "manifest cannot be accessed". +let __rnAutolinkedLibs: [(path: String, npm: String)] = [ +${guardEntries.join(',\n')}, +] +do { + let __here = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let __missing = __rnAutolinkedLibs.filter { + !FileManager.default.fileExists( + atPath: __here.appendingPathComponent($0.path) + .appendingPathComponent("Package.swift").path) + } + if !__missing.isEmpty { + var __msg = "" + for lib in __missing { + __msg += "error: Package.swift is missing for library \\"\\(lib.npm)\\" — its Swift Package Manager manifest is not present (a scaffolded manifest wiped by a node_modules reset without a committed patch, or the library ships none).\\n" + __msg += " 1. Run \`npx react-native spm scaffold\` to (re)generate it.\\n" + __msg += " 2. Persist it with \`npx patch-package \\(lib.npm)\` and commit the patch (node_modules is not committed).\\n" + __msg += " 3. Ask \\(lib.npm)'s maintainer to ship a Package.swift upstream.\\n" + __msg += " 4. Without a committed patch, this error returns on every fresh install / CI.\\n" + } + FileHandle.standardError.write(Data(__msg.utf8)) + fatalError("Missing Package.swift for: \\(__missing.map { $0.npm }.joined(separator: ", ")). See the message above.") + } +} + +` + : ''; + + return `// swift-tools-version: 6.0 +// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit manually. +// Top-level Autolinked package. Every autolinked dep (npm or spmModule) is +// referenced as .package(path: ) — each has its own synth +// Package.swift written in-place. AutolinkedAggregate depends on every dep's +// product so the app build pulls them all in. + +import PackageDescription +import Foundation + +${guardBlock}let package = Package( + name: "Autolinked", + platforms: [.iOS(.v15)], + products: [ + .library(name: "Autolinked", targets: ["AutolinkedAggregate"]), + ], +${packageDepsBlock} targets: [ + .target( + name: "AutolinkedAggregate",${aggregateDepsLine} + path: "AutolinkedAggregate" + )${inlineDeclsBlock} + ], + cxxLanguageStandard: .cxx20 +) +`; +} + +/** + * Per-dep synthesized Package.swift, written at + * /packages//Package.swift. `targetPath` points at a + * `root` directory symlink to the real source dir, so source files stay real + * (Xcode atomic-save works). + * + * The React + codegen package references are plain relative paths supplied by + * the caller (`reactNativePackagePath` / `codegenPackagePath`), computed from + * the synth's fixed location under the autolinking dir — the manifest holds no + * runtime discovery and no absolute paths. Headers are served by the + * React/ReactNativeHeaders binaryTargets and the ReactAppHeaders product, so no + * search-path flags are needed. Siblings use their absolute synth path from + * `siblingSynthAbsolutePaths` (production) or a `siblingPackageBaseRelative` + * fallback (tests). + */ +function generateSynthPackageSwift(spec /*: SynthPackageSpec */) /*: string */ { + const swiftName /*: string */ = spec.swiftName; + const exclude /*: Array */ = spec.exclude ?? []; + const sources /*: ?Array */ = spec.sources; + const publicHeadersPath /*: ?string */ = spec.publicHeadersPath ?? null; + // Per-dep header search paths from the podspec's + // pod_target_xcconfig HEADER_SEARCH_PATHS — already prefixed by the caller + // with the synth wrapper's `root/` so they resolve relative to target.path. + // Emitted as `.headerSearchPath()` directives, which SPM accepts on + // cSettings / cxxSettings without needing absolute paths. + const headerSearchPaths /*: Array */ = spec.headerSearchPaths ?? []; + const spmDependencies /*: Array<{swiftName: string}> */ = + spec.spmDependencies ?? []; + const hasReactDep /*: boolean */ = spec.hasReactDep !== false; + const resources /*: ?Array */ = spec.resources; + const isDynamic /*: boolean */ = spec.isDynamic !== false; + const targetPath /*: string */ = spec.targetPath ?? `Sources/${swiftName}`; + const siblingSynthAbsolutePaths /*: {[string]: string} */ = + spec.siblingSynthAbsolutePaths ?? {}; + + // Package dependencies — ReactNative + each spm sibling synth package. + // The React + codegen package paths are plain relative strings computed by + // the caller at generation time (the synth always lives at a fixed depth + // under the autolinking dir, and is regenerated on every `react-native + // spm` run), so the manifest holds no runtime discovery. Siblings use their + // absolute synth path when the caller provides one (production); else a + // relative fallback. + const packageDeps /*: Array */ = []; + if (hasReactDep) { + const reactNativePackagePath /*: string */ = + spec.reactNativePackagePath ?? '../../../../xcframeworks'; + const codegenPackagePath /*: string */ = + spec.codegenPackagePath ?? '../../../ios'; + packageDeps.push( + reactNativePackageDecl( + `.package(name: "ReactNative", path: "${reactNativePackagePath}")`, + ), + ); + // Per-app generated headers come from the ReactAppHeaders product in + // the codegen package. + packageDeps.push( + `.package(name: "React-GeneratedCode", path: "${codegenPackagePath}")`, + ); + } + for (const dep of spmDependencies) { + const absPath = siblingSynthAbsolutePaths[dep.swiftName]; + if (absPath != null) { + packageDeps.push( + `.package(name: "${dep.swiftName}", path: "${absPath}")`, + ); + } else { + const siblingRel /*: string */ = spec.siblingPackageBaseRelative ?? '..'; + packageDeps.push( + `.package(name: "${dep.swiftName}", path: "${siblingRel}/${dep.swiftName}")`, + ); + } + } + + // Target dependencies — products from each declared package dep. + const targetDeps /*: Array */ = []; + if (hasReactDep) { + targetDeps.push(reactProductDeps()); + } + for (const dep of spmDependencies) { + targetDeps.push( + `.product(name: "${dep.swiftName}", package: "${dep.swiftName}")`, + ); + } + + const excludeLine = + exclude.length > 0 + ? `\n exclude: [${exclude.map(e => `"${e}"`).join(', ')}],` + : ''; + // sources: explicit allowlist. One file per line because lists can run to + // dozens of entries and an unbroken array becomes unreadable in diffs. + const sourcesLine = + sources != null && sources.length > 0 + ? `\n sources: [\n${sources.map(s => ` "${s}",`).join('\n')}\n ],` + : ''; + const publicHeadersLine = + publicHeadersPath != null + ? `\n publicHeadersPath: "${publicHeadersPath}",` + : ''; + const resourcesLine = + resources != null && resources.length > 0 + ? `\n resources: [${resources.map(r => `.copy("${r}")`).join(', ')}],` + : ''; + + const packageDepsBlock = + packageDeps.length > 0 + ? ` dependencies: [\n ${packageDeps.join(',\n ')},\n ],\n` + : ''; + // `.headerSearchPath(...)` entries from the podspec — first-class + // directives keep SPM's diagnostics meaningful (clang reports the + // dep-relative path on miss). React headers need no paths at all. + const headerSearchPathDirectives = headerSearchPaths + .map(p => `.headerSearchPath("${p}")`) + .join(', '); + const cSettingsLine = + headerSearchPaths.length > 0 + ? `\n cSettings: [${headerSearchPathDirectives}],` + : ''; + const cxxSettingsLine = + headerSearchPaths.length > 0 + ? `\n cxxSettings: [${headerSearchPathDirectives}],` + : ''; + + return `// swift-tools-version: 6.0 +// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit manually. +// Synth Package.swift for autolinked dep "${swiftName}". + +import PackageDescription + +let package = Package( + name: "${swiftName}", + platforms: [.iOS(.v15)], + products: [ + .library(name: "${swiftName}"${isDynamic ? ', type: .dynamic' : ''}, targets: ["${swiftName}"]), + ], +${packageDepsBlock} targets: [ + .target( + name: "${swiftName}", + dependencies: [${targetDeps.join(', ')}], + path: "${targetPath}",${excludeLine}${sourcesLine}${publicHeadersLine}${resourcesLine}${cSettingsLine}${cxxSettingsLine} + linkerSettings: [.linkedFramework("UIKit"), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")] + ), + ], + cxxLanguageStandard: .cxx20 +) +`; +} + +function main(argv /*:: ?: Array */) /*: void */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + // Resolve to absolute so path.join() produces absolute paths everywhere — + // entryAbsDirs, the headers farm, etc. all assume an absolute appRoot. + const appRoot = path.resolve(args.appRoot); + remoteCfg = remotePackageConfig(appRoot); + if (remoteCfg != null) { + log(`Remote ReactNative package: ${remoteCfg.url} @ ${remoteCfg.version}`); + } + + let rnRoot = args.reactNativeRoot; + if (rnRoot == null) { + rnRoot = path.join(appRoot, 'node_modules', 'react-native'); + if (!fs.existsSync(rnRoot)) { + // Monorepo: try walking up + let dir = appRoot; + for (let i = 0; i < 5; i++) { + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + const c = path.join(dir, 'node_modules', 'react-native'); + if (fs.existsSync(c)) { + rnRoot = c; + break; + } + } + } + if (rnRoot == null || !fs.existsSync(rnRoot)) { + console.error( + '[generate-spm-autolinking] Could not find react-native. Pass --react-native-root.', + ); + process.exitCode = 1; + return; + } + } + + const autolinkingJsonPath = + args.autolinkingJson ?? + path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json'); + + // Output lands under /build/generated/autolinking/ — co-located + // with autolinking.json (written by generate-spm-autolinking-config.js) and + // alongside the iOS-conventional build/ tree (Pods/, build/xcframeworks/…). + const outputDir = + args.output != null + ? path.resolve(appRoot, args.output) + : path.join(appRoot, 'build', 'generated', 'autolinking'); + + // Collect all targets along with their routing metadata. + const entries /*: Array */ = []; + + // 1. From autolinking.json (npm packages with iOS native modules), expanded + // with transitive deps declared via `spm.dependencies` in each package's + // react-native.config.js (analog of podspec `s.dependency`). + const autolinkingData = readAutolinkingJson(autolinkingJsonPath); + const depsMap = autolinkingData?.dependencies; + if (depsMap != null) { + // Narrow each on-disk AutolinkingDepJson into the validated AutolinkedDep + // shape expected by expandSpmDependencies and autolinkingDepToSpmTarget. + const directDeps /*: Array */ = []; + for (const name of Object.keys(depsMap)) { + const dep = depsMap[name]; + if (dep == null) continue; + const iosPlatform = dep.platforms?.ios; + const root = dep.root; + if (iosPlatform == null || root == null) continue; + directDeps.push({ + name, + root, + platforms: {ios: iosPlatform}, + }); + } + const allDeps = expandSpmDependencies(directDeps, { + readConfig: defaultReadConfig, + resolveDep: defaultResolveDep, + }); + + // Map every autolinked npm name to its resolved Swift name (post-override) + // so transitive references inside autolinkingDepToSpmTarget find the right + // target identifier — not just the auto-derived toSwiftName. + const swiftNameByNpm /*: Map */ = new Map(); + for (const dep of allDeps) { + if (dep.swiftName != null) { + swiftNameByNpm.set(dep.name, dep.swiftName); + } + } + + for (const dep of allDeps) { + const target = autolinkingDepToSpmTarget( + dep.name, + dep, + outputDir, + swiftNameByNpm, + ); + if (target != null) { + entries.push({target, origin: 'npm', npmName: dep.name}); + log(`Found npm native module: ${target.name} → ${target.path}`); + } + } + } else { + log( + `No autolinking.json found at ${path.relative(appRoot, autolinkingJsonPath)} or no dependencies. Using only built-in modules.`, + ); + } + + // 2. From react-native.config.js spm.modules (user-defined extra modules). + // If the module declares `sources: [glob, ...]` (CocoaPods-style), expand + // the globs now relative to its dir and attach the file list to the target + // so the emission loop below renders `sources: [...]` literally. + const configModules = readSpmModulesFromConfig(appRoot); + for (const mod of configModules) { + const absPath = path.resolve(appRoot, mod.path); + const relPath = path.relative(outputDir, absPath); + const userSources = + Array.isArray(mod.sources) && mod.sources.length > 0 + ? expandSpmSourceGlobs(absPath, mod.sources) + : null; + entries.push({ + target: { + name: mod.name, + path: relPath, + exclude: mod.exclude ?? [], + publicHeadersPath: mod.publicHeadersPath ?? null, + sources: userSources, + }, + origin: 'spmModule', + }); + log(`Config module: ${mod.name} → ${relPath}`); + } + + // Resolve xcframeworks package path relative to outputDir (autolinked/). + // When provided this causes each target to declare a React dependency so + // Xcode adds the xcframework's header search paths (needed for ). + // Always set xcframeworksRelPath to the default even if the directory doesn't + // exist yet — on first run, step 2 (autolinking) runs before step 4 + // (xcframework symlinks), but the generated Swift code resolves paths at + // Xcode build time, not generation time. + let xcframeworksRelPath /*: string | null */ = null; + const absXcframeworks /*: string */ = + args.xcframeworksPath != null + ? path.resolve(appRoot, args.xcframeworksPath) + : path.join(appRoot, 'build', 'xcframeworks'); + xcframeworksRelPath = path.relative(outputDir, absXcframeworks); + + if (xcframeworksRelPath != null) { + log( + `React xcframeworks → ${xcframeworksRelPath} (relative to autolinked/)`, + ); + } + + // Whether autolinked targets declare a React dependency at all. Headers are + // served by the React/ReactNativeHeaders binaryTargets and the + // ReactAppHeaders product — no `-I` flags anywhere. + const hasReactDep = xcframeworksRelPath != null; + + // Each entry gets a wrapper dir at /packages// that + // contains the synth Package.swift and a `root` directory symlink pointing + // at the dep's real source dir. SPM derives package identity from the path + // basename, so the wrapper's unique name (SwiftName) sidesteps the basename + // collision that in-place at the source dir would have. Files inside the + // source dir stay real, so Xcode's atomic-save works through the dir + // symlink (intermediate path components — even symlinks — resolve cleanly; + // the issue was only file-symlinks as the final path component). + const entryAbsDirs /*: Map */ = new Map(); + for (const entry of entries) { + entryAbsDirs.set( + entry.target.name, + path.resolve(outputDir, entry.target.path), + ); + } + + const packagesDir = path.join(outputDir, 'packages'); + const headersDir = path.join(outputDir, 'headers'); + // libs// symlinks for self-managed deps. The symlink basename + // is the Swift module name (guaranteed unique per dep), so SPM's + // path-basename-based package identity never collides — even when two + // libs ship their own Package.swift inside `ios/` (a common convention). + // Wiped on every run; populated below as self-managed deps are visited. + const libsDir = path.join(outputDir, 'libs'); + fs.mkdirSync(packagesDir, {recursive: true}); + fs.mkdirSync(headersDir, {recursive: true}); + fs.rmSync(libsDir, {recursive: true, force: true}); + fs.mkdirSync(libsDir, {recursive: true}); + + const wrapperDirs /*: Map */ = new Map(); + const selfManagedDirs /*: Map */ = new Map(); + const aggregatorPackageDeps /*: Array */ = []; + // Community npm deps that autolink but ship/scaffold no Package.swift. We no + // longer silently synthesize one for them (that duplicated the scaffolder and + // hid the gap from the developer and the library author) — collect them and + // fail with an actionable message after the classification pass. spmModules + // (app-local, podspec-less, explicitly declared in react-native.config.js) + // keep their synth wrappers: there is nothing to scaffold for them. + const missingManifests /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */ = + []; + + for (const entry of entries) { + const {target} = entry; + const absSource /*: string */ = entryAbsDirs.get(target.name) ?? ''; + if (!fs.existsSync(absSource)) { + log(`Skipping ${target.name}: source dir missing (${absSource})`); + continue; + } + const selfManagedDir = findSelfManagedPackageDir(absSource); + if (selfManagedDir != null) { + // Record the manifest's actual directory — for the nested layout this + // is /ios, not . SPM resolves `.package(path:)` against that + // directory expecting Package.swift to live alongside. + selfManagedDirs.set(target.name, selfManagedDir); + // If a wrapper exists from a prior synth-mode run (i.e. the dep WAS + // autolinker-wrapped, then later transitioned to self-managed via + // `spm scaffold` or shipping its own Package.swift), remove the + // wrapper now. Without this, the pruning loop below preserves it + // (because the dep is "active" via selfManagedDirs) and Xcode's + // SwiftPM cache picks up the stale wrapper Package.swift instead of + // the self-managed one. + const staleWrapper = path.join(packagesDir, target.name); + if (fs.existsSync(staleWrapper)) { + fs.rmSync(staleWrapper, {recursive: true, force: true}); + log(`Removed stale wrapper: packages/${target.name}/`); + } + log( + `Self-managed: ${target.name} → ${path.relative(appRoot, selfManagedDir)} (using its own Package.swift)`, + ); + continue; + } + if (entry.origin === 'npm') { + // No shipped or scaffolded manifest — this is the gap we now surface. + // A mixed-language dep is reported distinctly (it can't be scaffolded at + // all, so "run spm scaffold" would be misleading). + missingManifests.push({ + name: target.name, + npmName: entry.npmName ?? target.name, + hasPodspec: hasPodspec(absSource), + mixed: hasMixedLanguageSources(absSource), + }); + // Drop any stale wrapper from a previous synth-mode run so SPM doesn't + // resolve against it. + const staleWrapper = path.join(packagesDir, target.name); + if (fs.existsSync(staleWrapper)) { + fs.rmSync(staleWrapper, {recursive: true, force: true}); + } + continue; + } + // spmModule: synth wrapper is the legitimate mechanism (no podspec exists + // to scaffold from, and the app developer declared it explicitly). + const wrapperDir = path.join(packagesDir, target.name); + wrapperDirs.set(target.name, wrapperDir); + fs.mkdirSync(wrapperDir, {recursive: true}); + ensureSymlink(path.join(wrapperDir, WRAPPER_ROOT_NAME), absSource); + } + + // Fail before writing any wrappers/aggregator: a missing community-lib + // manifest is a hard error the developer must resolve by scaffolding (or the + // library shipping its own). reportMissingManifests prints one `error:` line + // per dep so Xcode renders them as build errors. + if (missingManifests.length > 0) { + throw reportMissingManifests(missingManifests); + } + + // Sibling refs: each synth Package.swift declares its sibling deps via the + // dep's actual package root — wrapper dir for autolinker-managed deps, + // source dir for self-managed ones. SPM identity stays unique either way + // (wrapper basename = SwiftName; self-managed manifests declare the same + // package name). + const siblingPackagePaths /*: {[string]: string} */ = {}; + for (const [name, wrapper] of wrapperDirs.entries()) { + siblingPackagePaths[name] = wrapper; + } + for (const [name, sourceDir] of selfManagedDirs.entries()) { + siblingPackagePaths[name] = sourceDir; + } + + for (const entry of entries) { + const {target} = entry; + const absSource /*: string */ = entryAbsDirs.get(target.name) ?? ''; + + // Self-managed deps: skip the synth step entirely. The dep's own + // Package.swift handles its targets, headers, and React framework + // wiring. We just register it with the aggregator so the app pulls it + // in alongside autolinker-managed deps. The central headers// + // tree still gets populated so consumers (host app + sibling synths + // that hit -I autolinking/headers) can resolve `` + // by file path — synth packages use `-fno-implicit-module-maps`, so + // we can't rely on SPM's auto-generated module map alone. + if (selfManagedDirs.has(target.name)) { + // Centralized headers tree walks the WHOLE dep root, not just the + // manifest's directory — headers may live anywhere (e.g. common/cpp/ + // outside of ios/), and cross-package consumers should still resolve + // them via the centralized -I path. + linkHeaderTree(absSource, path.join(headersDir, target.name)); + // Route the manifest reference through a uniquely-named symlink at + // libs// so SPM derives the package identity from the + // alias basename. Two libs that both ship Package.swift inside their + // own `ios/` subdir would otherwise collide with identity "ios". + const realPackageDir = selfManagedDirs.get(target.name) ?? absSource; + const aliasPath = path.join(libsDir, target.name); + ensureSymlink(aliasPath, realPackageDir); + aggregatorPackageDeps.push({ + swiftName: target.name, + packagePath: `libs/${target.name}`, + npmName: entry.npmName ?? target.name, + }); + continue; + } + + const wrapperDir = wrapperDirs.get(target.name); + if (wrapperDir == null) continue; + const skipDirNames = new Set( + (target.exclude || []) + .filter(e => e.endsWith('/')) + .map(e => e.slice(0, -1)), + ); + + const siblingSynthAbsolutePaths /*: {[string]: string} */ = {}; + for (const sibling of target.spmTargetDependencies ?? []) { + const sibPath = siblingPackagePaths[sibling]; + if (sibPath != null) { + siblingSynthAbsolutePaths[sibling] = sibPath; + } + } + + // target.path = "." (the wrapper dir) so SPM sees an empty `include/` + // sibling of `root/` for its required `publicHeadersPath`. Without that, + // SPM defaults publicHeadersPath to "include" and errors out when no + // such dir exists inside the dep's source tree. Sources come from + // `root/<...>` via the dir symlink — paths from auto-discovery or + // user globs are relative to the dep's source dir, so we prefix with + // `root/` to keep them inside target.path. + const withRoot = (p /*: string */) => `${WRAPPER_ROOT_NAME}/${p}`; + const prefixedExclude /*: Array */ = (target.exclude ?? []).map( + withRoot, + ); + const prefixedResources /*: ?Array */ = + target.resources != null ? target.resources.map(withRoot) : undefined; + + // sources: explicit allowlist. Pre-resolved on the target (spmModule + // glob expansion) or auto-collected here. We always emit `sources:` so + // SPM never falls back to scanning the source dir verbatim (which would + // pick up tests/, *.js, *.podspec, etc.). + const rawSources /*: Array */ = + target.sources != null && target.sources.length > 0 + ? target.sources + : collectSpmSources(absSource); + const prefixedSources /*: ?Array */ = + rawSources.length > 0 ? rawSources.map(withRoot) : null; + + // Podspec HEADER_SEARCH_PATHS were captured relative to the dep's source + // dir. The wrapper exposes the source dir under `root/` (target.path is + // `.`, the wrapper dir), so each entry must be prefixed with `root/` so + // clang sees the real subtree. + const prefixedHeaderSearchPaths /*: ?Array */ = + target.headerSearchPaths != null && target.headerSearchPaths.length > 0 + ? target.headerSearchPaths.map(withRoot) + : null; + + const synthContent = generateSynthPackageSwift({ + swiftName: target.name, + exclude: prefixedExclude, + sources: prefixedSources, + // Stub include/ subdir lives in the wrapper dir; satisfies SPM's + // publicHeadersPath requirement without exposing anything. Cross-pkg + // angle includes resolve through the merged header tree (the autolinking + // header farm at /headers is folded into it). + publicHeadersPath: 'include', + resources: prefixedResources, + headerSearchPaths: prefixedHeaderSearchPaths, + spmDependencies: (target.spmTargetDependencies ?? []).map(swiftName => ({ + swiftName, + })), + hasReactDep, + // Relative paths from the synth dir (/packages/) to the + // app's React xcframeworks + codegen packages. Computed here because the + // synth's depth is fixed and it is regenerated every run — no runtime + // discovery needed in the manifest. + reactNativePackagePath: toPosix( + path.relative(wrapperDir, absXcframeworks), + ), + codegenPackagePath: toPosix( + path.relative( + wrapperDir, + path.join(appRoot, 'build', 'generated', 'ios'), + ), + ), + isDynamic: false, + targetPath: '.', + siblingSynthAbsolutePaths, + }); + + fs.writeFileSync( + path.join(wrapperDir, 'Package.swift'), + synthContent, + 'utf8', + ); + // Centralized headers tree at /headers//.h. + // Used two ways: + // * SPM-internal: cFlags add `-I /headers`, so cross-package + // angle includes like resolve. + // * Host app + sibling consumers: each wrapper's `include/` is a dir + // symlink to its slice of this tree, so `#import ` + // (e.g. ) resolves through SPM's + // publicHeadersPath propagation (-I .../packages//include). + const pkgHeadersDir = path.join(headersDir, target.name); + linkHeaderTree(absSource, pkgHeadersDir, skipDirNames); + + const includePath = path.join(wrapperDir, 'include'); + if (fs.existsSync(pkgHeadersDir)) { + ensureSymlink(includePath, pkgHeadersDir); + } else { + // Header-less package (rare): keep an empty dir so SPM's + // publicHeadersPath: "include" requirement is still satisfied. + fs.mkdirSync(includePath, {recursive: true}); + } + + log( + `Synth: packages/${target.name}/ → ${path.relative(appRoot, absSource)}`, + ); + + aggregatorPackageDeps.push({ + swiftName: target.name, + packagePath: `packages/${target.name}`, + }); + } + + // Prune stale wrappers + header dirs for entries no longer autolinked. + // Preserve both wrapper-managed and self-managed names; only entries that + // are no longer autolinked at all get removed. Note: `packages/` only has + // wrapper-managed names (self-managed deps live in their own source dirs), + // but `headers/` has both since we populate the central tree for everyone. + const activeNames /*: Set */ = new Set([ + ...wrapperDirs.keys(), + ...selfManagedDirs.keys(), + ]); + for (const subdir of ['packages', 'headers']) { + const dir = path.join(outputDir, subdir); + try { + const existing /*: Array<{name: string, isSymbolicLink(): boolean, isDirectory(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of existing) { + if (activeNames.has(entry.name)) continue; + const stale = path.join(dir, entry.name); + if (entry.isSymbolicLink() || !entry.isDirectory()) { + fs.unlinkSync(stale); + } else { + fs.rmSync(stale, {recursive: true, force: true}); + } + log(`Removed stale ${subdir}/${entry.name}`); + } + } catch { + // dir doesn't exist – fine + } + } + + // Top-level aggregator: references every entry as .package(path:) and + // depends on each via .product(...). No more inline targets — every + // autolinked dep is a real SPM package in its own source dir. + const aggregatorContent = generateAutolinkedPackageSwift({ + npmDeps: aggregatorPackageDeps, + hasReactDep, + xcframeworksRelPath, + }); + fs.mkdirSync(outputDir, {recursive: true}); + const outputPath = path.join(outputDir, 'Package.swift'); + fs.writeFileSync(outputPath, aggregatorContent, 'utf8'); + log(`Generated: ${path.relative(appRoot, outputPath)}`); + + // .spm-sync-watch-paths: absolute paths the Xcode auto-sync build phase + // should watch for add/remove of native source files. Updating the dir + // mtime via touch/rm of a child file is enough to invalidate the stamp, + // so the sync re-runs and the `sources:` allowlist is regenerated. + // Each module's source dir is already known via entryAbsDirs. + const watchPaths = Array.from(new Set(entryAbsDirs.values())) + .filter(p => p.length > 0 && fs.existsSync(p)) + .sort(); + fs.writeFileSync( + path.join(outputDir, '.spm-sync-watch-paths'), + watchPaths.join('\n') + (watchPaths.length > 0 ? '\n' : ''), + 'utf8', + ); + + // AutolinkedAggregate is glue; needs at least one source file (Swift, so we + // sidestep the Obj-C public-headers-dir requirement). + const aggregateDir = path.join(outputDir, 'AutolinkedAggregate'); + fs.mkdirSync(aggregateDir, {recursive: true}); + const stubPath = path.join(aggregateDir, 'AutolinkedAggregate.swift'); + if (!fs.existsSync(stubPath)) { + fs.writeFileSync( + stubPath, + '// Placeholder. Real native modules live in transitively-referenced sub-packages.\n', + 'utf8', + ); + } + const legacyStub = path.join(aggregateDir, 'AutolinkedAggregate.m'); + if (fs.existsSync(legacyStub)) { + fs.unlinkSync(legacyStub); + } + + // One-time migration cleanup: remove the legacy /autolinked/ tree + // and any stale in-source `Package.swift` / `include//` from the + // prior in-place layout (those files lived in user source dirs and have + // been replaced by the wrapper layout under outputDir). + const legacyAutolinkedDir = path.join(appRoot, 'autolinked'); + if ( + fs.existsSync(legacyAutolinkedDir) && + path.resolve(legacyAutolinkedDir) !== path.resolve(outputDir) + ) { + fs.rmSync(legacyAutolinkedDir, {recursive: true, force: true}); + log(`Removed legacy autolinked/ tree`); + } + for (const absSource of entryAbsDirs.values()) { + const legacyPkg = path.join(absSource, 'Package.swift'); + try { + const content = fs.readFileSync(legacyPkg, 'utf8'); + if (content.includes(AUTOGEN_MARKER)) { + fs.unlinkSync(legacyPkg); + log( + `Removed legacy in-place synth: ${path.relative(appRoot, legacyPkg)}`, + ); + } + } catch { + // not present – fine + } + const legacyInclude = path.join(absSource, 'include'); + try { + if (fs.lstatSync(legacyInclude).isDirectory()) { + fs.rmSync(legacyInclude, {recursive: true, force: true}); + log( + `Removed legacy in-place include/: ${path.relative(appRoot, legacyInclude)}`, + ); + } + } catch { + // not present – fine + } + } +} + +if (require.main === module) { + try { + main(); + } catch (e) { + if (e instanceof RemoteVersionError) { + log(e.message); + process.exitCode = 2; + } else { + throw e; + } + } +} + +module.exports = { + main, + generateAutolinkedPackageSwift, + generateSynthPackageSwift, + linkHeaderTree, + collectSpmSources, + expandSpmSourceGlobs, + findSelfManagedPackageDir, + hasPodspec, + hasMixedLanguageSources, + MissingManifestError, + reportMissingManifests, + AUTOGEN_MARKER, +}; diff --git a/packages/react-native/scripts/spm/generate-spm-package.js b/packages/react-native/scripts/spm/generate-spm-package.js new file mode 100644 index 000000000000..b3bb99e85e1f --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-package.js @@ -0,0 +1,364 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: import type {GeneratePackageArgs} from './spm-types'; */ + +/** + * generate-spm-package.js – Generates the xcframeworks sub-package for a + * React Native app using prebuilt XCFrameworks via Swift Package Manager. + * + * Usage: + * node generate-spm-package.js [options] + * + * Options: + * --app-root Path to the app directory (default: cwd) + * --react-native-root Path to react-native package root + * --version RN version for Maven artifact URLs + * --local-xcframework Use local xcframework (skips binary targets) + * --artifacts-dir Path to downloaded artifacts directory + * --app-name App/package name (default: from package.json) + * --target-name Main app target name (default: derived from app-name) + * --source-path Path to app source relative to app-root (default: auto-detected) + * --ios-version Minimum iOS version (default: 15) + * + * Generates build/xcframeworks/Package.swift + symlinks. The xcodeproj + * references this sub-package directly; no separate app-level Package.swift + * is needed. + */ + +const {REQUIRED_ARTIFACTS} = require('./download-spm-artifacts'); +const { + deriveAppName, + displayPath, + findProjectRoot, + makeLogger, + readPackageJson, + resolveReactNativeRoot, + toSwiftName, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); +const yargs = require('yargs'); + +const {log} = makeLogger('generate-spm-package'); + +function parseArgs(argv /*: Array */) /*: GeneratePackageArgs */ { + const parsed = yargs(argv) + .version(false) + .option('app-root', { + type: 'string', + default: process.cwd(), + describe: 'Path to the app directory', + }) + .option('react-native-root', { + type: 'string', + describe: 'Path to react-native package root', + }) + .option('version', { + type: 'string', + describe: 'RN version for Maven artifact URLs', + }) + .option('local-xcframework', { + type: 'string', + describe: 'Use local xcframework (skips binary targets)', + }) + .option('artifacts-dir', { + type: 'string', + describe: 'Path to downloaded artifacts directory', + }) + .option('app-name', { + type: 'string', + describe: 'App/package name (default: from package.json)', + }) + .option('target-name', { + type: 'string', + describe: 'Main app target name (default: derived from app-name)', + }) + .option('source-path', { + type: 'string', + describe: + 'Path to app source relative to app-root (default: auto-detected)', + }) + .option('ios-version', { + type: 'string', + default: '15', + describe: 'Minimum iOS version', + }) + .usage( + 'Usage: $0 [options]\n\nGenerates the xcframeworks sub-package for a React Native app using SPM.', + ) + .help() + .parseSync(); + + return { + appRoot: parsed['app-root'], + reactNativeRoot: parsed['react-native-root'] ?? null, + version: parsed.version ?? null, + localXcframework: parsed['local-xcframework'] ?? null, + artifactsDir: parsed['artifacts-dir'] ?? null, + appName: parsed['app-name'] ?? null, + targetName: parsed['target-name'] ?? null, + sourcePath: parsed['source-path'] ?? null, + iosVersion: parsed['ios-version'], + }; +} + +/** + * Find the app's main Swift/ObjC source directory. + * Looks for directories that contain native iOS source files. + */ +function findSourcePath( + appRoot /*: string */, + packageName /*: string */, +) /*: string */ { + // Derive from package name (e.g. "@react-native/tester" -> "Tester") + const derived = toSwiftName(packageName.replace(/^@[^/]+\//, '')); + + // Also check "RN" + derived (e.g. "Tester" -> "RNTester") and "RN" + whole name + const rnPrefixed = 'RN' + derived; + const candidates = [derived, rnPrefixed, 'ios', 'App', 'Sources', 'src']; + for (const c of candidates) { + if (fs.existsSync(path.join(appRoot, c))) { + return c; + } + } + + // Scan for a directory that looks like an iOS source root + // (contains .m, .mm, .swift, or .h files) + try { + const entries /*: Array<{name: string, isDirectory(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here + fs.readdirSync(appRoot, {withFileTypes: true}); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const dirPath = path.join(appRoot, entry.name); + const subEntries = fs.readdirSync(dirPath); + const hasNativeSources = subEntries.some((f /*: string | Buffer */) => + /\.(m|mm|swift|cpp|h|hpp)$/.test(String(f)), + ); + if (hasNativeSources) { + return entry.name; + } + } + } catch (_) { + // ignore + } + + return derived; +} + +/** + * Generates the Package.swift for the xcframeworks sub-package. + * + * When using local xcframeworks (from the cache), we put the binary targets in + * a dedicated Package.swift at build/xcframeworks/. The generated .xcodeproj + * references this sub-package via XCLocalSwiftPackageReference; the codegen + * Package.swift also imports it as a named package dependency. + */ +function generateXCFrameworksPackageSwift( + names /*: Array */, + artifactsDir /*:: ?: ?string */, +) /*: string */ { + // Rename the "React" product to "ReactNative" so consumers use + // .product(name: "ReactNative", package: "ReactNative") -- a clearer API. + // The binary target stays "React" (must match React.xcframework filename). + const productName = (n /*: string */) => (n === 'React' ? 'ReactNative' : n); + + const products = names + .map(n => { + const pName = productName(n); + return ` .library(name: "${pName}", targets: ["${n}"]),`; + }) + .join('\n'); + + // Binary target paths must be RELATIVE to the package root — SPM rejects + // absolute paths with "invalid local path" at manifest-load time. The + // bare `.xcframework` resolves to the symlink under + // /build/xcframeworks/ that generate-spm-package keeps pointed at the + // current cache slot. + const binaryTargets = names + .map(n => ` .binaryTarget(name: "${n}", path: "${n}.xcframework"),`) + .join('\n'); + + // Cache-slot identifier embedded as a top-of-file comment. SPM re-evaluates + // Package.swift when its content hash changes — a slot bump alters this + // line, busting the manifest cache and forcing SPM to re-read the binary + // target (which then re-copies React.framework into BUILT_PRODUCTS_DIR). + // Without it, a same-named symlink re-pointed at a different target leaves + // the manifest hash unchanged and the framework copy stale. + // + // Use only the trailing / segments of the artifactsDir so + // the comment is portable across users — the full absolute path includes + // ~/Library/Caches which differs per machine. The file is gitignored + // anyway, but this keeps any incidental diffing clean. + let slotComment = ''; + if (artifactsDir != null) { + const flavor = path.basename(artifactsDir); + const version = path.basename(path.dirname(artifactsDir)); + slotComment = `// Cache slot: ${version}/${flavor}\n`; + } + + return `// swift-tools-version: 6.0 +// AUTO-GENERATED by scripts/generate-spm-package.js – do not edit manually. +${slotComment}import PackageDescription + +let package = Package( + name: "ReactNative", + products: [ +${products} + ], + targets: [ +${binaryTargets} + ] +) +`; +} + +function main(argv /*:: ?: Array */) /*: void */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + // Ensure appRoot is always absolute so path.join/path.resolve produce absolute paths + // even when called with --app-root . or other relative paths. + const appRoot = path.resolve(args.appRoot); + + // Read app package.json + // package.json may be in a parent directory (e.g. when appRoot is ios/). + const projectRoot = findProjectRoot(appRoot); + const pkgJson = readPackageJson(projectRoot); + if (!pkgJson) { + throw new Error( + `[generate-spm-package] No package.json found in ${appRoot} or parent directories`, + ); + } + + let rnRoot = + args.reactNativeRoot != null + ? path.resolve(args.reactNativeRoot) + : resolveReactNativeRoot(appRoot, projectRoot); + if (rnRoot == null) { + throw new Error( + '[generate-spm-package] Could not find react-native. Pass --react-native-root.', + ); + } + + let version = args.version; + if (version == null) { + const rnPkg = readPackageJson(rnRoot); + version = rnPkg?.version ?? '0.0.0'; + } + + const rawName = pkgJson.name ?? path.basename(appRoot); + const sourcePath = args.sourcePath ?? findSourcePath(appRoot, rawName); + const appName = args.appName ?? deriveAppName(rawName, sourcePath); + const targetName = args.targetName ?? appName + 'App'; + + log(`App name: ${appName}`); + log(`Target name: ${targetName}`); + log(`Source path: ${sourcePath}`); + log(`Version: ${version}`); + + const artifactsDir = args.artifactsDir; + if (artifactsDir != null) { + const artifactsJsonPath = path.join(artifactsDir, 'artifacts.json'); + if (!fs.existsSync(artifactsJsonPath)) { + throw new Error( + `[generate-spm-package] --artifacts-dir specified but artifacts.json not found at: ${artifactsJsonPath}\n` + + ` Run: node scripts/download-spm-artifacts.js --output "${artifactsDir}"`, + ); + } + + // $FlowFixMe[incompatible-type] JSON.parse returns any + const raw /*: {[string]: {xcframeworkPath: string, url: string}} */ = + JSON.parse(fs.readFileSync(artifactsJsonPath, 'utf8')); + // Refuse to proceed with a partial artifact set. The generated xcodeproj + // references every REQUIRED_ARTIFACT as a package product, so a missing + // entry here would surface only as "Missing package product" in Xcode. + const missing = REQUIRED_ARTIFACTS.filter(name => raw[name] == null); + if (missing.length > 0) { + throw new Error( + `[generate-spm-package] artifacts.json is missing required entries: ${missing.join(', ')}\n` + + ` Re-run with --force-download to refresh the cache slot at ${artifactsDir}`, + ); + } + const xcfwLinksDir = path.join(appRoot, 'build', 'xcframeworks'); + fs.mkdirSync(xcfwLinksDir, {recursive: true}); + + // Consumers compile against the spec layout (headers-spec.js, emitted at + // prebuild time). The prebuilt core artifact must ship React.xcframework AND + // ReactNativeHeaders.xcframework together — published tarballs and + // download-spm-artifacts include both. The layout is NOT composed on the + // consumer side (that needs the full RN repo / ios-prebuild build scripts, + // which the npm package deliberately does not ship). + if (raw.ReactNativeHeaders == null) { + throw new Error( + 'Prebuilt artifacts are missing ReactNativeHeaders.xcframework — the ' + + 'React Native core artifact must ship it alongside React.xcframework. ' + + 'Re-download or rebuild the artifact (or point --artifacts at a ' + + 'complete slot).', + ); + } + + const names /*: Array */ = []; + const linkOne = (name /*: string */, target /*: string */) => { + const linkPath = path.join(xcfwLinksDir, `${name}.xcframework`); + try { + fs.unlinkSync(linkPath); + } catch { + /* doesn't exist yet */ + } + fs.symlinkSync(target, linkPath); + log( + `Symlink: build/xcframeworks/${name}.xcframework -> ${displayPath(target)}`, + ); + names.push(name); + }; + // $FlowFixMe[incompatible-use] Object.entries values typed as mixed + for (const [name, entry] of Object.entries(raw)) { + linkOne(name, entry.xcframeworkPath); + } + + // Pass the absolute artifacts dir so the binary target paths reference the + // current cache slot. When the slot changes (e.g. new nightly published), + // Package.swift content changes — SPM/Xcode notice and re-copy the framework. + const xcfwPkgContent = generateXCFrameworksPackageSwift( + names, + artifactsDir, + ); + const xcfwPkgPath = path.join(xcfwLinksDir, 'Package.swift'); + fs.writeFileSync(xcfwPkgPath, xcfwPkgContent, 'utf8'); + log(`Generated: ${path.relative(appRoot, xcfwPkgPath)}`); + + log(`Artifacts dir: ${displayPath(artifactsDir)} (local xcframework mode)`); + } else { + // Auto-detect: if build/xcframeworks/Package.swift already exists (e.g. from a + // previous run with --artifacts-dir), reuse it without re-downloading anything. + const xcfwLinksDir = path.join(appRoot, 'build', 'xcframeworks'); + if (fs.existsSync(path.join(xcfwLinksDir, 'Package.swift'))) { + log(`Auto-detected local xcframeworks: build/xcframeworks`); + } + } +} + +if (require.main === module) { + try { + main(); + } catch (e) { + console.error(e.message); + process.exitCode = 1; + } +} + +module.exports = { + main, + generateXCFrameworksPackageSwift, + findSourcePath, +}; diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js new file mode 100644 index 000000000000..c64d10987bc6 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -0,0 +1,1313 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/** + * generate-spm-xcodeproj.js – Surgical, in-place Swift Package Manager + * integration toolkit for an existing `.xcodeproj`. + * + * `injectSpmIntoExistingXcodeproj` adds the SPM package references, React build + * settings, the "Sync SPM Autolinking" build phase, and a scheme pre-action to + * a user's existing project — purely additively, recording every edit in a + * `.spm-injected.json` marker. `removeSpmInjection` is the exact inverse (used + * by `spm deinit`). Consumed as a library by setup-apple-spm.js; not a CLI. + */ + +const { + addArrayMembers, + addArrayStringValues, + ensureScalarField, + findApplicationTargets, + findField, + findObjectByUuid, + findProjectObject, + insertObjectsIntoSection, + namespacedUUID, + quoteIfNeeded, + removeArrayMembersByUuid, + removeArrayStringValues, + removeEmptyPodsGroup, + removeField, + removeObjectByUuid, + serializeEntry, +} = require('./spm-pbxproj'); +const {makeLogger, remotePackageConfig} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); + +const {log} = 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` +// (removeSpmInjection) can surgically revert and re-runs stay idempotent. +const SPM_INJECTED_MARKER = '.spm-injected.json'; + +// Maps each SPM product to its sub-package path (relative to app root). +// The xcodeproj must reference each sub-package directly so Xcode can +// resolve the product dependencies — SPM doesn't expose transitive products. +const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, packageName: string}> */ = + [ + { + product: 'ReactNative', + packagePath: 'build/xcframeworks', + packageName: 'ReactNative', + }, + { + product: 'ReactNativeDependencies', + packagePath: 'build/xcframeworks', + packageName: 'ReactNative', + }, + { + product: 'hermes-engine', + packagePath: 'build/xcframeworks', + packageName: 'ReactNative', + }, + { + product: 'Autolinked', + packagePath: 'build/generated/autolinking', + packageName: 'Autolinked', + }, + { + product: 'ReactCodegen', + packagePath: 'build/generated/ios', + packageName: 'React-GeneratedCode', + }, + { + product: 'ReactAppDependencyProvider', + packagePath: 'build/generated/ios', + packageName: 'React-GeneratedCode', + }, + ]; + +/*:: +type RemoteCfg = {url: string, version: string, identity: string}; +// Precise record of the build-setting edits injection made to ONE build config, +// so deinit can reverse exactly those (and nothing the user already had). +type BuildSettingChange = { + configUuid: string, + createdArrayKeys: Array, + appendedArrayValues: {[string]: Array}, + createdScalars: Array, +}; +type SpmGraph = { + uniquePackages: Array<{packagePath: string, packageName: string}>, + localPkgRefs: Array<{uuid: string, packagePath: string, comment: string}>, + remotePkgRef: ?{uuid: string, url: string, version: string, identity: string, comment: string}, + products: Array<{product: string, depUuid: string, buildFileUuid: string, pkgRefUuid: string, refComment: string}>, +}; +*/ + +/** + * Resolve the SPM dependency graph (package references + product + * dependencies + their frameworks build files) from SPM_PRODUCT_PACKAGES. + * `mkUuid(section, id)` supplies UUIDs, seeded with the host project's root + * UUID so injected IDs are stable across re-runs and collision-safe. + */ +function buildSpmDependencyGraph( + mkUuid /*: (section: string, id: string) => string */, + remote /*: ?RemoteCfg */, +) /*: SpmGraph */ { + // Remote mode: ReactNative-family products move to the remote package. + const productPackages = SPM_PRODUCT_PACKAGES.map(e => + remote != null && e.packagePath === 'build/xcframeworks' + ? {...e, packagePath: 'REMOTE', packageName: remote.identity} + : e, + ); + const uniquePackages = Array.from( + new Map( + productPackages + .filter(e => e.packagePath !== 'REMOTE') + .map(e => [ + e.packagePath, + {packagePath: e.packagePath, packageName: e.packageName}, + ]), + ).values(), + ); + const localPkgRefs = uniquePackages.map(pkg => ({ + uuid: mkUuid('XCLocalSwiftPackageReference', pkg.packagePath), + packagePath: pkg.packagePath, + comment: `XCLocalSwiftPackageReference "${pkg.packagePath}"`, + })); + const remotePkgRef = + remote != null + ? { + uuid: mkUuid('XCRemoteSwiftPackageReference', remote.url), + url: remote.url, + version: remote.version, + identity: remote.identity, + comment: `XCRemoteSwiftPackageReference "${remote.identity}"`, + } + : null; + const localByPath = new Map(localPkgRefs.map(r => [r.packagePath, r])); + const products = productPackages.map(entry => { + const {product, packagePath} = entry; + const isRemote = packagePath === 'REMOTE' && remotePkgRef != null; + const pkgRefUuid = isRemote + ? // $FlowFixMe[incompatible-use] guarded by isRemote + remotePkgRef.uuid + : // $FlowFixMe[incompatible-use] every non-REMOTE path is in localByPath + localByPath.get(packagePath).uuid; + const refComment = isRemote + ? // $FlowFixMe[incompatible-use] guarded by isRemote + `XCRemoteSwiftPackageReference "${remotePkgRef.identity}"` + : `XCLocalSwiftPackageReference "${packagePath}"`; + return { + product, + depUuid: mkUuid('XCSwiftPackageProductDependency', product), + buildFileUuid: mkUuid('PBXBuildFile', `spm:${product}`), + pkgRefUuid, + refComment, + }; + }); + return {uniquePackages, localPkgRefs, remotePkgRef, products}; +} + +/** + * Render the SPM graph into pbxproj section entry objects the in-place injector + * splices into an existing project. + */ +/*:: type PbxEntryT = {uuid: string, comment: string, fields: {[string]: string}}; */ + +function spmGraphToEntries( + graph /*: SpmGraph */, +) /*: {localRefs: Array, remoteRef: ?PbxEntryT, productDeps: Array, buildFiles: Array} */ { + const localRefs /*: Array */ = graph.localPkgRefs.map(ref => ({ + uuid: ref.uuid, + comment: ref.comment, + fields: { + isa: 'XCLocalSwiftPackageReference', + relativePath: quoteIfNeeded(ref.packagePath), + }, + })); + const remote = graph.remotePkgRef; + const remoteRef /*: ?PbxEntryT */ = + remote != null + ? { + uuid: remote.uuid, + comment: remote.comment, + fields: { + isa: 'XCRemoteSwiftPackageReference', + repositoryURL: quoteIfNeeded(remote.url), + requirement: `{\n\t\t\t\tkind = exactVersion;\n\t\t\t\tversion = "${remote.version}";\n\t\t\t}`, + }, + } + : null; + const productDeps /*: Array */ = graph.products.map(p => ({ + uuid: p.depUuid, + comment: p.product, + fields: { + isa: 'XCSwiftPackageProductDependency', + package: `${p.pkgRefUuid} /* ${p.refComment} */`, + productName: quoteIfNeeded(p.product), + }, + })); + const buildFiles /*: Array */ = graph.products.map(p => ({ + uuid: p.buildFileUuid, + comment: `${p.product} in Frameworks`, + fields: { + isa: 'PBXBuildFile', + productRef: `${p.depUuid} /* ${p.product} */`, + }, + })); + return {localRefs, remoteRef, productDeps, buildFiles}; +} + +// Sync SPM Autolinking: timestamp check + conditional node re-run. Shared by +// the build phase (safety net) and the scheme pre-action (the one that +// actually fires before SPM resolution, so a single build picks up +// dep-graph changes from `npm install`). +// Build a PBXShellScriptBuildPhase entry (the "Sync SPM Autolinking" phase). +function shellScriptPhase( + phaseUUID /*: string */, + name /*: string */, + script /*: string */, + options /*: {inputPaths?: string, outputPaths?: string} */ = {}, +) /*: {uuid: string, comment: string, fields: {[string]: string}} */ { + const empty = '(\n\t\t\t)'; + return { + uuid: phaseUUID, + comment: name, + fields: { + isa: 'PBXShellScriptBuildPhase', + buildActionMask: '2147483647', + files: empty, + inputFileListPaths: empty, + inputPaths: options.inputPaths ?? empty, + name: quoteIfNeeded(name), + outputFileListPaths: empty, + outputPaths: options.outputPaths ?? empty, + runOnlyForDeploymentPostprocessing: '0', + shellPath: '/bin/sh', + shellScript: quoteIfNeeded(script), + }, + }; +} + +function buildSyncAutolinkingScript( + reactNativePath /*: string */, +) /*: string */ { + return `set -euo pipefail + +STAMP="$SRCROOT/build/generated/autolinking/.spm-sync-stamp" +STALE=0 + +# Check 0: xcframework artifacts missing (fresh clone) +if [ ! -f "$SRCROOT/build/xcframeworks/artifacts.json" ] || \\ + [ ! -d "$SRCROOT/build/xcframeworks/React.xcframework" ]; then + STALE=1 +fi + +# Find project root (where package.json lives — may be an ancestor of SRCROOT) +PROJECT_ROOT="$SRCROOT" +while [ "$PROJECT_ROOT" != "/" ] && [ ! -f "$PROJECT_ROOT/package.json" ]; do + PROJECT_ROOT="$(dirname "$PROJECT_ROOT")" +done +if [ ! -f "$PROJECT_ROOT/package.json" ]; then + PROJECT_ROOT="$SRCROOT" +fi + +# Check 1: dependency inputs (covers app projects after any package manager install) +for INPUT in \\ + "$PROJECT_ROOT/package.json" \\ + "$PROJECT_ROOT/react-native.config.js"; do + if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then + STALE=1 + break + fi +done + +# Check workspace lockfiles and package-manager metadata. These cover package +# managers that do not reliably bump node_modules mtimes, and Yarn PnP projects +# that do not have node_modules at all. +if [ "$STALE" -eq 0 ]; then + DIR="$PROJECT_ROOT" + while [ "$DIR" != "/" ]; do + for INPUT in \\ + "$DIR/package-lock.json" \\ + "$DIR/npm-shrinkwrap.json" \\ + "$DIR/yarn.lock" \\ + "$DIR/pnpm-lock.yaml" \\ + "$DIR/bun.lock" \\ + "$DIR/bun.lockb" \\ + "$DIR/.pnp.cjs" \\ + "$DIR/.pnp.loader.mjs"; do + if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then + STALE=1 + break + fi + done + if [ "$STALE" -eq 1 ]; then + break + fi + DIR="$(dirname "$DIR")" + done +fi + +# Check node_modules mtime. In monorepos, node_modules may be hoisted to any +# ancestor between the app package and the workspace root. +if [ "$STALE" -eq 0 ]; then + DIR="$PROJECT_ROOT" + while [ "$DIR" != "/" ]; do + NM_DIR="$DIR/node_modules" + if [ -d "$NM_DIR" ] && [ "$NM_DIR" -nt "$STAMP" ]; then + STALE=1 + break + fi + DIR="$(dirname "$DIR")" + done +fi + +# Also check the app root directly when SRCROOT is not the package root. +if [ "$STALE" -eq 0 ] && [ "$SRCROOT" != "$PROJECT_ROOT" ]; then + if [ -d "$SRCROOT/node_modules" ] && [ "$SRCROOT/node_modules" -nt "$STAMP" ]; then + STALE=1 + fi +fi + +# Check 1.5: watched module source dirs (catches add/remove of source files +# in spm.modules and autolinked deps). Directory mtime updates on both add +# and remove of children, so a single -newer check covers both cases. +WATCH_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-paths" +if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then + while IFS= read -r DIR; do + [ -z "$DIR" ] && continue + if [ -d "$DIR" ] && [ -n "$(find "$DIR" -newer "$STAMP" -print -quit 2>/dev/null)" ]; then + STALE=1 + break + fi + done < "$WATCH_FILE" +fi + +# Check 2: codegen spec files changed via git (covers monorepo after git pull) +if [ "$STALE" -eq 0 ] && [ -f "$STAMP" ]; then + STAMP_TIME=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0) + LATEST_SPEC_COMMIT=$(git -C "$SRCROOT" log -1 --format=%ct -- '*.js' '*.ts' 2>/dev/null || echo 0) + if [ "$LATEST_SPEC_COMMIT" -gt "$STAMP_TIME" ]; then + STALE=1 + fi +fi + +if [ ! -f "$STAMP" ]; then + STALE=1 +fi + +if [ "$STALE" -eq 0 ]; then + exit 0 +fi + +echo "SPM sync inputs changed — re-syncing (codegen + autolinking)..." + +WITH_ENVIRONMENT="${reactNativePath}/scripts/xcode/with-environment.sh" + +if [ -f "$WITH_ENVIRONMENT" ]; then + # with-environment.sh references PODS_ROOT and $1, which may be unset. + # Temporarily disable nounset to avoid failures when sourcing. + export PODS_ROOT="\${PODS_ROOT:-$SRCROOT}" + set +u + . "$WITH_ENVIRONMENT" + set -u +fi + +cd "$SRCROOT" +if command -v npx >/dev/null 2>&1; then + npx react-native spm sync + RC=$? + if [ "$RC" -eq 2 ]; then + # Exit 2 = an autolinked community dependency has no Package.swift. The + # autolinker already printed an \`error:\` line per dep (so Xcode shows them + # and the fix). Fail the build — the developer must run + # \`npx react-native spm scaffold\` from a terminal to generate the manifest. + exit 1 + elif [ "$RC" -ne 0 ]; then + echo "warning: SPM sync failed — build may use stale codegen/autolinking" + exit 0 + fi +else + echo "warning: npx not found — skipping SPM sync" + exit 0 +fi +`; +} + +// XML-attribute escape (the five named entities). The sync script uses `>` +// and `&` for redirection and bg/and chains, plus `<` for heredocs and +// comparisons — all of which break Xcode's scheme parser if left raw. +function escapeXmlAttribute(s /*: string */) /*: string */ { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function generateXcscheme( + appName /*: string */, + targetUUID /*: string */, + projName /*: string */, + syncScript /*: string */, +) /*: string */ { + const escapedSync = escapeXmlAttribute(syncScript); + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; +} + +// When the xcodeproj is generated, the referenced SPM package directories +// (build/xcframeworks, autolinked, build/generated/ios) may not exist yet. +// Xcode resolves packages before any build phase runs, so we write minimal +// stub Package.swift files to let resolution succeed. The real generators +// (sync-spm-autolinking.js) overwrite these during the first build. + +/*:: +type StubPackageDef = { + packageName: string, + products: Array, +}; +*/ + +function generateStubPackageSwift(def /*: StubPackageDef */) /*: string */ { + const {packageName, products} = def; + const stubTarget = `${packageName.replace(/[^a-zA-Z0-9]/g, '')}Stub`; + const productLines = products + .map(p => ` .library(name: "${p}", targets: ["${stubTarget}"]),`) + .join('\n'); + return `// swift-tools-version: 5.9 +// GENERATED STUB — will be overwritten by sync-spm-autolinking.js during build. +import PackageDescription + +let package = Package( + name: "${packageName}", + products: [ +${productLines} + ], + targets: [ + .target(name: "${stubTarget}", path: "_stub", sources: ["Stub.swift"]), + ] +) +`; +} + +/** + * Ensures each referenced SPM sub-package directory has a valid Package.swift + * so Xcode can resolve packages before any build phase runs. + * Skips directories that already contain a Package.swift (from a previous build). + */ +function ensureStubPackages(appRoot /*: string */) /*: void */ { + // Derive stub definitions from SPM_PRODUCT_PACKAGES + const byPath = new Map /*:: */(); + for (const entry of SPM_PRODUCT_PACKAGES) { + const existing = byPath.get(entry.packagePath); + if (existing != null) { + existing.products.push(entry.product); + } else { + byPath.set(entry.packagePath, { + packageName: entry.packageName, + products: [entry.product], + }); + } + } + + for (const [relPath, def] of byPath) { + const pkgDir = path.join(appRoot, relPath); + const pkgSwiftPath = path.join(pkgDir, 'Package.swift'); + + if (fs.existsSync(pkgSwiftPath)) { + continue; + } + + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync(pkgSwiftPath, generateStubPackageSwift(def), 'utf8'); + + // Create minimal stub source file required by SPM + const stubDir = path.join(pkgDir, '_stub'); + fs.mkdirSync(stubDir, {recursive: true}); + const stubSwift = path.join(stubDir, 'Stub.swift'); + if (!fs.existsSync(stubSwift)) { + fs.writeFileSync( + stubSwift, + '// Placeholder — replaced during first build.\n', + 'utf8', + ); + } + + log(`Wrote stub Package.swift: ${relPath}/Package.swift`); + } +} + +// --------------------------------------------------------------------------- +// In-place injection: add SPM packages to a user's EXISTING xcodeproj. +// +// This never creates a target or scans sources — it splices the SPM dependency +// graph, the React build settings, and the sync build phase / scheme pre-action +// into the project the user already owns, leaving everything else +// byte-identical. The whole `spm add` / `spm update` xcodeproj strategy, so +// hand-tuned signing / capabilities / extra targets survive. Fails loud (the +// caller surfaces the error) when the project is CocoaPods-integrated or its +// shape can't be safely anchored. +// --------------------------------------------------------------------------- + +// The React build settings the app target needs to compile against the SPM +// products. +const INJECTED_ARRAY_SETTINGS = [ + { + key: 'HEADER_SEARCH_PATHS', + values: ['"$(SRCROOT)/build/generated/autolinking/headers"'], + }, + {key: 'OTHER_LDFLAGS', values: ['"-ObjC"']}, + { + key: 'OTHER_SWIFT_FLAGS', + values: [ + '"-Xcc"', + '"-fmodule-map-file=$(BUILT_PRODUCTS_DIR)/React.framework/Modules/module.modulemap"', + ], + }, +]; + +/** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */ +function targetBuildConfigUuids( + text /*: string */, + targetObj /*: {bodyOpen: number, bodyClose: number, ...} */, +) /*: Array */ { + const listField = findField(text, targetObj, 'buildConfigurationList'); + if (listField == null) { + return []; + } + const listMatch = listField.value.match(/[0-9A-Fa-f]{24}/); + if (listMatch == null) { + return []; + } + const listObj = findObjectByUuid(text, listMatch[0]); + if (listObj == null) { + return []; + } + const configs = findField(text, listObj, 'buildConfigurations'); + if (configs == null) { + return []; + } + const matches = configs.value.match(/[0-9A-Fa-f]{24}/g); + return matches != null ? Array.from(matches) : []; +} + +/** True when a build config layers a CocoaPods `Pods-*.xcconfig`. */ +function configUsesPods( + text /*: string */, + configUuid /*: string */, +) /*: boolean */ { + const obj = findObjectByUuid(text, configUuid); + if (obj == null) { + return false; + } + const base = findField(text, obj, 'baseConfigurationReference'); + return base != null && /Pods[-/]/.test(base.value); +} + +/** + * Inspect an existing pbxproj and decide whether it can be injected. Returns + * the chosen app target + its config/frameworks anchors, or a refusal reason + * the caller surfaces (fail-loud). + */ +function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: + | {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array, frameworksPhaseUuid: string} + | {ok: false, reason: string} */ { + const project = findProjectObject(text); + if (project == null) { + return {ok: false, reason: 'no PBXProject object found'}; + } + const apps = findApplicationTargets(text); + if (apps.length === 0) { + return {ok: false, reason: 'no application target found'}; + } + let target; + if (apps.length === 1) { + target = apps[0]; + } else { + const appName = opts.appName; + if (appName == null) { + return { + ok: false, + reason: `multiple application targets (${apps + .map(a => a.name) + .join(', ')}); pass --app-name to disambiguate`, + }; + } + target = apps.find(a => a.name === appName); + if (target == null) { + return { + ok: false, + reason: `no application target named "${appName}"`, + }; + } + } + const configUuids = targetBuildConfigUuids(text, target); + if (configUuids.length === 0) { + return {ok: false, reason: 'could not resolve target build configurations'}; + } + if (configUuids.some(c => configUsesPods(text, c))) { + return { + ok: false, + reason: + 'target uses CocoaPods (Pods-*.xcconfig) — in-place injection only ' + + 'supports SPM-only targets', + }; + } + // The target's own Frameworks build phase (where product build files link). + const buildPhases = findField(text, target, 'buildPhases'); + const phaseUuids = + buildPhases != null + ? (buildPhases.value.match(/[0-9A-Fa-f]{24}/g) ?? []) + : []; + let frameworksPhaseUuid = null; + for (const pu of phaseUuids) { + const po = findObjectByUuid(text, pu); + if (po != null) { + const isa = findField(text, po, 'isa'); + if (isa != null && /PBXFrameworksBuildPhase/.test(isa.value)) { + frameworksPhaseUuid = pu; + break; + } + } + } + if (frameworksPhaseUuid == null) { + return {ok: false, reason: 'target has no Frameworks build phase'}; + } + return { + ok: true, + rootUuid: project.uuid, + target, + configUuids, + frameworksPhaseUuid, + }; +} + +/** + * Splice the SPM dependency graph + React build settings + sync build phase + * into `text` and return the modified pbxproj. Pure string transform (no I/O), + * idempotent: objects already present (by UUID) and array members / settings + * already applied are skipped, so a second run is a no-op. + */ +function injectSpmIntoPbxproj( + input /*: string */, + plan /*: {rootUuid: string, targetUuid: string, configUuids: Array, frameworksPhaseUuid: string} */, + reactNativePath /*: string */, + remote /*: ?RemoteCfg */, +) /*: {text: string, injectedUuids: Array, createdArrayFields: Array<{container: 'project' | 'target', key: string}>, buildSettingChanges: Array} */ { + let text = input; + const mkUuid = (section /*: string */, id /*: string */) => + namespacedUUID(plan.rootUuid, section, id); + const graph = buildSpmDependencyGraph(mkUuid, remote); + const entries = spmGraphToEntries(graph); + const injectedUuids /*: Array */ = []; + + // 1. Insert the new objects (skip any UUID already present — idempotency). + const insertObjects = ( + sectionName /*: string */, + objs /*: ReadonlyArray<{readonly uuid: string, readonly comment?: ?string, readonly fields: {readonly [string]: string}, ...}> */, + ) => { + const fresh = objs.filter(o => !text.includes(o.uuid)); + for (const o of objs) { + injectedUuids.push(o.uuid); + } + if (fresh.length === 0) { + return; + } + text = insertObjectsIntoSection( + text, + sectionName, + fresh.map(serializeEntry).join('\n'), + ); + }; + insertObjects('XCLocalSwiftPackageReference', entries.localRefs); + if (entries.remoteRef != null) { + insertObjects('XCRemoteSwiftPackageReference', [entries.remoteRef]); + } + insertObjects('XCSwiftPackageProductDependency', entries.productDeps); + insertObjects('PBXBuildFile', entries.buildFiles); + + // Track array fields we CREATE (vs. append to a pre-existing one) so deinit + // can remove the whole field and land byte-identical to the original. + const createdArrayFields /*: Array<{container: 'project' | 'target', key: string}> */ = + []; + + // 2. packageReferences on the PBXProject. + const pkgRefMembers = [ + ...(graph.remotePkgRef != null + ? [{uuid: graph.remotePkgRef.uuid, comment: graph.remotePkgRef.comment}] + : []), + ...graph.localPkgRefs.map(r => ({uuid: r.uuid, comment: r.comment})), + ]; + const project = findProjectObject(text); + if (project != null) { + if (findField(text, project, 'packageReferences') == null) { + createdArrayFields.push({container: 'project', key: 'packageReferences'}); + } + text = addArrayMembers(text, project, 'packageReferences', pkgRefMembers); + } + + // 3. packageProductDependencies on the app target. + const productMembers = graph.products.map(p => ({ + uuid: p.depUuid, + comment: p.product, + })); + if ( + findField( + text, + findApplicationTargetByUuid(text, plan.targetUuid), + 'packageProductDependencies', + ) == null + ) { + createdArrayFields.push({ + container: 'target', + key: 'packageProductDependencies', + }); + } + text = addArrayMembers( + text, + findApplicationTargetByUuid(text, plan.targetUuid), + 'packageProductDependencies', + productMembers, + ); + + // 4. product build files into the target's Frameworks phase. + const phase = findObjectByUuid(text, plan.frameworksPhaseUuid); + if (phase != null) { + text = addArrayMembers( + text, + phase, + 'files', + graph.products.map(p => ({ + uuid: p.buildFileUuid, + comment: `${p.product} in Frameworks`, + })), + ); + } + + // 5. React build settings into every build config (Debug + Release). + const buildSettingChanges /*: Array */ = []; + for (const configUuid of plan.configUuids) { + const merged = mergeReactBuildSettings(text, configUuid, reactNativePath); + text = merged.text; + buildSettingChanges.push(merged.change); + } + + // 6. The Sync SPM Autolinking build phase (safety net; the scheme pre-action + // is what fires before SPM resolution). Prepended so it runs before + // Sources. We do NOT add a JS-bundle phase — an existing app already + // bundles JS via its own phase. + const syncScript = buildSyncAutolinkingScript(reactNativePath); + const syncPhaseUuid = mkUuid('PBXShellScriptBuildPhase', 'SyncAutolinking'); + if (!text.includes(syncPhaseUuid)) { + text = insertObjectsIntoSection( + text, + 'PBXShellScriptBuildPhase', + serializeEntry( + shellScriptPhase(syncPhaseUuid, 'Sync SPM Autolinking', syncScript), + ), + ); + } + injectedUuids.push(syncPhaseUuid); + text = addArrayMembers( + text, + findApplicationTargetByUuid(text, plan.targetUuid), + 'buildPhases', + [{uuid: syncPhaseUuid, comment: 'Sync SPM Autolinking'}], + {prepend: true}, + ); + + return {text, injectedUuids, createdArrayFields, buildSettingChanges}; +} + +/** Re-locate an application target by UUID against the current text. */ +function findApplicationTargetByUuid( + text /*: string */, + targetUuid /*: string */, +) /*: {uuid: string, bodyOpen: number, bodyClose: number} */ { + const obj = findObjectByUuid(text, targetUuid); + if (obj == null) { + throw new Error(`pbxproj: app target ${targetUuid} disappeared mid-edit`); + } + return obj; +} + +/** + * Merge the React build settings into one XCBuildConfiguration's dict. Returns + * the modified text plus a precise record of what was actually added — so + * `deinit` (removeSpmInjection) can reverse exactly these edits, never touching + * a value the user already had (key insight: ensureScalarField/ + * addArrayStringValues are no-ops / dedupe when a value is already present). + */ +function mergeReactBuildSettings( + input /*: string */, + configUuid /*: string */, + reactNativePath /*: string */, +) /*: {text: string, change: BuildSettingChange} */ { + let text = input; + const scalars = [ + {key: 'CLANG_CXX_LANGUAGE_STANDARD', value: '"c++20"'}, + {key: 'REACT_NATIVE_PATH', value: quoteIfNeeded(reactNativePath)}, + ]; + // Re-locate the buildSettings dict before each edit (offsets shift). + const dict = () => { + const cfg = findObjectByUuid(text, configUuid); + if (cfg == null) { + return null; + } + const bs = findField(text, cfg, 'buildSettings'); + if (bs == null) { + return null; + } + return { + uuid: configUuid, + bodyOpen: bs.valueStart, + bodyClose: bs.tokenEnd - 1, + }; + }; + const createdArrayKeys /*: Array */ = []; + const appendedArrayValues /*: {[string]: Array} */ = {}; + const createdScalars /*: Array */ = []; + for (const {key, values} of INJECTED_ARRAY_SETTINGS) { + const d = dict(); + if (d == null) { + continue; + } + const existing = findField(text, d, key); + if (existing == null) { + createdArrayKeys.push(key); + } else { + const fresh = values.filter(v => !existing.value.includes(v)); + if (fresh.length > 0) { + appendedArrayValues[key] = fresh; + } + } + text = addArrayStringValues(text, d, key, values); + } + for (const {key, value} of scalars) { + const d = dict(); + if (d == null) { + continue; + } + if (findField(text, d, key) == null) { + createdScalars.push(key); + } + text = ensureScalarField(text, d, key, value); + } + return { + text, + change: {configUuid, createdArrayKeys, appendedArrayValues, createdScalars}, + }; +} + +// Write only when content changed (avoids spurious Xcode reloads / git churn). +function writeIfChanged( + filePath /*: string */, + content /*: string */, +) /*: boolean */ { + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + try { + if (fs.readFileSync(filePath, 'utf8') === content) { + return false; + } + } catch { + /* file doesn't exist yet */ + } + fs.writeFileSync(filePath, content, 'utf8'); + return true; +} + +/** + * Add the "Sync SPM Autolinking" pre-action to an existing scheme's + * BuildAction, reusing the scheme's own primary BuildableReference. Returns + * the XML unchanged when the pre-action is already present. + */ +function addPreActionToScheme( + xml /*: string */, + targetUuid /*: string */, + syncScript /*: string */, +) /*: string */ { + if (xml.includes('title = "Sync SPM Autolinking"')) { + return xml; + } + const refMatch = xml.match( + new RegExp( + `]*BlueprintIdentifier = "${targetUuid}"[^>]*>`, + ), + ); + const attr = (name /*: string */) => { + const m = + refMatch != null + ? refMatch[0].match(new RegExp(`${name} = "([^"]*)"`)) + : null; + return m != null ? m[1] : ''; + }; + const cleanRef = + `\n` + + ` `; + const executionAction = + ` \n` + + ` \n` + + ` \n` + + ` ${cleanRef}\n` + + ` \n` + + ` \n` + + ` `; + + if (//.test(xml)) { + return xml.replace( + '', + `${executionAction}\n `, + ); + } + const openEnd = xml.indexOf('>', xml.indexOf('\n${executionAction}\n `; + return xml.slice(0, openEnd + 1) + block + xml.slice(openEnd + 1); +} + +/** + * Ensure the app target's shared scheme runs the sync pre-action before SPM + * resolution. Updates the scheme that builds the target if one exists, + * otherwise creates a fresh shared scheme. Returns 'updated' | 'created' | + * 'unchanged'. + */ +function injectOrCreateScheme( + xcodeprojDir /*: string */, + opts /*: {appName: string, targetUuid: string, projName: string, syncScript: string} */, +) /*: {status: 'updated' | 'unchanged' | 'created', file: string} */ { + const schemesDir = path.join(xcodeprojDir, 'xcshareddata', 'xcschemes'); + let schemeFiles /*: Array */ = []; + try { + schemeFiles = fs + .readdirSync(schemesDir) + .filter(f => f.endsWith('.xcscheme')); + } catch { + /* no shared schemes dir yet */ + } + for (const f of schemeFiles) { + const p = path.join(schemesDir, f); + const xml = fs.readFileSync(p, 'utf8'); + if (xml.includes(`BlueprintIdentifier = "${opts.targetUuid}"`)) { + const updated = addPreActionToScheme( + xml, + opts.targetUuid, + opts.syncScript, + ); + return { + status: writeIfChanged(p, updated) ? 'updated' : 'unchanged', + file: f, + }; + } + } + const file = `${opts.appName}.xcscheme`; + const xml = generateXcscheme( + opts.appName, + opts.targetUuid, + opts.projName, + opts.syncScript, + ); + writeIfChanged(path.join(schemesDir, file), xml); + return {status: 'created', file}; +} + +/** + * Strip the empty `Pods` group `pod deintegrate` leaves in the navigator. + * Called by `add --deintegrate` after deintegration so the converted project is + * visually clean. No-op when absent or when the group still has children. + */ +function cleanupLeftoverPodsGroup(xcodeprojPath /*: string */) /*: boolean */ { + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return false; + } + const original = fs.readFileSync(pbxprojPath, 'utf8'); + const cleaned = removeEmptyPodsGroup(original); + return cleaned !== original ? writeIfChanged(pbxprojPath, cleaned) : false; +} + +/** + * Add SPM packages to a user's EXISTING xcodeproj in place. Returns + * {status: 'injected', target} on success, or {status: 'refused', reason} + * when the project can't be safely edited (caller surfaces it; fail-loud). + */ +function injectSpmIntoExistingXcodeproj( + opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string} */, +) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { + const {appRoot, reactNativeRoot, xcodeprojPath} = opts; + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return { + status: 'refused', + reason: `no project.pbxproj at ${xcodeprojPath}`, + }; + } + const original = fs.readFileSync(pbxprojPath, 'utf8'); + const plan = planInjection(original, {appName: opts.appName}); + if (!plan.ok) { + return {status: 'refused', reason: plan.reason}; + } + const reactNativePath = path.relative(appRoot, reactNativeRoot); + const remote = remotePackageConfig(appRoot); + const {text, injectedUuids, createdArrayFields, buildSettingChanges} = + injectSpmIntoPbxproj( + original, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + }, + reactNativePath, + remote, + ); + + const changed = writeIfChanged(pbxprojPath, text); + log( + changed + ? `Injected SPM packages into ${path.relative(appRoot, pbxprojPath)}` + : `${path.relative(appRoot, pbxprojPath)} already up to date`, + ); + + const projName = path.basename(xcodeprojPath, '.xcodeproj'); + const schemeResult = injectOrCreateScheme(xcodeprojPath, { + appName: plan.target.name, + targetUuid: plan.target.uuid, + projName, + syncScript: buildSyncAutolinkingScript(reactNativePath), + }); + log(`Scheme sync pre-action: ${schemeResult.status}`); + + // Marker: idempotency signal + the exact, reversible record of every edit so + // `deinit` (removeSpmInjection) can undo precisely what was added. + writeIfChanged( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + JSON.stringify( + { + rootUuid: plan.rootUuid, + target: plan.target.name, + targetUuid: plan.target.uuid, + injectedUuids: Array.from(new Set(injectedUuids)).sort(), + createdArrayFields, + buildSettingChanges, + scheme: { + file: schemeResult.file, + created: schemeResult.status === 'created', + }, + }, + null, + 2, + ) + '\n', + ); + + ensureStubPackages(appRoot); + return {status: 'injected', target: plan.target.name}; +} + +/** + * Remove the "Sync SPM Autolinking" pre-action that addPreActionToScheme added + * to a scheme, and drop the `` wrapper if it is left empty (the + * byte-identical inverse for the common case where injection created it). + */ +function removePreActionFromScheme(xml /*: string */) /*: string */ { + const withoutAction = xml.replace( + /[ \t]*)[\s\S])*?title = "Sync SPM Autolinking"(?:(?!<\/ExecutionAction>)[\s\S])*?<\/ExecutionAction>\n?/, + '', + ); + return withoutAction.replace(/\n[ \t]*\s*<\/PreActions>/, ''); +} + +/** + * The exact inverse of `add` (injectSpmIntoExistingXcodeproj): using the + * `.spm-injected.json` marker's precise record of every edit, remove only what + * injection added — leaving any other (user) edits made afterwards intact. No + * `git checkout`, no prompt. Returns {status:'absent'} when the project was + * never injected. + */ +function removeSpmInjection( + opts /*: {appRoot: string, xcodeprojPath: string} */, +) /*: {status: 'removed', target: string} | {status: 'absent'} */ { + const {appRoot, xcodeprojPath} = opts; + const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); + if (!fs.existsSync(markerPath)) { + return {status: 'absent'}; + } + // $FlowFixMe[incompatible-type] JSON.parse returns any + const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + let text = fs.readFileSync(pbxprojPath, 'utf8'); + + const injectedUuids /*: Array */ = marker.injectedUuids ?? []; + + // 1. Drop our array members, then the array fields we created (now empty), + // then the injected object definitions. + text = removeArrayMembersByUuid(text, injectedUuids); + for (const f of marker.createdArrayFields ?? []) { + const obj = + f.container === 'project' + ? findProjectObject(text) + : findObjectByUuid(text, marker.targetUuid); + if (obj != null) { + text = removeField(text, obj, f.key); + } + } + for (const uuid of injectedUuids) { + text = removeObjectByUuid(text, uuid); + } + // Drop any section that injection created and we just emptied (e.g. + // XCLocalSwiftPackageReference) — a well-formed pbxproj never carries an + // empty `/* Begin X *​/ /* End X *​/` section, so this lands byte-identical. + text = text.replace( + /\/\* Begin (\w+) section \*\/\n\/\* End \1 section \*\/\n\n/g, + '', + ); + + // 2. Reverse the per-config build-setting edits (only what we added). + for (const change of marker.buildSettingChanges ?? []) { + const dict = () => { + const cfg = findObjectByUuid(text, change.configUuid); + if (cfg == null) { + return null; + } + const bs = findField(text, cfg, 'buildSettings'); + if (bs == null) { + return null; + } + return { + uuid: change.configUuid, + bodyOpen: bs.valueStart, + bodyClose: bs.tokenEnd - 1, + }; + }; + for (const key of Object.keys(change.appendedArrayValues ?? {})) { + const d = dict(); + if (d != null) { + text = removeArrayStringValues( + text, + d, + key, + change.appendedArrayValues[key], + ); + } + } + for (const key of change.createdArrayKeys ?? []) { + const d = dict(); + if (d != null) { + text = removeField(text, d, key); + } + } + for (const key of change.createdScalars ?? []) { + const d = dict(); + if (d != null) { + text = removeField(text, d, key); + } + } + } + + writeIfChanged(pbxprojPath, text); + log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`); + + // 3. Scheme: delete it if injection created it, else strip the pre-action. + const scheme = marker.scheme; + if (scheme != null && scheme.file != null) { + const schemePath = path.join( + xcodeprojPath, + 'xcshareddata', + 'xcschemes', + scheme.file, + ); + if (scheme.created === true) { + fs.rmSync(schemePath, {force: true}); + } else if (fs.existsSync(schemePath)) { + const xml = fs.readFileSync(schemePath, 'utf8'); + writeIfChanged(schemePath, removePreActionFromScheme(xml)); + } + } + + // 4. Drop the marker — the project is no longer SPM-injected. + fs.rmSync(markerPath, {force: true}); + return {status: 'removed', target: marker.target}; +} + +module.exports = { + generateXcscheme, + ensureStubPackages, + buildSpmDependencyGraph, + spmGraphToEntries, + planInjection, + injectSpmIntoPbxproj, + injectSpmIntoExistingXcodeproj, + removeSpmInjection, + cleanupLeftoverPodsGroup, + addPreActionToScheme, + removePreActionFromScheme, + SPM_INJECTED_MARKER, +}; diff --git a/packages/react-native/scripts/spm/read-podspec.js b/packages/react-native/scripts/spm/read-podspec.js new file mode 100644 index 000000000000..f8886d70629c --- /dev/null +++ b/packages/react-native/scripts/spm/read-podspec.js @@ -0,0 +1,665 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: import type {PodspecModel, PreprocessorDefine} from './spm-types'; */ + +/** + * read-podspec.js — produces a flattened, SPM-friendly view of an iOS + * podspec for the scaffolder. + * + * Strategy: prefer `pod ipc spec ` (CocoaPods evaluates the Ruby DSL, + * including `install_modules_dependencies(s)` and `$config[:...]` + * interpolation, so the JSON output reflects the real spec). Fall back to a + * best-effort regex parser when CocoaPods isn't available — handles simple + * RN libs but skips subspec blocks and Ruby helpers with a warning. + * + * The output `PodspecModel` collapses the spec's default subspecs into a + * single logical target view, since that's how RN consumers compile these + * libraries in practice. + */ + +const {spawnSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/*:: +type RawSpec = {[string]: unknown}; +*/ + +// --------------------------------------------------------------------------- +// Pod-IPC primary path +// --------------------------------------------------------------------------- + +/** + * Runs `pod ipc spec ` and parses the JSON it prints. Returns null + * on any failure: CocoaPods missing, command threw, output not JSON, etc. + * Callers should treat null as a signal to fall back to the regex parser. + */ +/** + * Strips RN-specific Ruby helpers from a podspec source so `pod ipc spec` + * can evaluate it without RN's Podfile-side helpers loaded. The stripped + * helpers (`install_modules_dependencies(s)`) typically inject the React-Core + * / React-Fabric family — all of which the scaffolder collapses into the + * single `ReactNative` product anyway, so dropping them is safe as long as + * the podspec also has an explicit `s.dependency "React-Core"` (almost all + * RN libs do). Writes the patched content to a temp file and returns its + * path; caller deletes when done. + */ +function patchPodspecForPodIpc(podspecPath /*: string */) /*: string */ { + const content = fs.readFileSync(podspecPath, 'utf8'); + // Comment out RN-Podfile-only helpers. We do NOT remove the lines so + // line numbers in any pod ipc errors still match the original file. + const patched = content + .replace( + /^(\s*)install_modules_dependencies\(([^)]*)\)/gm, + '$1# install_modules_dependencies($2) # stripped for pod ipc', + ) + .replace( + /^(\s*)use_react_native!\(([^)]*)\)/gm, + '$1# use_react_native!($2) # stripped for pod ipc', + ); + // Write the patched copy NEXT TO the original podspec, not in os.tmpdir. + // Podspecs commonly do `File.read(File.join(__dir__, 'package.json'))` or + // similar, expecting the package.json sibling. Keeping the patched file + // in the same directory makes those reads continue to work. + const depDir = path.dirname(podspecPath); + // pod ipc only accepts files ending in `.podspec` or `.podspec.json`. + // Use a dotfile prefix so the patched copy is invisible to normal listing + // but the suffix CocoaPods requires is preserved. + const tmpFile = path.join( + depDir, + `.spm-scaffold-${process.pid}-${path.basename(podspecPath)}`, + ); + fs.writeFileSync(tmpFile, patched, 'utf8'); + return tmpFile; +} + +function runPodIpcSpec(podspecPath /*: string */) /*: RawSpec | null */ { + // RN podspecs often call `install_modules_dependencies(s)`, a helper + // defined by RN's Podfile-side scripts. `pod ipc spec` doesn't load + // those, so the helper is undefined and the whole spec fails to parse. + // Pre-process to strip those calls before invoking pod ipc. + let patchedPath /*: ?string */ = null; + try { + patchedPath = patchPodspecForPodIpc(podspecPath); + } catch { + return null; + } + let result; + try { + result = spawnSync('pod', ['ipc', 'spec', patchedPath], { + encoding: 'utf8', + timeout: 30000, + maxBuffer: 8 * 1024 * 1024, + // Many RN podspecs gate subspec definitions on `RCT_NEW_ARCH_ENABLED` + // (e.g. safe-area-context wraps its Fabric `common` + `fabric` + // subspecs in `if fabric_enabled`). RN 0.76+ defaults to the new + // architecture, so unless the caller has explicitly opted out, set + // the env var here so `pod ipc spec` evaluates the full podspec. + env: { + ...process.env, + RCT_NEW_ARCH_ENABLED: process.env.RCT_NEW_ARCH_ENABLED ?? '1', + }, + }); + } catch { + cleanupPatchedPodspec(patchedPath); + return null; + } + cleanupPatchedPodspec(patchedPath); + if (result == null || result.error != null) { + return null; + } + if (typeof result.status !== 'number' || result.status !== 0) { + return null; + } + if (typeof result.stdout !== 'string' || result.stdout.length === 0) { + return null; + } + // Some podspecs print diagnostics to stdout during evaluation (e.g. skia: + // `-- SK_GRAPHITE: OFF ...`) before `pod ipc` emits the JSON. Parsing the + // raw stdout then throws and we'd silently fall back to the (much weaker) + // regex parser. Extract just the JSON object (first `{` … last `}`). + const stdout = result.stdout; + const start = stdout.indexOf('{'); + const end = stdout.lastIndexOf('}'); + if (start < 0 || end <= start) { + return null; + } + try { + return JSON.parse(stdout.slice(start, end + 1)); + } catch { + return null; + } +} + +function cleanupPatchedPodspec(patchedPath /*: ?string */) /*: void */ { + if (patchedPath == null) return; + try { + fs.unlinkSync(patchedPath); + } catch { + // best-effort cleanup; the file is named with `.spm-scaffold-...tmp` + // so a leftover is identifiable. + } +} + +// --------------------------------------------------------------------------- +// Regex fallback +// --------------------------------------------------------------------------- + +/** + * Best-effort Ruby podspec parser. Extracts the literal-string and + * literal-array fields most RN libs use. Skips subspec blocks, Ruby helper + * calls (install_modules_dependencies, ENV[], $config[:...]), and + * interpolation — appends a warning so the caller can surface it to the + * user. Always returns a RawSpec; pure-JS, no Ruby dep required. + */ +function regexPodspec(podspecPath /*: string */) /*: RawSpec */ { + const content = fs.readFileSync(podspecPath, 'utf8'); + const warnings /*: Array */ = []; + + // Matches: s. = "value" or s. = 'value' + function getStringField(name /*: string */) /*: string | null */ { + const re = new RegExp(`(?:s|spec)\\.${name}\\s*=\\s*["']([^"']+)["']`); + const m = content.match(re); + return m ? m[1] : null; + } + + // Matches: + // s. = ["a", "b"] (array) + // s. = "single" (single value treated as 1-element array) + function getArrayField(name /*: string */) /*: Array */ { + const reArr = new RegExp(`(?:s|spec)\\.${name}\\s*=\\s*\\[([^\\]]*)\\]`); + const mArr = content.match(reArr); + if (mArr != null) { + const inner = mArr[1]; + const out /*: Array */ = []; + const itemRe = /["']([^"']+)["']/g; + while (true) { + const m = itemRe.exec(inner); + if (m == null) break; + out.push(m[1]); + } + return out; + } + const single = getStringField(name); + return single != null ? [single] : []; + } + + // Matches: s.dependency "Name" (with optional version constraint) + function getDependencies() /*: Array */ { + const out /*: Array */ = []; + const re = /(?:s|spec)\.dependency\s+["']([^"']+)["']/g; + while (true) { + const m = re.exec(content); + if (m == null) break; + out.push(m[1]); + } + return out; + } + + // Matches: + // s.framework "X" → ["X"] + // s.framework = "X" → ["X"] + // s.frameworks = ["X","Y"] → ["X","Y"] + function getFrameworks(weak /*: boolean */) /*: Array */ { + const prefix = weak ? 'weak_framework' : 'framework'; + const out /*: Array */ = []; + // Plural form: s.frameworks = [...] or s.frameworks = "Foo" + out.push(...getArrayField(`${prefix}s`)); + // Singular form: s.framework = "Foo" — single value treated as a 1-elem list + out.push(...getArrayField(prefix)); + // Method-call form: s.framework "Foo", "Bar" — no `=`. `getArrayField` + // only matches assignment, so handle this case separately. + const callRe = new RegExp( + `(?:s|spec)\\.${prefix}s?\\s+((?:["'][^"']+["']\\s*,?\\s*)+)`, + 'g', + ); + while (true) { + const m = callRe.exec(content); + if (m == null) break; + const itemRe = /["']([^"']+)["']/g; + while (true) { + const im = itemRe.exec(m[1]); + if (im == null) break; + out.push(im[1]); + } + } + return Array.from(new Set(out)); + } + + // Matches: 'HEADER_SEARCH_PATHS' => '...' or => ["...","..."] + // Returns the raw token strings (still containing $(PODS_TARGET_SRCROOT) + // etc.); substitution happens at translation time. + function getHeaderSearchPaths() /*: Array */ { + const out /*: Array */ = []; + // Array form + const arrRe = /["']HEADER_SEARCH_PATHS["']\s*=>\s*\[([\s\S]*?)\]/g; + while (true) { + const m = arrRe.exec(content); + if (m == null) break; + const itemRe = /["']((?:[^"'\\]|\\.)+)["']/g; + while (true) { + const im = itemRe.exec(m[1]); + if (im == null) break; + // Each entry can be a single path or a space-separated list of paths. + for (const token of im[1].split(/\s+/)) { + if (token.length > 0) { + out.push(stripWrappingQuotes(token)); + } + } + } + } + // String form + const strRe = + /["']HEADER_SEARCH_PATHS["']\s*=>\s*["']((?:[^"'\\]|\\.)+)["']/g; + while (true) { + const m = strRe.exec(content); + if (m == null) break; + for (const token of m[1].split(/\s+/)) { + if (token.length > 0) { + out.push(stripWrappingQuotes(token)); + } + } + } + return Array.from(new Set(out)); + } + + // Surface known unparseable constructs so the caller can warn the user. + if (/(?:s|spec)\.subspec\s+["']/.test(content)) { + warnings.push( + 'Subspecs detected — regex parser only extracts top-level fields. Install CocoaPods (`gem install cocoapods`) to enable full `pod ipc spec` parsing.', + ); + } + if (/install_modules_dependencies/.test(content)) { + warnings.push( + '`install_modules_dependencies(s)` detected — dependency wiring may be incomplete without CocoaPods.', + ); + } + if (/\bENV\[/.test(content) || /\$config\[/.test(content)) { + warnings.push( + 'Env-var or Ruby-config interpolation detected — values may not translate cleanly without CocoaPods.', + ); + } + + return { + name: getStringField('name'), + version: getStringField('version'), + source_files: getArrayField('source_files'), + public_header_files: getArrayField('public_header_files'), + private_header_files: getArrayField('private_header_files'), + exclude_files: getArrayField('exclude_files'), + header_mappings_dir: getStringField('header_mappings_dir'), + header_dir: getStringField('header_dir'), + frameworks: getFrameworks(false), + weak_frameworks: getFrameworks(true), + libraries: getArrayField('libraries'), + dependencies: getDependencies(), + compiler_flags: tokenizeFlags(getStringField('compiler_flags')), + pod_target_xcconfig: {HEADER_SEARCH_PATHS: getHeaderSearchPaths()}, + resources: getArrayField('resources'), + requires_arc: /(?:s|spec)\.requires_arc\s*=\s*true/.test(content), + __regex_partial__: true, + __warnings__: warnings, + }; +} + +// Strips outer quote-like characters from a captured token. Handles bare +// quotes (`"foo"`, `'foo'`) AND Ruby-escaped quotes (`\"foo\"`) which show +// up verbatim when the regex captures something like +// `"HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/x\""`. +function stripWrappingQuotes(s /*: string */) /*: string */ { + return s.replace(/^\\?["']/, '').replace(/\\?["']$/, ''); +} + +function tokenizeFlags(value /*: string | null */) /*: Array */ { + if (value == null) return []; + return value.split(/\s+/).filter(Boolean); +} + +/** + * Split a flag string on whitespace while keeping quoted spans intact, so a + * define like `-DWORKLETS_FEATURE_FLAGS="[A:false][B:true]"` stays one token + * (the quotes are part of the macro value). A naive whitespace split would + * shred any define whose value contains spaces. + */ +function shellTokenize(value /*: string */) /*: Array */ { + const tokens /*: Array */ = []; + let cur = ''; + let quote /*: string | null */ = null; + let has = false; + for (let i = 0; i < value.length; i++) { + const c = value[i]; + if (quote != null) { + cur += c; + if (c === quote) quote = null; + } else if (c === '"' || c === "'") { + cur += c; + quote = c; + has = true; + } else if (/\s/.test(c)) { + if (has) tokens.push(cur); + cur = ''; + has = false; + } else { + cur += c; + has = true; + } + } + if (has) tokens.push(cur); + return tokens; +} + +// --------------------------------------------------------------------------- +// Subspec flattening +// --------------------------------------------------------------------------- + +/** + * Merges a podspec's default subspecs (or all subspecs when no defaults are + * declared) into a single logical view. RN libraries use subspecs for + * platform/feature gating (`apple` vs `common`); consumers compile the + * union anyway, so the flat view matches the actual build. + * + * Merge rules: + * - Array fields → concat + dedup + * - String fields → top-level wins; if absent, take the first subspec's value + * - pod_target_xcconfig HEADER_SEARCH_PATHS → array-union across all selected subspecs + * - dependencies → array-union of name strings (version constraints dropped) + */ +function flattenSubspecs(rawSpec /*: RawSpec */) /*: PodspecModel */ { + const warnings /*: Array */ = []; + // $FlowFixMe[prop-missing] dynamic shape + const partial /*: boolean */ = rawSpec.__regex_partial__ === true; + if (Array.isArray(rawSpec.__warnings__)) { + // $FlowFixMe[incompatible-type] runtime-validated dynamic shape + const rawWarnings /*: ReadonlyArray */ = rawSpec.__warnings__; + for (const w of rawWarnings) { + warnings.push(w); + } + } + + // Determine which subspecs to merge. pod ipc returns: + // - `subspecs`: array of nested specs (or undefined) + // - `default_subspecs`: array of names (or undefined → use all subspecs) + const subspecs = Array.isArray(rawSpec.subspecs) ? rawSpec.subspecs : []; + let selected = subspecs; + if (Array.isArray(rawSpec.default_subspecs)) { + const wanted = new Set(rawSpec.default_subspecs); + selected = subspecs.filter(s => { + // $FlowFixMe[prop-missing] dynamic shape + const name = s != null && typeof s.name === 'string' ? s.name : null; + return name != null && wanted.has(name); + }); + } + const layers = [rawSpec, ...selected]; + + function mergeArrayField(key /*: string */) /*: Array */ { + const out /*: Array */ = []; + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const value = layer != null ? layer[key] : null; + if (Array.isArray(value)) { + for (const v of value) { + if (typeof v === 'string') out.push(v); + } + } else if (typeof value === 'string') { + out.push(value); + } + } + return Array.from(new Set(out)); + } + + function mergeStringField(key /*: string */) /*: string | null */ { + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const value = layer != null ? layer[key] : null; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return null; + } + + function mergeHeaderSearchPaths() /*: Array */ { + const out /*: Array */ = []; + // HSP can live in any of the xcconfig blocks, and a single value often + // PACKS multiple space-separated paths, each individually quoted, plus a + // CocoaPods `/**` recursive-glob suffix (e.g. skia: + // `"$(SRCROOT)/cpp/"/** "$(SRCROOT)/cpp/skia" ...`). Shell-tokenize to keep + // each path intact, then strip ALL quotes (not just wrapping) per token. + const XCCONFIG_KEYS = [ + 'pod_target_xcconfig', + 'xcconfig', + 'user_target_xcconfig', + ]; + for (const layer of layers) { + if (layer == null || typeof layer !== 'object') continue; + for (const xcKey of XCCONFIG_KEYS) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed` + const xc = layer[xcKey]; + if (xc == null || typeof xc !== 'object') continue; + const hsp = xc.HEADER_SEARCH_PATHS; + const values = + typeof hsp === 'string' + ? [hsp] + : Array.isArray(hsp) + ? hsp.filter(v => typeof v === 'string') + : []; + for (const v of values) { + for (const tok of shellTokenize(v)) { + const cleaned = tok.replace(/['"]/g, ''); + if (cleaned.length > 0) out.push(cleaned); + } + } + } + } + return Array.from(new Set(out)); + } + + // Lift preprocessor defines from pod_target_xcconfig across all layers: + // `-D` tokens in OTHER_CFLAGS, and NAME[=VALUE] entries in + // GCC_PREPROCESSOR_DEFINITIONS (incl. per-config `[config=*Debug*]` keys). + // Non-define compiler flags in OTHER_CFLAGS are intentionally dropped — only + // `-D`s are safe to forward; arbitrary flags may be machine- or + // example-app-specific. $(inherited), unresolved $(...) tokens, and invalid + // C identifiers are skipped. + function mergePreprocessorDefines() /*: Array */ { + const out /*: Array */ = []; + const seen /*: Set */ = new Set(); + const validName = /^[A-Za-z_]\w*$/; + const add = ( + name /*: string */, + value /*: ?string */, + config /*: ?('debug' | 'release') */, + ) => { + if (!validName.test(name)) return; + if (/\$[({]/.test(name) || (value != null && /\$[({]/.test(value))) { + return; // unresolved Xcode/Ruby token — don't emit a broken define + } + const key = `${name}|${config ?? ''}`; + if (seen.has(key)) return; + seen.add(key); + out.push({name, value, config}); + }; + // Defines can live in the target xcconfig (pod_target_xcconfig) OR the + // aggregate/user xcconfig (`s.xcconfig` / user_target_xcconfig). worklets + // puts its version define in pod_target_xcconfig; reanimated puts its in + // `s.xcconfig` — scan all three. + const XCCONFIG_KEYS = [ + 'pod_target_xcconfig', + 'xcconfig', + 'user_target_xcconfig', + ]; + for (const layer of layers) { + if (layer == null || typeof layer !== 'object') continue; + for (const xcKey of XCCONFIG_KEYS) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed` + const xc = layer[xcKey]; + if (xc == null || typeof xc !== 'object') continue; + for (const rawKey of Object.keys(xc)) { + const cflags = /^OTHER_CFLAGS(?:\[config=\*(\w+)\*\])?$/i.exec( + rawKey, + ); + const ppDefs = + /^GCC_PREPROCESSOR_DEFINITIONS(?:\[config=\*(\w+)\*\])?$/i.exec( + rawKey, + ); + if (cflags == null && ppDefs == null) continue; + const cfgRaw = ( + (cflags?.[1] ?? ppDefs?.[1] ?? '') + '' + ).toLowerCase(); + const config = + cfgRaw === 'debug' + ? 'debug' + : cfgRaw === 'release' + ? 'release' + : null; + // $FlowFixMe[incompatible-use] xc value access is intentional + const val = xc[rawKey]; + const strs = + typeof val === 'string' + ? [val] + : Array.isArray(val) + ? val.filter(v => typeof v === 'string') + : []; + for (const s of strs) { + for (const tok of shellTokenize(s)) { + if (tok === '$(inherited)') continue; + // OTHER_CFLAGS: only `-D` tokens are defines; others are flags. + // GCC_PREPROCESSOR_DEFINITIONS: every token is `NAME[=VALUE]`. + let body /*: ?string */ = null; + if (cflags != null) { + if (tok.startsWith('-D')) body = tok.slice(2); + } else { + body = tok; + } + if (body == null || body.length === 0) continue; + const eq = body.indexOf('='); + const name = eq >= 0 ? body.slice(0, eq) : body; + const value = eq >= 0 ? body.slice(eq + 1) : null; + add(name, value, config); + } + } + } + } + } + return out; + } + + function mergeDependencies() /*: Array */ { + const out /*: Array */ = []; + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const deps = layer != null ? layer.dependencies : null; + if (deps == null) continue; + // pod ipc returns deps as: {name: [versionConstraint, ...], ...} + // regex returns deps as: [name, name, ...] + if (Array.isArray(deps)) { + for (const d of deps) { + if (typeof d === 'string') out.push(d); + } + } else if (typeof deps === 'object') { + for (const name of Object.keys(deps)) { + out.push(name); + } + } + } + return Array.from(new Set(out)); + } + + function mergeCompilerFlags() /*: Array */ { + const out /*: Array */ = []; + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const value = layer != null ? layer.compiler_flags : null; + if (typeof value === 'string') { + for (const tok of value.split(/\s+/)) { + if (tok.length > 0) out.push(tok); + } + } else if (Array.isArray(value)) { + for (const v of value) { + if (typeof v === 'string') { + for (const tok of v.split(/\s+/)) { + if (tok.length > 0) out.push(tok); + } + } + } + } + } + return out; + } + + // $FlowFixMe[prop-missing] dynamic shape + const reqArc /*: unknown */ = rawSpec.requires_arc; + const requiresArc = + reqArc === false || (Array.isArray(reqArc) && reqArc.length === 0) + ? false + : true; // RN ecosystem default + + return { + name: mergeStringField('name') ?? '', + version: mergeStringField('version') ?? '', + sourceFiles: mergeArrayField('source_files'), + publicHeaderFiles: mergeArrayField('public_header_files'), + privateHeaderFiles: mergeArrayField('private_header_files'), + excludeFiles: mergeArrayField('exclude_files'), + headerMappingsDir: mergeStringField('header_mappings_dir'), + // ALL subspecs' header_mappings_dir values (not just the merged one). Each + // implies a header search path of its parent dir so namespaced includes + // (`` for a header physically at + // `apple/reanimated/apple/sensor/X.h` with mappings dir `apple/reanimated`) + // resolve from the physical tree — CocoaPods does this via the + // header_mappings_dir copy step, which SPM has no equivalent for. + headerMappingsDirs: mergeArrayField('header_mappings_dir'), + headerDir: mergeStringField('header_dir'), + frameworks: mergeArrayField('frameworks'), + weakFrameworks: mergeArrayField('weak_frameworks'), + libraries: mergeArrayField('libraries'), + dependencies: mergeDependencies(), + preprocessorDefines: mergePreprocessorDefines(), + compilerFlags: mergeCompilerFlags(), + headerSearchPaths: mergeHeaderSearchPaths(), + resources: mergeArrayField('resources'), + requiresArc, + warnings, + partial, + }; +} + +// --------------------------------------------------------------------------- +// Top-level entry +// --------------------------------------------------------------------------- + +/** + * Reads and flattens a podspec at `podspecPath`. Tries `pod ipc spec` first + * (full Ruby DSL evaluation); falls back to a regex parser when CocoaPods + * isn't available. + * + * Throws when the file doesn't exist. Otherwise always returns a + * PodspecModel — warnings on the model surface partial parses to the + * caller, who can decide whether to proceed or abort scaffolding for that + * dep. + */ +function readPodspec(podspecPath /*: string */) /*: PodspecModel */ { + if (!fs.existsSync(podspecPath)) { + throw new Error(`readPodspec: file does not exist: ${podspecPath}`); + } + const podIpc = runPodIpcSpec(podspecPath); + const raw = podIpc != null ? podIpc : regexPodspec(podspecPath); + return flattenSubspecs(raw); +} + +module.exports = { + readPodspec, + runPodIpcSpec, + regexPodspec, + flattenSubspecs, +}; diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js new file mode 100644 index 000000000000..cc5532c72a09 --- /dev/null +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -0,0 +1,1105 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/*:: +import type { + AutolinkedDep, + AutolinkingIosPlatform, + PodspecModel, + ScaffoldResult, + SpmScaffoldSpec, +} from './spm-types'; +*/ + +/** + * scaffold-package-swift.js — generates a `Package.swift` into + * `node_modules//` for community RN libraries that don't ship SPM + * support. The autolinker's existing `isSelfManagedPackage` check + * (generate-spm-autolinking.js, AUTOGEN_MARKER) treats these scaffolded + * files as self-managed and references them directly from the aggregator — + * no autolinker changes required. + * + * Three layers: + * - translatePodspecToSpmTarget(model, dep): pure translation + * - emitScaffoldedPackageSwift(spec, ctx): pure Swift emission + * - scaffoldPackageSwiftForDep(dep, ctx): I/O + skip-rule + write + * - scaffoldAll(opts): orchestrator over autolinking.json + */ + +const { + defaultReadConfig, + defaultResolveDep, + expandSpmDependencies, +} = require('./expand-spm-dependencies'); +const {expandSpmSourceGlobs} = require('./generate-spm-autolinking'); +const {readPodspec} = require('./read-podspec'); +const { + SCAFFOLDER_MARKER, + makeLogger, + remotePackageConfig, + toSwiftName, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); + +const {log} = makeLogger('scaffold-package-swift'); + +// SCAFFOLDER_MARKER lives in spm-utils.js (shared, no import cycle). It must NOT +// contain the autolinker's AUTOGEN_MARKER ('// AUTO-GENERATED by +// scripts/generate-spm-autolinking.js') — that marker is what the autolinker +// uses to tell self-managed deps from its own synth output. Our marker is +// recognized by the scaffolder itself (skip-rule), but invisible to the +// autolinker (treated as self-managed). + +// Bump when the emitter's output format changes in a way that requires +// re-scaffolding existing files (rather than just being a content drift the +// cache-slot label already handles). v2: swift-tools-version moved to +// line 1 — pre-v2 scaffolds emit it on a later line, which recent Xcode +// rejects with "Swift tools version 3.1.0 ... no longer supported". v4: the +// single `let rnHeaders = appRoot + "/.../ReactHeadersAll"` split into the two +// `rnCoreHeaders` / `appHeaders` lets (merged tree replaced by two trees), so +// pre-v4 scaffolds reference a tree that is no longer materialized. v5: +// header resolution moved to product dependencies (ReactNativeHeaders + +// ReactAppHeaders binary/headers targets) — the rnCoreHeaders/appHeaders +// trees no longer exist, so pre-v5 scaffolds carry dead lets and would break +// if anything still referenced them. v7: the runtime appRoot walker and the +// siblingPath helper are gone — package paths are plain relative strings +// computed at scaffold time (the walker anchored on +// build/xcframeworks/Package.swift, which remote mode no longer writes). +// +// Skip-rule contract: when an existing file's version is < this constant, +// the scaffolder regenerates regardless of --force (the bump implies the +// existing file is broken under current tooling). A file with the marker +// but no version line is treated as v1. +// v8: relative app paths (codegen / xcframeworks) are now computed from the +// autolinker's libs/ symlink location instead of the real dep.root, +// fixing a doubled-path resolution failure on fresh SwiftPM resolves. +// v9: header search paths derived from each subspec's header_mappings_dir +// (dirname) so namespaced includes (``) resolve. v10: +// publicHeadersPath derived from the header_mappings_dir namespace root so a +// package exposes `` to dependents; pod-style sibling deps +// (reanimated's `s.dependency "RNWorklets"`) wired to their npm package. v11: +// sibling .package path uses the libs/ symlink name, not the npm +// name (fixes "package ... doesn't exist" on resolve). v12: preprocessor +// defines from pod_target_xcconfig emitted as `.define(...)`. v13: ObjC(++) +// targets get an ambient-import prefix header (Foundation/UIKit) `-include`d, +// replacing CocoaPods' generated prefix.pch. +const SCAFFOLDER_VERSION = 17; +const SCAFFOLDER_VERSION_LINE_RE = /^\/\/ AUTO-SCAFFOLDED-VERSION: (\d+)$/m; + +const AUTOGEN_MARKER = + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js'; + +// CocoaPods auto-generates a `-prefix.pch` that imports Foundation + +// UIKit into every ObjC translation unit, so pod sources can use NSThread / +// dispatch / UIKit symbols without an explicit import. SPM has no prefix-header +// mechanism, so we emit this file at the dep root and `-include` it on the +// target (cSettings + cxxSettings) to reproduce that ambient import. The +// `__OBJC__` guard makes it inert for plain C/C++ sources, and UIKit is +// `__has_include`-guarded for platforms that lack it. +const SCAFFOLD_PREFIX_HEADER = 'react-native-spm-prefix.h'; +const SCAFFOLD_PREFIX_HEADER_CONTENTS = `// AUTO-SCAFFOLDED by react-native spm scaffold — mirrors CocoaPods' default +// prefix header so ObjC sources that rely on an implicit Foundation/UIKit +// import compile under SPM (which has no prefix-header mechanism). Safe to +// delete + regenerate via \`npx react-native spm scaffold\`. +#ifdef __OBJC__ +#import +#if __has_include() +#import +#endif +#endif +`; + +// Names of deps the scaffolder always refuses to touch — `react-native` +// itself is handled by the xcframework subpackage, never as an autolinked +// target. +const NEVER_SCAFFOLD /*: ReadonlySet */ = new Set(['react-native']); + +// React-core / React-Fabric / etc. dependency names collapse to a single +// `.product(name: "ReactNative", package: "ReactNative")` reference because +// the prebuilt React.xcframework bundles them all under one product. This +// list matches what `install_modules_dependencies(s)` materializes plus +// common podspec hand-rolled additions. +const REACT_CORE_DEP_PREFIXES = [ + 'React-', + 'React_', + 'ReactCommon', + 'RCT-Folly', + 'RCT', + 'glog', + 'boost', + 'fmt', + 'DoubleConversion', + 'Yoga', + 'hermes-engine', +]; + +function isReactCoreDep(name /*: string */) /*: boolean */ { + return REACT_CORE_DEP_PREFIXES.some(p => name.startsWith(p)); +} + +// True when the dep ships a `codegenConfig` in package.json — RN's standard +// marker that the library participates in the New Architecture / codegen. Such +// a lib's Fabric sources include the app-generated component headers +// (`/ShadowNodes.h>` etc.), which are vended by +// the per-app React-GeneratedCode package — so it implicitly depends on React +// core even when its podspec only wires that via `install_modules_dependencies` +// (which we strip). Safe/quiet: a missing or unparseable package.json → false. +function depHasCodegenConfig(depRoot /*: string */) /*: boolean */ { + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(depRoot, 'package.json'), 'utf8'), + ); + return pkg != null && pkg.codegenConfig != null; + } catch { + return false; + } +} + +// Every subdirectory (relative to depRoot) under depRoot/base, recursively — +// used to expand a CocoaPods `path/**` recursive header search path into the +// concrete dirs SPM needs (SPM has no recursive search-path syntax). Skips +// VCS / build / dependency noise. +function collectSubdirs( + depRoot /*: string */, + base /*: string */, +) /*: Array */ { + const SKIP /*: Set */ = new Set([ + 'node_modules', + 'Pods', + 'build', + '.git', + ]); + const out /*: Array */ = []; + const walk = (absDir /*: string */, relDir /*: string */) => { + let entries /*: Array<{name: string, isDirectory(): boolean}> */; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(absDir, {withFileTypes: true}); + } catch { + return; + } + for (const e of entries) { + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in stubs + const name /*: string */ = e.name; + if (!e.isDirectory() || name.startsWith('.') || SKIP.has(name)) continue; + const rel = relDir === '.' ? name : `${relDir}/${name}`; + out.push(rel); + walk(path.join(absDir, name), rel); + } + }; + walk(path.join(depRoot, base), base === '.' ? '.' : base); + return out; +} + +// --------------------------------------------------------------------------- +// Translation +// --------------------------------------------------------------------------- + +/** + * Pure: turns a flattened PodspecModel into an SpmScaffoldSpec the emitter + * can consume. Substitutes Xcode build-setting tokens (`$(PODS_TARGET_SRCROOT)`) + * against the dep root, buckets dependencies, drops unknown tokens with a + * warning. + */ +function translatePodspecToSpmTarget( + model /*: PodspecModel */, + dep /*: AutolinkedDep */, + // Maps a podspec name (e.g. "RNWorklets") to the npm package name of an + // autolinked sibling (e.g. "react-native-worklets"). Lets us wire a + // `s.dependency "RNWorklets"` — a pod-style name the `react-native-*` + // heuristic can't recognize — to the right sibling package. Empty by default. + podToNpm /*: Map */ = new Map(), +) /*: SpmScaffoldSpec */ { + const warnings = [...model.warnings]; + + // Swift target name: ALWAYS toSwiftName(npm-name). The autolinker + // registers each autolinked dep under that name in its aggregator (and in + // any sibling spm.dependencies refs), so the scaffolded Package.swift's + // product/library name must match — otherwise SPM resolution fails with + // a name mismatch on `.product(name: "X", package: "X")`. + // + // The podspec's `header_dir` is captured separately: when it changes the + // include surface (e.g. `` instead of ``), + // header resolution already works via the -I flags from headerSearchPaths + // (path-style includes like safe-area-context's + // `` resolve through + // `-I common/cpp/`). Module-style includes that NEED the target name to + // match (e.g. reanimated's `` via SwiftPM's auto-generated + // module map) require an explicit `spm.name` override in + // react-native.config.js — handled by the existing autolinker flow, not + // here. + const swiftName = toSwiftName(dep.name); + + // Header search paths — substitute Xcode build-setting tokens against the + // dep root. Anything we can't substitute is dropped + warned (avoids + // emitting `$(SOMETHING)` literally into the Swift file). + const headerSearchPaths /*: Array */ = []; + const addSearchPath = (p /*: string */) => { + if (p.length > 0 && !headerSearchPaths.includes(p)) { + headerSearchPaths.push(p); + } + }; + for (const raw of model.headerSearchPaths) { + let substituted = raw + .replace(/\$\(PODS_TARGET_SRCROOT\)/g, '.') + .replace(/\$\{PODS_TARGET_SRCROOT\}/g, '.'); + if (/\$[({]/.test(substituted)) { + warnings.push( + `Dropped HEADER_SEARCH_PATHS entry "${raw}" — contains unresolved Xcode token. ` + + `Add it manually if needed.`, + ); + continue; + } + // CocoaPods `path/**` (or `/*`) = recursive search. SPM has no recursive + // search-path syntax, so add the base dir + every subdirectory under it. + const recursive = /\/\*\*?$/.test(substituted); + substituted = substituted + .replace(/\/\*\*?$/, '') // strip the glob marker + .replace(/\/{2,}/g, '/'); // collapse `cpp//` → `cpp/` + // Strip leading "./" (emitter prefixes with the target path) + trailing "/". + const base = substituted + .replace(/^\.\//, '') + .replace(/^\//, '') + .replace(/\/$/, ''); + addSearchPath(base === '' ? '.' : base); + if (recursive) { + for (const sub of collectSubdirs(dep.root, base === '' ? '.' : base)) { + addSearchPath(sub); + } + } + } + + // header_mappings_dir → search path. CocoaPods exposes a subspec's headers + // under `/...` by copying them into Pods/Headers + // preserving structure relative to the mappings dir. SPM has no such copy + // step, so namespaced includes like `` (header + // physically at `apple/reanimated/apple/sensor/X.h`, mappings dir + // `apple/reanimated`) only resolve if the mappings dir's PARENT (`apple`) is + // on the search path. Add dirname() of every subspec's mappings dir. + for (const mappingsDir of model.headerMappingsDirs) { + const parent = path.posix.dirname(mappingsDir.replace(/^\.\//, '')); + // dirname of a single-segment dir is "." (root) — already implicitly + // searched; skip it and anything that doesn't exist on disk. + if ( + parent.length > 0 && + parent !== '.' && + !headerSearchPaths.includes(parent) && + fs.existsSync(path.join(dep.root, parent)) + ) { + headerSearchPaths.push(parent); + } + } + + // Bucket dependencies. React-Core / React-jsi / RCT-Folly / glog etc. ALL + // collapse to a single `.product(name: "ReactNative", package: "ReactNative")` + // reference because they're bundled in the prebuilt React.xcframework. + // Sibling RN libs (autolinked or self-managed deps) flow through the + // existing spm-deps mechanism. Unknown names are dropped with a warning. + let coreReactNative = false; + const siblingNames /*: Array */ = []; + const selfPodspecName = model.name; + for (const depName of model.dependencies) { + // Cross-subspec refs like "react-native-safe-area-context/common" are a + // CocoaPods construct for one subspec depending on another from the + // SAME podspec. After flattenSubspecs has merged everything into one + // target the subspec ref is meaningless — drop silently. + if (depName.includes('/') && depName.startsWith(`${selfPodspecName}/`)) { + continue; + } + const podSibling = podToNpm.get(depName.split('/')[0]); + if (isReactCoreDep(depName)) { + coreReactNative = true; + } else if (depName.startsWith('react-native-')) { + // Generic cross-subspec ref guard for any "package/subspec" form. + const baseName = depName.split('/')[0]; + if (!siblingNames.includes(baseName)) { + siblingNames.push(baseName); + } + } else if (podSibling != null && podSibling !== dep.name) { + // A pod-style dependency name (e.g. reanimated's `s.dependency + // "RNWorklets"`) that resolves to an autolinked sibling's npm package + // (react-native-worklets). Wire it as a sibling .package/.product so + // the dep's cross-package includes (``) resolve. + if (!siblingNames.includes(podSibling)) { + siblingNames.push(podSibling); + } + } else { + // Could be a non-RN dep ("MMKV", "AFNetworking"). The scaffolder + // doesn't know how to wire those — surface a warning so user can + // edit the generated Package.swift. + warnings.push( + `Unknown dependency "${depName}" — not wired into the scaffolded Package.swift. Edit manually if needed.`, + ); + } + } + + // New-Architecture libraries declare their React-core dependency via the + // `install_modules_dependencies(s)` podspec helper (which auto-adds + // React-Core / React-RCTFabric / React-Codegen), NOT an explicit + // `s.dependency "React-Core"`. We strip that helper when evaluating the + // podspec, so the React-core dependency never surfaces in model.dependencies + // and `coreReactNative` would stay false. The authoritative, RN-standard + // marker for "this lib participates in the New Architecture / codegen" is a + // `codegenConfig` block in package.json — when present, the app's codegen has + // generated `react/renderer/components//{ShadowNodes,Props,…}.h` that + // the lib's Fabric sources include via angle brackets. Those generated + // headers live in the per-app React-GeneratedCode package, so the lib must + // depend on it (and, transitively, on React core). Treat codegenConfig as + // an implicit React-core dependency. + if (!coreReactNative && depHasCodegenConfig(dep.root)) { + coreReactNative = true; + } + + // SPM's `sources:` field does NOT accept CocoaPods-style globs — it wants + // a list of explicit file paths (relative to the target's `path:`). + // Expand the podspec globs against the actual filesystem so the emitted + // Package.swift parses cleanly. + let expandedSources /*: Array */ = []; + try { + expandedSources = expandSpmSourceGlobs(dep.root, model.sourceFiles); + } catch (e) { + warnings.push( + `Source glob expansion failed (${e.message}); emitting raw globs — SPM will likely reject them.`, + ); + expandedSources = model.sourceFiles; + } + // Strip files matching exclude_files (podspec's negation list). + if (model.excludeFiles.length > 0) { + try { + // $FlowFixMe[untyped-import] micromatch has no types + const micromatch = require('micromatch'); + const isExcluded = micromatch.matcher(model.excludeFiles); + expandedSources = expandedSources.filter(f => !isExcluded(f)); + } catch (e) { + warnings.push( + `exclude_files filtering failed (${e.message}); keeping all sources.`, + ); + } + } + + // Header-map emulation: CocoaPods builds a header map (USE_HEADERMAP=YES by + // default) so a source can `#import "Foo.h"` by bare name regardless of which + // subdirectory Foo.h lives in. SPM has no header map, so add every directory + // that contains a header to the search path. Covers libs (e.g. svg) that + // spread flat-named headers across many subdirs. + const headerFiles = expandedSources.filter(f => /\.(h|hh|hpp)$/i.test(f)); + try { + for (const f of expandSpmSourceGlobs(dep.root, model.publicHeaderFiles)) { + if (/\.(h|hh|hpp)$/i.test(f)) headerFiles.push(f); + } + } catch { + // public_header_files globbing is best-effort — source_files usually + // already covers the headers. + } + for (const f of headerFiles) { + const d = path.posix.dirname(f); + addSearchPath(d === '' ? '.' : d); + } + + // ObjC(++) sources may rely on CocoaPods' implicit prefix-header import of + // Foundation/UIKit. When present, emit + `-include` a prefix header (below). + // The prefix is `-include`d by bare name, so the dep root (".") must be on + // the header search path for clang to find it. + const needsObjCPrefix = expandedSources.some(f => /\.mm?$/.test(f)); + if (needsObjCPrefix && !headerSearchPaths.includes('.')) { + headerSearchPaths.push('.'); + } + + // SPM REQUIRES publicHeadersPath to be a real directory inside the target + // when the target compiles C-family sources (default is "include" which + // typically doesn't exist in a podspec-shaped dep). Pick the first + // existing prefix dir from public_header_files / source_files. The + // chosen dir doesn't need to be the include surface — path-style + // includes (``) resolve via headerSearchPaths + // instead. publicHeadersPath just has to point at a real dir containing + // some .h files so SPM accepts the target definition. + let publicHeadersPath /*: ?string */ = null; + // Prefer the namespace root (parent of a header_mappings_dir). SPM propagates + // a target's publicHeadersPath to DEPENDENT packages as a search path, so + // setting it to e.g. `Common/cpp` (parent of `Common/cpp/worklets`) is what + // lets a sibling package resolve ``. Prefer a cross-platform + // (Common) dir — that holds the C++ API siblings consume — over a + // platform-specific (apple/) one. + const mappingsParents = model.headerMappingsDirs + .map(d => path.posix.dirname(d.replace(/^\.\//, ''))) + .filter( + p => p.length > 0 && p !== '.' && fs.existsSync(path.join(dep.root, p)), + ); + if (mappingsParents.length > 0) { + publicHeadersPath = + mappingsParents.find(p => /(?:^|\/)common(?:\/|$)/i.test(p)) ?? + mappingsParents[0]; + } + if (publicHeadersPath == null) { + for (const glob of [...model.publicHeaderFiles, ...model.sourceFiles]) { + const prefix = glob.split('/')[0]; + if ( + prefix.length > 0 && + !prefix.includes('*') && + fs.existsSync(path.join(dep.root, prefix)) + ) { + publicHeadersPath = prefix; + break; + } + } + } + if (publicHeadersPath == null) { + warnings.push( + `Could not infer publicHeadersPath from source globs. SPM may reject the target — add a publicHeadersPath manually to the scaffolded Package.swift.`, + ); + } + + return { + swiftName, + sources: expandedSources, + headerSearchPaths, + preprocessorDefines: model.preprocessorDefines, + needsObjCPrefix, + coreReactNative, + siblingNames, + extraFrameworks: model.frameworks, + weakFrameworks: model.weakFrameworks, + compilerFlags: model.compilerFlags, + publicHeadersPath, + resources: model.resources, + warnings, + }; +} + +// --------------------------------------------------------------------------- +// Swift emission +// --------------------------------------------------------------------------- + +/*:: +type EmitContext = { + // Identifier embedded as a comment so SPM's manifest hash bumps when the + // active xcframework slot changes (otherwise cached evaluations would + // keep pointing at the prior slot's headers). + cacheSlotLabel: ?string, + // Remote SPM package mode (url/version/identity) — see remotePackageConfig. + remote?: ?{url: string, version: string, identity: string}, + // Relative path (posix, from the dep's package dir) to the app's codegen + // package (/build/generated/ios). Computed at scaffold time — + // safe because the file is re-scaffolded per app/cache slot, and any + // node_modules relayout implies a reinstall that drops the file anyway. + codegenPackageDir?: ?string, + // Relative path to the app's local xcframeworks package + // (/build/xcframeworks). Only referenced when remote == null. + localXcfwPackageDir?: ?string, +}; +*/ + +/** + * Renders the SpmScaffoldSpec to a complete Package.swift string suitable + * for writing into `node_modules//`. Fully declarative: all package + * references are plain relative paths computed at scaffold time (no runtime + * discovery — the scaffolder knows both the dep dir and the app root, and + * re-scaffolds whenever either could have moved). + */ +function emitScaffoldedPackageSwift( + spec /*: SpmScaffoldSpec */, + ctx /*:: ?: EmitContext */ = {cacheSlotLabel: null, remote: null}, +) /*: string */ { + const slotComment = + ctx.cacheSlotLabel != null ? `\n// Cache slot: ${ctx.cacheSlotLabel}` : ''; + + // React headers need NO search paths — they come from the React / + // ReactNativeHeaders binaryTargets and the ReactAppHeaders product (see + // the dependencies block below). Per-dep HEADER_SEARCH_PATHS from the + // podspec are emitted as `.headerSearchPath("")` so they resolve + // relative to `path:` ("." below). Custom podspec compiler flags keep + // using unsafeFlags (rare; surfaced verbatim). + const customFlagsCxx = spec.compilerFlags.map(f => `"${f}"`); + + const headerSearchPathDirectives = spec.headerSearchPaths + .map(p => `.headerSearchPath("${p}")`) + .join(', '); + + // Preprocessor defines → `.define("NAME", to: "VALUE", .when(...))`. The + // value is escaped as a Swift string literal because it may itself contain + // quotes (e.g. a string-literal macro `-DFOO="[A:false]"`). + const swiftStr = (s /*: string */) => + `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + const defineDirectives = (spec.preprocessorDefines ?? []) + .map(d => { + const toPart = d.value != null ? `, to: ${swiftStr(d.value)}` : ''; + const condPart = + d.config === 'debug' + ? ', .when(configuration: .debug)' + : d.config === 'release' + ? ', .when(configuration: .release)' + : ''; + return `.define(${swiftStr(d.name)}${toPart}${condPart})`; + }) + .join(', '); + + const settingsEntries = (extra /*: Array */) => { + const parts = [ + defineDirectives, + headerSearchPathDirectives, + ...extra, + ].filter(e => e.length > 0); + return `[${parts.join(', ')}]`; + }; + // Force-include the ambient-import prefix header on ObjC(++) sources (the + // `__OBJC__` guard makes it a no-op for plain C/C++). `-include` resolves the + // bare name via the dep-root header search path (".", ensured in translate). + const prefixFlag = spec.needsObjCPrefix + ? `.unsafeFlags(["-include", "${SCAFFOLD_PREFIX_HEADER}"])` + : ''; + const cSettings = settingsEntries([prefixFlag].filter(e => e.length > 0)); + const cxxSettings = settingsEntries( + [ + prefixFlag, + ...(customFlagsCxx.length > 0 + ? [`.unsafeFlags([${customFlagsCxx.join(', ')}])`] + : []), + ].filter(e => e.length > 0), + ); + + // Linker frameworks: defaults + podspec-declared extras + weak frameworks. + // Dedup on render so the user doesn't see duplicate UIKit lines. + const defaults = ['UIKit', 'Foundation', 'CoreGraphics']; + const linkedFrameworks = Array.from( + new Set([...defaults, ...spec.extraFrameworks]), + ); + const linkerEntries = [ + ...linkedFrameworks.map(f => `.linkedFramework("${f}")`), + ...spec.weakFrameworks.map( + f => `.linkedFramework("${f}", .when(platforms: [.iOS]))`, + ), + ]; + + // Dependencies block. Always declares ReactNative if any React-core dep + // was in the podspec; sibling RN deps come from autolinking's existing + // .package(path: ...) graph — we reference them by toSwiftName(npmName) + // since that's what the autolinker registers them under. + const packageDeps /*: Array */ = []; + const targetDeps /*: Array */ = []; + if (spec.coreReactNative) { + // Remote mode: React Native comes from the remote package identity (no + // app-layout knowledge). The per-app codegen package is generated INTO + // the app by definition, so it stays a path reference — relative, + // computed at scaffold time. + const remote = ctx.remote; + const rnLabel = remote != null ? remote.identity : 'ReactNative'; + const codegenDir = ctx.codegenPackageDir; + if (codegenDir == null) { + throw new Error( + 'emitScaffoldedPackageSwift: codegenPackageDir is required when the dep depends on React core.', + ); + } + if (remote != null) { + packageDeps.push( + `.package(url: "${remote.url}", exact: "${remote.version}")`, + ); + } else { + const xcfwDir = ctx.localXcfwPackageDir; + if (xcfwDir == null) { + throw new Error( + 'emitScaffoldedPackageSwift: localXcfwPackageDir is required when no remote package is configured.', + ); + } + packageDeps.push(`.package(name: "ReactNative", path: "${xcfwDir}")`); + } + packageDeps.push( + `.package(name: "React-GeneratedCode", path: "${codegenDir}")`, + ); + targetDeps.push(`.product(name: "ReactNative", package: "${rnLabel}")`); + targetDeps.push( + `.product(name: "ReactNativeHeaders", package: "${rnLabel}")`, + ); + targetDeps.push( + '.product(name: "ReactAppHeaders", package: "React-GeneratedCode")', + ); + } + for (const siblingName of spec.siblingNames) { + const swiftSibling = toSwiftName(siblingName); + // The autolinker references each self-managed (scaffolded) dep through a + // `libs/` symlink, and SPM resolves a manifest's relative + // package paths against that symlink location — so a sibling lives at + // `../` (NOT `../`, which would be `libs/` + // and not exist). + packageDeps.push( + `.package(name: "${swiftSibling}", path: "../${swiftSibling}")`, + ); + targetDeps.push( + `.product(name: "${swiftSibling}", package: "${swiftSibling}")`, + ); + } + + // sources: emit only when podspec declared them. If empty, SPM auto-scans + // the target dir — which for `path: "."` means the entire dep root. We + // exclude common non-source files via the `exclude:` list to keep that + // tractable. + const sourcesLine = + spec.sources.length > 0 + ? `\n sources: [\n${spec.sources.map(s => ` "${s}",`).join('\n')}\n ],` + : ''; + + const publicHeadersLine = + spec.publicHeadersPath != null + ? `\n publicHeadersPath: "${spec.publicHeadersPath}",` + : ''; + + const resourcesLine = + spec.resources.length > 0 + ? `\n resources: [${spec.resources.map(r => `.copy("${r}")`).join(', ')}],` + : ''; + + const packageDepsBlock = + packageDeps.length > 0 + ? ` dependencies: [\n ${packageDeps.join(',\n ')},\n ],\n` + : ''; + + const targetDepsLine = + targetDeps.length > 0 + ? `\n dependencies: [${targetDeps.join(', ')}],` + : ''; + + return `// swift-tools-version: 6.0 +${SCAFFOLDER_MARKER} +// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}${slotComment} +// Edit the contents below if needed and re-run \`npx patch-package \` +// to persist across \`npm install\`. To regenerate from the podspec, remove +// this file (or just this marker) and re-run \`npx react-native spm scaffold\`. +// +// Package references are plain relative paths, computed when this file was +// scaffolded. They stay correct because the file is re-scaffolded per app +// and cache slot, and any node_modules relayout reinstalls this package +// (dropping the file) anyway. + +import PackageDescription + +let package = Package( + name: "${spec.swiftName}", + platforms: [.iOS(.v15)], + products: [ + .library(name: "${spec.swiftName}", targets: ["${spec.swiftName}"]), + ], +${packageDepsBlock} targets: [ + .target( + name: "${spec.swiftName}",${targetDepsLine} + path: ".",${sourcesLine}${publicHeadersLine}${resourcesLine} + cSettings: ${cSettings}, + cxxSettings: ${cxxSettings}, + linkerSettings: [${linkerEntries.join(', ')}] + ), + ], + cxxLanguageStandard: .cxx20 +) +`; +} + +// --------------------------------------------------------------------------- +// Per-dep orchestrator +// --------------------------------------------------------------------------- + +/*:: +type ScaffoldContext = { + appRoot: string, + projectRoot: string, + reactNativeRoot: string, + // Forces overwrite of files carrying the scaffolder marker. Files + // WITHOUT the marker (user-edited, upstream-shipped) are never touched. + force: boolean, + // When true, no file is written — caller gets the would-be content and + // can preview. + dryRun: boolean, + // Slot label (e.g. "0.87.0-nightly-20260513-6e262624f/debug") embedded as + // a comment so SPM's manifest hash bumps on slot changes. + cacheSlotLabel: ?string, + // podspec-name → npm-name index over all autolinked deps, so pod-style + // `s.dependency` names (e.g. "RNWorklets") wire to the right sibling. + podToNpm?: Map, +}; +*/ + +/** + * Decides whether to scaffold one dep, runs the translation + emission, + * writes the file (unless dryRun). Pure path/skip logic — all I/O via fs. + */ +function scaffoldPackageSwiftForDep( + dep /*: AutolinkedDep */, + ctx /*: ScaffoldContext */, +) /*: ScaffoldResult */ { + const depName = dep.name; + + if (NEVER_SCAFFOLD.has(depName)) { + return { + depName, + status: 'skipped-is-react-native', + reason: 'react-native itself is handled by the xcframework package.', + }; + } + + // ios platform missing → no native code to wrap. + if (dep.platforms.ios == null) { + return { + depName, + status: 'skipped-no-ios', + reason: 'autolinking.json has no ios platform entry.', + }; + } + + // Dep can opt out via its own react-native.config.js. + const cfg = defaultReadConfig(dep.root); + // $FlowFixMe[prop-missing] config has dynamic shape + if (cfg != null && cfg.spm != null && cfg.spm.scaffold === false) { + return { + depName, + status: 'skipped-opt-out', + reason: 'react-native.config.js sets spm.scaffold = false.', + }; + } + + const pkgSwiftPath = path.join(dep.root, 'Package.swift'); + + // A self-managed Package.swift may live either at the dep root (legacy + // convention) or inside ios/ (preferred for community RN libs that want + // their npm-package root free of SPM artifacts). If a nested manifest + // exists and is user-authored, skip — writing a stray root manifest would + // cause the autolinker to prefer the wrong file. + const nestedPkgSwiftPath = path.join(dep.root, 'ios', 'Package.swift'); + if (!fs.existsSync(pkgSwiftPath) && fs.existsSync(nestedPkgSwiftPath)) { + const nested = fs.readFileSync(nestedPkgSwiftPath, 'utf8'); + if ( + !nested.includes(AUTOGEN_MARKER) && + !nested.includes(SCAFFOLDER_MARKER) + ) { + return { + depName, + status: 'skipped-self-managed', + reason: + 'Existing ios/Package.swift was not produced by this scaffolder. ' + + 'Leaving it alone.', + }; + } + } + + // Skip rules around existing files. + if (fs.existsSync(pkgSwiftPath)) { + const existing = fs.readFileSync(pkgSwiftPath, 'utf8'); + if (existing.includes(AUTOGEN_MARKER)) { + return { + depName, + status: 'skipped-autogen', + reason: + 'Existing Package.swift carries the autolinker AUTOGEN marker. ' + + 'spm update will regenerate the synth wrapper.', + }; + } + if (existing.includes(SCAFFOLDER_MARKER)) { + // Version bump → unconditional regen. A bumped SCAFFOLDER_VERSION + // implies the emitter's output format changed in a way that requires + // regenerating existing files; --force is not needed here. + const vMatch = existing.match(SCAFFOLDER_VERSION_LINE_RE); + const existingVersion = vMatch != null ? parseInt(vMatch[1], 10) : 1; + const versionStale = existingVersion < SCAFFOLDER_VERSION; + if (!ctx.force && !versionStale) { + // Slot label change is the other auto-regen trigger — bumps SPM's + // manifest hash so the cache-slot path gets re-resolved. + const m = existing.match(/^\/\/ Cache slot: (.+)$/m); + const existingSlot = m != null ? m[1] : null; + if (ctx.cacheSlotLabel == null || existingSlot === ctx.cacheSlotLabel) { + return { + depName, + status: 'skipped-scaffolder-marker', + reason: + 'Already scaffolded for this cache slot. Pass --force to regenerate.', + }; + } + } + if (versionStale) { + log( + `Re-scaffolding ${depName} (format v${existingVersion} → v${SCAFFOLDER_VERSION})`, + ); + } + // Fall through: regenerate (force, slot changed, or version bumped). + } else { + // No marker at all → user-managed or upstream-shipped. Never touch. + return { + depName, + status: 'skipped-self-managed', + reason: + 'Existing Package.swift was not produced by this scaffolder. ' + + 'Leaving it alone.', + }; + } + } + + // Find a podspec to read. autolinking.json may have provided podspecPath; + // otherwise glob for *.podspec in dep root. + // $FlowFixMe[prop-missing] dynamic shape from autolinking.json + let podspecPath /*: ?string */ = dep.platforms.ios.podspecPath ?? null; + if (podspecPath == null) { + try { + const entries = fs.readdirSync(dep.root); + const candidate = entries.find(e => e.endsWith('.podspec')); + if (candidate != null) { + podspecPath = path.join(dep.root, candidate); + } + } catch { + // dep.root may not exist; treat as no-podspec + } + } + if (podspecPath == null || !fs.existsSync(podspecPath)) { + return { + depName, + status: 'skipped-no-podspec', + reason: 'No .podspec found in dep root.', + }; + } + + let model /*: PodspecModel */; + try { + model = readPodspec(podspecPath); + } catch (e) { + return { + depName, + status: 'error', + reason: `Podspec read failed: ${e.message}`, + }; + } + + const spec = translatePodspecToSpmTarget( + model, + dep, + ctx.podToNpm ?? new Map(), + ); + + // Mixed-language fail-closed: SPM can't compile Swift + C-family in one + // target. Don't emit a manifest that would fail with a cryptic "mixed + // language source files" resolve error — skip with a clear reason (and remove + // any stale scaffolded manifest so the autolinker reports it distinctly and + // points the user at opting it out / a binary distribution). + const hasSwift = spec.sources.some(f => /\.swift$/i.test(f)); + const hasClang = spec.sources.some(f => /\.(mm?|c|cc|cpp|cxx)$/i.test(f)); + if (hasSwift && hasClang) { + if (!ctx.dryRun && fs.existsSync(pkgSwiftPath)) { + const existing = fs.readFileSync(pkgSwiftPath, 'utf8'); + if (existing.includes(SCAFFOLDER_MARKER)) { + fs.rmSync(pkgSwiftPath, {force: true}); + } + } + return { + depName, + status: 'skipped-mixed-language', + reason: + 'has mixed Swift + Objective-C/C++ sources, which SPM cannot compile ' + + 'in one target (and the bidirectional ObjC↔Swift interop typical of ' + + 'such libs cannot be split without a circular dependency). Opt it out ' + + 'via react-native.config.js (platforms.ios = null) or use a prebuilt ' + + 'xcframework.', + }; + } + + // Relative paths into the app, embedded in the scaffolded Package.swift. + // + // The manifest is written to /Package.swift, but the autolinker + // references the dep through a symlink at + // /build/generated/autolinking/libs/. On a fresh resolve + // SwiftPM interprets a manifest's relative `.package(path:)` entries against + // that SYMLINK location (it does not canonicalize the symlink first), so the + // paths must be relative to the symlink dir — NOT the real dep.root. + // Computing from dep.root produced a doubled path + // (…/autolinking/ios/build/generated/ios) → opaque "package manifest cannot + // be accessed" resolution failure. posix separators, as SPM expects. + const symlinkDir = path.join( + ctx.appRoot, + 'build', + 'generated', + 'autolinking', + 'libs', + spec.swiftName, + ); + const relFromManifest = (...segments /*: Array */) => + path + .relative(symlinkDir, path.join(ctx.appRoot, ...segments)) + .split(path.sep) + .join('/'); + const content = emitScaffoldedPackageSwift(spec, { + cacheSlotLabel: ctx.cacheSlotLabel, + remote: remotePackageConfig(ctx.appRoot), + codegenPackageDir: relFromManifest('build', 'generated', 'ios'), + localXcfwPackageDir: relFromManifest('build', 'xcframeworks'), + }); + + // Distinguish "first-time scaffold" (no file at all) from "regenerate" + // (file with the scaffolder marker, e.g. slot changed). Used by the + // CLI orchestrator to decide whether to prompt for confirmation: + // first-time scaffolds touch a node_modules dir the user may not expect, + // regens are silent because we're maintaining a file we already own. + const previouslyExisted = fs.existsSync(pkgSwiftPath); + + if (!ctx.dryRun) { + fs.writeFileSync(pkgSwiftPath, content, 'utf8'); + // Emit the ambient-import prefix header next to the manifest when the + // target has ObjC(++) sources (the manifest `-include`s it by bare name). + if (spec.needsObjCPrefix) { + fs.writeFileSync( + path.join(dep.root, SCAFFOLD_PREFIX_HEADER), + SCAFFOLD_PREFIX_HEADER_CONTENTS, + 'utf8', + ); + } + } + + return { + depName, + status: 'written', + packageSwiftPath: pkgSwiftPath, + warnings: spec.warnings, + previouslyExisted, + }; +} + +// --------------------------------------------------------------------------- +// Multi-dep orchestrator +// --------------------------------------------------------------------------- + +/*:: +type ScaffoldAllOptions = { + appRoot: string, + projectRoot: string, + reactNativeRoot: string, + force?: boolean, + dryRun?: boolean, + cacheSlotLabel?: ?string, + autolinkingJsonPath?: string, + // npm dep names to skip entirely — used when the user declined the + // confirmation prompt for first-time scaffolds. Skipped deps still + // appear in the returned results array with status='skipped-opt-out'. + skipDeps?: ReadonlyArray, +}; +*/ + +/** + * Walks autolinking.json, scaffolds each iOS-native dep, returns one + * ScaffoldResult per dep. Caller (setup-apple-spm.js) prints a structured + * summary. + */ +function scaffoldAll( + opts /*: ScaffoldAllOptions */, +) /*: Array */ { + const {appRoot, projectRoot, reactNativeRoot} = opts; + const autolinkingJsonPath = + opts.autolinkingJsonPath ?? + path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json'); + + if (!fs.existsSync(autolinkingJsonPath)) { + log( + `autolinking.json not found at ${path.relative(appRoot, autolinkingJsonPath)}; nothing to scaffold.`, + ); + return []; + } + + /*:: type AutolinkingJson = {dependencies?: ?{[string]: {root?: string, platforms?: {ios?: ?{...}, ...}, ...}}, ...}; */ + // $FlowFixMe[incompatible-type] JSON.parse returns any + const data /*: AutolinkingJson */ = JSON.parse( + fs.readFileSync(autolinkingJsonPath, 'utf8'), + ); + const deps = data.dependencies; + if (deps == null) { + return []; + } + + // Narrow the direct autolinking.json entries with an iOS platform, then + // expand transitive `spm.dependencies` so the scaffolder covers EXACTLY the + // set the autolinker considers. Without this, a transitive native dep that + // ships no Package.swift would be flagged by the autolinker but never + // scaffolded here — leaving `react-native spm scaffold` unable to clear the + // autolinker's missing-manifest error. + const results /*: Array */ = []; + const directDeps /*: Array */ = []; + for (const name of Object.keys(deps)) { + const raw = deps[name]; + if (raw == null) continue; + const root = raw.root; + const ios = raw.platforms?.ios; + if (typeof root !== 'string' || ios == null) { + results.push({ + depName: name, + status: 'skipped-no-ios', + reason: 'no iOS platform in autolinking.json', + }); + continue; + } + // $FlowFixMe[incompatible-type] `ios` shape is runtime-validated above + const iosPlatform /*: AutolinkingIosPlatform */ = ios; + directDeps.push({name, root, platforms: {ios: iosPlatform}}); + } + + let allDeps /*: Array */; + try { + allDeps = expandSpmDependencies(directDeps, { + readConfig: defaultReadConfig, + resolveDep: defaultResolveDep, + }); + } catch (e) { + // A transitive-resolution failure shouldn't abort the whole scaffold pass; + // fall back to the direct deps so at least those get manifests. + log(`Transitive spm.dependencies expansion failed: ${e.message}`); + allDeps = directDeps; + } + + // Index every autolinked dep's podspec name → its npm name, so a dep that + // depends on a sibling by its pod name (reanimated's `s.dependency + // "RNWorklets"`) can be wired to the sibling's package (react-native-worklets). + // The podspec file basename is the pod name for RN-ecosystem libs (and is + // cheap — no `pod ipc` pre-pass). + const podToNpm /*: Map */ = new Map(); + for (const dep of allDeps) { + const podspecPath = dep.platforms?.ios?.podspecPath; + if (typeof podspecPath === 'string' && podspecPath.length > 0) { + podToNpm.set(path.basename(podspecPath, '.podspec'), dep.name); + } + } + + const ctx /*: ScaffoldContext */ = { + appRoot, + projectRoot, + reactNativeRoot, + force: opts.force === true, + dryRun: opts.dryRun === true, + cacheSlotLabel: opts.cacheSlotLabel ?? null, + podToNpm, + }; + const skipSet /*: Set */ = new Set(opts.skipDeps ?? []); + + for (const dep of allDeps) { + if (skipSet.has(dep.name)) { + results.push({ + depName: dep.name, + status: 'skipped-opt-out', + reason: 'User declined scaffolding for this dep.', + }); + continue; + } + try { + results.push(scaffoldPackageSwiftForDep(dep, ctx)); + } catch (e) { + results.push({depName: dep.name, status: 'error', reason: e.message}); + } + } + return results; +} + +module.exports = { + scaffoldAll, + scaffoldPackageSwiftForDep, + translatePodspecToSpmTarget, + emitScaffoldedPackageSwift, + SCAFFOLDER_MARKER, + SCAFFOLDER_VERSION, +}; diff --git a/packages/react-native/scripts/spm/spm-pbxproj.js b/packages/react-native/scripts/spm/spm-pbxproj.js new file mode 100644 index 000000000000..62625125f090 --- /dev/null +++ b/packages/react-native/scripts/spm/spm-pbxproj.js @@ -0,0 +1,568 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const crypto = require('crypto'); + +/** + * Generate a deterministic 24-hex-character UUID from a seed string. + * Uses MD5 hash truncated to 24 chars (standard Xcode pbxproj UUID length). + */ +function generateUUID(seed /*: string */) /*: string */ { + return crypto + .createHash('md5') + .update(seed) + .digest('hex') + .substring(0, 24) + .toUpperCase(); +} + +/** + * Escapes a string for OpenStep plist format if needed. + */ +function quoteIfNeeded(s /*: string */) /*: string */ { + if (/^[a-zA-Z0-9._/]+$/.test(s)) { + return s; + } + return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; +} + +/** + * Serialize a single pbxproj object entry to its OpenStep text form, + * including the leading `\t\t` and trailing `};` but NO trailing + * newline. Short entries (≤3 scalar fields) collapse to one line, matching + * Xcode's own formatting. Used by the in-place injector to splice single + * entries into an existing project. + */ +function serializeEntry( + entry /*: {readonly uuid: string, readonly comment?: ?string, readonly fields: {readonly [string]: string}, ...} */, +) /*: string */ { + const comment = + entry.comment != null && entry.comment !== '' + ? ` /* ${entry.comment} */` + : ''; + let out = `\t\t${entry.uuid}${comment} = {`; + const fieldKeys = Object.keys(entry.fields); + if ( + fieldKeys.length <= 3 && + !fieldKeys.some(k => entry.fields[k].includes('\n')) + ) { + // Single-line format for short entries + out += fieldKeys.map(k => `${k} = ${entry.fields[k]};`).join(' '); + out += '};'; + } else { + out += '\n'; + for (const key of fieldKeys) { + out += `\t\t\t${key} = ${entry.fields[key]};\n`; + } + out += '\t\t};'; + } + return out; +} + +// --------------------------------------------------------------------------- +// Surgical in-place pbxproj editing. +// +// To ADD SPM packages to a user's EXISTING project.pbxproj we splice new +// objects and array members into the existing text by string anchors, leaving +// every untouched byte identical (so the git diff is just the added lines). +// These helpers operate on the raw OpenStep text — there is no AST. Quote-aware +// delimiter matching lets them skip over field values (e.g. a shellScript +// containing braces/parens) without miscounting. +// --------------------------------------------------------------------------- + +/** + * Derive a deterministic UUID for an injected object, namespaced by the host + * project's root-object UUID so it is (a) stable across re-runs (idempotency) + * and (b) astronomically unlikely to collide with the user's existing + * randomly-assigned 24-hex IDs. `salt` lets the caller re-derive on the + * ~1-in-2^96 collision. + */ +function namespacedUUID( + rootUUID /*: string */, + section /*: string */, + id /*: string */, + salt /*: string */ = '', +) /*: string */ { + return generateUUID(`${rootUUID}:spm${salt}:${section}:${id}`); +} + +function escapeRegExp(s /*: string */) /*: string */ { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Given an index pointing at an opening `"`, return the index of the matching + * closing `"` (honoring backslash escapes). + */ +function scanString(text /*: string */, openIdx /*: number */) /*: number */ { + for (let i = openIdx + 1; i < text.length; i++) { + const c = text[i]; + if (c === '\\') { + i++; + continue; + } + if (c === '"') { + return i; + } + } + throw new Error('pbxproj: unterminated string literal'); +} + +/** + * Given an index pointing at an opening `{` or `(`, return the index of the + * matching close delimiter. Nesting counts both brace and paren forms; quoted + * strings are skipped. Well-formed OpenStep never mismatches the two forms. + */ +function scanToClose(text /*: string */, openIdx /*: number */) /*: number */ { + let depth = 0; + for (let i = openIdx; i < text.length; i++) { + const c = text[i]; + if (c === '"') { + i = scanString(text, i); + continue; + } + if (c === '{' || c === '(') { + depth++; + } else if (c === '}' || c === ')') { + depth--; + if (depth === 0) { + return i; + } + } + } + throw new Error('pbxproj: unbalanced delimiters'); +} + +/*:: +type ObjectRange = {uuid: string, bodyOpen: number, bodyClose: number}; +// Any object whose body range is known — field accessors only need the body +// bounds, so they accept the richer shapes callers carry (e.g. app targets +// with a name, or buildSettings dicts) inexactly. +type BodyRange = {bodyOpen: number, bodyClose: number, ...}; +type FieldRange = {matchStart: number, valueStart: number, value: string, tokenEnd: number}; +*/ + +/** + * Locate the object with the given 24-hex UUID. Returns the index of the body + * `{` and its matching `}`. Matches both single-line and multi-line entries. + */ +function findObjectByUuid( + text /*: string */, + uuid /*: string */, +) /*: ObjectRange | null */ { + const m = new RegExp(`\\n\\t*${uuid}\\b[^\\n]*?= \\{`).exec(text); + if (m == null) { + return null; + } + const bodyOpen = text.indexOf('{', m.index); + const bodyClose = scanToClose(text, bodyOpen); + return {uuid, bodyOpen, bodyClose}; +} + +/** + * Find a field within a multi-line object body (`\n\t+key = value;`). Returns + * the value token range (value excludes the trailing `;`; `tokenEnd` points AT + * the `;`). Containers (`( … )` / `{ … }`) and quoted strings are matched as a + * whole. Returns null when the key is absent. + */ +function findField( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, +) /*: FieldRange | null */ { + const body = text.slice(obj.bodyOpen, obj.bodyClose); + const m = new RegExp(`\\n\\t+${escapeRegExp(key)} = `).exec(body); + if (m == null) { + return null; + } + const matchStart = obj.bodyOpen + m.index; + const valueStart = matchStart + m[0].length; + const fc = text[valueStart]; + let tokenEnd; + if (fc === '(' || fc === '{') { + tokenEnd = scanToClose(text, valueStart) + 1; + } else if (fc === '"') { + tokenEnd = scanString(text, valueStart) + 1; + } else { + tokenEnd = text.indexOf(';', valueStart); + } + return { + matchStart, + valueStart, + value: text.slice(valueStart, tokenEnd), + tokenEnd, + }; +} + +/** Locate the `/* Begin X section *​/ … /* End X section *​/` byte range. */ +function findSection( + text /*: string */, + name /*: string */, +) /*: {begin: number, contentStart: number, end: number} | null */ { + const beginTag = `/* Begin ${name} section */`; + const endTag = `/* End ${name} section */`; + const begin = text.indexOf(beginTag); + const end = text.indexOf(endTag); + if (begin < 0 || end < 0) { + return null; + } + return {begin, contentStart: begin + beginTag.length, end}; +} + +/** The PBXProject root object (via the trailing `rootObject = ;`). */ +function findProjectObject(text /*: string */) /*: ObjectRange | null */ { + const m = /\n\trootObject = ([0-9A-Fa-f]{24})/.exec(text); + if (m == null) { + return null; + } + return findObjectByUuid(text, m[1]); +} + +/** + * Every PBXNativeTarget whose productType is an application. Returns uuid + + * name + body range for each. Used to pick the app target to inject into + * (and to refuse on ambiguity). + */ +function findApplicationTargets( + text /*: string */, +) /*: Array<{uuid: string, name: string, bodyOpen: number, bodyClose: number}> */ { + const section = findSection(text, 'PBXNativeTarget'); + if (section == null) { + return []; + } + const out = []; + const re = /\n\t\t([0-9A-Fa-f]{24})(?: \/\* (.*?) \*\/)? = \{/g; + re.lastIndex = section.contentStart; + for (;;) { + const m = re.exec(text); + if (m == null || m.index >= section.end) { + break; + } + const uuid = m[1]; + const comment = m[2]; + const bodyOpen = text.indexOf('{', m.index); + const bodyClose = scanToClose(text, bodyOpen); + const obj = {uuid, bodyOpen, bodyClose}; + const productType = findField(text, obj, 'productType'); + if ( + productType != null && + /com\.apple\.product-type\.application/.test(productType.value) + ) { + const nameField = findField(text, obj, 'name'); + const name = + nameField != null + ? nameField.value.replace(/^"|"$/g, '') + : (comment ?? uuid); + out.push({uuid, name, bodyOpen, bodyClose}); + } + re.lastIndex = bodyClose; + } + return out; +} + +/** UUIDs already referenced inside a `( … )` array field value. */ +function uuidsInArray(value /*: string */) /*: Set */ { + const found = new Set /*:: */(); + const re = /\b([0-9A-Fa-f]{24})\b/g; + for (;;) { + const m = re.exec(value); + if (m == null) { + break; + } + found.add(m[1]); + } + return found; +} + +/** + * The leading-tab indent of fields inside an object body (e.g. `\t\t\t` for a + * top-level object, `\t\t\t\t` for a nested dict like buildSettings). Used so + * inserted fields/members match the surrounding depth at any nesting level. + */ +function detectFieldIndent( + text /*: string */, + obj /*: BodyRange */, +) /*: string */ { + const m = /\n(\t+)\S/.exec(text.slice(obj.bodyOpen, obj.bodyClose)); + return m != null ? m[1] : '\t\t\t'; +} + +/** + * Insert one or more already-serialized object entries (text produced by + * serializeEntry, no surrounding newlines) into the named section — created + * just before the close of the `objects` dict if the section is absent. + */ +function insertObjectsIntoSection( + text /*: string */, + sectionName /*: string */, + entriesText /*: string */, +) /*: string */ { + const section = findSection(text, sectionName); + if (section != null) { + return ( + text.slice(0, section.end) + entriesText + '\n' + text.slice(section.end) + ); + } + // No such section yet — create it just before the `objects` dict closes. + const anchor = '\n\t};\n\trootObject = '; + const at = text.indexOf(anchor); + if (at < 0) { + throw new Error('pbxproj: could not find end of objects dict'); + } + const block = + `/* Begin ${sectionName} section */\n${entriesText}\n` + + `/* End ${sectionName} section */\n\n`; + return text.slice(0, at + 1) + block + text.slice(at + 1); +} + +/** + * Append members to a `( … )` array field, deduping by UUID. Creates the field + * (with a `$(inherited)`-free literal list) after the object's opening `{` when + * absent. `members` are `{uuid, comment}`. Indentation is derived from the + * object so it works for top-level fields and nested dicts alike. + */ +function addArrayMembers( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + members /*: ReadonlyArray<{readonly uuid: string, readonly comment?: ?string, ...}> */, + options /*: {prepend?: boolean} */ = {}, +) /*: string */ { + const fieldIndent = detectFieldIndent(text, obj); + const memberIndent = fieldIndent + '\t'; + const line = ( + m /*: {readonly uuid: string, readonly comment?: ?string, ...} */, + ) => + `${memberIndent}${m.uuid}${m.comment != null && m.comment !== '' ? ` /* ${m.comment} */` : ''},\n`; + + const field = findField(text, obj, key); + if (field != null) { + const existing = uuidsInArray(field.value); + const fresh = members.filter(m => !existing.has(m.uuid)); + if (fresh.length === 0) { + return text; + } + // Prepend: insert right after the array's opening `(\n` so the new members + // run first (used for the sync phase, which must precede Sources). + const insertAt = + options.prepend === true + ? text.indexOf('\n', field.valueStart) + 1 + : text.lastIndexOf('\n', field.tokenEnd - 1) + 1; + return ( + text.slice(0, insertAt) + fresh.map(line).join('') + text.slice(insertAt) + ); + } + const block = `\n${fieldIndent}${key} = (\n${members.map(line).join('')}${fieldIndent});`; + return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); +} + +/** + * Append raw string values to a `( … )` array build-setting (e.g. + * OTHER_LDFLAGS), deduping by exact token. Creates the setting seeded with + * `"$(inherited)"` when absent. Values must already be plist-quoted by caller. + */ +function addArrayStringValues( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + values /*: Array */, +) /*: string */ { + const fieldIndent = detectFieldIndent(text, obj); + const memberIndent = fieldIndent + '\t'; + const arrayBlock = (members /*: Array */) => + `(\n${members.map(v => `${memberIndent}${v},\n`).join('')}${fieldIndent})`; + + const field = findField(text, obj, key); + if (field != null) { + const fresh = values.filter(v => !field.value.includes(v)); + if (fresh.length === 0) { + return text; + } + if (field.value.trimStart().startsWith('(')) { + // Existing array — splice fresh members before the closing `)`. + const lineStart = text.lastIndexOf('\n', field.tokenEnd - 1) + 1; + const lines = fresh.map(v => `${memberIndent}${v},\n`).join(''); + return text.slice(0, lineStart) + lines + text.slice(lineStart); + } + // Existing scalar — promote to an array preserving the prior value. + const replacement = arrayBlock([ + '"$(inherited)"', + field.value.trim(), + ...fresh, + ]); + return ( + text.slice(0, field.valueStart) + replacement + text.slice(field.tokenEnd) + ); + } + const block = `\n${fieldIndent}${key} = ${arrayBlock(['"$(inherited)"', ...values])};`; + return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); +} + +/** + * Add a scalar field after the object's `{` only when ABSENT (never clobbers a + * value the user already set). Returns text unchanged if the key exists. + */ +function ensureScalarField( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + value /*: string */, +) /*: string */ { + if (findField(text, obj, key) != null) { + return text; + } + const fieldIndent = detectFieldIndent(text, obj); + const block = `\n${fieldIndent}${key} = ${value};`; + return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); +} +// --------------------------------------------------------------------------- +// Surgical removal — the inverse of the additive helpers above. `deinit` uses +// these to undo exactly what injection added, leaving every other byte (incl. +// user edits made after injection) untouched. All are pure string transforms. +// --------------------------------------------------------------------------- + +/** + * Remove the object whose UUID is `uuid` (its whole `\t\t … = { … };` + * entry, single- or multi-line). No-op when the object is absent. + */ +function removeObjectByUuid( + text /*: string */, + uuid /*: string */, +) /*: string */ { + const obj = findObjectByUuid(text, uuid); + if (obj == null) { + return text; + } + // Start at the newline preceding the entry's line; end just past its `;`. + // Leaving the trailing newline in place preserves it as the next entry's + // separator (byte-identical to never having inserted the line). + const start = text.lastIndexOf('\n', obj.bodyOpen); + let end = obj.bodyClose + 1; // past `}` + if (text[end] === ';') { + end++; + } + return text.slice(0, start) + text.slice(end); +} + +/** + * Remove array-member lines (`\n\t+ /* … *​/,`) referencing any of + * `uuids` from every `( … )` list in the file (packageReferences, + * packageProductDependencies, a Frameworks phase's `files`, buildPhases, …). + * Only matches member lines (trailing comma), never the object-definition line + * (which ends in `= {`), so it composes safely with removeObjectByUuid. + */ +function removeArrayMembersByUuid( + text /*: string */, + uuids /*: ReadonlyArray */, +) /*: string */ { + let out = text; + for (const uuid of uuids) { + out = out.replace( + new RegExp(`\\n[\\t ]*${escapeRegExp(uuid)}\\b[^\\n]*,`, 'g'), + '', + ); + } + return out; +} + +/** Remove a whole `\n\t+key = value;` field from `obj`. No-op when absent. */ +function removeField( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, +) /*: string */ { + const f = findField(text, obj, key); + if (f == null) { + return text; + } + // f.matchStart points at the leading `\n`; f.tokenEnd points AT the `;`. + return text.slice(0, f.matchStart) + text.slice(f.tokenEnd + 1); +} + +/** + * Remove specific raw string members from an existing `( … )` array field + * (inverse of addArrayStringValues' append branch). Leaves the field and any + * other members in place. No-op when the field or a value is absent. + */ +function removeArrayStringValues( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + values /*: ReadonlyArray */, +) /*: string */ { + const f = findField(text, obj, key); + if (f == null) { + return text; + } + let region = text.slice(f.valueStart, f.tokenEnd); + for (const val of values) { + region = region.replace(new RegExp(`\\n[\\t ]*${escapeRegExp(val)},`), ''); + } + return text.slice(0, f.valueStart) + region + text.slice(f.tokenEnd); +} + +/** + * Remove the empty `Pods` PBXGroup that `pod deintegrate` can leave behind in + * the navigator (build integration is already gone — xcconfigs/[CP] phases/ + * linking — but the group lingers). Removes the group object AND its membership + * in any parent group. Only acts when the group is EMPTY (`children = ()`), so a + * still-integrated project (non-empty Pods group) is never touched. No-op when + * absent. PBXGroup bodies contain no nested braces, so `[^{}]` body matching is + * safe. + */ +function removeEmptyPodsGroup(text /*: string */) /*: string */ { + const m = /\n[\t ]*([0-9A-Fa-f]{24}) \/\* Pods \*\/ = \{[^{}]*?\};/.exec( + text, + ); + if (m == null) { + return text; + } + const block = m[0]; + if ( + !/isa = PBXGroup;/.test(block) || + !/children = \(\s*\);/.test(block) || + !/\b(?:path|name) = Pods;/.test(block) + ) { + return text; + } + const uuid = m[1]; + // Drop the parent group's child reference first, then the group object. + return removeObjectByUuid(removeArrayMembersByUuid(text, [uuid]), uuid); +} + +module.exports = { + generateUUID, + namespacedUUID, + serializeEntry, + quoteIfNeeded, + // Surgical-edit toolkit (in-place injection): + scanString, + scanToClose, + findObjectByUuid, + findField, + findSection, + findProjectObject, + findApplicationTargets, + uuidsInArray, + detectFieldIndent, + insertObjectsIntoSection, + addArrayMembers, + addArrayStringValues, + ensureScalarField, + escapeRegExp, + // Surgical removal (deinit): + removeObjectByUuid, + removeArrayMembersByUuid, + removeField, + removeArrayStringValues, + removeEmptyPodsGroup, +}; diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js new file mode 100644 index 000000000000..679ca27f2236 --- /dev/null +++ b/packages/react-native/scripts/spm/spm-types.js @@ -0,0 +1,380 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +/*:: +export type SetupArgs = { + action: 'add' | 'update' | 'deinit' | 'sync' | 'codegen' | 'download' | 'scaffold' | null, + version: string | null, + // Local artifact source (advanced). A `.xcframework` file → use it directly + // (no download); a directory → cache dir (read if populated, download there + // if empty). Replaces the old --local-xcframework + --artifacts-dir pair. + artifacts: string | null, + flavor: string, + skipCodegen: boolean, + // Artifact download policy: 'auto' fetches when missing, 'skip' never + // fetches, 'force' clears the cache slot and re-downloads. + downloadPolicy: 'auto' | 'skip' | 'force', + // `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). + 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, +}; + +export type DownloadArgs = { + version: string | null, + flavor: string, + output: string | null, + coreTarball: string | null, + headersTarball: string | null, +}; + +export type ResolvedArtifact = { + url: string, + version: string, +}; + +export type ProcessResult = { + label: string, + version: string, + xcframeworkPath: string, + url: string, +}; + +export type ArtifactResultEntry = + | {name: string, error: void, label: string, version: string, xcframeworkPath: string, url: string} + | {name: string, error: string}; + +export type AutolinkingArgs = { + appRoot: string, + reactNativeRoot: string | null, + autolinkingJson: string | null, + output: string | null, + xcframeworksPath: string | null, +}; + +export type SpmTarget = { + name: string, + path: string, + exclude: Array, + publicHeadersPath: string | null, + resources?: Array, + // Swift target names this target depends on (already toSwiftName()'d). + // Emitted into the target's SPM `dependencies:` array so the compiler sees + // the dependent target's headers / module map. + spmTargetDependencies?: Array, + // Explicit allowlist of source files (paths relative to target.path). + // Mirrors CocoaPods' `s.source_files`. When non-empty, the SPM target + // declares `sources: [...]` and only these files are compiled — test dirs, + // .js/.podspec/.md siblings, etc. can never sneak in. Null/empty means + // fall back to SPM's default scan of target.path. + sources?: ?Array, + // Header search paths declared by the dep's podspec + // (pod_target_xcconfig HEADER_SEARCH_PATHS), already substituted relative + // to the dep's source dir. Path-style includes like + // `` resolve through these. + // Emitted as `.headerSearchPath(...)` directives in the target's + // cSettings / cxxSettings. + headerSearchPaths?: ?Array, +}; + +// Routing metadata kept alongside an SpmTarget in main(). Lives in a wrapper +// (not on SpmTarget) so the target type stays describable from the outside. +export type TargetEntry = { + target: SpmTarget, + origin: 'npm' | 'spmModule', + // The dep's npm package name (origin 'npm' only). Used in the + // missing-manifest error so the message names the package the developer + // installed, not its derived Swift target name. + npmName?: string, + // Filled in for npm-origin entries during the mirror step; consumed by the + // synth-package emission step further down. + mirrorReady?: ?{ + synthPkgDir: string, + mirroredResources: ?Array, + }, +}; + +// --------------------------------------------------------------------------- +// autolinking.json shape (output of @react-native-community/cli config). +// All fields are optional because the JSON is user-influenced; the consumer +// checks at runtime. +// --------------------------------------------------------------------------- +export type AutolinkingIosPlatform = { + sourceDir?: ?string, + podspecPath?: ?string, + ... +}; +// As parsed from autolinking.json — all fields optional because the JSON is +// user-influenced. main() validates and narrows to AutolinkedDep before use. +export type AutolinkingDepJson = { + root?: ?string, + platforms?: ?{ios?: ?AutolinkingIosPlatform, ...}, + ... +}; +export type RawAutolinkingJson = { + dependencies?: ?{[string]: AutolinkingDepJson}, + ... +}; +// Validated/normalized dep — name and ios platform are guaranteed present. +// Produced from AutolinkingDepJson in main() and passed through +// expandSpmDependencies to autolinkingDepToSpmTarget. +export type AutolinkedDep = { + name: string, + root: string, + platforms: {ios: AutolinkingIosPlatform, ...}, + // Resolved Swift target / module / headers-subdir name. Defaults to + // toSwiftName(name) and is overridden by the dep's react-native.config.js + // `spm.name`. Populated by expandSpmDependencies — always present after + // expansion; optional in the type so caller-side construction stays simple. + swiftName?: string, + // Populated by expandSpmDependencies from each dep's + // react-native.config.js `spm.dependencies` array. + spmDependencies?: Array, + ... +}; + +// CLI `config` output minimally typed for the bits we read in +// generate-spm-autolinking-config.js. +export type CliConfigJson = { + root?: ?string, + reactNativePath?: ?string, + project?: ?{ios?: ?{sourceDir?: ?string, ...}, ...}, + ... +}; + +// Entry shape for an spmModule declared in react-native.config.js. +export type SpmModuleConfig = { + name: string, + path: string, + exclude?: Array, + publicHeadersPath?: ?string, + // Optional CocoaPods-style glob allowlist (analog of s.source_files). + // When set, replaces auto source discovery for the module — only files + // matching one of these patterns are passed to SPM via `sources:`. + sources?: Array, +}; + +// --------------------------------------------------------------------------- +// Inputs to the Swift emitters in generate-spm-autolinking.js. +// --------------------------------------------------------------------------- +export type NpmDepRef = { + swiftName: string, + // Path passed to .package(path:). Relative to autolinked/ (the aggregator's + // dir). For in-place synth this is the dep's real source dir. + packagePath?: string, + // The npm package name (e.g. react-native-safe-area-context). Used by the + // aggregator's eval-time missing-manifest guard to name the library a + // developer installed (not its Swift target name). + npmName?: string, +}; + +export type AggregatorInput = { + npmDeps?: ReadonlyArray, + inlineTargets?: ReadonlyArray, + hasReactDep?: boolean, + // Relative path from the aggregator's dir (autolinking/) to + // build/xcframeworks. Used for the inline-target ReactNative dep. + xcframeworksRelPath?: ?string, +}; + +export type SynthPackageSpec = { + swiftName: string, + exclude?: Array, + publicHeadersPath?: ?string, + // Explicit allowlist of source paths (relative to `targetPath`). When + // present and non-empty, the synth Package.swift emits `sources: [...]` + // — SPM will only compile these files. + sources?: ?Array, + spmDependencies?: Array<{swiftName: string}>, + hasReactDep?: boolean, + // Relative path (posix, from the synth dir /packages/) to + // the app's React xcframeworks package and codegen package. Computed by the + // caller at generation time — the synth holds no runtime discovery. + reactNativePackagePath?: string, + codegenPackagePath?: string, + resources?: ?Array, + isDynamic?: boolean, + targetPath?: string, + // Fallback relative base for sibling synth packages when the caller does not + // supply absolute paths (tests). Production uses siblingSynthAbsolutePaths. + siblingPackageBaseRelative?: string, + siblingSynthAbsolutePaths?: {[swiftName: string]: string}, + // Header search paths from the dep's podspec `pod_target_xcconfig` + // HEADER_SEARCH_PATHS, with `$(PODS_TARGET_SRCROOT)` substituted and the + // synth wrapper's `root/` prefix applied. Emitted as `.headerSearchPath()` + // directives on cSettings / cxxSettings so path-style includes like + // `` resolve through the + // dep's own `common/cpp/` subtree. + headerSearchPaths?: ?Array, +}; + + +export type GeneratePackageArgs = { + appRoot: string, + reactNativeRoot: string | null, + version: string | null, + localXcframework: string | null, + artifactsDir: string | null, + appName: string | null, + targetName: string | null, + sourcePath: string | null, + iosVersion: string, +}; + +// A preprocessor define lifted from a podspec's pod_target_xcconfig +// (OTHER_CFLAGS `-D...` tokens + GCC_PREPROCESSOR_DEFINITIONS entries). `value` +// is null for a bare `-DNAME` (define with no value); `config` scopes the +// define to a build configuration (from `[config=*Debug*]`/`[config=*Release*]` +// xcconfig keys), null = unconditional. Emitted as SPM `.define(...)`. +export type PreprocessorDefine = { + name: string, + value: ?string, + config: ?('debug' | 'release'), +}; + +// --------------------------------------------------------------------------- +// Scaffold types — for the `npx react-native spm scaffold` command that +// generates a `Package.swift` into `node_modules//` for community RN +// libraries that don't ship SPM support. Inputs come from the dep's podspec +// (read via `pod ipc spec --format=json` when CocoaPods is available, or a +// regex fallback). The output Package.swift is treated as "self-managed" by +// the autolinker — see isSelfManagedPackage in generate-spm-autolinking.js. +// --------------------------------------------------------------------------- + +// Flattened, subspec-merged view of a podspec — what the translation layer +// consumes. HEADER_SEARCH_PATHS / source globs are kept as raw strings; +// substitution of `$(PODS_TARGET_SRCROOT)` etc. happens during translation +// so the raw model stays portable. +export type PodspecModel = { + name: string, + version: string, + sourceFiles: Array, + publicHeaderFiles: Array, + privateHeaderFiles: Array, + excludeFiles: Array, + headerMappingsDir: ?string, + // Every subspec's header_mappings_dir (union). dirname() of each is added as + // a header search path so `` includes resolve from the + // physical source tree (SPM has no header_mappings_dir copy step). + headerMappingsDirs: Array, + headerDir: ?string, + frameworks: Array, + weakFrameworks: Array, + libraries: Array, + // Merged dependency name list. With pod-ipc, includes the deps materialized + // by `install_modules_dependencies(s)` (React-Core, React-Fabric, ...). + // Version constraints stripped — we only need the name to bucket. + dependencies: Array, + compilerFlags: Array, + // Raw header-search-path entries from `pod_target_xcconfig['HEADER_SEARCH_PATHS']`. + // May contain Xcode build setting placeholders like `$(PODS_TARGET_SRCROOT)`. + headerSearchPaths: Array, + // Preprocessor defines lifted from pod_target_xcconfig OTHER_CFLAGS (`-D` + // tokens) + GCC_PREPROCESSOR_DEFINITIONS (incl. per-config variants). Already + // resolved by `pod ipc` (e.g. `-DWORKLETS_VERSION=#{package['version']}` → + // `WORKLETS_VERSION=0.9.2`). Emitted as `.define(...)` on the SPM target. + preprocessorDefines: Array, + // File paths or glob patterns the dep declares as bundled resources. + resources: Array, + requiresArc: boolean, + // Warnings collected during parsing — surfaced in the scaffold summary so + // a user can spot fields that didn't translate cleanly (unknown env vars, + // unrecognized $(...) tokens, etc.). Never throws on parse errors. + warnings: Array, + // True when produced by the regex fallback rather than pod-ipc. Used to + // emit a louder banner in the scaffold summary explaining that dependency + // wiring may be incomplete. + partial: boolean, +}; + +// Intermediate translation result — concrete data the Swift emitter consumes. +// Decouples podspec reading from SPM-specific shaping so each side can be +// tested in isolation. +export type SpmScaffoldSpec = { + // Swift target / module name. Default: toSwiftName(podspec.name); overridden + // by `header_dir` when present. + swiftName: string, + // Source file paths relative to the dep root, ready for `sources: [...]` + // emission after the `root/` wrapper-dir prefix is applied at emit time. + sources: Array, + // Header search paths resolved to dep-root-relative form. Each entry + // becomes `.headerSearchPath("")` in cSettings + cxxSettings. + headerSearchPaths: Array, + // Preprocessor defines (resolved by pod ipc) — emitted as `.define(...)` in + // cSettings + cxxSettings, honoring any per-config scope. + preprocessorDefines: Array, + // True when the target has ObjC(++) sources (.m/.mm). Drives emitting + + // `-include`ing a prefix header that ambient-imports Foundation/UIKit (which + // CocoaPods provides via a generated prefix.pch and SPM does not). + needsObjCPrefix: boolean, + // Bucketed dependency references — pre-computed by the translation layer. + // `coreReactNative` is true when ANY React-* / RCT* / RCT-Folly / glog + // dep is present (so we add a single `.product(name: "ReactNative", ...)`). + // `siblingNames` are npm names that match other autolinked deps — resolved + // to Swift names by the scaffold orchestrator before emit. + coreReactNative: boolean, + siblingNames: Array, + // Extra frameworks beyond the autolinker's default UIKit/Foundation/CoreGraphics + // set. Merged with the defaults at emit time. + extraFrameworks: Array, + weakFrameworks: Array, + // Compiler flags lifted from `s.compiler_flags`. Tokenized, ready for + // `cxxSettings: [.unsafeFlags([...])]`. + compilerFlags: Array, + // Public-headers strategy. `mappingsDir` (when set) drives publicHeadersPath + // so the autolinker's centralized headers tree exposes `#import `. + publicHeadersPath: ?string, + // File paths (relative to dep root) the emitter should declare as `.copy(...)` + // resources on the target. + resources: Array, + // Carried through from the PodspecModel; surfaced in the scaffold summary. + warnings: Array, +}; + +// Per-dep outcome from one scaffold run. The orchestrator returns an array +// of these so the CLI summary can print a structured table. +export type ScaffoldResult = + | { + depName: string, + status: 'written', + packageSwiftPath: string, + warnings: Array, + // True when the dep's Package.swift already existed (a regen — slot + // changed, --force, etc.); false on first-time scaffolds. The CLI + // orchestrator prompts only for first-time scaffolds. + previouslyExisted: boolean, + } + | { + depName: string, + status: + | 'skipped-self-managed' + | 'skipped-autogen' + | 'skipped-scaffolder-marker' + | 'skipped-no-ios' + | 'skipped-no-podspec' + | 'skipped-opt-out' + | 'skipped-mixed-language' + | 'skipped-is-react-native', + reason: string, + } + | { + depName: string, + status: 'error', + reason: string, + }; +*/ + +module.exports = {}; diff --git a/packages/react-native/scripts/spm/spm-utils.js b/packages/react-native/scripts/spm/spm-utils.js new file mode 100644 index 000000000000..c38e916f31cf --- /dev/null +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -0,0 +1,632 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +/** + * Creates a logger trio {log, warn, die} that prefixes messages with [name]. + * log – green prefix, writes to stdout + * warn – yellow prefix, writes to stderr + * die – red prefix, writes to stderr, sets exitCode=1, throws + */ +function makeLogger(name /*: string */) /*: { + log: (msg: string) => void, + warn: (msg: string) => void, + die: (msg: string) => empty, +} */ { + // Prefix every newline-separated line of the message so multi-line output + // wraps cleanly when terminal log scrapers look for the `[name]` tag. + function format(color /*: string */, msg /*: string */) /*: string */ { + const prefix = `\x1b[${color}m[${name}]\x1b[0m`; + return msg + .split('\n') + .map(line => `${prefix} ${line}`) + .join('\n'); + } + return { + log(msg /*: string */) /*: void */ { + console.log(format('32', msg)); + }, + warn(msg /*: string */) /*: void */ { + console.warn(format('33', msg)); + }, + die(msg /*: string */) /*: empty */ { + console.error(format('31', msg)); + process.exitCode = 1; + throw new Error(msg); + }, + }; +} + +/** + * Returns a short, human-readable representation of an absolute path: + * - Paths under $HOME are shown as ~/... + * - Paths under cwd are shown as relative (if ≤2 levels up) + * - Otherwise the absolute path is returned unchanged + */ +function displayPath(p /*: string */) /*: string */ { + const home = os.homedir(); + if (p === home) return '~'; + if (p.startsWith(home + path.sep)) { + return '~' + p.slice(home.length); + } + const rel = path.relative(process.cwd(), p); + if (rel && !rel.startsWith('../../..')) { + return rel; + } + return p; +} + +/** + * Canonical React Native binary cache root. Mirrors CocoaPods' + * `ReactNativePodsUtils.shared_cache_dir()` (~/Library/Caches/ReactNative, added + * in #56847) so SPM and CocoaPods share one cache root — and so SPM stops using + * a `com.facebook.ReactNative` (bundle-id) dir that other tools may also touch. + * Honor `RCT_SKIP_CACHES=1` (same env var as CocoaPods) to bypass the shared + * tarball cache. + */ +function sharedCacheDir() /*: string */ { + return path.join(os.homedir(), 'Library', 'Caches', 'ReactNative'); +} + +/** + * Returns the default versioned cache directory for SPM's EXTRACTED xcframeworks, + * nested under the canonical cache root. Downloaded tarballs themselves go in the + * flat shared cache (sharedCacheDir()) so they are reused across SPM/CocoaPods. + * + * @param {string} versionKey Version string used as directory name. + * Pass the raw --version arg (e.g. 'nightly') so the + * cache slot is stable regardless of the resolved hash. + * @param {string} flavor 'debug' or 'release' + */ +function defaultCacheDir( + versionKey /*: string */, + flavor /*: string */, +) /*: string */ { + return path.join(sharedCacheDir(), 'spm-artifacts', versionKey, flavor); +} + +/** + * Sanitize a package/app name to a valid Swift identifier. + * e.g. "@react-native/tester" -> "RNTester", "my-app" -> "MyApp" + */ +function toSwiftName(name /*: string */) /*: string */ { + const base = name.replace(/^@[^/]+\//, ''); + return base + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map(s => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); +} + +/** + * Derive a default app name from the raw package name and source path. + * Prefers the source directory name when it's meaningful (e.g. "RNTester"), + * falls back to the package name for generic dirs like "ios" or "src". + */ +function deriveAppName( + rawName /*: string */, + sourcePath /*: string */, +) /*: string */ { + const genericSourceDirs = new Set(['ios', 'app', 'sources', 'src']); + const cleanName = rawName.replace(/^@[^/]+\//, ''); + return toSwiftName( + sourcePath !== toSwiftName(cleanName) && + !genericSourceDirs.has(sourcePath.toLowerCase()) + ? sourcePath + : cleanName, + ); +} + +// $FlowFixMe[unclear-type] JSON data has dynamic shape +function readPackageJson(dir /*: string */) /*: Object | null */ { + const pkgPath = path.join(dir, 'package.json'); + if (!fs.existsSync(pkgPath)) { + return null; + } + // $FlowFixMe[incompatible-return] JSON.parse returns any + return JSON.parse(fs.readFileSync(pkgPath, 'utf8')); +} + +/** + * Walk up from startDir until we find a directory containing package.json. + * Returns startDir itself if it contains package.json, or startDir as fallback + * if no package.json is found anywhere up the tree. + */ +function findProjectRoot(startDir /*: string */) /*: string */ { + const start = path.resolve(startDir); + let dir = start; + // Bounded by filesystem depth — path.dirname converges to '/' or 'C:\\'. + // The `dir = ...` updates would otherwise drop the start-fallback narrowing. + while (dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir; + } + dir = path.dirname(dir); + } + // At filesystem root — last check before falling back. + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir; + } + return start; +} + +/** + * Resolve the react-native package root from an app directory. + * Checks appRoot/projectRoot and their ancestors for node_modules/react-native, + * then falls back to __dirname-relative resolution (monorepo layout). + * + * Returns null if react-native cannot be found. + */ +function resolveReactNativeRoot( + appRoot /*: string */, + projectRoot /*: string */, +) /*: string | null */ { + const candidates /*: Array */ = []; + const seen /*: Set */ = new Set(); + + function addAncestorCandidates(startDir /*: string */) /*: void */ { + let dir = path.resolve(startDir); + while (true) { + const candidate = path.join(dir, 'node_modules', 'react-native'); + if (!seen.has(candidate)) { + seen.add(candidate); + candidates.push(candidate); + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + } + + addAncestorCandidates(appRoot); + addAncestorCandidates(projectRoot); + candidates.push(path.resolve(__dirname, '../..')); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return path.resolve(candidate); + } + } + return null; +} + +// The per-app farm lives INSIDE the codegen package (build/generated/ios) so +// it can be vended as a normal SPM headers target ("ReactAppHeaders") — the +// farm reaches consumers via SPM product dependencies, not -I. +const PER_APP_HEADERS_REL = 'build/generated/ios/ReactAppHeaders'; + +// Marker at the top of a scaffolder-generated Package.swift. Lives here (not in +// scaffold-package-swift.js) so the autolinker can recognize scaffolded files +// without a circular import (scaffold-package-swift requires the autolinker). +const SCAFFOLDER_MARKER = + '// AUTO-SCAFFOLDED by react-native spm scaffold — safe to edit & commit via patch-package.'; + +// Remote SPM package mode (prototype of the GitHub-distribution endgame): +// when active, app + libraries all depend on ONE remote package identity +// (`.package(url:exact:)`) instead of the local path-based artifacts package +// — SPM unifies the version across the graph and the local compose/symlink +// machinery is skipped. Activated via RN_SPM_REMOTE_URL + +// RN_SPM_REMOTE_VERSION (persisted per-app so Xcode-phase re-syncs without +// the env keep the mode). +const REMOTE_CONFIG_REL = 'build/generated/autolinking/spm-remote.json'; +function remotePackageIdentity(url /*: string */) /*: string */ { + const tail = url.replace(/\/+$/, '').split('/').pop() ?? ''; + return tail.replace(/\.git$/, '').toLowerCase(); +} + +/** + * Thrown by remotePackageConfig when remote SPM mode is active (a URL is set) + * but no usable RN version can be determined: react-native isn't installed, or + * the installed version is a non-publishable dev placeholder (e.g. the monorepo + * '1000.0.0', which has no remote tag) and no override was supplied. Carries a + * developer-facing message; the CLI turns it into a hard build error (exit 2). + */ +class RemoteVersionError extends Error { + constructor(message /*: string */) { + super(message); + this.name = 'RemoteVersionError'; + } +} + +/** + * Resolve the version of the installed react-native by walking up from appRoot + * looking for node_modules/react-native/package.json. Mirrors the autolinker's + * appRoot-first, up-to-5-ancestors walk-up. Returns null when not found or the + * package.json has no string version. + */ +function resolveInstalledRnVersion(appRoot /*: string */) /*: ?string */ { + let dir = path.resolve(appRoot); + for (let i = 0; i <= 5; i++) { + const pkgPath = path.join( + dir, + 'node_modules', + 'react-native', + 'package.json', + ); + if (fs.existsSync(pkgPath)) { + try { + const j = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (typeof j.version === 'string') { + return j.version; + } + } catch {} + return null; + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + return null; +} + +/** + * True when a version string can be resolved to a published remote tag. False + * for the monorepo dev placeholder ('1000.0.0') and 0.0.0-* dev builds, neither + * of which is published — in remote mode these require an explicit override. + */ +function isPublishableVersion(v /*: ?string */) /*: boolean */ { + if (v == null || v === '') { + return false; + } + if (v === '1000.0.0') { + return false; + } + if (/^0\.0\.0(-|$)/.test(v)) { + return false; + } + return true; +} + +/** + * Resolve the remote SPM package config for `appRoot`, or null for local mode. + * + * Remote mode is gated by a URL alone (env RN_SPM_REMOTE_URL or persisted + * `url`). The version is an OVERRIDE chain — env RN_SPM_REMOTE_VERSION → + * persisted `versionOverride` → legacy persisted `version` (back-compat) — and + * when no override is set it is DERIVED from the installed react-native. The + * derived value is never persisted, so an npm RN upgrade auto-re-pins the SPM + * graph on the next sync. A derived version that isn't publishable (or a + * missing RN install) throws RemoteVersionError so the dev placeholder doesn't + * silently pin an unpublished tag. + */ +function remotePackageConfig( + appRoot /*: string */, +) /*: ?{url: string, version: string, identity: string} */ { + const envUrl = process.env.RN_SPM_REMOTE_URL; + const envVersion = process.env.RN_SPM_REMOTE_VERSION; + const cfgPath = path.join(appRoot, REMOTE_CONFIG_REL); + + // Read any persisted config first. Legacy {url, version} (where `version` + // was a hard pin) is read with `version` honored as an override. + let persisted /*: {url?: string, versionOverride?: string, version?: string} */ = + {}; + if (fs.existsSync(cfgPath)) { + try { + const j = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); + if (j != null && typeof j === 'object') { + persisted = j; + } + } catch {} + } + + // URL alone activates remote mode (env wins over persisted). + const url = envUrl != null && envUrl !== '' ? envUrl : persisted.url; + if (url == null || url === '') { + return null; // local mode + } + + // Version override chain. When none is set, derive from the installed RN — + // the SPM graph must compile against the same RN the JS/native code uses. + const override = + envVersion != null && envVersion !== '' + ? envVersion + : (persisted.versionOverride ?? persisted.version); + const version = override ?? resolveInstalledRnVersion(appRoot); + + if (version == null) { + throw new RemoteVersionError( + `Remote SPM mode is on (URL=${url}) but no React Native version could ` + + 'be resolved (react-native was not found in node_modules). Set ' + + 'RN_SPM_REMOTE_VERSION to a published tag, or install react-native.', + ); + } + if (override == null && !isPublishableVersion(version)) { + throw new RemoteVersionError( + `Remote SPM mode is on (URL=${url}) but React Native resolves to ` + + `non-publishable version '${version}'. Set RN_SPM_REMOTE_VERSION to a ` + + 'published tag, or install a released react-native.', + ); + } + + // Persist {url, versionOverride?} only when env-driven (matching the prior + // behavior of capturing env so Xcode-phase re-syncs keep the mode). A derived + // version is never frozen: omit versionOverride so the next run re-derives. + const envDriven = + (envUrl != null && envUrl !== '') || + (envVersion != null && envVersion !== ''); + if (envDriven) { + const toPersist /*: {url: string, versionOverride?: string} */ = {url}; + if (override != null) { + toPersist.versionOverride = override; + } + fs.mkdirSync(path.dirname(cfgPath), {recursive: true}); + fs.writeFileSync(cfgPath, JSON.stringify(toPersist, null, 2) + '\n'); + } + + return { + url, + version, + identity: remotePackageIdentity(url), + }; +} +function perAppHeadersDir(appRoot /*: string */) /*: string */ { + return path.join(appRoot, PER_APP_HEADERS_REL); +} + +/** + * Creates a first-wins symlink linker rooted at `outDir`. `linkInto` maps a + * virtual import path to a physical file; `foldDir` recursively links every + * .h/.hpp under a source root. `seen` records virtual->realpath so identical + * duplicates collapse to one inode and non-identical collisions are surfaced. + * Each header tree gets its OWN linker (own `seen` map) so first-wins does not + * span the shared/per-app boundary. + */ +function createHeaderLinker( + outDir /*: string */, + logger /*: {log: (msg: string) => void} */, +) /*: { + seen: Map, + stats: {collisions: number}, + linkInto: (virtualPath: string, physical: string) => void, + foldDir: (srcRoot: string) => void, +} */ { + const seen /*: Map */ = new Map(); + const stats = {collisions: 0}; + + function linkInto( + virtualPath /*: string */, + physical /*: string */, + ) /*: void */ { + let real; + try { + real = fs.realpathSync(physical); + } catch { + return; // physical file missing — skip + } + const prev = seen.get(virtualPath); + if (prev != null) { + if (prev !== real) { + stats.collisions++; + logger.log( + `WARNING: merged headers: non-identical collision for ${virtualPath} (kept first)`, + ); + } + return; + } + seen.set(virtualPath, real); + const dest = path.join(outDir, virtualPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + fs.symlinkSync(real, dest); + } + + function foldDir(srcRoot /*: string */) /*: void */ { + let real; + try { + real = fs.realpathSync(srcRoot); + } catch { + return; + } + if (!fs.statSync(real).isDirectory()) { + return; + } + const dirs /*: Array */ = [real]; + while (dirs.length > 0) { + const dir = dirs.pop(); + if (dir == null) { + break; + } + for (const ent of fs.readdirSync(dir, {withFileTypes: true})) { + const name = String(ent.name); + const child = path.join(dir, name); + // statSync (not the Dirent flags) so symlinks are followed — the + // autolinking header farm is itself a symlink farm, so its leaf + // headers are symlinks, not regular files. + let st; + try { + st = fs.statSync(child); + } catch { + continue; // broken symlink — skip + } + if (st.isDirectory()) { + dirs.push(child); + } else if ( + st.isFile() && + (name.endsWith('.h') || name.endsWith('.hpp')) + ) { + linkInto(path.relative(real, child), child); + } + } + } + } + + return {seen, stats, linkInto, foldDir}; +} + +/*:: +type HeaderTreeResult = {path: ?string, virtualPaths: Set}; +*/ + +/** + * Materializes the PER-APP header tree at + * /build/xcframeworks/ReactAppHeaders: autolinking dep headers + codegen + * output. Per-app because it depends on which libraries the app links and the + * app's generated specs. Returns {path, virtualPaths}. + */ +function buildPerAppHeaderTree( + appRoot /*: string */, + logger /*: {log: (msg: string) => void} */ = {log() {}}, +) /*: HeaderTreeResult */ { + const outDir = perAppHeadersDir(appRoot); + // Build into a temp sibling, then swap: the farm now lives INSIDE + // build/generated/ios (one of the trees being folded), so building in place + // would make foldDir walk the half-built farm itself. + const tmpDir = outDir + '.tmp'; + fs.rmSync(tmpDir, {recursive: true, force: true}); + fs.rmSync(outDir, {recursive: true, force: true}); + fs.mkdirSync(tmpDir, {recursive: true}); + + const linker = createHeaderLinker(tmpDir, logger); + linker.foldDir( + path.join(appRoot, 'build', 'generated', 'autolinking', 'headers'), + ); + linker.foldDir(path.join(appRoot, 'build', 'generated', 'ios')); + linker.foldDir( + path.join(appRoot, 'build', 'generated', 'ios', 'ReactCodegen'), + ); + + // Stub source so the farm is a valid SPM target (vended headers-only — + // see the ReactAppHeaders target in the codegen Package.swift template). + fs.writeFileSync( + path.join(tmpDir, 'ReactAppHeadersStub.c'), + '// ReactAppHeaders vends the per-app generated headers; this stub\n' + + '// satisfies SPM, which requires at least one source file per target.\n' + + 'static int ReactAppHeadersStub __attribute__((unused)) = 0;\n', + ); + fs.renameSync(tmpDir, outDir); + + logger.log( + `Built per-app header tree (${linker.seen.size} headers` + + (linker.stats.collisions > 0 + ? `, ${linker.stats.collisions} non-identical collisions` + : '') + + ')', + ); + return {path: outDir, virtualPaths: new Set(linker.seen.keys())}; +} + +/** + * Runs React Native codegen and installs the SPM Package.swift template + * into build/generated/ios/. Used by both setup-apple-spm.js and + * sync-spm-autolinking.js. + */ + +/** + * Installs the SPM codegen template into build/generated/ios/Package.swift. + * No-op when the template or the generated/ios dir is missing — codegen + * may not have produced output yet, or the project may be SPM-only. + * + * The template is copied verbatim in local mode: it holds only fixed-relative + * `.package(path:)` references (it lives at a known depth inside the app), and + * headers come from the React/ReactNativeHeaders binaryTargets + the + * ReactAppHeaders product — no loader, no absolute paths. In remote mode the + * ReactNative path-dep is rewritten to the `.package(url:exact:)` identity. + */ +function installSpmCodegenTemplate( + appRoot /*: string */, + reactNativeRoot /*: string */, + logger /*: {log: (msg: string) => void} */ = {log() {}}, +) /*: void */ { + const spmTemplate = path.join( + reactNativeRoot, + 'scripts', + 'codegen', + 'templates', + 'Package.swift.spm-template', + ); + const codegenPkgSwift = path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'Package.swift', + ); + if ( + !fs.existsSync(spmTemplate) || + !fs.existsSync(path.dirname(codegenPkgSwift)) + ) { + return; + } + let content = fs.readFileSync(spmTemplate, 'utf8'); + // Remote mode: the codegen package depends on the remote ReactNative + // package identity instead of the local path-based artifacts package. + const remote = remotePackageConfig(appRoot); + if (remote != null) { + content = content + .replace( + '.package(name: "ReactNative", path: "../../xcframeworks"),', + `.package(url: "${remote.url}", exact: "${remote.version}"),`, + ) + .split('package: "ReactNative")') + .join(`package: "${remote.identity}")`); + } + fs.writeFileSync(codegenPkgSwift, content, 'utf8'); + logger.log( + 'Installed SPM codegen template' + (remote != null ? ' (remote mode)' : ''), + ); +} + +function runCodegenAndInstallTemplate( + projectRoot /*: string */, + appRoot /*: string */, + reactNativeRoot /*: string */, + logger /*: {log: (msg: string) => void} */ = {log() {}}, + opts /*: {installTemplate?: boolean} */ = {}, +) /*: void */ { + const codegenScript = path.join( + reactNativeRoot, + 'scripts', + 'generate-codegen-artifacts.js', + ); + if (!fs.existsSync(codegenScript)) { + return; + } + logger.log('Running codegen...'); + const {execSync} = require('child_process'); + const codegenArgs = + `node "${codegenScript}" -p "${projectRoot}" -t ios` + + (projectRoot !== appRoot ? ` -o "${appRoot}"` : ''); + execSync(codegenArgs, {stdio: 'inherit', cwd: projectRoot}); + // Callers that re-point the xcframework symlinks after codegen (e.g. the SPM + // sync, which runs generate-spm-package afterwards) install the template + // themselves once the symlinks are final; they pass installTemplate: false to + // avoid a wasted write that the later install would immediately supersede. + if (opts.installTemplate !== false) { + installSpmCodegenTemplate(appRoot, reactNativeRoot, logger); + } +} + +module.exports = { + makeLogger, + displayPath, + sharedCacheDir, + defaultCacheDir, + toSwiftName, + deriveAppName, + readPackageJson, + findProjectRoot, + resolveReactNativeRoot, + buildPerAppHeaderTree, + remotePackageConfig, + resolveInstalledRnVersion, + isPublishableVersion, + RemoteVersionError, + installSpmCodegenTemplate, + runCodegenAndInstallTemplate, + SCAFFOLDER_MARKER, +}; diff --git a/packages/react-native/scripts/spm/sync-spm-autolinking.js b/packages/react-native/scripts/spm/sync-spm-autolinking.js new file mode 100644 index 000000000000..b7c90ad961d3 --- /dev/null +++ b/packages/react-native/scripts/spm/sync-spm-autolinking.js @@ -0,0 +1,241 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +/** + * sync-spm-autolinking.js – Lightweight script invoked by the Xcode pre-build + * phase to re-run autolinking (step 2) and package generation (step 4) when + * dependency inputs have changed. + * + * Usage (called from the Xcode build phase shell script): + * node sync-spm-autolinking.js --app-root --react-native-root + * + * The autolinking config (autolinking.json) is generated by the caller + * (setup-apple-spm.js) BEFORE this script runs, so codegen below can reuse it + * (findCodegenEnabledLibraries short-circuits on a present autolinking.json) + * and the slow `@react-native-community/cli config` runs exactly once per sync. + * + * This script: + * 0. Runs react-native codegen → build/generated/ios/ (reuses autolinking.json) + * 1. Ensures xcframework artifacts are downloaded (auto-heals fresh clones) + * 2. Calls generate-spm-autolinking.js → build/generated/autolinking/Package.swift + * 3. Calls generate-spm-package.js → build/xcframeworks/Package.swift + symlinks + * 4. Installs the codegen template + resolves the VFS overlay template + * 5. Writes build/generated/autolinking/.spm-sync-stamp + */ + +const { + main: downloadArtifacts, + resolveCacheSlotVersion, +} = require('./download-spm-artifacts'); +const {main: generateAutolinking} = require('./generate-spm-autolinking'); +const {main: generatePackage} = require('./generate-spm-package'); +const { + RemoteVersionError, + buildPerAppHeaderTree, + defaultCacheDir, + displayPath, + findProjectRoot, + installSpmCodegenTemplate, + makeLogger, + readPackageJson, + remotePackageConfig, + runCodegenAndInstallTemplate, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); +const yargs = require('yargs'); + +const {log} = makeLogger('sync-spm-autolinking'); + +/** + * Pure decision logic for a sync run. Given whether the app is in remote-SPM + * mode and whether a local artifacts cache is already present, decide which + * side-effecting steps the sync should perform. + * + * - Remote mode: SPM resolves artifacts itself — never download, never + * regenerate the local xcframeworks sub-package. + * - Local mode: regenerate the sub-package always; download only when the + * cache slot isn't populated yet. + */ +function decideSyncPlan( + remote /*: unknown */, + hasCachedArtifacts /*: boolean */, +) /*: {isRemote: boolean, shouldDownload: boolean, shouldGeneratePackage: boolean} */ { + const isRemote = remote != null; + return { + isRemote, + shouldDownload: !isRemote && !hasCachedArtifacts, + shouldGeneratePackage: !isRemote, + }; +} + +// Collaborators are injected (with these real implementations as defaults) so +// main() can be exercised end-to-end in tests without mocking the module +// system. Tests pass fakes that record calls and point defaultCacheDir at a +// tempdir; everything fs-based runs for real against that tempdir. +const defaultDeps = { + runCodegenAndInstallTemplate, + readPackageJson, + resolveCacheSlotVersion, + defaultCacheDir, + remotePackageConfig, + downloadArtifacts, + generateAutolinking, + generatePackage, + installSpmCodegenTemplate, + buildPerAppHeaderTree, + findProjectRoot, +}; + +async function main( + argv /*:: ?: Array */, + overrides /*:: ?: Partial */, +) /*: Promise */ { + const deps = {...defaultDeps, ...(overrides ?? {})}; + const parsed = yargs(argv ?? process.argv.slice(2)) + .version(false) + .option('app-root', { + type: 'string', + demandOption: true, + describe: 'Path to the app directory', + }) + .option('react-native-root', { + type: 'string', + demandOption: true, + describe: 'Path to react-native package root', + }) + .help() + .parseSync(); + + const appRoot = path.resolve(parsed['app-root']); + const reactNativeRoot = path.resolve(parsed['react-native-root']); + const projectRoot = deps.findProjectRoot(appRoot); + + // The caller (setup-apple-spm.js) already generated autolinking.json before + // invoking this script, so codegen reuses it here. Defer the codegen template + // install until after generate-spm-package re-points the xcframework symlinks + // (see installSpmCodegenTemplate call below) — installing it now would write a + // template that the post-symlink install immediately supersedes. + try { + deps.runCodegenAndInstallTemplate( + projectRoot, + appRoot, + reactNativeRoot, + {log}, + { + installTemplate: false, + }, + ); + } catch { + log('Codegen failed — continuing with existing output'); + } + + const pkg = deps.readPackageJson(reactNativeRoot); + const rawVersion = pkg?.version ?? '0.0.0'; + const flavor = 'debug'; + + // Resolve the cache slot for the current RN version. For dev / nightly + // labels this is the actual nightly hash, so we look at the right slot + // even when package.json still says '1000.0.0'. A new nightly publish + // means a new slot — old `1000.0.0` slots no longer prevent re-download. + const slotVersion = await deps.resolveCacheSlotVersion(rawVersion); + const expectedCacheDir = deps.defaultCacheDir(slotVersion, flavor); + const expectedArtifactsJson = path.join(expectedCacheDir, 'artifacts.json'); + + // Remote SPM package mode: artifacts come from the remote package (SPM + // resolves + caches them) — no Maven download, no local artifacts package. + const remote = deps.remotePackageConfig(appRoot); + const plan = decideSyncPlan(remote, fs.existsSync(expectedArtifactsJson)); + + if (plan.shouldDownload) { + log( + `Downloading xcframework artifacts (slot: ${slotVersion}, ${displayPath(expectedCacheDir)})...`, + ); + await deps.downloadArtifacts([ + '--version', + rawVersion, + '--flavor', + flavor, + '--output', + expectedCacheDir, + ]); + } else if (!plan.isRemote) { + log( + `Using cached xcframework artifacts (slot: ${slotVersion}, ${displayPath(expectedCacheDir)})`, + ); + } else if (remote != null) { + log(`Remote ReactNative package: ${remote.url} @ ${remote.version}`); + } + // Always feed the expected slot into generate-spm-package — it rewrites the + // local symlinks at /build/xcframeworks/ to point at this slot. If the + // version changed and they previously pointed at an older slot, this fixes + // them up. Idempotent when nothing changed. + const artifactsDir /*: string */ = expectedCacheDir; + + log('Re-generating build/generated/autolinking/Package.swift...'); + deps.generateAutolinking([ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + ]); + + if (plan.shouldGeneratePackage) { + log('Re-generating xcframeworks sub-package...'); + deps.generatePackage([ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + '--artifacts-dir', + artifactsDir, + ]); + } + + // (Re)install the static codegen template now that build/generated/ios is finalized. + deps.installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}); + + // Rebuild the per-app generated-headers farm (vended as the ReactAppHeaders + // SPM target inside the codegen package). React core headers need no trees + // — they live inside the composed artifacts (see generate-spm-package). The + // generated manifests are fully declarative (fixed-relative package paths), + // so no path-locator JSON is written. + deps.buildPerAppHeaderTree(appRoot, {log}); + + const stampPath = path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-sync-stamp', + ); + fs.mkdirSync(path.dirname(stampPath), {recursive: true}); + fs.writeFileSync(stampPath, new Date().toISOString() + '\n', 'utf8'); + log('SPM autolinking sync complete.'); +} + +if (require.main === module) { + main().catch(e => { + if (e instanceof RemoteVersionError) { + // Clean message + exit 2 (the build phase hard-fails on it) instead of a + // stack trace, matching how setup-apple-spm.js surfaces remote-mode + // version errors. + log(e.message); + process.exitCode = 2; + return; + } + console.error(e); + process.exitCode = 1; + }); +} + +module.exports = {main, decideSyncPlan}; diff --git a/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm b/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm index 2eb7bff28e67..be4ed093ea16 100644 --- a/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm +++ b/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm @@ -13,7 +13,7 @@ #import #import -#import "RCTFabricComponentsPlugins.h" +#import using namespace facebook::react; diff --git a/packages/rn-tester/RNTester/AppDelegate.h b/packages/rn-tester/RNTester/AppDelegate.h index df578639dc44..a4a5daca71ea 100644 --- a/packages/rn-tester/RNTester/AppDelegate.h +++ b/packages/rn-tester/RNTester/AppDelegate.h @@ -5,8 +5,17 @@ * LICENSE file in the root directory of this source tree. */ +// ZERO-I: bare angle includes have no framework spelling, so the SPM zero-I +// build uses the form. rn-tester also builds via CocoaPods, where +// only the bare form resolves — hence the dual. Single-mode consumers write +// just the form matching their setup. +#if __has_include() +#import +#import +#else #import #import +#endif #import @interface AppDelegate : RCTDefaultReactNativeFactoryDelegate diff --git a/packages/rn-tester/js/examples/TestLibrary/TestLibraryExample.js b/packages/rn-tester/js/examples/TestLibrary/TestLibraryExample.js new file mode 100644 index 000000000000..9c825a0737d6 --- /dev/null +++ b/packages/rn-tester/js/examples/TestLibrary/TestLibraryExample.js @@ -0,0 +1,102 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; + +const {RNTesterThemeContext} = require('../../components/RNTesterTheme'); +const React = require('react'); +const {Alert, Button, StyleSheet, Text, View} = require('react-native'); +const {greet} = require('react-native-test-library-apple'); +const {getVersion} = require('react-native-test-library-common'); + +class TestLibraryDemo extends React.Component< + {...}, + {appleResult: string, commonResult: string}, +> { + state: {appleResult: string, commonResult: string} = { + appleResult: '(not called yet)', + commonResult: '(not called yet)', + }; + + render(): React.Node { + return ( + + {theme => ( + + +