Skip to content

Commit 425fde6

Browse files
committed
initial implementation
1 parent 2d560d0 commit 425fde6

11 files changed

+438
-20
lines changed

LICENSE

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2024 fxwx23
3+
Copyright (c) 2024 fxwx23 (Fumitaka Watanabe)
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

Package.swift

+23-16
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,27 @@
44
import PackageDescription
55

66
let package = Package(
7-
name: "PrivacyReportGen",
8-
products: [
9-
// Products define the executables and libraries a package produces, making them visible to other packages.
10-
.library(
11-
name: "PrivacyReportGen",
12-
targets: ["PrivacyReportGen"]),
13-
],
14-
targets: [
15-
// Targets are the basic building blocks of a package, defining a module or a test suite.
16-
// Targets can depend on other targets in this package and products from dependencies.
17-
.target(
18-
name: "PrivacyReportGen"),
19-
.testTarget(
20-
name: "PrivacyReportGenTests",
21-
dependencies: ["PrivacyReportGen"]),
22-
]
7+
name: "PrivacyReportGen",
8+
platforms: [.macOS(.v14)],
9+
products: [
10+
.plugin(name: "PrivacyReport Generate", targets: ["PrivacyReport Generate"])
11+
],
12+
targets: [
13+
.plugin(
14+
name: "PrivacyReport Generate",
15+
capability: .command(
16+
intent: .custom(
17+
verb: "generate-privacy-report",
18+
description: "Generate privacy report from xcarchive"),
19+
permissions: [
20+
.writeToPackageDirectory(reason: "Save generated privacy report if needed")
21+
]
22+
)
23+
),
24+
.target(name: "PrivacyReportGen"),
25+
.testTarget(
26+
name: "PrivacyReportGenTests",
27+
dependencies: ["PrivacyReportGen"]
28+
),
29+
]
2330
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Foundation
2+
import PackagePlugin
3+
4+
enum ArgumentParsingError: LocalizedError, CustomStringConvertible {
5+
case missingValue(option: String)
6+
case unintendedValue(option: String)
7+
8+
var description: String {
9+
switch self {
10+
case .missingValue(let option):
11+
return "missing value for the option named --\(option)"
12+
case .unintendedValue(let option):
13+
return "unintended value for the option named --\(option)"
14+
}
15+
}
16+
17+
var errorDescription: String? {
18+
return description
19+
}
20+
}
21+
22+
extension ArgumentExtractor {
23+
mutating func extractXCArchiveURL() throws -> URL {
24+
let optionName = "xcarchive-path"
25+
let xcarchivePath = extractOption(named: optionName)
26+
guard let path = xcarchivePath.first else {
27+
throw ArgumentParsingError.missingValue(option: optionName)
28+
}
29+
guard path.hasSuffix(".xcarchive") else {
30+
throw ArgumentParsingError.unintendedValue(option: optionName)
31+
}
32+
return URL(fileURLWithPath: path, isDirectory: true)
33+
}
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Foundation
2+
import PackagePlugin
3+
4+
/// Generate privacy report from xcarchive.
5+
@main struct PrivacyReportGenerate: CommandPlugin {
6+
7+
func performCommand(context: PluginContext, arguments: [String]) throws {
8+
var argumentExtractor = ArgumentExtractor(arguments)
9+
10+
let xcachiveURL = try argumentExtractor.extractXCArchiveURL()
11+
12+
let jsonFlag = argumentExtractor.extractFlag(named: "json")
13+
let outputDirectoryPathOption = argumentExtractor.extractOption(named: "output-directory")
14+
let reportNameOption = argumentExtractor.extractOption(named: "report-name")
15+
16+
let privacyReportGen = PrivacyReportGen(
17+
arguments: .init(
18+
outputFileType: jsonFlag == 1 ? .json : .plist,
19+
outputDirectoryPath: outputDirectoryPathOption.first,
20+
reportName: reportNameOption.first
21+
)
22+
)
23+
24+
try privacyReportGen.generateReportFromXCArchive(at: xcachiveURL)
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../Sources/PrivacyReportGen

README.md

+47-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,47 @@
1-
# PrivacyReportGen
1+
# PrivacyReportGen
2+
3+
PrivacyReportGen is an open-source tool that generates JSON or plist files from xcarchive files, containing the same data as the [privacy peport](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests#4239187) PDF file that can be generated from Xcode. This allows for easy comparison of differences between reports.
4+
5+
## Features
6+
7+
- Extracts privacy report data from xcarchive files
8+
- Generates JSON and plist format files
9+
- Facilitates comparison of privacy report data
10+
11+
## Installation
12+
13+
To use PrivacyReportGen, install it via Swift Package Manager. Add PrivacyReportGen as plugin in your `Package.swift`.
14+
15+
```Package.swift
16+
let package = Package(
17+
name: "Tools",
18+
platforms: [.macOS(.v14)],
19+
products: [ ... ],
20+
dependencies: [
21+
...
22+
.package(url: "https://github.com/fxwx23/PrivacyReportGen", exact: "0.0.0"),
23+
],
24+
targets: [ ... ]
25+
)
26+
```
27+
28+
## Usage
29+
30+
To generate a privacy report in JSON or plist format, run the following command plugin:
31+
```bash
32+
$ swift package plugin generate-privacy-report --xcarchive-path '/path/to/your/App.xcarchive'
33+
```
34+
35+
### Command Options
36+
- `--xcarchive-path` : Specifies the path to the xcarchive file from which to generate the privacy report data. (Required)
37+
- `--json` : Specifies JSON as the output format for the generated privacy report. (default is plist)
38+
- `--output-directory` : Specifies the directory where the generated privacy report file will be saved. (default is package path)
39+
- `--report-name` : Specifies the name of the generated privacy report file. (default is `PrivacyReport` )
40+
41+
## Contributing
42+
43+
Contributions are welcome! Please fork the repository and submit a pull request with your improvements.
44+
45+
## License
46+
47+
PrivacyReportGen is released under the MIT License. See LICENSE for more information.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import Foundation
2+
3+
/// AppPrivacyConfiguration is mapping for prvacy manifest named PrivacyInfo.xcprivacy.
4+
///
5+
/// refs:
6+
/// - https://developer.apple.com/documentation/bundleresources/privacy_manifest_files
7+
/// - https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/adding_a_privacy_manifest_to_your_app_or_third-party_sdk
8+
/// - https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests
9+
/// - https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api
10+
struct AppPrivacyConfiguration: Codable {
11+
var isTrackingEnabled: Bool?
12+
var trackingDomains: [String]?
13+
var privacyNutritionLabels: [PrivacyNutritionLabel]
14+
var privacyAccessedAPITypes: [PrivacyAccessedAPIType]
15+
16+
enum CodingKeys: String, CodingKey {
17+
case isTrackingEnabled = "NSPrivacyTracking"
18+
case trackingDomains = "NSPrivacyTrackingDomains"
19+
case privacyNutritionLabels = "NSPrivacyCollectedDataTypes"
20+
case privacyAccessedAPITypes = "NSPrivacyAccessedAPITypes"
21+
}
22+
23+
struct PrivacyNutritionLabel: Codable {
24+
var collectedDataType: CollectedDataType
25+
var isLinkedToUser: Bool
26+
var isUsedForTracking: Bool
27+
var collectionPurposes: [CollectionPurpose]
28+
29+
enum CodingKeys: String, CodingKey {
30+
case collectedDataType = "NSPrivacyCollectedDataType"
31+
case isLinkedToUser = "NSPrivacyCollectedDataTypeLinked"
32+
case isUsedForTracking = "NSPrivacyCollectedDataTypeTracking"
33+
case collectionPurposes = "NSPrivacyCollectedDataTypePurposes"
34+
}
35+
36+
enum CollectedDataType: String, Codable {
37+
case name = "NSPrivacyCollectedDataTypeName"
38+
case emailAddress = "NSPrivacyCollectedDataTypeEmailAddress"
39+
case phoneNumber = "NSPrivacyCollectedDataTypePhoneNumber"
40+
case physicalAddress = "NSPrivacyCollectedDataTypePhysicalAddress"
41+
case otherUserContactInfo = "NSPrivacyCollectedDataTypeOtherUserContactInfo"
42+
case health = "NSPrivacyCollectedDataTypeHealth"
43+
case fitness = "NSPrivacyCollectedDataTypeFitness"
44+
case paymentInfo = "NSPrivacyCollectedDataTypePaymentInfo"
45+
case creditInfo = "NSPrivacyCollectedDataTypeCreditInfo"
46+
case otherFinancialInfo = "NSPrivacyCollectedDataTypeOtherFinancialInfo"
47+
case preciseLocation = "NSPrivacyCollectedDataTypePreciseLocation"
48+
case coarseLocation = "NSPrivacyCollectedDataTypeCoarseLocation"
49+
case sensitiveInfo = "NSPrivacyCollectedDataTypeSensitiveInfo"
50+
case contacts = "NSPrivacyCollectedDataTypeContacts"
51+
case emailsOrTextMessages = "NSPrivacyCollectedDataTypeEmailsOrTextMessages"
52+
case photosOrVideos = "NSPrivacyCollectedDataTypePhotosorVideos"
53+
case audioData = "NSPrivacyCollectedDataTypeAudioData"
54+
case gameplayContent = "NSPrivacyCollectedDataTypeGameplayContent"
55+
case customerSupport = "NSPrivacyCollectedDataTypeCustomerSupport"
56+
case otherUserContent = "NSPrivacyCollectedDataTypeOtherUserContent"
57+
case browsingHistory = "NSPrivacyCollectedDataTypeBrowsingHistory"
58+
case searchHistory = "NSPrivacyCollectedDataTypeSearchHistory"
59+
case userId = "NSPrivacyCollectedDataTypeUserID"
60+
case deviceId = "NSPrivacyCollectedDataTypeDeviceID"
61+
case purchaseHistory = "NSPrivacyCollectedDataTypePurchaseHistory"
62+
case productInteraction = "NSPrivacyCollectedDataTypeProductInteraction"
63+
case advertisingData = "NSPrivacyCollectedDataTypeAdvertisingData"
64+
case otherUsageData = "NSPrivacyCollectedDataTypeOtherUsageData"
65+
case crashData = "NSPrivacyCollectedDataTypeCrashData"
66+
case performanceData = "NSPrivacyCollectedDataTypePerformanceData"
67+
case otherDiagnosticData = "NSPrivacyCollectedDataTypeOtherDiagnosticData"
68+
case environmentScanning = "NSPrivacyCollectedDataTypeEnvironmentScanning"
69+
case hands = "NSPrivacyCollectedDataTypeHands"
70+
case head = "NSPrivacyCollectedDataTypeHead"
71+
case otherDataTypes = "NSPrivacyCollectedDataTypeOtherDataTypes"
72+
}
73+
74+
enum CollectionPurpose: String, Codable {
75+
case thirdPartyAdvertising = "NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising"
76+
case developerAdvertising = "NSPrivacyCollectedDataTypePurposeDeveloperAdvertising"
77+
case analytics = "NSPrivacyCollectedDataTypePurposeAnalytics"
78+
case productPersonalization = "NSPrivacyCollectedDataTypePurposeProductPersonalization"
79+
case appFunctionality = "NSPrivacyCollectedDataTypePurposeAppFunctionality"
80+
case purposeOther = "NSPrivacyCollectedDataTypePurposeOther"
81+
}
82+
}
83+
84+
struct PrivacyAccessedAPIType: Codable {
85+
var accessedAPIType: String
86+
var accessedAPIReasons: [String]
87+
88+
enum CodingKeys: String, CodingKey {
89+
case accessedAPIType = "NSPrivacyAccessedAPIType"
90+
case accessedAPIReasons = "NSPrivacyAccessedAPITypeReasons"
91+
}
92+
}
93+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import Foundation
2+
3+
enum PrivacyManifestExploringError: LocalizedError, CustomStringConvertible {
4+
case directoryEnumeratorInitializationFailed
5+
var description: String {
6+
switch self {
7+
case .directoryEnumeratorInitializationFailed:
8+
"failed to initialize directory enumerator"
9+
}
10+
}
11+
12+
var errorDescription: String? {
13+
return description
14+
}
15+
}
16+
17+
class PrivacyManifestExplorer {
18+
private static let fileManager = FileManager.default
19+
20+
func exploreManifestURLs(at xcachiveURL: URL) throws -> [URL] {
21+
let resourceKeys = Set<URLResourceKey>([.nameKey, .isDirectoryKey])
22+
guard
23+
let directoryEnumerator = Self.fileManager.enumerator(
24+
at: xcachiveURL,
25+
includingPropertiesForKeys: Array(resourceKeys),
26+
options: [.skipsHiddenFiles, .producesRelativePathURLs]
27+
)
28+
else {
29+
throw PrivacyManifestExploringError.directoryEnumeratorInitializationFailed
30+
}
31+
32+
var manifestURLs: [URL] = []
33+
for case let fileURL as URL in directoryEnumerator {
34+
guard let resourceValues = try? fileURL.resourceValues(forKeys: resourceKeys),
35+
let isDirectory = resourceValues.isDirectory,
36+
let name = resourceValues.name
37+
else {
38+
continue
39+
}
40+
41+
if isDirectory {
42+
if shouldSkipDescendants(at: directoryEnumerator.level, name: name) {
43+
directoryEnumerator.skipDescendants()
44+
}
45+
} else if name == "PrivacyInfo.xcprivacy" {
46+
manifestURLs.append(fileURL)
47+
}
48+
}
49+
50+
return manifestURLs
51+
}
52+
53+
private func shouldSkipDescendants(at directoryLevel: Int, name: String) -> Bool {
54+
switch (directoryLevel, name) {
55+
case (1, "Products"), (2, "Applications"), (4, "Frameworks"):
56+
return false
57+
case (3, _) where name.hasSuffix(".app"):
58+
return false
59+
case _ where name.hasSuffix(".bundle") || name.hasSuffix(".framework"):
60+
return false
61+
default:
62+
return true
63+
}
64+
}
65+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import Foundation
2+
3+
struct PrivacyManifestFile {
4+
let path: String
5+
let configuration: AppPrivacyConfiguration
6+
}

0 commit comments

Comments
 (0)