diff --git a/.github/workflows/microsoft-build-spm.yml b/.github/workflows/microsoft-build-spm.yml new file mode 100644 index 000000000000..46a6c25a62c8 --- /dev/null +++ b/.github/workflows/microsoft-build-spm.yml @@ -0,0 +1,256 @@ +name: Build SwiftPM + +on: + workflow_call: + +jobs: + resolve-hermes: + name: "Resolve Hermes" + runs-on: macos-15 + timeout-minutes: 10 + outputs: + hermes-commit: ${{ steps.resolve.outputs.hermes-commit }} + cache-hit: ${{ steps.cache.outputs.cache-hit }} + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 0 + + - name: Setup Xcode + run: sudo xcode-select --switch /Applications/Xcode_16.2.app + + - name: Set up Node.js + uses: actions/setup-node@v4.4.0 + with: + node-version: '22' + cache: yarn + registry-url: https://registry.npmjs.org + + - name: Install npm dependencies + run: yarn install + + - name: Resolve Hermes commit at merge base + id: resolve + working-directory: packages/react-native + run: | + COMMIT=$(node -e "const {hermesCommitAtMergeBase} = require('./scripts/ios-prebuild/hermes'); console.log(hermesCommitAtMergeBase().commit);" 2>&1 | grep -E '^[0-9a-f]{40}$') + echo "hermes-commit=$COMMIT" >> "$GITHUB_OUTPUT" + echo "Resolved Hermes commit: $COMMIT" + + - name: Restore Hermes cache + id: cache + uses: actions/cache/restore@v4 + with: + key: hermes-v1-${{ steps.resolve.outputs.hermes-commit }}-Debug + path: hermes-destroot + + - name: Upload cached Hermes artifacts + if: steps.cache.outputs.cache-hit == 'true' + uses: actions/upload-artifact@v4 + with: + name: hermes-artifacts + path: hermes-destroot + retention-days: 1 + + build-hermesc: + name: "Build hermesc" + if: ${{ needs.resolve-hermes.outputs.cache-hit != 'true' }} + needs: resolve-hermes + runs-on: macos-15 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + + - name: Setup Xcode + run: sudo xcode-select --switch /Applications/Xcode_16.2.app + + - name: Clone Hermes + uses: actions/checkout@v4 + with: + repository: facebook/hermes + ref: ${{ needs.resolve-hermes.outputs.hermes-commit }} + path: hermes + + - name: Build hermesc + working-directory: hermes + env: + HERMES_PATH: ${{ github.workspace }}/hermes + JSI_PATH: ${{ github.workspace }}/hermes/API/jsi + MAC_DEPLOYMENT_TARGET: '14.0' + run: | + source $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh + build_host_hermesc + + - name: Upload hermesc artifact + uses: actions/upload-artifact@v4 + with: + name: hermesc + path: hermes/build_host_hermesc + retention-days: 1 + + build-hermes-slice: + name: "Hermes ${{ matrix.slice }}" + if: ${{ needs.resolve-hermes.outputs.cache-hit != 'true' }} + needs: [resolve-hermes, build-hermesc] + runs-on: macos-15 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + slice: [iphoneos, iphonesimulator, macosx, xros, xrsimulator] + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + + - name: Setup Xcode + run: sudo xcode-select --switch /Applications/Xcode_16.2.app + + - name: Download visionOS SDK + if: ${{ matrix.slice == 'xros' || matrix.slice == 'xrsimulator' }} + run: | + sudo xcodebuild -runFirstLaunch + sudo xcrun simctl list + sudo xcodebuild -downloadPlatform visionOS + sudo xcodebuild -runFirstLaunch + + - name: Clone Hermes + uses: actions/checkout@v4 + with: + repository: facebook/hermes + ref: ${{ needs.resolve-hermes.outputs.hermes-commit }} + path: hermes + + - name: Download hermesc + uses: actions/download-artifact@v4 + with: + name: hermesc + path: hermes/build_host_hermesc + + - name: Restore hermesc permissions + run: chmod +x ${{ github.workspace }}/hermes/build_host_hermesc/bin/hermesc + + - name: Build Hermes slice (${{ matrix.slice }}) + working-directory: hermes + env: + BUILD_TYPE: Debug + HERMES_PATH: ${{ github.workspace }}/hermes + JSI_PATH: ${{ github.workspace }}/hermes/API/jsi + IOS_DEPLOYMENT_TARGET: '15.1' + MAC_DEPLOYMENT_TARGET: '14.0' + XROS_DEPLOYMENT_TARGET: '1.0' + RELEASE_VERSION: '1000.0.0' + run: | + bash $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh "${{ matrix.slice }}" + + - name: Upload slice artifact + uses: actions/upload-artifact@v4 + with: + name: hermes-slice-${{ matrix.slice }} + path: hermes/destroot + retention-days: 1 + + assemble-hermes: + name: "Assemble Hermes xcframework" + if: ${{ needs.resolve-hermes.outputs.cache-hit != 'true' }} + needs: [resolve-hermes, build-hermes-slice] + runs-on: macos-15 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + + - name: Download all slice artifacts + uses: actions/download-artifact@v4 + with: + pattern: hermes-slice-* + path: /tmp/slices + + - name: Assemble destroot from slices + run: | + mkdir -p ${{ github.workspace }}/hermes/destroot/Library/Frameworks + for slice_dir in /tmp/slices/hermes-slice-*; do + slice_name=$(basename "$slice_dir" | sed 's/hermes-slice-//') + echo "Copying slice: $slice_name" + cp -R "$slice_dir/Library/Frameworks/$slice_name" ${{ github.workspace }}/hermes/destroot/Library/Frameworks/ + # Copy include and bin directories (identical across slices, only need one copy) + if [ -d "$slice_dir/include" ] && [ ! -d ${{ github.workspace }}/hermes/destroot/include ]; then + cp -R "$slice_dir/include" ${{ github.workspace }}/hermes/destroot/ + fi + if [ -d "$slice_dir/bin" ]; then + cp -R "$slice_dir/bin" ${{ github.workspace }}/hermes/destroot/ + fi + done + echo "Assembled destroot contents:" + ls -la ${{ github.workspace }}/hermes/destroot/Library/Frameworks/ + + - name: Create universal xcframework + working-directory: hermes + env: + HERMES_PATH: ${{ github.workspace }}/hermes + run: | + source $GITHUB_WORKSPACE/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh + create_universal_framework "iphoneos" "iphonesimulator" "macosx" "xros" "xrsimulator" + + - name: Save Hermes cache + uses: actions/cache/save@v4 + with: + key: hermes-v1-${{ needs.resolve-hermes.outputs.hermes-commit }}-Debug + path: hermes/destroot + + - name: Upload Hermes artifacts + uses: actions/upload-artifact@v4 + with: + name: hermes-artifacts + path: hermes/destroot + retention-days: 1 + + build-spm: + name: "SPM ${{ matrix.platform }}" + needs: [resolve-hermes, assemble-hermes] + # Run when upstream jobs succeeded or were skipped (cache hit) + if: ${{ always() && !cancelled() && !failure() }} + runs-on: macos-26 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + platform: [ios, macos, visionos] + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 0 + + - name: Setup toolchain + uses: ./.github/actions/microsoft-setup-toolchain + with: + node-version: '22' + platform: ${{ matrix.platform }} + + - name: Install npm dependencies + run: yarn install + + - name: Download Hermes artifacts + uses: actions/download-artifact@v4 + with: + name: hermes-artifacts + path: packages/react-native/.build/artifacts/hermes/destroot + + - name: Create Hermes version marker + working-directory: packages/react-native + run: | + VERSION=$(node -p "require('./package.json').version") + echo "${VERSION}-Debug" > .build/artifacts/hermes/version.txt + + - name: Setup SPM workspace (using prebuilt Hermes) + working-directory: packages/react-native + run: node scripts/ios-prebuild.js -s -f Debug + + - name: Build SPM (${{ matrix.platform }}) + working-directory: packages/react-native + run: node scripts/ios-prebuild.js -b -f Debug -p ${{ matrix.platform }} diff --git a/.github/workflows/microsoft-pr.yml b/.github/workflows/microsoft-pr.yml index 968a5d5d9bf3..a1d3d2d4c274 100644 --- a/.github/workflows/microsoft-pr.yml +++ b/.github/workflows/microsoft-pr.yml @@ -132,6 +132,11 @@ jobs: permissions: {} uses: ./.github/workflows/microsoft-build-rntester.yml + build-spm: + name: "Build SPM" + permissions: {} + uses: ./.github/workflows/microsoft-build-spm.yml + test-react-native-macos-init: name: "Test react-native-macos init" permissions: {} @@ -156,6 +161,7 @@ jobs: - yarn-constraints - javascript-tests - build-rntester + - build-spm - test-react-native-macos-init # - react-native-test-app-integration steps: diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index c748b549463b..8c9210c0bdb0 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -249,7 +249,13 @@ let reactJsErrorHandler = RNTarget( let reactGraphicsApple = RNTarget( name: .reactGraphicsApple, path: "ReactCommon/react/renderer/graphics/platform/ios", - linkedFrameworks: ["UIKit", "CoreGraphics"], + linkedFrameworks: ["CoreGraphics"], + // [macOS] Package.swift evaluates on the host (macOS), not the target, so #if os(macOS) doesn't work for cross-compilation. + // not the target. Use .when(platforms:) for cross-compilation support. + platformLinkerSettings: [ + .linkedFramework("UIKit", .when(platforms: [.iOS, .visionOS])), + .linkedFramework("AppKit", .when(platforms: [.macOS])), + ], dependencies: [.reactDebug, .jsi, .reactUtils, .reactNativeDependencies] ) @@ -363,12 +369,27 @@ let reactCore = RNTarget( "ReactCommon/react/runtime/platform/ios", // explicit header search path to break circular dependency. RCTHost imports `RCTDefines.h` in ReactCore, ReacCore needs to import RCTHost ], linkedFrameworks: ["CoreServices"], + // [macOS] RCTUIKit is part of React-Core on 0.81 — add platform-conditional UIKit/AppKit linking + platformLinkerSettings: [ + .linkedFramework("UIKit", .when(platforms: [.iOS, .visionOS])), + .linkedFramework("AppKit", .when(platforms: [.macOS])), + ], + // macOS] excludedPaths: ["Fabric", "Tests", "Resources", "Runtime/RCTJscInstanceFactory.mm", "I18n/strings", "CxxBridge/JSCExecutorFactory.mm", "CoreModules"], dependencies: [.reactNativeDependencies, .reactCxxReact, .reactPerfLogger, .jsi, .reactJsiExecutor, .reactUtils, .reactFeatureFlags, .reactRuntimeScheduler, .yoga, .reactJsInspector, .reactJsiTooling, .rctDeprecation, .reactCoreRCTWebsocket, .reactRCTImage, .reactTurboModuleCore, .reactRCTText, .reactRCTBlob, .reactRCTAnimation, .reactRCTNetwork, .reactFabric, .hermesPrebuilt], sources: [".", "Runtime/RCTHermesInstanceFactory.mm"] ) /// React-Fabric.podspec +// [macOS: on macOS, use platform/macos view sources instead of platform/cxx +#if os(macOS) +let reactFabricViewPlatformSources = ["components/view/platform/macos"] +let reactFabricViewPlatformExcludes = ["components/view/platform/cxx"] +#else +let reactFabricViewPlatformExcludes = ["components/view/platform/macos"] +let reactFabricViewPlatformSources = ["components/view/platform/cxx"] +#endif +// macOS] let reactFabric = RNTarget( name: .reactFabric, path: "ReactCommon/react/renderer", @@ -379,7 +400,8 @@ let reactFabric = RNTarget( "components/view/tests", "components/view/platform/android", "components/view/platform/windows", - "components/view/platform/macos", + // "components/view/platform/cxx", // [macOS] excluded on macOS, included on iOS/visionOS (see reactFabricViewPlatformExcludes) + // "components/view/platform/macos", // [macOS] excluded on iOS/visionOS, included on macOS (see reactFabricViewPlatformExcludes) "components/scrollview/tests", "components/scrollview/platform/android", "mounting/tests", @@ -402,9 +424,9 @@ let reactFabric = RNTarget( "components/unimplementedview", "components/virtualview", "components/root/tests", - ], + ] + reactFabricViewPlatformExcludes, // [macOS] dependencies: [.reactNativeDependencies, .reactJsiExecutor, .rctTypesafety, .reactTurboModuleCore, .jsi, .logger, .reactDebug, .reactFeatureFlags, .reactUtils, .reactRuntimeScheduler, .reactCxxReact, .reactRendererDebug, .reactGraphics, .yoga], - sources: ["animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/view/platform/cxx", "components/scrollview", "components/scrollview/platform/cxx", "components/legacyviewmanagerinterop", "dom", "scheduler", "mounting", "observers/events", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency"] + sources: ["animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/scrollview", "components/scrollview/platform/cxx", "components/legacyviewmanagerinterop", "dom", "scheduler", "mounting", "observers/events", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency"] + reactFabricViewPlatformSources // [macOS] ) /// React-RCTFabric.podspec @@ -424,7 +446,8 @@ let reactFabricComponents = RNTarget( "components/view/platform/android", "components/view/platform/windows", "components/view/platform/macos", - "components/switch/iosswitch/react/renderer/components/switch/MacOSSwitchShadowNode.mm", + // [macOS] Both IOSSwitchShadowNode.mm and MacOSSwitchShadowNode.mm are included; + // they use #if TARGET_OS_OSX guards internally so only the correct one compiles. "components/textinput/platform/android", "components/text/platform/android", "components/textinput/platform/macos", @@ -591,7 +614,7 @@ let targets = [ let package = Package( name: react, - platforms: [.iOS(.v15), .macCatalyst(SupportedPlatform.MacCatalystVersion.v13)], + platforms: [.iOS(.v15), .macOS(.v14) /* [macOS] */, .macCatalyst(SupportedPlatform.MacCatalystVersion.v13)], products: [ .library( name: react, @@ -632,14 +655,16 @@ class BinaryTarget: BaseTarget { class RNTarget: BaseTarget { let linkedFrameworks: [String] + let platformLinkerSettings: [LinkerSetting] // [macOS] Platform-conditional framework linking (e.g. UIKit vs AppKit) let excludedPaths: [String] let dependencies: [String] let sources: [String]? let publicHeadersPath: String? let defines: [CXXSetting] - init(name: String, path: String, searchPaths: [String] = [], linkedFrameworks: [String] = [], excludedPaths: [String] = [], dependencies: [String] = [], sources: [String]? = nil, publicHeadersPath: String? = ".", defines: [CXXSetting] = []) { + init(name: String, path: String, searchPaths: [String] = [], linkedFrameworks: [String] = [], platformLinkerSettings: [LinkerSetting] = [], excludedPaths: [String] = [], dependencies: [String] = [], sources: [String]? = nil, publicHeadersPath: String? = ".", defines: [CXXSetting] = []) { self.linkedFrameworks = linkedFrameworks + self.platformLinkerSettings = platformLinkerSettings self.excludedPaths = excludedPaths self.dependencies = dependencies self.sources = sources @@ -675,7 +700,7 @@ class RNTarget: BaseTarget { override func target(targets: [BaseTarget]) -> Target { let searchPaths: [String] = self.headerSearchPaths(targets: targets) - let linkerSettings = self.linkedFrameworks.reduce([]) { $0 + [LinkerSetting.linkedFramework($1)] } + let linkerSettings = self.linkedFrameworks.reduce([]) { $0 + [LinkerSetting.linkedFramework($1)] } + self.platformLinkerSettings // [macOS] return Target.reactNativeTarget( name: self.name, diff --git a/packages/react-native/ReactCommon/react/renderer/components/switch/iosswitch/react/renderer/components/switch/IOSSwitchShadowNode.mm b/packages/react-native/ReactCommon/react/renderer/components/switch/iosswitch/react/renderer/components/switch/IOSSwitchShadowNode.mm index d1981f17c062..7b8bc2e7d380 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/switch/iosswitch/react/renderer/components/switch/IOSSwitchShadowNode.mm +++ b/packages/react-native/ReactCommon/react/renderer/components/switch/iosswitch/react/renderer/components/switch/IOSSwitchShadowNode.mm @@ -5,6 +5,9 @@ * LICENSE file in the root directory of this source tree. */ +#include // [macOS] +#if !TARGET_OS_OSX // [macOS] + #import #import #include "AppleSwitchShadowNode.h" @@ -26,3 +29,5 @@ } } // namespace facebook::react + +#endif // !TARGET_OS_OSX [macOS] diff --git a/packages/react-native/ReactCommon/react/renderer/components/switch/iosswitch/react/renderer/components/switch/MacOSSwitchShadowNode.mm b/packages/react-native/ReactCommon/react/renderer/components/switch/iosswitch/react/renderer/components/switch/MacOSSwitchShadowNode.mm index cd62f626ba68..ec76095bc8e0 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/switch/iosswitch/react/renderer/components/switch/MacOSSwitchShadowNode.mm +++ b/packages/react-native/ReactCommon/react/renderer/components/switch/iosswitch/react/renderer/components/switch/MacOSSwitchShadowNode.mm @@ -5,6 +5,9 @@ * LICENSE file in the root directory of this source tree. */ +#include // [macOS] +#if TARGET_OS_OSX // [macOS] + #import #include "AppleSwitchShadowNode.h" @@ -30,3 +33,5 @@ } } // namespace facebook::react + +#endif // TARGET_OS_OSX [macOS] diff --git a/packages/react-native/scripts/ios-prebuild/cli.js b/packages/react-native/scripts/ios-prebuild/cli.js index 01301c800af7..c2f3586f46f5 100644 --- a/packages/react-native/scripts/ios-prebuild/cli.js +++ b/packages/react-native/scripts/ios-prebuild/cli.js @@ -18,6 +18,8 @@ const platforms /*: $ReadOnlyArray */ = [ 'ios', 'ios-simulator', 'mac-catalyst', + 'macos', // [macOS] + 'visionos', // [macOS] ]; // CI can't use commas in cache keys, so 'macOS,variant=Mac Catalyst' was creating troubles @@ -26,6 +28,8 @@ const platformToDestination /*: $ReadOnly<{|[Platform]: Destination|}> */ = { ios: 'iOS', 'ios-simulator': 'iOS Simulator', 'mac-catalyst': 'macOS,variant=Mac Catalyst', + macos: 'macOS', // [macOS] + visionos: 'visionOS', // [macOS] }; const cli = yargs diff --git a/packages/react-native/scripts/ios-prebuild/hermes.js b/packages/react-native/scripts/ios-prebuild/hermes.js index e5e67bfcb0a6..6c3e9859a32a 100644 --- a/packages/react-native/scripts/ios-prebuild/hermes.js +++ b/packages/react-native/scripts/ios-prebuild/hermes.js @@ -8,9 +8,14 @@ * @format */ +const { + findMatchingHermesVersion, + hermesCommitAtMergeBase, +} = require('./macosVersionResolver'); // [macOS] const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); +const os = require('os'); // [macOS] const path = require('path'); const stream = require('stream'); const {promisify} = require('util'); @@ -56,6 +61,29 @@ async function prepareHermesArtifactsAsync( // Resolve the version from the environment variable or use the default version let resolvedVersion = process.env.HERMES_VERSION ?? version; + // [macOS] Map macOS version to upstream RN version for artifact lookup. + // If no mapped version is found (main branch / 1000.0.0), allowBuildFromSource + // enables the fallback to hermesCommitAtMergeBase() when no prebuilt artifacts exist. + let allowBuildFromSource = false; + if (!process.env.HERMES_VERSION) { + const packageJsonPath = path.resolve( + __dirname, + '..', + '..', + 'package.json', + ); + const mappedVersion = findMatchingHermesVersion(packageJsonPath); + if (mappedVersion != null) { + hermesLog( + `Using mapped upstream version for Hermes lookup: ${mappedVersion}`, + ); + resolvedVersion = mappedVersion; + } else { + allowBuildFromSource = true; + } + } + // macOS] + if (resolvedVersion === 'nightly') { hermesLog('Using latest nightly tarball'); const hermesVersion = await getNightlyVersionFromNPM(); @@ -74,7 +102,11 @@ async function prepareHermesArtifactsAsync( return artifactsPath; } - const sourceType = await hermesSourceType(resolvedVersion, buildType); + const sourceType = await hermesSourceType( + resolvedVersion, + buildType, + allowBuildFromSource, + ); localPath = await resolveSourceFromSourceType( sourceType, resolvedVersion, @@ -124,12 +156,14 @@ type HermesEngineSourceType = | 'local_prebuilt_tarball' | 'download_prebuild_tarball' | 'download_prebuilt_nightly_tarball' + | 'build_from_hermes_commit' */ const HermesEngineSourceTypes = { LOCAL_PREBUILT_TARBALL: 'local_prebuilt_tarball', DOWNLOAD_PREBUILD_TARBALL: 'download_prebuild_tarball', DOWNLOAD_PREBUILT_NIGHTLY_TARBALL: 'download_prebuilt_nightly_tarball', + BUILD_FROM_HERMES_COMMIT: 'build_from_hermes_commit', // [macOS] } /*:: as const */; /** @@ -224,10 +258,16 @@ async function hermesArtifactExists( /** * Determines the source type for Hermes based on availability + * + * @param version - The resolved version string + * @param buildType - Debug or Release + * @param allowBuildFromSource - If true (macOS main branch), fall back to BUILD_FROM_HERMES_COMMIT + * when no prebuilt artifacts exist. If false, fall back to nightly download (original behavior). */ async function hermesSourceType( version /*: string */, buildType /*: BuildFlavor */, + allowBuildFromSource /*: boolean */ = false, ) /*: Promise */ { if (hermesEngineTarballEnvvarDefined()) { hermesLog('Using local prebuild tarball'); @@ -247,6 +287,16 @@ async function hermesSourceType( return HermesEngineSourceTypes.DOWNLOAD_PREBUILT_NIGHTLY_TARBALL; } + // [macOS] When on the macOS main branch (no mapped version, no explicit HERMES_VERSION), + // fall back to resolving the Hermes commit at the merge base with facebook/react-native. + if (allowBuildFromSource) { + hermesLog( + 'No prebuilt Hermes artifact found. Will attempt to resolve from merge base with facebook/react-native.', + ); + return HermesEngineSourceTypes.BUILD_FROM_HERMES_COMMIT; + } + // macOS] + hermesLog( 'Using download prebuild nightly tarball - this is a fallback and might not work.', ); @@ -266,6 +316,8 @@ async function resolveSourceFromSourceType( return downloadPrebuildTarball(version, buildType, artifactsPath); case HermesEngineSourceTypes.DOWNLOAD_PREBUILT_NIGHTLY_TARBALL: return downloadPrebuiltNightlyTarball(version, buildType, artifactsPath); + case HermesEngineSourceTypes.BUILD_FROM_HERMES_COMMIT: // [macOS] + return buildFromHermesCommit(version, buildType, artifactsPath); default: abort( `[Hermes] Unsupported or invalid source type provided: ${sourceType}`, @@ -372,6 +424,113 @@ async function downloadHermesTarball( return destPath; } +// [macOS +/** + * Handles the case where no prebuilt Hermes artifacts are available. + * Determines the Hermes commit at the merge base with facebook/react-native + * and provides actionable guidance for building Hermes. + */ +async function buildFromHermesCommit( + version /*: string */, + buildType /*: BuildFlavor */, + artifactsPath /*: string */, +) /*: Promise */ { + const {commit, timestamp} = hermesCommitAtMergeBase(); + hermesLog( + `Building Hermes from source at commit ${commit} (merge base timestamp: ${timestamp})`, + ); + + const HERMES_GITHUB_URL = 'https://github.com/facebook/hermes.git'; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-build-')); + const hermesDir = path.join(tmpDir, 'hermes'); + + try { + // Clone Hermes at the identified commit using the most efficient + // single-fetch pattern (see https://github.com/actions/checkout) + hermesLog(`Cloning Hermes at commit ${commit}...`); + execSync(`git init "${hermesDir}"`, {stdio: 'inherit'}); + execSync(`git -C "${hermesDir}" remote add origin ${HERMES_GITHUB_URL}`, { + stdio: 'inherit', + }); + execSync( + `git -C "${hermesDir}" fetch --no-tags --depth 1 origin +${commit}:refs/remotes/origin/main`, + {stdio: 'inherit', timeout: 300000}, + ); + execSync(`git -C "${hermesDir}" checkout main`, {stdio: 'inherit'}); + + const reactNativeRoot = path.resolve(__dirname, '..', '..'); + const buildScript = path.join( + reactNativeRoot, + 'sdks', + 'hermes-engine', + 'utils', + 'build-ios-framework.sh', + ); + + const buildEnv = { + ...process.env, + BUILD_TYPE: buildType, + HERMES_PATH: hermesDir, + JSI_PATH: path.join(hermesDir, 'API', 'jsi'), + REACT_NATIVE_PATH: reactNativeRoot, + // Deployment targets matching react-native-macos minimums + IOS_DEPLOYMENT_TARGET: '15.1', + MAC_DEPLOYMENT_TARGET: '14.0', + XROS_DEPLOYMENT_TARGET: '1.0', + RELEASE_VERSION: version, + }; + + hermesLog(`Building Hermes frameworks (${buildType})...`); + execSync(`bash "${buildScript}"`, { + cwd: hermesDir, + stdio: 'inherit', + timeout: 3600000, // 60 minutes + env: buildEnv, + }); + + // Create tarball from the destroot (same structure as Maven artifacts) + const tarballName = `hermes-ios-${buildType.toLowerCase()}.tar.gz`; + const tarballPath = path.join(artifactsPath, tarballName); + hermesLog('Creating Hermes tarball from build output...'); + execSync(`tar -czf "${tarballPath}" -C "${hermesDir}" destroot`, { + stdio: 'inherit', + }); + + hermesLog(`Hermes built from source and packaged at ${tarballPath}`); + return tarballPath; + } catch (e) { + // Dump CMake error logs before cleanup for debugging + try { + const cmakeErrorLog = path.join( + hermesDir, + 'build_host_hermesc', + 'CMakeFiles', + 'CMakeError.log', + ); + if (fs.existsSync(cmakeErrorLog)) { + hermesLog('=== CMakeError.log ==='); + hermesLog(fs.readFileSync(cmakeErrorLog, 'utf8')); + } + } catch (_) { + // ignore + } + + abort( + `[Hermes] Failed to build Hermes from source at commit ${commit}.\n` + + `Error: ${e.message}\n` + + `To resolve, either:\n` + + ` 1. Set HERMES_ENGINE_TARBALL_PATH to a local Hermes tarball path\n` + + ` 2. Set HERMES_VERSION to an upstream RN version with published artifacts\n` + + ` 3. Build Hermes manually from commit ${commit} and provide the tarball path via HERMES_ENGINE_TARBALL_PATH`, + ); + return ''; // unreachable + } finally { + // Clean up + fs.rmSync(tmpDir, {recursive: true, force: true}); + } +} +// macOS] + function abort(message /*: string */) { hermesLog(message, 'error'); throw new Error(message); @@ -379,4 +538,6 @@ function abort(message /*: string */) { module.exports = { prepareHermesArtifactsAsync, + findMatchingHermesVersion, // [macOS] re-exported from macosVersionResolver.js + hermesCommitAtMergeBase, // [macOS] re-exported from macosVersionResolver.js }; diff --git a/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js b/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js new file mode 100644 index 000000000000..4654117f97a2 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/macosVersionResolver.js @@ -0,0 +1,199 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * [macOS] This file is specific to react-native-macos and has no upstream equivalent. + * It handles version resolution for macOS fork branches where the package version + * differs from upstream react-native. + * + * @flow + * @format + */ + +const {createLogger} = require('./utils'); +const {execSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const macosLog = createLogger('macOS'); + +/** + * For react-native-macos stable branches, maps the macOS package version + * to the upstream react-native version using peerDependencies. + * Returns null for version 1000.0.0 (main branch dev version). + * + * This is the JavaScript equivalent of the Ruby `findMatchingHermesVersion` + * in sdks/hermes-engine/hermes-utils.rb. + */ +function findMatchingHermesVersion( + packageJsonPath /*: string */, +) /*: ?string */ { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + + if (pkg.version === '1000.0.0') { + macosLog( + 'Main branch detected (1000.0.0), no matching upstream Hermes version', + ); + return null; + } + + if (pkg.peerDependencies && pkg.peerDependencies['react-native']) { + const upstreamVersion = pkg.peerDependencies['react-native']; + macosLog( + `Mapped macOS version ${pkg.version} to upstream RN version: ${upstreamVersion}`, + ); + return upstreamVersion; + } + + macosLog( + 'No matching Hermes version found in peerDependencies. Defaulting to package version.', + ); + return null; +} + +/** + * Finds the Hermes commit at the merge base with facebook/react-native. + * Used on the main branch (1000.0.0) where no prebuilt artifacts exist. + * + * Since react-native-macos lags slightly behind facebook/react-native, we can't always use + * the latest Hermes commit because Hermes and JSI don't always guarantee backwards compatibility. + * Instead, we take the commit hash of Hermes at the time of the merge base with facebook/react-native. + * + * This is the JavaScript equivalent of the Ruby `hermes_commit_at_merge_base` + * in sdks/hermes-engine/hermes-utils.rb. + */ +function hermesCommitAtMergeBase() /*: {| commit: string, timestamp: string |} */ { + const HERMES_GITHUB_URL = 'https://github.com/facebook/hermes.git'; + + // Fetch upstream react-native + macosLog('Fetching facebook/react-native to find merge base...'); + try { + execSync('git fetch -q https://github.com/facebook/react-native.git', { + stdio: 'pipe', + }); + } catch (e) { + abort( + '[Hermes] Failed to fetch facebook/react-native into the local repository.', + ); + } + + // Find merge base between our HEAD and upstream's HEAD + const mergeBase = execSync('git merge-base FETCH_HEAD HEAD', { + encoding: 'utf8', + }).trim(); + if (!mergeBase) { + abort( + "[Hermes] Unable to find the merge base between our HEAD and upstream's HEAD.", + ); + } + + // Get timestamp of merge base + const timestamp = execSync(`git show -s --format=%ci ${mergeBase}`, { + encoding: 'utf8', + }).trim(); + if (!timestamp) { + abort( + `[Hermes] Unable to extract the timestamp for the merge base (${mergeBase}).`, + ); + } + + // Clone Hermes bare (minimal) into a temp directory and find the commit + macosLog( + `Merge base timestamp: ${timestamp}. Cloning Hermes to find matching commit...`, + ); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-')); + const hermesGitDir = path.join(tmpDir, 'hermes.git'); + + try { + // Explicitly use Hermes 'main' branch since the default branch changed to 'static_h' (Hermes V1) + execSync( + `git clone -q --bare --filter=blob:none --single-branch --branch main ${HERMES_GITHUB_URL} "${hermesGitDir}"`, + {stdio: 'pipe', timeout: 120000}, + ); + + // Find the Hermes commit at the time of the merge base on branch 'main' + const commit = execSync( + `git --git-dir="${hermesGitDir}" rev-list -1 --before="${timestamp}" refs/heads/main`, + {encoding: 'utf8'}, + ).trim(); + + if (!commit) { + abort( + `[Hermes] Unable to find the Hermes commit hash at time ${timestamp} on branch 'main'.`, + ); + } + + macosLog( + `Using Hermes commit from the merge base with facebook/react-native: ${commit} (timestamp: ${timestamp})`, + ); + return {commit, timestamp}; + } finally { + // Clean up temp directory + fs.rmSync(tmpDir, {recursive: true, force: true}); + } +} + +/** + * Finds the upstream react-native version at the merge base with facebook/react-native. + * Falls back to null if the version at merge base is also 1000.0.0 (i.e. merge base is + * on upstream main, not a release branch). + */ +function findVersionAtMergeBase() /*: ?string */ { + try { + // hermesCommitAtMergeBase() already fetches facebook/react-native, but we + // might not have FETCH_HEAD if this runs standalone. Fetch it. + execSync('git fetch -q https://github.com/facebook/react-native.git', { + stdio: 'pipe', + timeout: 60000, + }); + const mergeBase = execSync('git merge-base FETCH_HEAD HEAD', { + encoding: 'utf8', + }).trim(); + if (!mergeBase) { + return null; + } + // Read the package.json version at the merge base commit + const pkgJson = execSync( + `git show ${mergeBase}:packages/react-native/package.json`, + {encoding: 'utf8'}, + ); + const version = JSON.parse(pkgJson).version; + // If the merge base is also on main (1000.0.0), this doesn't help + if (version === '1000.0.0') { + return null; + } + return version; + } catch (_) { + return null; + } +} + +async function getLatestStableVersionFromNPM() /*: Promise */ { + const npmResponse /*: Response */ = await fetch( + 'https://registry.npmjs.org/react-native/latest', + ); + + if (!npmResponse.ok) { + throw new Error( + `Couldn't get latest stable version from NPM: ${npmResponse.status} ${npmResponse.statusText}`, + ); + } + + const json = await npmResponse.json(); + return json.version; +} + +function abort(message /*: string */) { + macosLog(message, 'error'); + throw new Error(message); +} + +module.exports = { + findMatchingHermesVersion, + hermesCommitAtMergeBase, + findVersionAtMergeBase, + getLatestStableVersionFromNPM, +}; diff --git a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js index 6b5880e98c11..56b8f5fe7142 100644 --- a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js +++ b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js @@ -10,6 +10,11 @@ /*:: import type {BuildFlavor} from './types'; */ +const { + findMatchingHermesVersion, + findVersionAtMergeBase, + getLatestStableVersionFromNPM, +} = require('./macosVersionResolver'); // [macOS] const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); @@ -44,6 +49,35 @@ async function prepareReactNativeDependenciesArtifactsAsync( // Resolve the version from the environment variable or use the default version let resolvedVersion = process.env.RN_DEP_VERSION ?? version; + // [macOS] Map macOS version to upstream RN version for artifact lookup. + // For stable branches, peerDependencies maps to the upstream version. + // For the main branch (1000.0.0), fall back to the latest stable RN release. + if (!process.env.RN_DEP_VERSION) { + const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json'); + const mappedVersion = findMatchingHermesVersion(packageJsonPath); + if (mappedVersion != null) { + dependencyLog( + `Using mapped upstream version for ReactNativeDependencies lookup: ${mappedVersion}`, + ); + resolvedVersion = mappedVersion; + } else if (resolvedVersion === '1000.0.0') { + const versionAtMergeBase = findVersionAtMergeBase(); + if (versionAtMergeBase != null) { + dependencyLog( + `Main branch detected. Using upstream version at merge base for ReactNativeDependencies: ${versionAtMergeBase}`, + ); + resolvedVersion = versionAtMergeBase; + } else { + const latestStable = await getLatestStableVersionFromNPM(); + dependencyLog( + `Main branch detected. Using latest stable RN version for ReactNativeDependencies: ${latestStable}`, + ); + resolvedVersion = latestStable; + } + } + } + // macOS] + if (resolvedVersion === 'nightly') { dependencyLog('Using latest nightly tarball'); const rnVersion = await getNightlyVersionFromNPM(); diff --git a/packages/react-native/scripts/ios-prebuild/setup.js b/packages/react-native/scripts/ios-prebuild/setup.js index 65e34bb6e482..73fe69993f02 100644 --- a/packages/react-native/scripts/ios-prebuild/setup.js +++ b/packages/react-native/scripts/ios-prebuild/setup.js @@ -190,6 +190,16 @@ async function setup( 'ReactCommon/react/renderer/components/view/platform/cxx', 'ReactCommon/react/renderer/components/view', ); + // [macOS - link macOS-specific view platform headers + link( + 'ReactCommon/react/renderer/components/view/platform/macos', + 'ReactCommon/react/renderer/components/view', + ); + link( + 'ReactCommon/react/renderer/components/view/platform/macos', + 'react/renderer/components/view', + ); + // macOS] link('ReactCommon/react/renderer/mounting'); link('ReactCommon/react/renderer/attributedstring'); link('ReactCommon/runtimeexecutor/ReactCommon', 'ReactCommon'); diff --git a/packages/react-native/scripts/ios-prebuild/types.js b/packages/react-native/scripts/ios-prebuild/types.js index c1ac1489c804..87f516c5ba99 100644 --- a/packages/react-native/scripts/ios-prebuild/types.js +++ b/packages/react-native/scripts/ios-prebuild/types.js @@ -12,12 +12,16 @@ export type Platform = 'ios' | 'ios-simulator' | - 'mac-catalyst'; + 'mac-catalyst' | + 'macos' | // [macOS] + 'visionos'; // [macOS] export type Destination = 'iOS' | 'iOS Simulator' | - 'macOS,variant=Mac Catalyst'; + 'macOS,variant=Mac Catalyst' | + 'macOS' | // [macOS] + 'visionOS'; // [macOS] export type BuildFlavor = 'Debug' | 'Release'; */ diff --git a/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh b/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh index 76b23e18970c..44ce6d663582 100755 --- a/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh +++ b/packages/react-native/sdks/hermes-engine/utils/build-apple-framework.sh @@ -12,7 +12,7 @@ CURR_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" IMPORT_HERMESC_PATH=${HERMES_OVERRIDE_HERMESC_PATH:-$PWD/build_host_hermesc/ImportHermesc.cmake} BUILD_TYPE=${BUILD_TYPE:-Debug} -HERMES_PATH="$CURR_SCRIPT_DIR/.." +HERMES_PATH=${HERMES_PATH:-"$CURR_SCRIPT_DIR/.."} REACT_NATIVE_PATH=${REACT_NATIVE_PATH:-$CURR_SCRIPT_DIR/../../..} NUM_CORES=$(sysctl -n hw.ncpu) diff --git a/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh b/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh index 08382b7d4deb..c6cd66001aed 100755 --- a/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh +++ b/packages/react-native/sdks/hermes-engine/utils/build-ios-framework.sh @@ -10,7 +10,7 @@ fi set -e # Given a specific target, retrieve the right architecture for it -# $1 the target you want to build. Allowed values: iphoneos, iphonesimulator, catalyst, xros, xrsimulator +# $1 the target you want to build. Allowed values: iphoneos, iphonesimulator, catalyst, macosx, xros, xrsimulator function get_architecture { if [[ $1 == "iphoneos" || $1 == "xros" ]]; then echo "arm64" @@ -20,7 +20,7 @@ function get_architecture { echo "arm64" elif [[ $1 == "appletvsimulator" ]]; then echo "x86_64;arm64" - elif [[ $1 == "catalyst" ]]; then + elif [[ $1 == "catalyst" || $1 == "macosx" ]]; then echo "x86_64;arm64" else echo "Error: unknown architecture passed $1" @@ -29,7 +29,9 @@ function get_architecture { } function get_deployment_target { - if [[ $1 == "xros" || $1 == "xrsimulator" ]]; then + if [[ $1 == "macosx" ]]; then + echo "$(get_mac_deployment_target)" + elif [[ $1 == "xros" || $1 == "xrsimulator" ]]; then echo "$(get_visionos_deployment_target)" else # tvOS and iOS use the same deployment target echo "$(get_ios_deployment_target)" @@ -53,7 +55,7 @@ function build_framework { # group the frameworks together to create a universal framework function build_universal_framework { if [ ! -d destroot/Library/Frameworks/universal/hermes.xcframework ]; then - create_universal_framework "iphoneos" "iphonesimulator" "catalyst" "xros" "xrsimulator" "appletvos" "appletvsimulator" + create_universal_framework "macosx" "iphoneos" "iphonesimulator" "catalyst" "xros" "xrsimulator" "appletvos" "appletvsimulator" else echo "Skipping; Clean \"destroot\" to rebuild". fi @@ -63,6 +65,7 @@ function build_universal_framework { # this is used to preserve backward compatibility function create_framework { if [ ! -d destroot/Library/Frameworks/universal/hermes.xcframework ]; then + build_framework "macosx" build_framework "iphoneos" build_framework "iphonesimulator" build_framework "appletvos"