diff --git a/.github/workflows/microsoft-build-spm.yml b/.github/workflows/microsoft-build-spm.yml new file mode 100644 index 000000000000..719540f138d9 --- /dev/null +++ b/.github/workflows/microsoft-build-spm.yml @@ -0,0 +1,73 @@ +name: Build SPM + +on: + workflow_call: + +jobs: + build-hermes: + name: "Build Hermes" + runs-on: macos-26 + timeout-minutes: 90 + 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: macos + + - name: Install npm dependencies + run: yarn install + + - name: Build Hermes from source + working-directory: packages/react-native + run: node scripts/ios-prebuild.js -s -f Debug + + - name: Upload Hermes artifacts + uses: actions/upload-artifact@v4 + with: + name: hermes-artifacts + path: packages/react-native/.build/artifacts/hermes + retention-days: 1 + + build-spm: + name: "${{ matrix.platform }}" + needs: build-hermes + 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 + + - 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/Libraries/LinkingIOS/RCTLinkingManager.mm b/packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm index f0c943f6aa30..16243f3cc84f 100644 --- a/packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm +++ b/packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm @@ -14,6 +14,8 @@ #import "RCTLinkingPlugins.h" +#if !TARGET_OS_OSX // [macOS] + static NSString *const kOpenURLNotification = @"RCTOpenURLNotification"; static void postNotificationWithURL(NSURL *URL, id sender) @@ -22,6 +24,24 @@ static void postNotificationWithURL(NSURL *URL, id sender) [[NSNotificationCenter defaultCenter] postNotificationName:kOpenURLNotification object:sender userInfo:payload]; } +#else // [macOS + +NSString *const RCTOpenURLNotification = @"RCTOpenURLNotification"; + +static NSString *initialURL = nil; +static BOOL moduleInitalized = NO; +static BOOL alwaysForegroundLastWindow = YES; + +static void postNotificationWithURL(NSString *url, id sender) +{ + NSDictionary *payload = @{@"url": url}; + [[NSNotificationCenter defaultCenter] postNotificationName:RCTOpenURLNotification + object:sender + userInfo:payload]; +} + +#endif // macOS] + @interface RCTLinkingManager () @end @@ -34,6 +54,8 @@ - (dispatch_queue_t)methodQueue return dispatch_get_main_queue(); } +#if !TARGET_OS_OSX // [macOS] + - (void)startObserving { [[NSNotificationCenter defaultCenter] addObserver:self @@ -47,11 +69,33 @@ - (void)stopObserving [[NSNotificationCenter defaultCenter] removeObserver:self]; } +#else // [macOS + +- (void)startObserving +{ + moduleInitalized = YES; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleOpenURLNotification:) + name:RCTOpenURLNotification + object:nil]; +} + +- (void)stopObserving +{ + moduleInitalized = NO; + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +#endif // macOS] + - (NSArray *)supportedEvents { return @[ @"url" ]; } +#if !TARGET_OS_OSX // [macOS] + + (BOOL)application:(UIApplication *)app openURL:(NSURL *)URL options:(NSDictionary *)options @@ -87,6 +131,43 @@ - (void)handleOpenURLNotification:(NSNotification *)notification [self sendEventWithName:@"url" body:notification.userInfo]; } +#else // [macOS + ++ (void)setAlwaysForegroundLastWindow:(BOOL)alwaysForeground +{ + alwaysForegroundLastWindow = alwaysForeground; +} + ++ (void)getUrlEventHandler:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent +{ + // extract url value from the event + NSString *url = [[event paramDescriptorForKeyword:keyDirectObject] stringValue]; + + // If the application was launched via URL, this handler will be called before + // the module is initialized by the bridge. Store the initial URL, because we are not listening to the notification yet. + if (!moduleInitalized && initialURL == nil) { + initialURL = url; + } + + postNotificationWithURL(url, self); +} + +- (void)handleOpenURLNotification:(NSNotification *)notification +{ + // Activate app, because [NSApp mainWindow] returns nil when the app is hidden and another app is maximized + [NSApp activateIgnoringOtherApps:YES]; + // foreground top level window + if (alwaysForegroundLastWindow) { + NSWindow *lastWindow = [[NSApp windows] lastObject]; + [lastWindow makeKeyAndOrderFront:nil]; + } + [self sendEventWithName:@"url" body:notification.userInfo]; +} + +#endif // macOS] + +#if !TARGET_OS_OSX // [macOS] + RCT_EXPORT_METHOD(openURL : (NSURL *)URL resolve : (RCTPromiseResolveBlock)resolve reject @@ -184,6 +265,44 @@ - (void)handleOpenURLNotification:(NSNotification *)notification }]; } +#else // [macOS + +RCT_EXPORT_METHOD(openURL:(NSURL *)URL + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + BOOL result = [[NSWorkspace sharedWorkspace] openURL:URL]; + if (result) { + resolve(@YES); + } else { + reject(RCTErrorUnspecified, [NSString stringWithFormat:@"Unable to open URL: %@", URL], nil); + } +} + +RCT_EXPORT_METHOD(canOpenURL:(NSURL *)URL + resolve:(RCTPromiseResolveBlock)resolve + reject:(__unused RCTPromiseRejectBlock)reject) +{ + resolve(@YES); +} + +RCT_EXPORT_METHOD(getInitialURL:(RCTPromiseResolveBlock)resolve + reject:(__unused RCTPromiseRejectBlock)reject) +{ + resolve(RCTNullIfNil(initialURL)); +} + +RCT_EXPORT_METHOD(openSettings:(RCTPromiseResolveBlock)resolve + reject:(__unused RCTPromiseRejectBlock)reject) +{ + // macOS doesn't have a direct equivalent of UIApplicationOpenSettingsURLString + // Open System Preferences instead + [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@"x-apple.systempreferences:"]]; + resolve(nil); +} + +#endif // macOS] + RCT_EXPORT_METHOD(sendIntent : (NSString *)action extras : (NSArray *_Nullable)extras resolve diff --git a/packages/react-native/Libraries/LinkingIOS/React-RCTLinking.podspec b/packages/react-native/Libraries/LinkingIOS/React-RCTLinking.podspec index a359bb874781..325c2f0df621 100644 --- a/packages/react-native/Libraries/LinkingIOS/React-RCTLinking.podspec +++ b/packages/react-native/Libraries/LinkingIOS/React-RCTLinking.podspec @@ -32,10 +32,6 @@ Pod::Spec.new do |s| s.compiler_flags = '-Wno-nullability-completeness' s.source = source s.source_files = podspec_sources("*.{m,mm}", "") -# [macOS - s.osx.exclude_files = "RCTLinkingManager.mm" - s.osx.source_files = "macos/RCTLinkingManager.mm" -# macOS] s.preserve_paths = "package.json", "LICENSE", "LICENSE-docs" s.header_dir = "RCTLinking" s.pod_target_xcconfig = { diff --git a/packages/react-native/Libraries/LinkingIOS/macos/RCTLinkingManager.mm b/packages/react-native/Libraries/LinkingIOS/macos/RCTLinkingManager.mm deleted file mode 100644 index 6b0108d8da3f..000000000000 --- a/packages/react-native/Libraries/LinkingIOS/macos/RCTLinkingManager.mm +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright (c) 2015-present, Facebook, Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -// [macOS] - -#import "RCTLinkingManager.h" - -#import - -#import -#import -#import - -#import "RCTLinkingPlugins.h" - -NSString *const RCTOpenURLNotification = @"RCTOpenURLNotification"; - -static NSString *initialURL = nil; -static BOOL moduleInitalized = NO; -static BOOL alwaysForegroundLastWindow = YES; - -static void postNotificationWithURL(NSString *url, id sender) -{ - NSDictionary *payload = @{@"url": url}; - [[NSNotificationCenter defaultCenter] postNotificationName:RCTOpenURLNotification - object:sender - userInfo:payload]; -} - -@implementation RCTLinkingManager - -RCT_EXPORT_MODULE() - -- (dispatch_queue_t)methodQueue -{ - return dispatch_get_main_queue(); -} - -- (void)startObserving -{ - moduleInitalized = YES; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleOpenURLNotification:) - name:RCTOpenURLNotification - object:nil]; -} - -- (void)stopObserving -{ - moduleInitalized = NO; - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (NSArray *)supportedEvents -{ - return @[@"url"]; -} - -+ (void)setAlwaysForegroundLastWindow:(BOOL)alwaysForeground -{ - alwaysForegroundLastWindow = alwaysForeground; -} - -+ (void)getUrlEventHandler:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent -{ - // extract url value from the event - NSString* url = [[event paramDescriptorForKeyword:keyDirectObject] stringValue]; - - // If the application was launched via URL, this handler will be called before - // the module is initialized by the bridge. Store the initial URL, becase we are not listening to the notification yet. - if (!moduleInitalized && initialURL == nil) { - initialURL = url; - } - - postNotificationWithURL(url, self); -} - -- (void)handleOpenURLNotification:(NSNotification *)notification -{ - // Activate app, because [NSApp mainWindow] returns nil when the app is hidden and another app is maximized - [NSApp activateIgnoringOtherApps:YES]; - // foreground top level window - if (alwaysForegroundLastWindow) { - NSWindow *lastWindow = [[NSApp windows] lastObject]; - [lastWindow makeKeyAndOrderFront:nil]; - } - [self sendEventWithName:@"url" body:notification.userInfo]; -} - -RCT_EXPORT_METHOD(openURL:(NSURL *)URL - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) -{ - BOOL result = [[NSWorkspace sharedWorkspace] openURL:URL]; - if (result) { - resolve(@YES); - } else { - reject(RCTErrorUnspecified, [NSString stringWithFormat:@"Unable to open URL: %@", URL], nil); - } -} - -RCT_EXPORT_METHOD(canOpenURL:(NSURL *)URL - resolve:(RCTPromiseResolveBlock)resolve - reject:(__unused RCTPromiseRejectBlock)reject) -{ - resolve(@YES); -} - -RCT_EXPORT_METHOD(getInitialURL:(RCTPromiseResolveBlock)resolve - reject:(__unused RCTPromiseRejectBlock)reject) -{ - resolve(RCTNullIfNil(initialURL)); -} -@end - -Class RCTLinkingManagerCls(void) { - return RCTLinkingManager.class; -} diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index c387085ea236..caa39547fc3d 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -246,7 +246,7 @@ let reactJsErrorHandler = RNTarget( let reactGraphicsApple = RNTarget( name: .reactGraphicsApple, path: "ReactCommon/react/renderer/graphics/platform/ios", - linkedFrameworks: ["UIKit", "CoreGraphics"], + linkedFrameworks: ["CoreGraphics"], dependencies: [.reactDebug, .jsi, .reactUtils, .reactNativeDependencies] ) @@ -360,8 +360,8 @@ 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"] ) @@ -376,7 +376,6 @@ let reactFabric = RNTarget( "components/view/tests", "components/view/platform/android", "components/view/platform/windows", - "components/view/platform/macos", "components/scrollview/tests", "components/scrollview/platform/android", "mounting/tests", @@ -420,16 +419,13 @@ let reactFabricComponents = RNTarget( "components/modal/platform/cxx", "components/view/platform/android", "components/view/platform/windows", - "components/view/platform/macos", "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 +520,16 @@ 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", + excludedPaths: ["README.md"] +) +// macOS] + // MARK: Target list let targets = [ reactDebug, @@ -581,13 +587,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), .macCatalyst(SupportedPlatform.MacCatalystVersion.v13)], products: [ .library( name: react, @@ -753,6 +760,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 { @@ -792,6 +800,14 @@ extension Target { .define("USE_HERMES", to: "1"), ] + defines + cxxCommonHeaderPaths + // [macOS] Platform-specific framework linking for targets that need UIKit (iOS/visionOS) vs AppKit (macOS) + var conditionalLinkerSettings: [LinkerSetting] = linkerSettings + if name == "React-graphics-Apple" || name == "React-RCTUIKit" { + conditionalLinkerSettings.append(.linkedFramework("UIKit", .when(platforms: [.iOS, .visionOS]))) + conditionalLinkerSettings.append(.linkedFramework("AppKit", .when(platforms: [.macOS]))) + } + // macOS] + return .target( name: name, dependencies: dependencies, @@ -800,7 +816,7 @@ extension Target { sources: sources, publicHeadersPath: publicHeadersPath, cxxSettings: cxxSettings, - linkerSettings: linkerSettings + linkerSettings: conditionalLinkerSettings ) } } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 78b616a03a7a..e858d506c15b 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -341,11 +341,6 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & needsInvalidateLayer = YES; } - // `cursor` - if (oldViewProps.cursor != newViewProps.cursor) { - needsInvalidateLayer = YES; - } - // `shouldRasterize` if (oldViewProps.shouldRasterize != newViewProps.shouldRasterize) { self.layer.shouldRasterize = newViewProps.shouldRasterize; diff --git a/packages/react-native/React/Views/RCTCursor.m b/packages/react-native/React/Views/RCTCursor.m index 05575465fbef..9a959cdcb15d 100644 --- a/packages/react-native/React/Views/RCTCursor.m +++ b/packages/react-native/React/Views/RCTCursor.m @@ -7,7 +7,7 @@ // [macOS] -#import +#import #import "RCTCursor.h" #if defined(__MAC_OS_X_VERSION_MAX_ALLOWED) && __MAC_OS_X_VERSION_MAX_ALLOWED >= 150000 /* __MAC_15_0 */ 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..16ba8ae8f0c0 100644 --- a/packages/react-native/scripts/ios-prebuild/hermes.js +++ b/packages/react-native/scripts/ios-prebuild/hermes.js @@ -11,6 +11,7 @@ const {computeNightlyTarballURL, createLogger} = require('./utils'); const {execSync} = require('child_process'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const stream = require('stream'); const {promisify} = require('util'); @@ -22,6 +23,124 @@ const hermesLog = createLogger('Hermes'); import type {BuildFlavor, Destination, Platform} from './types'; */ +// [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') { + hermesLog( + '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']; + hermesLog( + `Mapped macOS version ${pkg.version} to upstream RN version: ${upstreamVersion}`, + ); + return upstreamVersion; + } + + hermesLog( + '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 + hermesLog('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 + hermesLog( + `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'.`, + ); + } + + hermesLog( + `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}); + } +} +// macOS] + /** * Downloads hermes artifacts from the specified version and build type. If you want to specify a specific * version of hermes, use the HERMES_VERSION environment variable. The path to the artifacts will be inside @@ -56,6 +175,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 +216,11 @@ async function prepareHermesArtifactsAsync( return artifactsPath; } - const sourceType = await hermesSourceType(resolvedVersion, buildType); + const sourceType = await hermesSourceType( + resolvedVersion, + buildType, + allowBuildFromSource, + ); localPath = await resolveSourceFromSourceType( sourceType, resolvedVersion, @@ -99,6 +245,13 @@ async function prepareHermesArtifactsAsync( fs.unlinkSync(localPath); } + // [macOS] The Maven tarball contains a standalone macOS framework at + // destroot/Library/Frameworks/macosx/ but does NOT include it in the + // xcframework at destroot/Library/Frameworks/universal/. SPM's BinaryTarget + // requires an xcframework, so we rebuild it to include the macOS slice. + ensureMacOSSliceInXCFramework(artifactsPath); + // macOS] + return artifactsPath; } @@ -124,12 +277,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 +376,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 +405,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 +434,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 +542,219 @@ async function downloadHermesTarball( return destPath; } +// [macOS +/** + * The Hermes tarball from Maven contains a standalone macOS framework at + * destroot/Library/Frameworks/macosx/hermes.framework (or hermesvm.framework) + * but the xcframework at destroot/Library/Frameworks/universal/ does not include + * a macOS slice. This function rebuilds the xcframework to include it. + * + * This is needed because SPM's BinaryTarget requires an xcframework, and + * xcodebuild needs the macOS slice present to build for macOS. + */ +function ensureMacOSSliceInXCFramework(artifactsPath /*: string */) { + const frameworksDir = path.join( + artifactsPath, + 'destroot', + 'Library', + 'Frameworks', + ); + const macosDir = path.join(frameworksDir, 'macosx'); + const universalDir = path.join(frameworksDir, 'universal'); + + if (!fs.existsSync(macosDir)) { + hermesLog( + 'No macOS framework found in tarball, skipping xcframework rebuild', + ); + return; + } + + // Find the framework name (hermes.framework or hermesvm.framework) + const macosFrameworks = fs + .readdirSync(macosDir) + .filter(f => f.endsWith('.framework')); + if (macosFrameworks.length === 0) { + hermesLog('No .framework found in macosx directory, skipping'); + return; + } + const frameworkName = macosFrameworks[0]; + + // Find the existing xcframework + const xcframeworks = fs.existsSync(universalDir) + ? fs.readdirSync(universalDir).filter(f => f.endsWith('.xcframework')) + : []; + + if (xcframeworks.length === 0) { + hermesLog( + 'No existing xcframework found, creating one from macOS framework only', + ); + fs.mkdirSync(universalDir, {recursive: true}); + const xcframeworkName = frameworkName.replace('.framework', '.xcframework'); + execSync( + `xcodebuild -create-xcframework ` + + `-framework "${path.join(macosDir, frameworkName)}" ` + + `-output "${path.join(universalDir, xcframeworkName)}"`, + {stdio: 'inherit'}, + ); + hermesLog(`Created ${xcframeworkName} with macOS slice`); + return; + } + + const xcframeworkName = xcframeworks[0]; + const xcframeworkPath = path.join(universalDir, xcframeworkName); + + // Check if macOS slice already exists in the xcframework + const existingSlices = fs + .readdirSync(xcframeworkPath) + .filter(d => d.startsWith('macos-')); + if (existingSlices.length > 0) { + hermesLog('macOS slice already present in xcframework, skipping rebuild'); + return; + } + + hermesLog(`Rebuilding ${xcframeworkName} to include macOS slice...`); + + // Collect all existing framework slices from the xcframework + const sliceDirs = fs.readdirSync(xcframeworkPath).filter(d => { + const slicePath = path.join(xcframeworkPath, d); + return fs.statSync(slicePath).isDirectory() && d !== '_CodeSignature'; + }); + + // Build the -framework arguments for xcodebuild -create-xcframework + const frameworkArgs = sliceDirs + .map(sliceDir => { + const slicePath = path.join(xcframeworkPath, sliceDir); + const frameworks = fs + .readdirSync(slicePath) + .filter(f => f.endsWith('.framework')); + if (frameworks.length > 0) { + return `-framework "${path.join(slicePath, frameworks[0])}"`; + } + return null; + }) + .filter(Boolean); + + // Add the macOS framework + frameworkArgs.push(`-framework "${path.join(macosDir, frameworkName)}"`); + + // Rebuild the xcframework + const tmpXCFramework = path.join(universalDir, `${xcframeworkName}.tmp`); + try { + execSync( + `xcodebuild -create-xcframework ${frameworkArgs.join(' ')} -output "${tmpXCFramework}"`, + {stdio: 'inherit'}, + ); + + // Replace the original xcframework + fs.rmSync(xcframeworkPath, {recursive: true, force: true}); + fs.renameSync(tmpXCFramework, xcframeworkPath); + + hermesLog(`Rebuilt ${xcframeworkName} with macOS slice added`); + } catch (e) { + // Clean up on failure + if (fs.existsSync(tmpXCFramework)) { + fs.rmSync(tmpXCFramework, {recursive: true, force: true}); + } + hermesLog( + `Warning: Failed to rebuild xcframework with macOS slice: ${e.message}. ` + + `The macOS framework is available at ${path.join(macosDir, frameworkName)} ` + + `but could not be added to the xcframework.`, + ); + } +} + +/** + * 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 + hermesLog(`Cloning Hermes at commit ${commit}...`); + execSync(`git clone --depth 1 ${HERMES_GITHUB_URL} "${hermesDir}"`, { + stdio: 'inherit', + timeout: 300000, + }); + execSync(`git -C "${hermesDir}" fetch --depth 1 origin ${commit}`, { + stdio: 'inherit', + timeout: 120000, + }); + execSync(`git -C "${hermesDir}" checkout ${commit}`, { + stdio: 'inherit', + }); + + // The build-ios-framework.sh script runs from the hermes directory. + // It sources build-apple-framework.sh which sets HERMES_PATH relative to itself, + // but we override it to point to the cloned Hermes repo. + const reactNativeRoot = path.resolve(__dirname, '..', '..'); + const buildScript = path.join( + reactNativeRoot, + 'sdks', + 'hermes-engine', + 'utils', + 'build-ios-framework.sh', + ); + + hermesLog(`Building Hermes frameworks (${buildType})...`); + execSync(`bash "${buildScript}"`, { + cwd: hermesDir, + stdio: 'inherit', + timeout: 3600000, // 60 minutes + env: { + ...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, + }, + }); + + // 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) { + 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 +762,6 @@ function abort(message /*: string */) { module.exports = { prepareHermesArtifactsAsync, + findMatchingHermesVersion, // [macOS] + hermesCommitAtMergeBase, // [macOS] }; 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..1e92697d2a72 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 @@ -53,7 +53,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