diff --git a/packages/react-native/scripts/cocoapods/__tests__/test_utils/XcodebuildMock.rb b/packages/react-native/scripts/cocoapods/__tests__/test_utils/XcodebuildMock.rb new file mode 100644 index 00000000000000..4cbef2e811dac5 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/__tests__/test_utils/XcodebuildMock.rb @@ -0,0 +1,26 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +class XcodebuildMock < Xcodebuild + @@version = "" + @@version_invocation_count = 0 + + def self.set_version=(v) + @@version = v + end + + def self.version + @@version_invocation_count += 1 + @@version + end + + def self.version_invocation_count + @@version_invocation_count + end + + def self.reset() + @@version_invocation_count = 0 + end +end diff --git a/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb b/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb index 1c0959ec248f01..157a6e4f347371 100644 --- a/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb +++ b/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb @@ -15,6 +15,7 @@ require_relative "./test_utils/PathnameMock.rb" require_relative "./test_utils/TargetDefinitionMock.rb" require_relative "./test_utils/XcodeprojMock.rb" +require_relative "./test_utils/XcodebuildMock.rb" class UtilsTests < Test::Unit::TestCase def setup @@ -30,6 +31,7 @@ def teardown SysctlChecker.reset() Environment.reset() Xcodeproj::Plist.reset() + XcodebuildMock.reset() ENV['RCT_NEW_ARCH_ENABLED'] = '0' ENV['USE_HERMES'] = '1' ENV['USE_FRAMEWORKS'] = nil @@ -526,9 +528,56 @@ def test_applyMacCatalystPatches_correctlyAppliesNecessaryPatches # ================================= # # Test - Apply Xcode 15 Patch # # ================================= # + def test_applyXcode15Patch_whenXcodebuild14_correctlyAppliesNecessaryPatch + # Arrange + XcodebuildMock.set_version = "Xcode 14.3" + first_target = prepare_target("FirstTarget") + second_target = prepare_target("SecondTarget") + third_target = TargetMock.new("ThirdTarget", [ + BuildConfigurationMock.new("Debug", { + "GCC_PREPROCESSOR_DEFINITIONS" => '$(inherited) "SomeFlag=1" ' + }), + BuildConfigurationMock.new("Release", { + "GCC_PREPROCESSOR_DEFINITIONS" => '$(inherited) "SomeFlag=1" ' + }), + ], nil) - def test_applyXcode15Patch_correctlyAppliesNecessaryPatch + user_project_mock = UserProjectMock.new("/a/path", [ + prepare_config("Debug"), + prepare_config("Release"), + ], + :native_targets => [ + first_target, + second_target + ] + ) + pods_projects_mock = PodsProjectMock.new([], {"hermes-engine" => {}}, :native_targets => [ + third_target + ]) + installer = InstallerMock.new(pods_projects_mock, [ + AggregatedProjectMock.new(user_project_mock) + ]) + + # Act + user_project_mock.build_configurations.each do |config| + assert_nil(config.build_settings["OTHER_LDFLAGS"]) + end + + ReactNativePodsUtils.apply_xcode_15_patch(installer, :xcodebuild_manager => XcodebuildMock) + + # Assert + user_project_mock.build_configurations.each do |config| + assert_equal("$(inherited) _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION", config.build_settings["GCC_PREPROCESSOR_DEFINITIONS"]) + assert_equal("$(inherited) ", config.build_settings["OTHER_LDFLAGS"]) + end + + # User project and Pods project + assert_equal(2, XcodebuildMock.version_invocation_count) + end + + def test_applyXcode15Patch_whenXcodebuild15_correctlyAppliesNecessaryPatch # Arrange + XcodebuildMock.set_version = "Xcode 15.0" first_target = prepare_target("FirstTarget") second_target = prepare_target("SecondTarget") third_target = TargetMock.new("ThirdTarget", [ @@ -557,24 +606,70 @@ def test_applyXcode15Patch_correctlyAppliesNecessaryPatch ]) # Act - ReactNativePodsUtils.apply_xcode_15_patch(installer) + user_project_mock.build_configurations.each do |config| + assert_nil(config.build_settings["OTHER_LDFLAGS"]) + end + + ReactNativePodsUtils.apply_xcode_15_patch(installer, :xcodebuild_manager => XcodebuildMock) # Assert - first_target.build_configurations.each do |config| - assert_equal(config.build_settings["GCC_PREPROCESSOR_DEFINITIONS"].strip, - '$(inherited) "_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION"' - ) + user_project_mock.build_configurations.each do |config| + assert_equal("$(inherited) _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION", config.build_settings["GCC_PREPROCESSOR_DEFINITIONS"]) + assert_equal("$(inherited) -Wl -ld_classic ", config.build_settings["OTHER_LDFLAGS"]) end - second_target.build_configurations.each do |config| - assert_equal(config.build_settings["GCC_PREPROCESSOR_DEFINITIONS"].strip, - '$(inherited) "_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION"' - ) + + # User project and Pods project + assert_equal(2, XcodebuildMock.version_invocation_count) + end + + def test_applyXcode15Patch_whenXcodebuild14ButProjectHasSettings_correctlyRemovesNecessaryPatch + # Arrange + XcodebuildMock.set_version = "Xcode 14.3" + first_target = prepare_target("FirstTarget") + second_target = prepare_target("SecondTarget") + third_target = TargetMock.new("ThirdTarget", [ + BuildConfigurationMock.new("Debug", { + "GCC_PREPROCESSOR_DEFINITIONS" => '$(inherited) "SomeFlag=1" ' + }), + BuildConfigurationMock.new("Release", { + "GCC_PREPROCESSOR_DEFINITIONS" => '$(inherited) "SomeFlag=1" ' + }), + ], nil) + + debug_config = prepare_config("Debug", {"OTHER_LDFLAGS" => "$(inherited) -Wl -ld_classic "}) + release_config = prepare_config("Release", {"OTHER_LDFLAGS" => "$(inherited) -Wl -ld_classic "}) + + user_project_mock = UserProjectMock.new("/a/path", [ + debug_config, + release_config, + ], + :native_targets => [ + first_target, + second_target + ] + ) + pods_projects_mock = PodsProjectMock.new([debug_config.clone, release_config.clone], {"hermes-engine" => {}}, :native_targets => [ + third_target + ]) + installer = InstallerMock.new(pods_projects_mock, [ + AggregatedProjectMock.new(user_project_mock) + ]) + + # Act + user_project_mock.build_configurations.each do |config| + assert_equal("$(inherited) -Wl -ld_classic ", config.build_settings["OTHER_LDFLAGS"]) end - third_target.build_configurations.each do |config| - assert_equal(config.build_settings["GCC_PREPROCESSOR_DEFINITIONS"].strip, - '$(inherited) "SomeFlag=1" "_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION"' - ) + + ReactNativePodsUtils.apply_xcode_15_patch(installer, :xcodebuild_manager => XcodebuildMock) + + # Assert + user_project_mock.build_configurations.each do |config| + assert_equal("$(inherited) _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION", config.build_settings["GCC_PREPROCESSOR_DEFINITIONS"]) + assert_equal("$(inherited) ", config.build_settings["OTHER_LDFLAGS"]) end + + # User project and Pods project + assert_equal(2, XcodebuildMock.version_invocation_count) end # ==================================== # @@ -923,12 +1018,14 @@ def prepare_user_project_mock_with_plists ]) end -def prepare_config(config_name) - return BuildConfigurationMock.new(config_name, {"LIBRARY_SEARCH_PATHS" => [ +def prepare_config(config_name, extra_config = {}) + config = {"LIBRARY_SEARCH_PATHS" => [ "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", "\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"", "another/path", - ]}) + ]}.merge(extra_config) + + return BuildConfigurationMock.new(config_name, config) end def prepare_target(name, product_type = nil, dependencies = []) diff --git a/packages/react-native/scripts/cocoapods/helpers.rb b/packages/react-native/scripts/cocoapods/helpers.rb index e4bfafa52af04f..4a46189002286b 100644 --- a/packages/react-native/scripts/cocoapods/helpers.rb +++ b/packages/react-native/scripts/cocoapods/helpers.rb @@ -11,6 +11,14 @@ def call_sysctl_arm64 end end +# Helper class that is used to easily send commands to Xcodebuild +# And that can be subclassed for testing purposes. +class Xcodebuild + def self.version + `xcodebuild -version` + end +end + # Helper object to wrap system properties like RUBY_PLATFORM # This makes it easier to mock the behaviour in tests class Environment diff --git a/packages/react-native/scripts/cocoapods/utils.rb b/packages/react-native/scripts/cocoapods/utils.rb index d88d2277e20e5c..0428453c006fe2 100644 --- a/packages/react-native/scripts/cocoapods/utils.rb +++ b/packages/react-native/scripts/cocoapods/utils.rb @@ -152,16 +152,31 @@ def self.apply_mac_catalyst_patches(installer) end end - def self.apply_xcode_15_patch(installer) - installer.target_installation_results.pod_target_installation_results - .each do |pod_name, target_installation_result| - target_installation_result.native_target.build_configurations.each do |config| - # unary_function and binary_function are no longer provided in c++20 and newer standard modes as part of Xcode 15. They can be re-enabled with setting _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION - # Ref: https://developer.apple.com/documentation/xcode-release-notes/xcode-15-release-notes#Deprecations - config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= '$(inherited) ' - config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << '"_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION" ' + def self.apply_xcode_15_patch(installer, xcodebuild_manager: Xcodebuild) + projects = self.extract_projects(installer) + + gcc_preprocessor_definition_key = 'GCC_PREPROCESSOR_DEFINITIONS' + other_ld_flags_key = 'OTHER_LDFLAGS' + libcpp_cxx17_fix = '_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION' + xcode15_compatibility_flags = '-Wl -ld_classic ' + + projects.each do |project| + project.build_configurations.each do |config| + # fix for unary_function and binary_function + self.safe_init(config, gcc_preprocessor_definition_key) + self.add_value_to_setting_if_missing(config, gcc_preprocessor_definition_key, libcpp_cxx17_fix) + + # fix for weak linking + self.safe_init(config, other_ld_flags_key) + if self.is_using_xcode15_or_greter(:xcodebuild_manager => xcodebuild_manager) + self.add_value_to_setting_if_missing(config, other_ld_flags_key, xcode15_compatibility_flags) + else + self.remove_value_to_setting_if_present(config, other_ld_flags_key, xcode15_compatibility_flags) + end end + project.save() end + end def self.apply_flags_for_fabric(installer, fabric_enabled: false) @@ -323,6 +338,49 @@ def self.extract_projects(installer) .push(installer.pods_project) end + def self.safe_init(config, setting_name) + old_config = config.build_settings[setting_name] + if old_config == nil + config.build_settings[setting_name] ||= '$(inherited) ' + end + end + + def self.add_value_to_setting_if_missing(config, setting_name, value) + old_config = config.build_settings[setting_name] + if !old_config.include?(value) + config.build_settings[setting_name] << value + end + end + + def self.remove_value_to_setting_if_present(config, setting_name, value) + old_config = config.build_settings[setting_name] + if old_config.include?(value) + # Old config can be either an Array or a String + if old_config.is_a?(Array) + old_config = old_config.join(" ") + end + new_config = old_config.gsub(value, "") + config.build_settings[setting_name] = new_config + end + end + + def self.is_using_xcode15_or_greter(xcodebuild_manager: Xcodebuild) + xcodebuild_version = xcodebuild_manager.version + + # The output of xcodebuild -version is something like + # Xcode 15.0 + # or + # Xcode 14.3.1 + # We want to capture the version digits + regex = /(\d+)\.(\d+)(?:\.(\d+))?/ + if match_data = xcodebuild_version.match(regex) + major = match_data[1].to_i + return major >= 15 + end + + return false + end + def self.add_compiler_flag_to_project(installer, flag, configuration: nil) projects = self.extract_projects(installer)