diff --git a/.github/actions/microsoft-setup-toolchain/action.yml b/.github/actions/microsoft-setup-toolchain/action.yml index 3d731fc7776d..8e8b6120536d 100644 --- a/.github/actions/microsoft-setup-toolchain/action.yml +++ b/.github/actions/microsoft-setup-toolchain/action.yml @@ -19,13 +19,17 @@ inputs: xcode-developer-dir: description: Set the path for the active Xcode developer directory default: "/Applications/Xcode.app" + cmake-version: + description: CMake version to install. Set to 'system' to skip installation and use the runner's pre-installed cmake. + default: "3.31.9" runs: using: composite steps: - name: Install cmake + if: ${{ inputs.cmake-version != 'system' }} uses: jwlawson/actions-setup-cmake@v2 with: - cmake-version: '3.31.9' + cmake-version: ${{ inputs.cmake-version }} - name: Set up Ccache id: setup-ccache if: ${{ inputs.platform == 'ios' || inputs.platform == 'macos' || inputs.platform == 'visionos' }} 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 c387085ea236..9ee8a536aef2 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -246,7 +246,15 @@ let reactJsErrorHandler = RNTarget( let reactGraphicsApple = RNTarget( name: .reactGraphicsApple, path: "ReactCommon/react/renderer/graphics/platform/ios", - linkedFrameworks: ["UIKit", "CoreGraphics"], + linkedFrameworks: ["CoreGraphics"], + // [macOS: UIKit on iOS/visionOS, AppKit on macOS + // Note: #if os(macOS) doesn't work here because Package.swift runs on the host, + // not the target. Use .when(platforms:) for cross-compilation support. + platformLinkerSettings: [ + .linkedFramework("UIKit", .when(platforms: [.iOS, .visionOS])), + .linkedFramework("AppKit", .when(platforms: [.macOS])), + ], + // macOS] dependencies: [.reactDebug, .jsi, .reactUtils, .reactNativeDependencies] ) @@ -360,12 +368,21 @@ 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"], - 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], + excludedPaths: ["Fabric", "Tests", "Resources", "Runtime/RCTJscInstanceFactory.mm", "I18n/strings", "CxxBridge/JSCExecutorFactory.mm", "CoreModules", "RCTUIKit"], + dependencies: [.reactNativeDependencies, .reactCxxReact, .reactPerfLogger, .jsi, .reactJsiExecutor, .reactUtils, .reactFeatureFlags, .reactRuntimeScheduler, .yoga, .reactJsInspector, .reactJsiTooling, .rctDeprecation, .reactCoreRCTWebsocket, .reactRCTImage, .reactTurboModuleCore, .reactRCTText, .reactRCTBlob, .reactRCTAnimation, .reactRCTNetwork, .reactFabric, .hermesPrebuilt, .reactRCTUIKit], 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 reactFabricViewPlatformSources = ["components/view/platform/cxx"] +let reactFabricViewPlatformExcludes = ["components/view/platform/macos"] +#endif +// macOS] let reactFabric = RNTarget( name: .reactFabric, path: "ReactCommon/react/renderer", @@ -376,7 +393,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", @@ -399,9 +417,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 @@ -420,16 +438,14 @@ let reactFabricComponents = RNTarget( "components/modal/platform/cxx", "components/view/platform/android", "components/view/platform/windows", - "components/view/platform/macos", + // "components/view/platform/macos", // [macOS] not needed here — sources don't include components/view "components/textinput/platform/android", "components/text/platform/android", - "components/textinput/platform/macos", "components/text/tests", "textlayoutmanager/tests", "textlayoutmanager/platform/android", "textlayoutmanager/platform/cxx", "textlayoutmanager/platform/windows", - "textlayoutmanager/platform/macos", "conponents/rncore", // this was the old folder where RN Core Components were generated. If you ran codegen in the past, you might have some files in it that might make the build fail. ], dependencies: [.reactNativeDependencies, .reactCore, .reactJsiExecutor, .reactTurboModuleCore, .jsi, .logger, .reactDebug, .reactFeatureFlags, .reactUtils, .reactRuntimeScheduler, .reactCxxReact, .yoga, .reactRendererDebug, .reactGraphics, .reactFabric, .reactTurboModuleBridging], @@ -524,6 +540,22 @@ let reactSettings = RNTarget( dependencies: [.reactTurboModuleCore, .yoga] ) +// [macOS +/// React-RCTUIKit.podspec +/// UIKit/AppKit compatibility layer for React Native macOS. +let reactRCTUIKit = RNTarget( + name: .reactRCTUIKit, + path: "React/RCTUIKit", + // [macOS: UIKit on iOS/visionOS, AppKit on macOS + platformLinkerSettings: [ + .linkedFramework("UIKit", .when(platforms: [.iOS, .visionOS])), + .linkedFramework("AppKit", .when(platforms: [.macOS])), + ], + // macOS] + excludedPaths: ["README.md"] +) +// macOS] + // MARK: Target list let targets = [ reactDebug, @@ -581,13 +613,14 @@ let targets = [ reactAppDelegate, reactSettings, reactRuntimeExecutor, + reactRCTUIKit, // [macOS] ] // MARK: Package object 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, @@ -628,14 +661,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 @@ -671,7 +706,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, @@ -753,6 +788,7 @@ extension String { static let reactNativeModuleDom = "React-domnativemodule" static let reactAppDelegate = "React-RCTAppDelegate" static let reactSettings = "React-RCTSettings" + static let reactRCTUIKit = "React-RCTUIKit" // [macOS] } func relativeSearchPath(_ depth: Int, _ path: String) -> String { diff --git a/packages/react-native/scripts/ios-prebuild/cli.js b/packages/react-native/scripts/ios-prebuild/cli.js index 01301c800af7..86b639d00ad8 100644 --- a/packages/react-native/scripts/ios-prebuild/cli.js +++ b/packages/react-native/scripts/ios-prebuild/cli.js @@ -17,7 +17,10 @@ import type {BuildFlavor, Destination, Platform} from './types'; const platforms /*: $ReadOnlyArray */ = [ 'ios', 'ios-simulator', + 'macos', // [macOS] 'mac-catalyst', + 'visionos', // [macOS] + 'visionos-simulator', // [macOS] ]; // CI can't use commas in cache keys, so 'macOS,variant=Mac Catalyst' was creating troubles @@ -25,7 +28,10 @@ const platforms /*: $ReadOnlyArray */ = [ const platformToDestination /*: $ReadOnly<{|[Platform]: Destination|}> */ = { ios: 'iOS', 'ios-simulator': 'iOS Simulator', + macos: 'macOS', // [macOS] 'mac-catalyst': 'macOS,variant=Mac Catalyst', + visionos: 'xrOS', // [macOS] + 'visionos-simulator': 'xrOS Simulator', // [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 356901ebdf22..c1ca2e89de8a 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 */; /** @@ -221,10 +255,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'); @@ -244,6 +284,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.', ); @@ -263,6 +313,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}`, @@ -369,6 +421,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); @@ -376,4 +535,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 bac3a0e05197..1bdd9b08bb62 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 bd26c3634f44..c2cd31e65460 100644 --- a/packages/react-native/scripts/ios-prebuild/setup.js +++ b/packages/react-native/scripts/ios-prebuild/setup.js @@ -145,6 +145,7 @@ async function setup( link('Libraries/LinkingIOS', 'React'); link('Libraries/Settings', 'React'); + link('React/RCTUIKit', 'React'); // [macOS] link('Libraries/PushNotificationIOS', 'React'); link('Libraries/Settings', 'React'); link('Libraries/Vibration', 'React'); @@ -184,6 +185,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..5ad9e8b25106 100644 --- a/packages/react-native/scripts/ios-prebuild/types.js +++ b/packages/react-native/scripts/ios-prebuild/types.js @@ -12,12 +12,18 @@ export type Platform = 'ios' | 'ios-simulator' | - 'mac-catalyst'; + 'macos' | + 'mac-catalyst' | + 'visionos' | + 'visionos-simulator'; export type Destination = 'iOS' | 'iOS Simulator' | - 'macOS,variant=Mac Catalyst'; + 'macOS' | + 'macOS,variant=Mac Catalyst' | + 'xrOS' | + 'xrOS Simulator'; 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"