From 61f584c3afb79ba705be8b6372ff9b6c5599680e Mon Sep 17 00:00:00 2001 From: aleqsio Date: Tue, 30 Apr 2024 18:19:28 -0700 Subject: [PATCH] Implement privacy manifest aggregation (#44214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: As of now, Apple does not respect privacy manifests added as cocoapods resource bundles. This forces react-native developers to manually copy `.xcprivacy` files content for each native dependency that accesses restricted reason APIs to the root file. This PR adds an aggregation step that crawls through pod dependencies to collect all reasons into the root privacy info file. ## Changelog: [IOS][ADDED] – Add privacy manifest aggregation. Pull Request resolved: https://github.com/facebook/react-native/pull/44214 Test Plan: When run on RNTester, it appends aggregated entries (while keeping existing ones) to existing .xcprivacy file without modifing .pbxproj: ![image](https://github.com/facebook/react-native/assets/5597580/1d07a07d-bbec-4266-a599-a8d629078971) When run on RNTester with the xcprivacy file removed from xcode beforehand, it creates a new .xcprivacy file, and adds it to Compile Bundle Resources in the same way as in the new template: ![image](https://github.com/facebook/react-native/assets/5597580/f80a3b4e-e41a-4906-8e2f-06cca0bc225a) When run on RNTester with an empty .xcprivacy file, it appends aggregated entries from pods AND reasons for react-native core. When run with `privacy_file_aggregation_enabled: false` in `use_react_native`, it falls back to existing behavior: ![image](https://github.com/facebook/react-native/assets/5597580/4519bba1-c80e-4cd0-b19c-bbbebfa8493b) Reviewed By: cipolleschi Differential Revision: D56481045 Pulled By: philIip fbshipit-source-id: 1841bad821511c734d0cc0fcff5065ed92af76d8 --- .../cocoapods/privacy_manifest_utils.rb | 164 ++++++++++++++++++ .../react-native/scripts/cocoapods/utils.rb | 38 ---- .../react-native/scripts/react_native_pods.rb | 13 +- 3 files changed, 175 insertions(+), 40 deletions(-) create mode 100644 packages/react-native/scripts/cocoapods/privacy_manifest_utils.rb diff --git a/packages/react-native/scripts/cocoapods/privacy_manifest_utils.rb b/packages/react-native/scripts/cocoapods/privacy_manifest_utils.rb new file mode 100644 index 00000000000000..322da24f0e9a87 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/privacy_manifest_utils.rb @@ -0,0 +1,164 @@ +# 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. + +module PrivacyManifestUtils + def self.add_aggregated_privacy_manifest(installer) + user_project = get_user_project_from(installer) + targets = get_application_targets(user_project) + file_path = get_privacyinfo_file_path(user_project) + + privacy_info = read_privacyinfo_file(file_path) || { + "NSPrivacyCollectedDataTypes" => [], + "NSPrivacyTracking" => false + } + + # Get all required reason APIs defined in current pods + required_reason_apis = get_used_required_reason_apis(installer) + + # Add the Required Reason APIs from React Native core + get_core_accessed_apis.each do |accessed_api| + api_type = accessed_api["NSPrivacyAccessedAPIType"] + reasons = accessed_api["NSPrivacyAccessedAPITypeReasons"] + required_reason_apis[api_type] ||= [] + required_reason_apis[api_type] += reasons + end + + # Merge the Required Reason APIs from pods with the ones from the existing PrivacyInfo file + (privacy_info["NSPrivacyAccessedAPITypes"] || []).each do |accessed_api| + api_type = accessed_api["NSPrivacyAccessedAPIType"] + reasons = accessed_api["NSPrivacyAccessedAPITypeReasons"] + # Add reasons from existing PrivacyInfo file to the ones from pods + required_reason_apis[api_type] ||= [] + required_reason_apis[api_type] += reasons + end + + # Update the existing PrivacyInfo file with the new aggregated data + privacy_info["NSPrivacyAccessedAPITypes"] = required_reason_apis.map { |api_type, reasons| + { + "NSPrivacyAccessedAPIType" => api_type, + "NSPrivacyAccessedAPITypeReasons" => reasons.uniq + } + } + + Xcodeproj::Plist.write_to_path(privacy_info, file_path) + + targets.each do |target| + ensure_reference(file_path, user_project, target) + end + end + + def self.get_application_targets(user_project) + return user_project.targets.filter { |t| t.symbol_type == :application } + end + + def self.read_privacyinfo_file(file_path) + # Maybe add missing default NSPrivacyTracking, NSPrivacyTrackingDomains, NSPrivacyCollectedDataTypes, but this works without those keys + source_data = nil + # Try to read an existing PrivacyInfo.xcprivacy file + begin + source_data = Xcodeproj::Plist.read_from_path(file_path) + Pod::UI.puts "[Privacy Manifest Aggregation] Appending aggregated reasons to existing PrivacyInfo.xcprivacy file." + rescue => e + Pod::UI.puts "[Privacy Manifest Aggregation] No existing PrivacyInfo.xcprivacy file found, creating a new one." + end + return source_data + end + + def self.ensure_reference(file_path, user_project, target) + reference_exists = target.resources_build_phase.files_references.any? { |file_ref| file_ref.path.end_with? "PrivacyInfo.xcprivacy" } + unless reference_exists + # We try to find the main group, but if it doesn't exist, we default to adding the file to the project root – both work + file_root = user_project.root_object.main_group.children.first { |group| group.name == target.name } || user_project + file_ref = file_root.new_file(file_path) + build_file = target.resources_build_phase.add_file_reference(file_ref, true) + end + end + + def self.get_privacyinfo_file_path(user_project) + # We try to find a file we know exists in the project to get the path to the main group directory + info_plist_path = user_project.files.find { |file_ref| file_ref.name == "Info.plist" } + if info_plist_path.nil? + # return path that is sibling to .xcodeproj + path = user_project.path + return File.join(File.dirname(path), "PrivacyInfo.xcprivacy") + end + return File.join(File.dirname(info_plist_path.real_path),"PrivacyInfo.xcprivacy") + end + + def self.get_used_required_reason_apis(installer) + # A dictionary with keys of type string (NSPrivacyAccessedAPIType) and values of type string[] (NSPrivacyAccessedAPITypeReasons[]) + used_apis = {} + Pod::UI.puts "[Privacy Manifest Aggregation] Reading .xcprivacy files to aggregate all used Required Reason APIs." + installer.pod_targets.each do |pod_target| + # puts pod_target + pod_target.file_accessors.each do |file_accessor| + file_accessor.resource_bundles.each do |bundle_name, bundle_files| + bundle_files.each do |file_path| + # This needs to be named like that due to apple requirements + if File.basename(file_path) == 'PrivacyInfo.xcprivacy' + content = Xcodeproj::Plist.read_from_path(file_path) + accessed_api_types = content["NSPrivacyAccessedAPITypes"] + accessed_api_types.each do |accessed_api| + api_type = accessed_api["NSPrivacyAccessedAPIType"] + reasons = accessed_api["NSPrivacyAccessedAPITypeReasons"] + used_apis[api_type] ||= [] + used_apis[api_type] += reasons + end + end + end + end + end + end + return used_apis + end + + def self.get_privacy_manifest_paths_from(user_project) + privacy_manifests = user_project + .files + .select { |p| + p.path&.end_with?('PrivacyInfo.xcprivacy') + } + return privacy_manifests + end + + def self.get_core_accessed_apis() + file_timestamp_accessed_api = { + "NSPrivacyAccessedAPIType" => "NSPrivacyAccessedAPICategoryFileTimestamp", + "NSPrivacyAccessedAPITypeReasons" => ["C617.1"], + } + user_defaults_accessed_api = { + "NSPrivacyAccessedAPIType" => "NSPrivacyAccessedAPICategoryUserDefaults", + "NSPrivacyAccessedAPITypeReasons" => ["CA92.1"], + } + boot_time_accessed_api = { + "NSPrivacyAccessedAPIType" => "NSPrivacyAccessedAPICategorySystemBootTime", + "NSPrivacyAccessedAPITypeReasons" => ["35F9.1"], + } + return [file_timestamp_accessed_api, user_defaults_accessed_api, boot_time_accessed_api] + end + + + def self.get_user_project_from(installer) + user_project = installer.aggregate_targets + .map{ |t| t.user_project } + .first + return user_project + end + + def self.add_privacy_manifest_if_needed(installer) + user_project = get_user_project_from(installer) + privacy_manifest = self.get_privacy_manifest_paths_from(user_project).first + if privacy_manifest.nil? + privacy_manifest = { + "NSPrivacyCollectedDataTypes" => [], + "NSPrivacyTracking" => false, + "NSPrivacyAccessedAPITypes" => get_core_accessed_apis + } + path = File.join(user_project.path.parent, "PrivacyInfo.xcprivacy") + Xcodeproj::Plist.write_to_path(privacy_manifest, path) + Pod::UI.puts "Your app does not have a privacy manifest! A template has been generated containing Required Reasons API usage in the core React Native library. Please add the PrivacyInfo.xcprivacy file to your project and complete data use, tracking and any additional required reasons your app is using according to Apple's guidance: https://developer.apple.com/documentation/bundleresources/privacy_manifest_files. Then, you will need to manually add this file to your project in Xcode.".red + end + end +end diff --git a/packages/react-native/scripts/cocoapods/utils.rb b/packages/react-native/scripts/cocoapods/utils.rb index 1b16090b2615db..b5195aadc7cbbb 100644 --- a/packages/react-native/scripts/cocoapods/utils.rb +++ b/packages/react-native/scripts/cocoapods/utils.rb @@ -592,44 +592,6 @@ def self.set_imagemanager_search_path(target_installation_result) ReactNativePodsUtils.update_header_paths_if_depends_on(target_installation_result, "React-ImageManager", header_search_paths) end - def self.get_privacy_manifest_paths_from(user_project) - privacy_manifests = user_project - .files - .select { |p| - p.path&.end_with?('PrivacyInfo.xcprivacy') - } - return privacy_manifests - end - - def self.add_privacy_manifest_if_needed(installer) - user_project = installer.aggregate_targets - .map{ |t| t.user_project } - .first - privacy_manifest = self.get_privacy_manifest_paths_from(user_project).first - if privacy_manifest.nil? - file_timestamp_reason = { - "NSPrivacyAccessedAPIType" => "NSPrivacyAccessedAPICategoryFileTimestamp", - "NSPrivacyAccessedAPITypeReasons" => ["C617.1"], - } - user_defaults_reason = { - "NSPrivacyAccessedAPIType" => "NSPrivacyAccessedAPICategoryUserDefaults", - "NSPrivacyAccessedAPITypeReasons" => ["CA92.1"], - } - boot_time_reason = { - "NSPrivacyAccessedAPIType" => "NSPrivacyAccessedAPICategorySystemBootTime", - "NSPrivacyAccessedAPITypeReasons" => ["35F9.1"], - } - privacy_manifest = { - "NSPrivacyCollectedDataTypes" => [], - "NSPrivacyTracking" => false, - "NSPrivacyAccessedAPITypes" => [file_timestamp_reason, user_defaults_reason, boot_time_reason] - } - path = File.join(user_project.path.parent, "PrivacyInfo.xcprivacy") - Xcodeproj::Plist.write_to_path(privacy_manifest, path) - Pod::UI.puts "Your app does not have a privacy manifest! A template has been generated containing Required Reasons API usage in the core React Native library. Please add the PrivacyInfo.xcprivacy file to your project and complete data use, tracking and any additional required reasons your app is using according to Apple's guidance: https://developer.apple.com/documentation/bundleresources/privacy_manifest_files. Then, you will need to manually add this file to your project in Xcode.".red - end - end - def self.react_native_pods return [ "DoubleConversion", diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index 6a7dc5ae58891d..2068431e6d0088 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -16,6 +16,7 @@ require_relative './cocoapods/local_podspec_patch.rb' require_relative './cocoapods/runtime.rb' require_relative './cocoapods/helpers.rb' +require_relative './cocoapods/privacy_manifest_utils.rb' # Importing to expose use_native_modules! require_relative './cocoapods/autolinking.rb' @@ -63,7 +64,8 @@ def use_react_native! ( production: false, # deprecated hermes_enabled: ENV['USE_HERMES'] && ENV['USE_HERMES'] == '0' ? false : true, app_path: '..', - config_file_dir: '' + config_file_dir: '', + privacy_file_aggregation_enabled: true ) # Set the app_path as env variable so the podspecs can access it. @@ -87,6 +89,7 @@ def use_react_native! ( ENV['RCT_FABRIC_ENABLED'] = fabric_enabled ? "1" : "0" ENV['USE_HERMES'] = hermes_enabled ? "1" : "0" + ENV['RCT_AGGREGATE_PRIVACY_FILES'] = privacy_file_aggregation_enabled ? "1" : "0" prefix = path @@ -273,6 +276,7 @@ def react_native_post_install( fabric_enabled = ENV['RCT_FABRIC_ENABLED'] == '1' hermes_enabled = ENV['USE_HERMES'] == '1' + privacy_file_aggregation_enabled = ENV['RCT_AGGREGATE_PRIVACY_FILES'] == '1' if hermes_enabled ReactNativePodsUtils.set_gcc_preprocessor_definition_for_React_hermes(installer) @@ -287,7 +291,12 @@ def react_native_post_install( ReactNativePodsUtils.updateOSDeploymentTarget(installer) ReactNativePodsUtils.set_dynamic_frameworks_flags(installer) ReactNativePodsUtils.add_ndebug_flag_to_pods_in_release(installer) - ReactNativePodsUtils.add_privacy_manifest_if_needed(installer) + + if privacy_file_aggregation_enabled + PrivacyManifestUtils.add_aggregated_privacy_manifest(installer) + else + PrivacyManifestUtils.add_privacy_manifest_if_needed(installer) + end NewArchitectureHelper.set_clang_cxx_language_standard_if_needed(installer) NewArchitectureHelper.modify_flags_for_new_architecture(installer, NewArchitectureHelper.new_arch_enabled)