diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 9b78b67e..0ee33272 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ 504EC3011FED79650016851F /* Frameworks */, 504EC3021FED79650016851F /* Resources */, 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, + 012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */, ); buildRules = ( ); @@ -141,8 +142,6 @@ Base, ); mainGroup = 504EC2FB1FED79650016851F; - packageReferences = ( - ); productRefGroup = 504EC3051FED79650016851F /* Products */; projectDirPath = ""; projectRoot = ""; @@ -169,6 +168,26 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Fix Privacy Manifest"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" "; + showEnvVarsInLog = 0; + }; 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/ios/App/app_privacy_manifest_fixer/.gitignore b/ios/App/app_privacy_manifest_fixer/.gitignore new file mode 100644 index 00000000..b903c5f0 --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/.gitignore @@ -0,0 +1,5 @@ +# macOS +.DS_Store + +# Build +/Build/ diff --git a/ios/App/app_privacy_manifest_fixer/CHANGELOG.md b/ios/App/app_privacy_manifest_fixer/CHANGELOG.md new file mode 100644 index 00000000..0c8a036c --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/CHANGELOG.md @@ -0,0 +1,58 @@ +## 1.4.1 +- Fix macOS app re-signing issue. +- Automatically enable Hardened Runtime in macOS codesign. +- Add clean script. + +## 1.4.0 +- Support for macOS app ([#9](https://github.com/crasowas/app_privacy_manifest_fixer/issues/9)). + +## 1.3.11 +- Fix install issue by skipping `PBXAggregateTarget` ([#4](https://github.com/crasowas/app_privacy_manifest_fixer/issues/4)). + +## 1.3.10 +- Fix app re-signing issue. +- Enhance Build Phases script robustness. + +## 1.3.9 +- Add log file output. + +## 1.3.8 +- Add version info to privacy access report. +- Remove empty tables from privacy access report. + +## 1.3.7 +- Enhance API symbols analysis with strings tool. +- Improve performance of API usage analysis. + +## 1.3.5 +- Fix issue with inaccurate privacy manifest search. +- Disable dependency analysis to force the script to run on every build. +- Add placeholder for privacy access report. +- Update build output directory naming convention. +- Add examples for privacy access report. + +## 1.3.0 +- Add privacy access report generation. + +## 1.2.3 +- Fix issue with relative path parameter. +- Add support for all application targets. + +## 1.2.1 +- Fix backup issue with empty user templates directory. + +## 1.2.0 +- Add uninstall script. + +## 1.1.2 +- Remove `Templates/.gitignore` to track `UserTemplates`. +- Fix incorrect use of `App.xcprivacy` template in `App.framework`. + +## 1.1.0 +- Add logs for latest release fetch failure. +- Fix issue with converting published time to local time. +- Disable showing environment variables in the build log. +- Add `--install-builds-only` command line option. + +## 1.0.0 +- Initial version. \ No newline at end of file diff --git a/ios/App/app_privacy_manifest_fixer/Common/constants.sh b/ios/App/app_privacy_manifest_fixer/Common/constants.sh new file mode 100755 index 00000000..bcc529df --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/Common/constants.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +# Copyright (c) 2025, crasowas. +# +# Use of this source code is governed by a MIT-style license +# that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +set -e + +# Prevent duplicate loading +if [ -n "$CONSTANTS_SH_LOADED" ]; then + return +fi + +readonly CONSTANTS_SH_LOADED=1 + +# File name of the privacy manifest +readonly PRIVACY_MANIFEST_FILE_NAME="PrivacyInfo.xcprivacy" + +# Common privacy manifest template file names +readonly APP_TEMPLATE_FILE_NAME="AppTemplate.xcprivacy" +readonly FRAMEWORK_TEMPLATE_FILE_NAME="FrameworkTemplate.xcprivacy" + +# Universal delimiter +readonly DELIMITER=":" + +# Space escape symbol for handling space in path +readonly SPACE_ESCAPE="\u0020" + +# Default value when the version cannot be retrieved +readonly UNKNOWN_VERSION="unknown" + +# Categories of required reason APIs +readonly API_CATEGORIES=( + "NSPrivacyAccessedAPICategoryFileTimestamp" + "NSPrivacyAccessedAPICategorySystemBootTime" + "NSPrivacyAccessedAPICategoryDiskSpace" + "NSPrivacyAccessedAPICategoryActiveKeyboards" + "NSPrivacyAccessedAPICategoryUserDefaults" +) + +# Symbol of the required reason APIs and their categories +# +# See also: +# * https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api +# * https://github.com/Wooder/ios_17_required_reason_api_scanner/blob/main/required_reason_api_binary_scanner.sh +readonly API_SYMBOLS=( + # NSPrivacyAccessedAPICategoryFileTimestamp + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlist" + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlistbulk" + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fgetattrlist" + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}stat" + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fstat" + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fstatat" + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}lstat" + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlistat" + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSFileCreationDate" + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSFileModificationDate" + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSURLContentModificationDateKey" + "NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSURLCreationDateKey" + # NSPrivacyAccessedAPICategorySystemBootTime + "NSPrivacyAccessedAPICategorySystemBootTime${DELIMITER}systemUptime" + "NSPrivacyAccessedAPICategorySystemBootTime${DELIMITER}mach_absolute_time" + # NSPrivacyAccessedAPICategoryDiskSpace + "NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}statfs" + "NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}statvfs" + "NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}fstatfs" + "NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}fstatvfs" + "NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSFileSystemFreeSize" + "NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSFileSystemSize" + "NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityKey" + "NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityForImportantUsageKey" + "NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityForOpportunisticUsageKey" + "NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeTotalCapacityKey" + # NSPrivacyAccessedAPICategoryActiveKeyboards + "NSPrivacyAccessedAPICategoryActiveKeyboards${DELIMITER}activeInputModes" + # NSPrivacyAccessedAPICategoryUserDefaults + "NSPrivacyAccessedAPICategoryUserDefaults${DELIMITER}NSUserDefaults" +) diff --git a/ios/App/app_privacy_manifest_fixer/Common/utils.sh b/ios/App/app_privacy_manifest_fixer/Common/utils.sh new file mode 100755 index 00000000..395a447d --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/Common/utils.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +# Copyright (c) 2025, crasowas. +# +# Use of this source code is governed by a MIT-style license +# that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +set -e + +# Prevent duplicate loading +if [ -n "$UTILS_SH_LOADED" ]; then + return +fi + +readonly UTILS_SH_LOADED=1 + +# Absolute path of the script and the tool's root directory +script_path="$(realpath "${BASH_SOURCE[0]}")" +tool_root_path="$(dirname "$(dirname "$script_path")")" + +# Load common constants +source "$tool_root_path/Common/constants.sh" + +# Print the elements of an array along with their indices +function print_array() { + local -a array=("$@") + + for ((i=0; i<${#array[@]}; i++)); do + echo "[$i] $(decode_path "${array[i]}")" + done +} + +# Split a string into substrings using a specified delimiter +function split_string_by_delimiter() { + local string="$1" + local -a substrings=() + + IFS="$DELIMITER" read -ra substrings <<< "$string" + + echo "${substrings[@]}" +} + +# Encode a path string by replacing space with an escape character +function encode_path() { + echo "$1" | sed "s/ /$SPACE_ESCAPE/g" +} + +# Decode a path string by replacing encoded character with space +function decode_path() { + echo "$1" | sed "s/$SPACE_ESCAPE/ /g" +} + +# Get the dependency name by removing common suffixes +function get_dependency_name() { + local path="$1" + + local dir_name="$(basename "$path")" + # Remove `.app`, `.framework`, and `.xcframework` suffixes + local dep_name="${dir_name%.*}" + + echo "$dep_name" +} + +# Get the executable name from the specified `Info.plist` file +function get_plist_executable() { + local plist_file="$1" + + if [ ! -f "$plist_file" ]; then + echo "" + else + /usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$plist_file" 2>/dev/null || echo "" + fi +} + +# Get the version from the specified `Info.plist` file +function get_plist_version() { + local plist_file="$1" + + if [ ! -f "$plist_file" ]; then + echo "$UNKNOWN_VERSION" + else + /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$plist_file" 2>/dev/null || echo "$UNKNOWN_VERSION" + fi +} + +# Get the path of the specified framework version +function get_framework_path() { + local path="$1" + local version_path="$2" + + if [ -z "$version_path" ]; then + echo "$path" + else + echo "$path/$version_path" + fi +} + +# Search for privacy manifest files in the specified directory +function search_privacy_manifest_files() { + local path="$1" + local -a privacy_manifest_files=() + + # Create a temporary file to store search results + local temp_file="$(mktemp)" + + # Ensure the temporary file is deleted on script exit + trap "rm -f $temp_file" EXIT + + # Find privacy manifest files within the specified directory and store the results in the temporary file + find "$path" -type f -name "$PRIVACY_MANIFEST_FILE_NAME" -print0 2>/dev/null > "$temp_file" + + while IFS= read -r -d '' file; do + privacy_manifest_files+=($(encode_path "$file")) + done < "$temp_file" + + echo "${privacy_manifest_files[@]}" +} + +# Get the privacy manifest file with the shortest path +function get_privacy_manifest_file() { + local privacy_manifest_file="$(printf "%s\n" "$@" | awk '{print length, $0}' | sort -n | head -n1 | cut -d ' ' -f2-)" + + echo "$(decode_path "$privacy_manifest_file")" +} diff --git a/ios/App/app_privacy_manifest_fixer/Helper/xcode_install_helper.rb b/ios/App/app_privacy_manifest_fixer/Helper/xcode_install_helper.rb new file mode 100644 index 00000000..f4b41780 --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/Helper/xcode_install_helper.rb @@ -0,0 +1,80 @@ +# Copyright (c) 2024, crasowas. +# +# Use of this source code is governed by a MIT-style license +# that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +require 'xcodeproj' + +RUN_SCRIPT_PHASE_NAME = 'Fix Privacy Manifest' + +if ARGV.length < 2 + puts "Usage: ruby xcode_install_helper.rb [install_builds_only (true|false)]" + exit 1 +end + +project_path = ARGV[0] +run_script_content = ARGV[1] +install_builds_only = ARGV[2] == 'true' + +# Find the first .xcodeproj file in the project directory +xcodeproj_path = Dir.glob(File.join(project_path, "*.xcodeproj")).first + +# Validate the .xcodeproj file existence +unless xcodeproj_path + puts "Error: No .xcodeproj file found in the specified directory." + exit 1 +end + +# Open the Xcode project file +begin + project = Xcodeproj::Project.open(xcodeproj_path) +rescue StandardError => e + puts "Error: Unable to open the project file - #{e.message}" + exit 1 +end + +# Process all targets in the project +project.targets.each do |target| + # Skip PBXAggregateTarget + if target.is_a?(Xcodeproj::Project::Object::PBXAggregateTarget) + puts "Skipping aggregate target: #{target.name}." + next + end + + # Check if the target is a native application target + if target.product_type == 'com.apple.product-type.application' + puts "Processing target: #{target.name}..." + + # Check for an existing Run Script phase with the specified name + existing_phase = target.shell_script_build_phases.find { |phase| phase.name == RUN_SCRIPT_PHASE_NAME } + + # Remove the existing Run Script phase if found + if existing_phase + puts " - Removing existing Run Script." + target.build_phases.delete(existing_phase) + end + + # Add the new Run Script phase at the end + puts " - Adding new Run Script." + new_phase = target.new_shell_script_build_phase(RUN_SCRIPT_PHASE_NAME) + new_phase.shell_script = run_script_content + # Disable showing environment variables in the build log + new_phase.show_env_vars_in_log = '0' + # Run only for deployment post-processing if install_builds_only is true + new_phase.run_only_for_deployment_postprocessing = install_builds_only ? '1' : '0' + # Disable dependency analysis to force the script to run on every build, unless restricted to deployment builds by post-processing setting + new_phase.always_out_of_date = '1' + else + puts "Skipping non-application target: #{target.name}." + end +end + +# Save the project file +begin + project.save + puts "Successfully added the Run Script phase: '#{RUN_SCRIPT_PHASE_NAME}'." +rescue StandardError => e + puts "Error: Unable to save the project file - #{e.message}" + exit 1 +end diff --git a/ios/App/app_privacy_manifest_fixer/Helper/xcode_uninstall_helper.rb b/ios/App/app_privacy_manifest_fixer/Helper/xcode_uninstall_helper.rb new file mode 100644 index 00000000..89c24833 --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/Helper/xcode_uninstall_helper.rb @@ -0,0 +1,63 @@ +# Copyright (c) 2024, crasowas. +# +# Use of this source code is governed by a MIT-style license +# that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +require 'xcodeproj' + +RUN_SCRIPT_PHASE_NAME = 'Fix Privacy Manifest' + +if ARGV.length < 1 + puts "Usage: ruby xcode_uninstall_helper.rb " + exit 1 +end + +project_path = ARGV[0] + +# Find the first .xcodeproj file in the project directory +xcodeproj_path = Dir.glob(File.join(project_path, "*.xcodeproj")).first + +# Validate the .xcodeproj file existence +unless xcodeproj_path + puts "Error: No .xcodeproj file found in the specified directory." + exit 1 +end + +# Open the Xcode project file +begin + project = Xcodeproj::Project.open(xcodeproj_path) +rescue StandardError => e + puts "Error: Unable to open the project file - #{e.message}" + exit 1 +end + +# Process all targets in the project +project.targets.each do |target| + # Check if the target is an application target + if target.product_type == 'com.apple.product-type.application' + puts "Processing target: #{target.name}..." + + # Check for an existing Run Script phase with the specified name + existing_phase = target.shell_script_build_phases.find { |phase| phase.name == RUN_SCRIPT_PHASE_NAME } + + # Remove the existing Run Script phase if found + if existing_phase + puts " - Removing existing Run Script." + target.build_phases.delete(existing_phase) + else + puts " - No existing Run Script found." + end + else + puts "Skipping non-application target: #{target.name}." + end +end + +# Save the project file +begin + project.save + puts "Successfully removed the Run Script phase: '#{RUN_SCRIPT_PHASE_NAME}'." +rescue StandardError => e + puts "Error: Unable to save the project file - #{e.message}" + exit 1 +end diff --git a/ios/App/app_privacy_manifest_fixer/LICENSE b/ios/App/app_privacy_manifest_fixer/LICENSE new file mode 100644 index 00000000..8d6ecbb9 --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 crasowas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ios/App/app_privacy_manifest_fixer/README.md b/ios/App/app_privacy_manifest_fixer/README.md new file mode 100644 index 00000000..3296b771 --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/README.md @@ -0,0 +1,240 @@ +# App Privacy Manifest Fixer + +[![Latest Version](https://img.shields.io/github/v/release/crasowas/app_privacy_manifest_fixer?logo=github)](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest) +![Supported Platforms](https://img.shields.io/badge/Supported%20Platforms-iOS%20%7C%20macOS-brightgreen) +[![License](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) + +**English | [简体中文](./README.zh-CN.md)** + +This tool is an automation solution based on Shell scripts, designed to analyze and fix the privacy manifest of iOS/macOS apps to ensure compliance with App Store requirements. It leverages the [App Store Privacy Manifest Analyzer](https://github.com/crasowas/app_store_required_privacy_manifest_analyser) to analyze API usage within the app and its dependencies, and generate or fix the `PrivacyInfo.xcprivacy` file. + +## ✨ Features + +- **Non-Intrusive Integration**: No need to modify the source code or adjust the project structure. +- **Fast Installation and Uninstallation**: Quickly install or uninstall the tool with a single command. +- **Automatic Analysis and Fixes**: Automatically analyzes API usage and fixes privacy manifest issues during the project build. +- **Flexible Template Customization**: Supports custom privacy manifest templates for apps and frameworks, accommodating various usage scenarios. +- **Privacy Access Report**: Automatically generates a report displaying the `NSPrivacyAccessedAPITypes` declarations for the app and SDKs. +- **Effortless Version Upgrades**: Provides an upgrade script for quick updates to the latest version. + +## 📥 Installation + +### Download the Tool + +1. Download the [latest release](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest). +2. Extract the downloaded file: + - The extracted directory is usually named `app_privacy_manifest_fixer-xxx` (where `xxx` is the version number). + - It is recommended to rename it to `app_privacy_manifest_fixer` or use the full directory name in subsequent paths. + - **It is advised to move the directory to your iOS/macOS project to avoid path-related issues on different devices, and to easily customize the privacy manifest template for each project**. + +### ⚡ Automatic Installation (Recommended) + +1. **Navigate to the tool's directory**: + + ```shell + cd /path/to/app_privacy_manifest_fixer + ``` + +2. **Run the installation script**: + + ```shell + sh install.sh + ``` + + - For Flutter projects, `project_path` should be the path to the `ios/macos` directory within the Flutter project. + - If the installation command is run again, the tool will first remove any existing installation (if present). To modify command-line options, simply rerun the installation command without the need to uninstall first. + +### Manual Installation + +If you prefer not to use the installation script, you can manually add the `Fix Privacy Manifest` task to the Xcode **Build Phases**. Follow these steps: + +#### 1. Add the Script in Xcode + +- Open your iOS/macOS project in Xcode, go to the **TARGETS** tab, and select your app target. +- Navigate to **Build Phases**, click the **+** button, and select **New Run Script Phase**. +- Rename the newly created **Run Script** to `Fix Privacy Manifest`. +- In the Shell script box, add the following code: + + ```shell + # Use relative path (recommended): if `app_privacy_manifest_fixer` is within the project directory + "$PROJECT_DIR/path/to/app_privacy_manifest_fixer/fixer.sh" + + # Use absolute path: if `app_privacy_manifest_fixer` is outside the project directory + # "/absolute/path/to/app_privacy_manifest_fixer/fixer.sh" + ``` + + **Modify `path/to` or `absolute/path/to` as needed, and ensure the paths are correct. Remove or comment out the unused lines accordingly.** + +#### 2. Adjust the Script Execution Order + +**Move this script after all other Build Phases to ensure the privacy manifest is fixed after all resource copying and build tasks are completed**. + +### Build Phases Screenshot + +Below is a screenshot of the Xcode Build Phases configuration after successful automatic/manual installation (with no command-line options enabled): + +![Build Phases Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011407.png) + +## 🚀 Getting Started + +After installation, the tool will automatically run with each project build, and the resulting application bundle will include the fixes. + +If the `--install-builds-only` command-line option is enabled during installation, the tool will only run during the installation of the build. + +### Xcode Build Log Screenshot + +Below is a screenshot of the log output from the tool during the project build (by default, it will be saved to the `app_privacy_manifest_fixer/Build` directory, unless the `-s` command-line option is enabled): + +![Xcode Build Log Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011551.png) + +## 📖 Usage + +### Command Line Options + +- **Force overwrite existing privacy manifest (Not recommended)**: + + ```shell + sh install.sh -f + ``` + + Enabling the `-f` option will force the tool to generate a new privacy manifest based on the API usage analysis and privacy manifest template, overwriting the existing privacy manifest. By default (without `-f`), the tool only fixes missing privacy manifests. + +- **Silent mode**: + + ```shell + sh install.sh -s + ``` + + Enabling the `-s` option disables output during the fix process. The tool will no longer copy the generated `*.app`, automatically generate the privacy access report, or output the fix logs. By default (without `-s`), these outputs are stored in the `app_privacy_manifest_fixer/Build` directory. + +- **Run only during installation builds (Recommended)**: + + ```shell + sh install.sh --install-builds-only + ``` + + Enabling the `--install-builds-only` option makes the tool run only during installation builds (such as the **Archive** operation), optimizing build performance for daily development. If you manually installed, this option is ineffective, and you need to manually check the **"For install builds only"** option. + + **Note**: If the iOS/macOS project is built in a development environment (where the generated app contains `*.debug.dylib` files), the tool's API usage analysis results may be inaccurate. + +### Upgrade the Tool + +To update to the latest version, run the following command: + +```shell +sh upgrade.sh +``` + +### Uninstall the Tool + +To quickly uninstall the tool, run the following command: + +```shell +sh uninstall.sh +``` + +### Clean the Tool-Generated Files + +To remove files generated by the tool, run the following command: + +```shell +sh clean.sh +``` + +## 🔥 Privacy Manifest Templates + +The privacy manifest templates are stored in the [`Templates`](https://github.com/crasowas/app_privacy_manifest_fixer/tree/main/Templates) directory, with the default templates already included in the root directory. + +**How can you customize the privacy manifests for apps or SDKs? Simply use [custom templates](#custom-templates)!** + +### Template Types + +The templates are categorized as follows: +- **AppTemplate.xcprivacy**: A privacy manifest template for the app. +- **FrameworkTemplate.xcprivacy**: A generic privacy manifest template for frameworks. +- **FrameworkName.xcprivacy**: A privacy manifest template for a specific framework, available only in the `Templates/UserTemplates` directory. + +### Template Priority + +For an app, the priority of privacy manifest templates is as follows: +- `Templates/UserTemplates/AppTemplate.xcprivacy` > `Templates/AppTemplate.xcprivacy` + +For a specific framework, the priority of privacy manifest templates is as follows: +- `Templates/UserTemplates/FrameworkName.xcprivacy` > `Templates/UserTemplates/FrameworkTemplate.xcprivacy` > `Templates/FrameworkTemplate.xcprivacy` + +### Default Templates + +The default templates are located in the `Templates` root directory and currently include the following templates: +- `Templates/AppTemplate.xcprivacy` +- `Templates/FrameworkTemplate.xcprivacy` + +These templates will be modified based on the API usage analysis results, especially the `NSPrivacyAccessedAPIType` entries, to generate new privacy manifests for fixes, ensuring compliance with App Store requirements. + +**If adjustments to the privacy manifest template are needed, such as in the following scenarios, avoid directly modifying the default templates. Instead, use a custom template. If a custom template with the same name exists, it will take precedence over the default template for fixes.** +- Generating a non-compliant privacy manifest due to inaccurate API usage analysis. +- Modifying the reason declared in the template. +- Adding declarations for collected data. + +The privacy access API categories and their associated declared reasons in `AppTemplate.xcprivacy` are listed below: + +| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) | +|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| NSPrivacyAccessedAPICategoryFileTimestamp | C617.1: Inside app or group container | +| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device | +| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device | +| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device | +| NSPrivacyAccessedAPICategoryUserDefaults | CA92.1: Access info from same app | + +The privacy access API categories and their associated declared reasons in `FrameworkTemplate.xcprivacy` are listed below: + +| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) | +|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| NSPrivacyAccessedAPICategoryFileTimestamp | 0A2A.1: 3rd-party SDK wrapper on-device | +| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device | +| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device | +| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device | +| NSPrivacyAccessedAPICategoryUserDefaults | C56D.1: 3rd-party SDK wrapper on-device | + +### Custom Templates + +To create custom templates, place them in the `Templates/UserTemplates` directory with the following structure: +- `Templates/UserTemplates/AppTemplate.xcprivacy` +- `Templates/UserTemplates/FrameworkTemplate.xcprivacy` +- `Templates/UserTemplates/FrameworkName.xcprivacy` + +Among these templates, only `FrameworkTemplate.xcprivacy` will be modified based on the API usage analysis results to adjust the `NSPrivacyAccessedAPIType` entries, thereby generating a new privacy manifest for framework fixes. The other templates will remain unchanged and will be directly used for fixes. + +**Important Notes:** +- The template for a specific framework must follow the naming convention `FrameworkName.xcprivacy`, where `FrameworkName` should match the name of the framework. For example, the template for `Flutter.framework` should be named `Flutter.xcprivacy`. +- For macOS frameworks, the naming convention should be `FrameworkName.Version.xcprivacy`, where the version name is added to distinguish different versions. For a single version macOS framework, the `Version` is typically `A`. +- The name of an SDK may not exactly match the name of the framework. To determine the correct framework name, check the `Frameworks` directory in the application bundle after building the project. + +## 📑 Privacy Access Report + +By default, the tool automatically generates privacy access reports for both the original and fixed versions of the app during each project build, and stores the reports in the `app_privacy_manifest_fixer/Build` directory. + +If you need to manually generate a privacy access report for a specific app, run the following command: + +```shell +sh Report/report.sh +# : Path to the app (e.g., /path/to/App.app) +# : Path to save the report file (e.g., /path/to/report.html) +``` + +**Note**: The report generated by the tool currently only includes the privacy access section (`NSPrivacyAccessedAPITypes`). To view the data collection section (`NSPrivacyCollectedDataTypes`), please use Xcode to generate the `PrivacyReport`. + +### Sample Report Screenshots + +| Original App Report (report-original.html) | Fixed App Report (report.html) | +|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| +| ![Original App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230746.png) | ![Fixed App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230822.png) | + +## 💡 Important Considerations + +- If the latest version of the SDK supports a privacy manifest, please upgrade as soon as possible to avoid unnecessary risks. +- This tool is a temporary solution and should not replace proper SDK management practices. +- Before submitting your app for review, ensure that the privacy manifest fix complies with the latest App Store requirements. + +## 🙌 Contributing + +Contributions in any form are welcome, including code optimizations, bug fixes, documentation improvements, and more. Please follow the project's guidelines and maintain a consistent coding style. Thank you for your support! diff --git a/ios/App/app_privacy_manifest_fixer/README.zh-CN.md b/ios/App/app_privacy_manifest_fixer/README.zh-CN.md new file mode 100644 index 00000000..19bc9a8b --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/README.zh-CN.md @@ -0,0 +1,240 @@ +# App Privacy Manifest Fixer + +[![Latest Version](https://img.shields.io/github/v/release/crasowas/app_privacy_manifest_fixer?logo=github)](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest) +![Supported Platforms](https://img.shields.io/badge/Supported%20Platforms-iOS%20%7C%20macOS-brightgreen) +[![License](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) + +**[English](./README.md) | 简体中文** + +本工具是一个基于 Shell 脚本的自动化解决方案,旨在分析和修复 iOS/macOS App 的隐私清单,确保 App 符合 App Store 的要求。它利用 [App Store Privacy Manifest Analyzer](https://github.com/crasowas/app_store_required_privacy_manifest_analyser) 对 App 及其依赖项进行 API 使用分析,并生成或修复`PrivacyInfo.xcprivacy`文件。 + +## ✨ 功能特点 + +- **非侵入式集成**:无需修改源码或调整项目结构。 +- **极速安装与卸载**:一行命令即可快速完成工具的安装或卸载。 +- **自动分析与修复**:项目构建时自动分析 API 使用情况并修复隐私清单问题。 +- **灵活定制模板**:支持自定义 App 和 Framework 的隐私清单模板,满足多种使用场景。 +- **隐私访问报告**:自动生成报告用于查看 App 和 SDK 的`NSPrivacyAccessedAPITypes`声明情况。 +- **版本轻松升级**:提供升级脚本快速更新至最新版本。 + +## 📥 安装 + +### 下载工具 + +1. 下载[最新发布版本](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest)。 +2. 解压下载的文件: + - 解压后的目录通常为`app_privacy_manifest_fixer-xxx`(其中`xxx`是版本号)。 + - 建议重命名为`app_privacy_manifest_fixer`,或在后续路径中使用完整目录名。 + - **建议将该目录移动至 iOS/macOS 项目中,以避免因路径问题在不同设备上运行时出现错误,同时便于为每个项目单独自定义隐私清单模板**。 + +### ⚡ 自动安装(推荐) + +1. **切换到工具所在目录**: + + ```shell + cd /path/to/app_privacy_manifest_fixer + ``` + +2. **运行以下安装脚本**: + + ```shell + sh install.sh + ``` + + - 如果是 Flutter 项目,`project_path`应为 Flutter 项目中的`ios/macos`目录路径。 + - 重复运行安装命令时,工具会先移除现有安装(如果有)。若需修改命令行选项,只需重新运行安装命令,无需先卸载。 + +### 手动安装 + +如果不使用安装脚本,可以手动添加`Fix Privacy Manifest`任务到 Xcode 的 **Build Phases** 完成安装。安装步骤如下: + +#### 1. 在 Xcode 中添加脚本 + +- 用 Xcode 打开你的 iOS/macOS 项目,进入 **TARGETS** 选项卡,选择你的 App 目标。 +- 进入 **Build Phases**,点击 **+** 按钮,选择 **New Run Script Phase**。 +- 将新建的 **Run Script** 重命名为`Fix Privacy Manifest`。 +- 在 Shell 脚本框中添加以下代码: + + ```shell + # 使用相对路径(推荐):如果`app_privacy_manifest_fixer`在项目目录内 + "$PROJECT_DIR/path/to/app_privacy_manifest_fixer/fixer.sh" + + # 使用绝对路径:如果`app_privacy_manifest_fixer`不在项目目录内 + # "/absolute/path/to/app_privacy_manifest_fixer/fixer.sh" + ``` + + **请根据实际情况修改`path/to`或`absolute/path/to`,并确保路径正确。同时,删除或注释掉不适用的行**。 + +#### 2. 调整脚本执行顺序 + +**将该脚本移动到所有其他 Build Phases 之后,确保隐私清单在所有资源拷贝和编译任务完成后再进行修复**。 + +### Build Phases 截图 + +下面是自动/手动安装成功后的 Xcode Build Phases 配置截图(未启用任何命令行选项): + +![Build Phases Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011407.png) + +## 🚀 快速开始 + +安装后,工具将在每次构建项目时自动运行,构建完成后得到的 App 包已经是修复后的结果。 + +如果启用`--install-builds-only`命令行选项安装,工具将仅在安装构建时运行。 + +### Xcode Build Log 截图 + +下面是项目构建时工具输出的日志截图(默认会存储到`app_privacy_manifest_fixer/Build`目录,除非启用`-s`命令行选项): + +![Xcode Build Log Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011551.png) + +## 📖 使用方法 + +### 命令行选项 + +- **强制覆盖现有隐私清单(不推荐)**: + + ```shell + sh install.sh -f + ``` + + 启用`-f`选项后,工具会根据 API 使用分析结果和隐私清单模板生成新的隐私清单,并强制覆盖现有隐私清单。默认情况下(未启用`-f`),工具仅修复缺失的隐私清单。 + +- **静默模式**: + + ```shell + sh install.sh -s + ``` + + 启用`-s`选项后,工具将禁用修复时的输出,不再复制构建生成的`*.app`、自动生成隐私访问报告或输出修复日志。默认情况下(未启用`-s`),这些输出存储在`app_privacy_manifest_fixer/Build`目录。 + +- **仅在安装构建时运行(推荐)**: + + ```shell + sh install.sh --install-builds-only + ``` + + 启用`--install-builds-only`选项后,工具仅在执行安装构建(如 **Archive** 操作)时运行,以优化日常开发时的构建性能。如果你是手动安装的,该命令行选项无效,需要手动勾选 **"For install builds only"** 选项。 + + **注意**:如果 iOS/macOS 项目在开发环境构建(生成的 App 包含`*.debug.dylib`文件),工具的 API 使用分析结果可能不准确。 + +### 升级工具 + +要更新至最新版本,请运行以下命令: + +```shell +sh upgrade.sh +``` + +### 卸载工具 + +要快速卸载本工具,请运行以下命令: + +```shell +sh uninstall.sh +``` + +### 清理工具生成的文件 + +要删除工具生成的文件,请运行以下命令: + +```shell +sh clean.sh +``` + +## 🔥 隐私清单模板 + +隐私清单模板存储在[`Templates`](https://github.com/crasowas/app_privacy_manifest_fixer/tree/main/Templates)目录,其中根目录已经包含默认模板。 + +**如何为 App 或 SDK 自定义隐私清单?只需使用[自定义模板](#自定义模板)!** + +### 模板类型 + +模板分为以下几类: +- **AppTemplate.xcprivacy**:App 的隐私清单模板。 +- **FrameworkTemplate.xcprivacy**:通用的 Framework 隐私清单模板。 +- **FrameworkName.xcprivacy**:特定的 Framework 隐私清单模板,仅在`Templates/UserTemplates`目录有效。 + +### 模板优先级 + +对于 App,隐私清单模板的优先级如下: +- `Templates/UserTemplates/AppTemplate.xcprivacy` > `Templates/AppTemplate.xcprivacy` + +对于特定的 Framework,隐私清单模板的优先级如下: +- `Templates/UserTemplates/FrameworkName.xcprivacy` > `Templates/UserTemplates/FrameworkTemplate.xcprivacy` > `Templates/FrameworkTemplate.xcprivacy` + +### 默认模板 + +默认模板位于`Templates`根目录,目前包括以下模板: +- `Templates/AppTemplate.xcprivacy` +- `Templates/FrameworkTemplate.xcprivacy` + +这些模板将根据 API 使用分析结果进行修改,特别是`NSPrivacyAccessedAPIType`条目将被调整,以生成新的隐私清单用于修复,确保符合 App Store 要求。 + +**如果需要调整隐私清单模板,例如以下场景,请避免直接修改默认模板,而是使用自定义模板。如果存在相同名称的自定义模板,它将优先于默认模板用于修复。** +- 由于 API 使用分析结果不准确,生成了不合规的隐私清单。 +- 需要修改模板中声明的理由。 +- 需要声明收集的数据。 + +`AppTemplate.xcprivacy`中隐私访问 API 类别及其对应声明的理由如下: + +| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) | +|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| NSPrivacyAccessedAPICategoryFileTimestamp | C617.1: Inside app or group container | +| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device | +| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device | +| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device | +| NSPrivacyAccessedAPICategoryUserDefaults | CA92.1: Access info from same app | + +`FrameworkTemplate.xcprivacy`中隐私访问 API 类别及其对应声明的理由如下: + +| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) | +|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| NSPrivacyAccessedAPICategoryFileTimestamp | 0A2A.1: 3rd-party SDK wrapper on-device | +| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device | +| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device | +| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device | +| NSPrivacyAccessedAPICategoryUserDefaults | C56D.1: 3rd-party SDK wrapper on-device | + +### 自定义模板 + +要创建自定义模板,请将其放在`Templates/UserTemplates`目录,结构如下: +- `Templates/UserTemplates/AppTemplate.xcprivacy` +- `Templates/UserTemplates/FrameworkTemplate.xcprivacy` +- `Templates/UserTemplates/FrameworkName.xcprivacy` + +在这些模板中,只有`FrameworkTemplate.xcprivacy`会根据 API 使用分析结果对`NSPrivacyAccessedAPIType`条目进行调整,以生成新的隐私清单用于 Framework 修复。其他模板保持不变,将直接用于修复。 + +**重要说明:** +- 特定的 Framework 模板必须遵循命名规范`FrameworkName.xcprivacy`,其中`FrameworkName`需与 Framework 的名称匹配。例如`Flutter.framework`的模板应命名为`Flutter.xcprivacy`。 +- 对于 macOS Framework,应遵循命名规范`FrameworkName.Version.xcprivacy`,额外增加版本名称用于区分不同的版本。对于单一版本的 macOS Framework,`Version`通常为`A`。 +- SDK 的名称可能与 Framework 的名称不完全一致。要确定正确的 Framework 名称,请在构建项目后检查 App 包中的`Frameworks`目录。 + +## 📑 隐私访问报告 + +默认情况下,工具会自动在每次构建时为原始 App 和修复后的 App 生成隐私访问报告,并存储到`app_privacy_manifest_fixer/Build`目录。 + +如果需要手动为特定 App 生成隐私访问报告,请运行以下命令: + +```shell +sh Report/report.sh +# : App路径(例如:/path/to/App.app) +# : 报告文件保存路径(例如:/path/to/report.html) +``` + +**注意**:工具生成的报告目前仅包含隐私访问部分(`NSPrivacyAccessedAPITypes`),如果想看数据收集部分(`NSPrivacyCollectedDataTypes`)请使用 Xcode 生成`PrivacyReport`。 + +### 报告示例截图 + +| 原始 App 报告(report-original.html) | 修复后 App 报告(report.html) | +|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| +| ![Original App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230746.png) | ![Fixed App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230822.png) | + +## 💡 重要考量 + +- 如果最新版本的 SDK 支持隐私清单,请尽可能升级,以避免不必要的风险。 +- 此工具仅为临时解决方案,不应替代正确的 SDK 管理实践。 +- 在提交 App 审核之前,请检查隐私清单修复后是否符合最新的 App Store 要求。 + +## 🙌 贡献 + +欢迎任何形式的贡献,包括代码优化、Bug 修复、文档改进等。请确保遵循项目规范,并保持代码风格一致。感谢你的支持! diff --git a/ios/App/app_privacy_manifest_fixer/Report/report-template.html b/ios/App/app_privacy_manifest_fixer/Report/report-template.html new file mode 100644 index 00000000..a0429c2f --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/Report/report-template.html @@ -0,0 +1,124 @@ + + + + + + + + + Privacy Access Report + + + + +
+ + This report was generated using version {{TOOL_VERSION}}. + + Like this + project? 🌟Star it on GitHub! +
+{{REPORT_CONTENT}} + + \ No newline at end of file diff --git a/ios/App/app_privacy_manifest_fixer/Report/report.sh b/ios/App/app_privacy_manifest_fixer/Report/report.sh new file mode 100755 index 00000000..101605da --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/Report/report.sh @@ -0,0 +1,285 @@ +#!/bin/bash + +# Copyright (c) 2024, crasowas. +# +# Use of this source code is governed by a MIT-style license +# that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +set -e + +# Absolute path of the script and the tool's root directory +script_path="$(realpath "$0")" +tool_root_path="$(dirname "$(dirname "$script_path")")" + +# Load common constants and utils +source "$tool_root_path/Common/constants.sh" +source "$tool_root_path/Common/utils.sh" + +# Path to the app +app_path="$1" + +# Check if the app exists +if [ ! -d "$app_path" ] || [[ "$app_path" != *.app ]]; then + echo "Unable to find the app: $app_path" + exit 1 +fi + +# Check if the app is iOS or macOS +is_ios_app=true +frameworks_dir="$app_path/Frameworks" +if [ -d "$app_path/Contents/MacOS" ]; then + is_ios_app=false + frameworks_dir="$app_path/Contents/Frameworks" +fi + +report_output_file="$2" +# Additional arguments as template usage records +template_usage_records=("${@:2}") + +# Copy report template to output file +report_template_file="$tool_root_path/Report/report-template.html" +if ! rsync -a "$report_template_file" "$report_output_file"; then + echo "Failed to copy the report template to $report_output_file" + exit 1 +fi + +# Read the current tool's version from the VERSION file +tool_version_file="$tool_root_path/VERSION" +tool_version="N/A" +if [ -f "$tool_version_file" ]; then + tool_version="$(cat "$tool_version_file")" +fi + +# Initialize report content +report_content="" + +# Get the template file used for fixing based on the app or framework name +function get_used_template_file() { + local name="$1" + + for template_usage_record in "${template_usage_records[@]}"; do + if [[ "$template_usage_record" == "$name$DELIMITER"* ]]; then + echo "${template_usage_record#*$DELIMITER}" + return + fi + done + + echo "" +} + +# Analyze accessed API types and their corresponding reasons +function analyze_privacy_accessed_api() { + local privacy_manifest_file="$1" + local -a results=() + + if [ -f "$privacy_manifest_file" ]; then + local api_count=$(xmllint --xpath 'count(//dict/key[text()="NSPrivacyAccessedAPIType"])' "$privacy_manifest_file") + + for ((i=1; i<=api_count; i++)); do + local api_type=$(xmllint --xpath "(//dict/key[text()='NSPrivacyAccessedAPIType']/following-sibling::string[1])[$i]/text()" "$privacy_manifest_file" 2>/dev/null) + local api_reasons=$(xmllint --xpath "(//dict/key[text()='NSPrivacyAccessedAPITypeReasons']/following-sibling::array[1])[position()=$i]/string/text()" "$privacy_manifest_file" 2>/dev/null | paste -sd "/" -) + + if [ -z "$api_type" ]; then + api_type="N/A" + fi + + if [ -z "$api_reasons" ]; then + api_reasons="N/A" + fi + + results+=("$api_type$DELIMITER$api_reasons") + done + fi + + echo "${results[@]}" +} + +# Get the path to the `Info.plist` file for the specified app or framework +function get_plist_file() { + local path="$1" + local version_path="$2" + local plist_file="" + + if [[ "$path" == *.app ]]; then + if [ "$is_ios_app" == true ]; then + plist_file="$path/Info.plist" + else + plist_file="$path/Contents/Info.plist" + fi + elif [[ "$path" == *.framework ]]; then + local framework_path="$(get_framework_path "$path" "$version_path")" + + if [ "$is_ios_app" == true ]; then + plist_file="$framework_path/Info.plist" + else + plist_file="$framework_path/Resources/Info.plist" + fi + fi + + echo "$plist_file" +} + +# Add an HTML
element with the `card` class +function add_html_card_container() { + local card="$1" + + report_content="$report_content
$card
" +} + +# Generate an HTML

element +function generate_html_header() { + local title="$1" + local version="$2" + + echo "

$titleVersion $version

" +} + +# Generate an HTML element with optional `warning` class +function generate_html_anchor() { + local text="$1" + local href="$2" + local warning="$3" + + if [ "$warning" == true ]; then + echo "$text" + else + echo "$text" + fi +} + +# Generate an HTML element +function generate_html_table() { + local thead="$1" + local tbody="$2" + + echo "
$thead$tbody
" +} + +# Generate an HTML element +function generate_html_thead() { + local ths=("$@") + local tr="" + + for th in "${ths[@]}"; do + tr="$tr$th" + done + + echo "$tr" +} + +# Generate an HTML element +function generate_html_tbody() { + local trs=("$@") + local tbody="" + + for tr in "${trs[@]}"; do + tbody="$tbody" + local tds=($(split_string_by_delimiter "$tr")) + + for td in "${tds[@]}"; do + tbody="$tbody$td" + done + + tbody="$tbody" + done + + echo "$tbody" +} + +# Generate the report content for the specified directory +function generate_report_content() { + local path="$1" + local version_path="$2" + local privacy_manifest_file="" + + if [[ "$path" == *.app ]]; then + # Per the documentation, the privacy manifest should be placed at the root of the app’s bundle for iOS, while for macOS, it should be located in `Contents/Resources/` within the app’s bundle + # Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-app + if [ "$is_ios_app" == true ]; then + privacy_manifest_file="$path/$PRIVACY_MANIFEST_FILE_NAME" + else + privacy_manifest_file="$path/Contents/Resources/$PRIVACY_MANIFEST_FILE_NAME" + fi + else + # Per the documentation, the privacy manifest should be placed at the root of the iOS framework, while for a macOS framework with multiple versions, it should be located in the `Resources` directory within the corresponding version + # Some SDKs don’t follow the guideline, so we use a search-based approach for now + # Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-framework + local framework_path="$(get_framework_path "$path" "$version_path")" + local privacy_manifest_files=($(search_privacy_manifest_files "$framework_path")) + privacy_manifest_file="$(get_privacy_manifest_file "${privacy_manifest_files[@]}")" + fi + + local name="$(basename "$path")" + local title="$name" + if [ -n "$version_path" ]; then + title="$name ($version_path)" + fi + + local plist_file="$(get_plist_file "$path" "$version_path")" + local version="$(get_plist_version "$plist_file")" + local card="$(generate_html_header "$title" "$version")" + + if [ -f "$privacy_manifest_file" ]; then + card="$card$(generate_html_anchor "$PRIVACY_MANIFEST_FILE_NAME" "$privacy_manifest_file" false)" + + local used_template_file="$(get_used_template_file "$name$version_path")" + + if [ -f "$used_template_file" ]; then + card="$card$(generate_html_anchor "Template Used: $(basename "$used_template_file")" "$used_template_file" false)" + fi + + local trs=($(analyze_privacy_accessed_api "$privacy_manifest_file")) + + # Generate table only if the accessed privacy API types array is not empty + if [[ ${#trs[@]} -gt 0 ]]; then + local thead="$(generate_html_thead "NSPrivacyAccessedAPIType" "NSPrivacyAccessedAPITypeReasons")" + local tbody="$(generate_html_tbody "${trs[@]}")" + + card="$card$(generate_html_table "$thead" "$tbody")" + fi + else + card="$card$(generate_html_anchor "Missing Privacy Manifest" "$path" true)" + fi + + add_html_card_container "$card" +} + +# Generate the report content for app +function generate_app_report_content() { + generate_report_content "$app_path" "" +} + +# Generate the report content for frameworks +function generate_frameworks_report_content() { + if ! [ -d "$frameworks_dir" ]; then + return + fi + + for path in "$frameworks_dir"/*; do + if [ -d "$path" ]; then + local versions_dir="$path/Versions" + + if [ -d "$versions_dir" ]; then + for version in $(ls -1 "$versions_dir" | grep -vE '^Current$'); do + local version_path="Versions/$version" + generate_report_content "$path" "$version_path" + done + else + generate_report_content "$path" "" + fi + fi + done +} + +# Generate the final report with all content +function generate_final_report() { + # Replace placeholders in the template with the tool's version and report content + sed -i "" -e "s|{{TOOL_VERSION}}|$tool_version|g" -e "s|{{REPORT_CONTENT}}|${report_content}|g" "$report_output_file" + + echo "Privacy Access Report has been generated: $report_output_file" +} + +generate_app_report_content +generate_frameworks_report_content +generate_final_report diff --git a/ios/App/app_privacy_manifest_fixer/Templates/AppTemplate.xcprivacy b/ios/App/app_privacy_manifest_fixer/Templates/AppTemplate.xcprivacy new file mode 100644 index 00000000..16d91490 --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/Templates/AppTemplate.xcprivacy @@ -0,0 +1,55 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryActiveKeyboards + NSPrivacyAccessedAPITypeReasons + + 54BD.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + \ No newline at end of file diff --git a/ios/App/app_privacy_manifest_fixer/Templates/FrameworkTemplate.xcprivacy b/ios/App/app_privacy_manifest_fixer/Templates/FrameworkTemplate.xcprivacy new file mode 100644 index 00000000..f8efea16 --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/Templates/FrameworkTemplate.xcprivacy @@ -0,0 +1,55 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + 0A2A.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryActiveKeyboards + NSPrivacyAccessedAPITypeReasons + + 54BD.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + C56D.1 + + + + + diff --git a/ios/App/app_privacy_manifest_fixer/Templates/UserTemplates/.gitkeep b/ios/App/app_privacy_manifest_fixer/Templates/UserTemplates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/ios/App/app_privacy_manifest_fixer/VERSION b/ios/App/app_privacy_manifest_fixer/VERSION new file mode 100644 index 00000000..9bdb566f --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/VERSION @@ -0,0 +1 @@ +v1.4.1 \ No newline at end of file diff --git a/ios/App/app_privacy_manifest_fixer/clean.sh b/ios/App/app_privacy_manifest_fixer/clean.sh new file mode 100755 index 00000000..d1abd471 --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/clean.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Copyright (c) 2025, crasowas. +# +# Use of this source code is governed by a MIT-style license +# that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +set -e + +target_paths=("Build") + +echo "Cleaning..." + +deleted_anything=false + +for path in "${target_paths[@]}"; do + if [ -e "$path" ]; then + echo "Removing $path..." + rm -rf "./$path" + deleted_anything=true + fi +done + +if [ "$deleted_anything" == true ]; then + echo "Cleanup completed." +else + echo "Nothing to clean." +fi diff --git a/ios/App/app_privacy_manifest_fixer/fixer.sh b/ios/App/app_privacy_manifest_fixer/fixer.sh new file mode 100755 index 00000000..f45aa7fa --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/fixer.sh @@ -0,0 +1,490 @@ +#!/bin/bash + +# Copyright (c) 2024, crasowas. +# +# Use of this source code is governed by a MIT-style license +# that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +set -e + +# Absolute path of the script and the tool's root directory +script_path="$(realpath "$0")" +tool_root_path="$(dirname "$script_path")" + +# Load common constants and utils +source "$tool_root_path/Common/constants.sh" +source "$tool_root_path/Common/utils.sh" + +# Force replace the existing privacy manifest when the `-f` option is enabled +force=false + +# Enable silent mode to disable build output when the `-s` option is enabled +silent=false + +# Parse command-line options +while getopts ":fs" opt; do + case $opt in + f) + force=true + ;; + s) + silent=true + ;; + \?) + echo "Invalid option: -$OPTARG" >&2 + exit 1 + ;; + esac +done + +shift $((OPTIND - 1)) + +# Path of the app produced by the build process +app_path="${TARGET_BUILD_DIR}/${WRAPPER_NAME}" + +# Check if the app exists +if [ ! -d "$app_path" ] || [[ "$app_path" != *.app ]]; then + echo "Unable to find the app: $app_path" + exit 1 +fi + +# Check if the app is iOS or macOS +is_ios_app=true +frameworks_dir="$app_path/Frameworks" +if [ -d "$app_path/Contents/MacOS" ]; then + is_ios_app=false + frameworks_dir="$app_path/Contents/Frameworks" +fi + +# Default template directories +templates_dir="$tool_root_path/Templates" +user_templates_dir="$tool_root_path/Templates/UserTemplates" + +# Use user-defined app privacy manifest template if it exists, otherwise fallback to default +app_template_file="$user_templates_dir/$APP_TEMPLATE_FILE_NAME" +if [ ! -f "$app_template_file" ]; then + app_template_file="$templates_dir/$APP_TEMPLATE_FILE_NAME" +fi + +# Use user-defined framework privacy manifest template if it exists, otherwise fallback to default +framework_template_file="$user_templates_dir/$FRAMEWORK_TEMPLATE_FILE_NAME" +if [ ! -f "$framework_template_file" ]; then + framework_template_file="$templates_dir/$FRAMEWORK_TEMPLATE_FILE_NAME" +fi + +# Disable build output in silent mode +if [ "$silent" == false ]; then + # Script used to generate the privacy access report + report_script="$tool_root_path/Report/report.sh" + # An array to record template usage for generating the privacy access report + template_usage_records=() + + # Build output directory + build_dir="$tool_root_path/Build/${PRODUCT_NAME}-${CONFIGURATION}_${MARKETING_VERSION}_${CURRENT_PROJECT_VERSION}_$(date +%Y%m%d%H%M%S)" + # Ensure the build directory exists + mkdir -p "$build_dir" + + # Redirect both stdout and stderr to a log file while keeping console output + exec > >(tee "$build_dir/fix.log") 2>&1 +fi + +# Get the path to the `Info.plist` file for the specified app or framework +function get_plist_file() { + local path="$1" + local version_path="$2" + local plist_file="" + + if [[ "$path" == *.app ]]; then + if [ "$is_ios_app" == true ]; then + plist_file="$path/Info.plist" + else + plist_file="$path/Contents/Info.plist" + fi + elif [[ "$path" == *.framework ]]; then + local framework_path="$(get_framework_path "$path" "$version_path")" + + if [ "$is_ios_app" == true ]; then + plist_file="$framework_path/Info.plist" + else + plist_file="$framework_path/Resources/Info.plist" + fi + fi + + echo "$plist_file" +} + +# Get the path to the executable for the specified app or framework +function get_executable_path() { + local path="$1" + local version_path="$2" + local executable_path="" + + local plist_file="$(get_plist_file "$path" "$version_path")" + local executable_name="$(get_plist_executable "$plist_file")" + + if [[ "$path" == *.app ]]; then + if [ "$is_ios_app" == true ]; then + executable_path="$path/$executable_name" + else + executable_path="$path/Contents/MacOS/$executable_name" + fi + elif [[ "$path" == *.framework ]]; then + local framework_path="$(get_framework_path "$path" "$version_path")" + executable_path="$framework_path/$executable_name" + fi + + echo "$executable_path" +} + +# Analyze the specified binary file for API symbols and their categories +function analyze_binary_file() { + local path="$1" + local -a results=() + + # Check if the API symbol exists in the binary file using `nm` and `strings` + local nm_output=$(nm "$path" 2>/dev/null | xcrun swift-demangle) + local strings_output=$(strings "$path") + local combined_output="$nm_output"$'\n'"$strings_output" + + for api_symbol in "${API_SYMBOLS[@]}"; do + local substrings=($(split_string_by_delimiter "$api_symbol")) + local category=${substrings[0]} + local api=${substrings[1]} + + if echo "$combined_output" | grep -E "$api\$" >/dev/null; then + local index=-1 + for ((i=0; i < ${#results[@]}; i++)); do + local result="${results[i]}" + local result_substrings=($(split_string_by_delimiter "$result")) + # If the category matches an existing result, update it + if [ "$category" == "${result_substrings[0]}" ]; then + index=i + results[i]="${result_substrings[0]}$DELIMITER${result_substrings[1]},$api$DELIMITER${result_substrings[2]}" + break + fi + done + + # If no matching category found, add a new result + if [[ $index -eq -1 ]]; then + results+=("$category$DELIMITER$api$DELIMITER$(encode_path "$path")") + fi + fi + done + + echo "${results[@]}" +} + +# Analyze API usage in a binary file +function analyze_api_usage() { + local path="$1" + local version_path="$2" + local -a results=() + + local binary_file="$(get_executable_path "$path" "$version_path")" + + if [ -f "$binary_file" ]; then + results+=($(analyze_binary_file "$binary_file")) + fi + + echo "${results[@]}" +} + + + +# Get unique categories from analysis results +function get_categories() { + local results=("$@") + local -a categories=() + + for result in "${results[@]}"; do + local substrings=($(split_string_by_delimiter "$result")) + local category=${substrings[0]} + if [[ ! "${categories[@]}" =~ "$category" ]]; then + categories+=("$category") + fi + done + + echo "${categories[@]}" +} + +# Get template file for the specified app or framework +function get_template_file() { + local path="$1" + local version_path="$2" + local template_file="" + + if [[ "$path" == *.app ]]; then + template_file="$app_template_file" + else + # Give priority to the user-defined framework privacy manifest template + local dep_name="$(get_dependency_name "$path")" + if [ -n "$version_path" ]; then + dep_name="$dep_name.$(basename "$version_path")" + fi + + local dep_template_file="$user_templates_dir/${dep_name}.xcprivacy" + if [ -f "$dep_template_file" ]; then + template_file="$dep_template_file" + else + template_file="$framework_template_file" + fi + fi + + echo "$template_file" +} + +# Check if the specified template file should be modified +# +# The following template files will be modified based on analysis: +# * Templates/AppTemplate.xcprivacy +# * Templates/FrameworkTemplate.xcprivacy +# * Templates/UserTemplates/FrameworkTemplate.xcprivacy +function is_template_modifiable() { + local template_file="$1" + + local template_file_name="$(basename "$template_file")" + + if [[ "$template_file" != "$user_templates_dir"* ]] || [ "$template_file_name" == "$FRAMEWORK_TEMPLATE_FILE_NAME" ]; then + return 0 + else + return 1 + fi +} + +# Check if `Hardened Runtime` is enabled for the specified path +function is_hardened_runtime_enabled() { + local path="$1" + + # Check environment variable first + if [ "${ENABLE_HARDENED_RUNTIME:-}" == "YES" ]; then + return 0 + fi + + # Check the code signature flags if environment variable is not set + local flags=$(codesign -dvvv "$path" 2>&1 | grep "flags=") + if echo "$flags" | grep -q "runtime"; then + return 0 + else + return 1 + fi +} + +# Re-sign the target app or framework if code signing is enabled +function resign() { + local path="$1" + + if [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ] && [ "${CODE_SIGNING_REQUIRED:-}" != "NO" ] && [ "${CODE_SIGNING_ALLOWED:-}" != "NO" ]; then + echo "Re-signing $path with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME:-$EXPANDED_CODE_SIGN_IDENTITY}" + + local codesign_cmd="/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} ${OTHER_CODE_SIGN_FLAGS:-} --preserve-metadata=identifier,entitlements" + + if [ "$is_ios_app" == true ]; then + $codesign_cmd "$path" + else + if is_hardened_runtime_enabled "$path"; then + codesign_cmd="$codesign_cmd -o runtime" + fi + + if [ -d "$path/Contents/MacOS" ]; then + find "$path/Contents/MacOS" -type f -name "*.dylib" | while read -r dylib_file; do + $codesign_cmd "$dylib_file" + done + fi + + $codesign_cmd "$path" + fi + fi +} + +# Fix the privacy manifest for the app or specified framework +# To accelerate the build, existing privacy manifests will be left unchanged unless the `-f` option is enabled +# After fixing, the app or framework will be automatically re-signed +function fix() { + local path="$1" + local version_path="$2" + local force_resign="$3" + local privacy_manifest_file="" + + if [[ "$path" == *.app ]]; then + # Per the documentation, the privacy manifest should be placed at the root of the app’s bundle for iOS, while for macOS, it should be located in `Contents/Resources/` within the app’s bundle + # Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-app + if [ "$is_ios_app" == true ]; then + privacy_manifest_file="$path/$PRIVACY_MANIFEST_FILE_NAME" + else + privacy_manifest_file="$path/Contents/Resources/$PRIVACY_MANIFEST_FILE_NAME" + fi + else + # Per the documentation, the privacy manifest should be placed at the root of the iOS framework, while for a macOS framework with multiple versions, it should be located in the `Resources` directory within the corresponding version + # Some SDKs don’t follow the guideline, so we use a search-based approach for now + # Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-framework + local framework_path="$(get_framework_path "$path" "$version_path")" + local privacy_manifest_files=($(search_privacy_manifest_files "$framework_path")) + privacy_manifest_file="$(get_privacy_manifest_file "${privacy_manifest_files[@]}")" + + if [ -z "$privacy_manifest_file" ]; then + if [ "$is_ios_app" == true ]; then + privacy_manifest_file="$framework_path/$PRIVACY_MANIFEST_FILE_NAME" + else + privacy_manifest_file="$framework_path/Resources/$PRIVACY_MANIFEST_FILE_NAME" + fi + fi + fi + + # Check if the privacy manifest file exists + if [ -f "$privacy_manifest_file" ]; then + echo "💡 Found privacy manifest file: $privacy_manifest_file" + + if [ "$force" == false ]; then + if [ "$force_resign" == true ]; then + resign "$path" + fi + echo "✅ Privacy manifest file already exists, skipping fix." + return + fi + else + echo "⚠️ Missing privacy manifest file!" + fi + + local results=($(analyze_api_usage "$path" "$version_path")) + echo "API usage analysis result(s): ${#results[@]}" + print_array "${results[@]}" + + local template_file="$(get_template_file "$path" "$version_path")" + template_usage_records+=("$(basename "$path")$version_path$DELIMITER$template_file") + + # Copy the template file to the privacy manifest location, overwriting if it exists + cp "$template_file" "$privacy_manifest_file" + + if is_template_modifiable "$template_file"; then + local categories=($(get_categories "${results[@]}")) + local remove_categories=() + + # Check if categories is non-empty + if [[ ${#categories[@]} -gt 0 ]]; then + # Convert categories to a single space-separated string for easy matching + local categories_set=" ${categories[*]} " + + # Iterate through each element in `API_CATEGORIES` + for element in "${API_CATEGORIES[@]}"; do + # If element is not found in `categories_set`, add it to `remove_categories` + if [[ ! $categories_set =~ " $element " ]]; then + remove_categories+=("$element") + fi + done + else + # If categories is empty, add all of `API_CATEGORIES` to `remove_categories` + remove_categories=("${API_CATEGORIES[@]}") + fi + + # Remove extra spaces in the XML file to simplify node removal + xmllint --noblanks "$privacy_manifest_file" -o "$privacy_manifest_file" + + # Build a sed command to remove all matching nodes at once + local sed_pattern="" + for category in "${remove_categories[@]}"; do + # Find the node for the current category + local remove_node="$(xmllint --xpath "//dict[string='$category']" "$privacy_manifest_file" 2>/dev/null || true)" + + # If the node is found, escape special characters and append it to the sed pattern + if [ -n "$remove_node" ]; then + local escaped_node=$(echo "$remove_node" | sed 's/[\/&]/\\&/g') + sed_pattern+="s/$escaped_node//g;" + fi + done + + # Apply the combined sed pattern to the file if it's not empty + if [ -n "$sed_pattern" ]; then + sed -i "" "$sed_pattern" "$privacy_manifest_file" + fi + + # Reformat the XML file to restore spaces for readability + xmllint --format "$privacy_manifest_file" -o "$privacy_manifest_file" + fi + + resign "$path" + + echo "✅ Privacy manifest file fixed: $privacy_manifest_file." +} + +# Fix privacy manifests for all frameworks +function fix_frameworks() { + if ! [ -d "$frameworks_dir" ]; then + return + fi + + echo "🛠️ Fixing Frameworks..." + for path in "$frameworks_dir"/*; do + if [ -d "$path" ]; then + local dep_name="$(get_dependency_name "$path")" + local versions_dir="$path/Versions" + + if [ -d "$versions_dir" ]; then + for version in $(ls -1 "$versions_dir" | grep -vE '^Current$'); do + local version_path="Versions/$version" + echo "Analyzing $dep_name ($version_path) ..." + fix "$path" "$version_path" false + echo "" + done + else + echo "Analyzing $dep_name ..." + fix "$path" "" false + echo "" + fi + fi + done +} + +# Fix the privacy manifest for the app +function fix_app() { + echo "🛠️ Fixing $(basename "$app_path" .app) App..." + # Since the framework may have undergone fixes, the app must be forcefully re-signed + fix "$app_path" "" true + echo "" +} + +# Generate the privacy access report for the app +function generate_report() { + local original="$1" + + if [ "$silent" == true ]; then + return + fi + + local app_name="$(basename "$app_path")" + local name="${app_name%.*}" + local report_name="" + + # Adjust output names if the app is flagged as original + if [ "$original" == true ]; then + app_name="${name}-original.app" + report_name="report-original.html" + else + app_name="$name.app" + report_name="report.html" + fi + + local target_app_path="$build_dir/$app_name" + local report_path="$build_dir/$report_name" + + echo "Copy app to $target_app_path" + rsync -a "$app_path/" "$target_app_path/" + + # Generate the privacy access report using the script + sh "$report_script" "$target_app_path" "$report_path" "${template_usage_records[@]}" + echo "" +} + +start_time=$(date +%s) + +generate_report true +fix_frameworks +fix_app +generate_report false + +end_time=$(date +%s) + +echo "🎉 All fixed! ⏰ $((end_time - start_time)) seconds" +echo "🌟 If you found this script helpful, please consider giving it a star on GitHub. Your support is appreciated. Thank you!" +echo "🔗 Homepage: https://github.com/crasowas/app_privacy_manifest_fixer" +echo "🐛 Report issues: https://github.com/crasowas/app_privacy_manifest_fixer/issues" diff --git a/ios/App/app_privacy_manifest_fixer/install.sh b/ios/App/app_privacy_manifest_fixer/install.sh new file mode 100755 index 00000000..153c7049 --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/install.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +# Copyright (c) 2024, crasowas. +# +# Use of this source code is governed by a MIT-style license +# that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +set -e + +# Check if at least one argument (project_path) is provided +if [[ "$#" -lt 1 ]]; then + echo "Usage: $0 [options...]" + exit 1 +fi + +project_path="$1" + +shift + +options=() +install_builds_only=false + +# Check if the `--install-builds-only` option is provided and separate it from other options +for arg in "$@"; do + if [ "$arg" == "--install-builds-only" ]; then + install_builds_only=true + else + options+=("$arg") + fi +done + +# Verify Ruby installation +if ! command -v ruby &>/dev/null; then + echo "Ruby is not installed. Please install Ruby and try again." + exit 1 +fi + +# Check if xcodeproj gem is installed +if ! gem list -i xcodeproj &>/dev/null; then + echo "The 'xcodeproj' gem is not installed." + read -p "Would you like to install it now? [Y/n] " response + if [[ "$response" =~ ^[Nn]$ ]]; then + echo "Please install 'xcodeproj' manually and re-run the script." + exit 1 + fi + gem install xcodeproj || { echo "Failed to install 'xcodeproj'."; exit 1; } +fi + +# Convert project path to an absolute path if it is relative +if [[ ! "$project_path" = /* ]]; then + project_path="$(realpath "$project_path")" +fi + +# Absolute path of the script and the tool's root directory +script_path="$(realpath "$0")" +tool_root_path="$(dirname "$script_path")" + +tool_portable_path="$tool_root_path" +# If the tool's root directory is inside the project path, make the path portable +if [[ "$tool_root_path" == "$project_path"* ]]; then + # Extract the path of the tool's root directory relative to the project path + tool_relative_path="${tool_root_path#$project_path}" + # Formulate a portable path using the `PROJECT_DIR` environment variable provided by Xcode + tool_portable_path="\${PROJECT_DIR}${tool_relative_path}" +fi + +run_script_content="\"$tool_portable_path/fixer.sh\" ${options[@]}" + +# Execute the Ruby helper script +ruby "$tool_root_path/Helper/xcode_install_helper.rb" "$project_path" "$run_script_content" "$install_builds_only" diff --git a/ios/App/app_privacy_manifest_fixer/uninstall.sh b/ios/App/app_privacy_manifest_fixer/uninstall.sh new file mode 100755 index 00000000..942a86de --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/uninstall.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Copyright (c) 2024, crasowas. +# +# Use of this source code is governed by a MIT-style license +# that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +set -e + +# Check if the project path is provided +if [[ $# -eq 0 ]]; then + echo "Usage: $0 " + exit 1 +fi + +project_path="$1" + +# Verify Ruby installation +if ! command -v ruby &>/dev/null; then + echo "Ruby is not installed. Please install Ruby and try again." + exit 1 +fi + +# Check if xcodeproj gem is installed +if ! gem list -i xcodeproj &>/dev/null; then + echo "The 'xcodeproj' gem is not installed." + read -p "Would you like to install it now? [Y/n] " response + if [[ "$response" =~ ^[Nn]$ ]]; then + echo "Please install 'xcodeproj' manually and re-run the script." + exit 1 + fi + gem install xcodeproj || { echo "Failed to install 'xcodeproj'."; exit 1; } +fi + +# Convert project path to an absolute path if it is relative +if [[ ! "$project_path" = /* ]]; then + project_path="$(realpath "$project_path")" +fi + +# Absolute path of the script and the tool's root directory +script_path="$(realpath "$0")" +tool_root_path="$(dirname "$script_path")" + +# Execute the Ruby helper script +ruby "$tool_root_path/Helper/xcode_uninstall_helper.rb" "$project_path" diff --git a/ios/App/app_privacy_manifest_fixer/upgrade.sh b/ios/App/app_privacy_manifest_fixer/upgrade.sh new file mode 100755 index 00000000..f5e6afe9 --- /dev/null +++ b/ios/App/app_privacy_manifest_fixer/upgrade.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +# Copyright (c) 2024, crasowas. +# +# Use of this source code is governed by a MIT-style license +# that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +set -e + +# Absolute path of the script and the tool's root directory +script_path="$(realpath "$0")" +tool_root_path="$(dirname "$script_path")" + +# Repository details +readonly REPO_OWNER="crasowas" +readonly REPO_NAME="app_privacy_manifest_fixer" + +# URL to fetch the latest release information +readonly LATEST_RELEASE_URL="https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases/latest" + +# Fetch the release information from GitHub API +release_info=$(curl -s "$LATEST_RELEASE_URL") + +# Extract the latest release version, download URL, and published time +latest_version=$(echo "$release_info" | grep -o '"tag_name": "[^"]*' | sed 's/"tag_name": "//') +download_url=$(echo "$release_info" | grep -o '"zipball_url": "[^"]*' | sed 's/"zipball_url": "//') +published_time=$(echo "$release_info" | grep -o '"published_at": "[^"]*' | sed 's/"published_at": "//') + +# Ensure the latest version, download URL, and published time are successfully retrieved +if [ -z "$latest_version" ] || [ -z "$download_url" ] || [ -z "$published_time" ]; then + echo "Unable to fetch the latest release information." + echo "Request URL: $LATEST_RELEASE_URL" + echo "Response Data: $release_info" + exit 1 +fi + +# Convert UTC time to local time +published_time=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$published_time" +"%s" | xargs -I{} date -j -r {} +"%Y-%m-%d %H:%M:%S %z") + +# Read the current tool's version from the VERSION file +tool_version_file="$tool_root_path/VERSION" +if [ ! -f "$tool_version_file" ]; then + echo "VERSION file not found." + exit 1 +fi + +local_version="$(cat "$tool_version_file")" + +# Skip upgrade if the current version is already the latest +if [ "$local_version" == "$latest_version" ]; then + echo "Version $latest_version • $published_time" + echo "Already up-to-date." + exit 0 +fi + +# Create a temporary directory for downloading the release +temp_dir=$(mktemp -d) +trap "rm -rf $temp_dir" EXIT + +download_file_name="latest-release.tar.gz" + +# Download the latest release archive +echo "Downloading version $latest_version..." +curl -L "$download_url" -o "$temp_dir/$download_file_name" + +# Check if the download was successful +if [ $? -ne 0 ]; then + echo "Download failed, please check your network connection and try again." + exit 1 +fi + +# Extract the downloaded release archive +echo "Extracting files..." +tar -xzf "$temp_dir/$download_file_name" -C "$temp_dir" + +# Find the extracted release +extracted_release_path=$(find "$temp_dir" -mindepth 1 -maxdepth 1 -type d -name "*$REPO_NAME*" | head -n 1) + +# Verify that an extracted release was found +if [ -z "$extracted_release_path" ]; then + echo "No extracted release found for the latest version." + exit 1 +fi + +user_templates_dir="$tool_root_path/Templates/UserTemplates" +user_templates_backup_dir="$temp_dir/Templates/UserTemplates" + +# Backup the user templates directory if it exists +if [ -d "$user_templates_dir" ]; then + echo "Backing up user templates..." + mkdir -p "$user_templates_backup_dir" + rsync -a --exclude='.*' "$user_templates_dir/" "$user_templates_backup_dir/" +fi + +# Replace old version files with the new version files +echo "Replacing old version files..." +rsync -a --delete "$extracted_release_path/" "$tool_root_path/" + +# Restore the user templates from the backup +if [ -d "$user_templates_backup_dir" ]; then + echo "Restoring user templates..." + rsync -a --exclude='.*' "$user_templates_backup_dir/" "$user_templates_dir/" +fi + +# Upgrade complete +echo "Version $latest_version • $published_time" +echo "Upgrade completed successfully!"