Skip to content

Commit 20e8b43

Browse files
authored
refactor(apple): port 'privacy_manifest.rb' to JS (#2419)
1 parent 495ab48 commit 20e8b43

File tree

4 files changed

+279
-0
lines changed

4 files changed

+279
-0
lines changed

ios/privacyManifest.mjs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// @ts-check
2+
import * as nodefs from "node:fs";
3+
import * as path from "node:path";
4+
import { isObject, toPlist } from "./utils.mjs";
5+
6+
/**
7+
* @import { ApplePlatform, JSONObject, JSONValue } from "../scripts/types.js";
8+
*
9+
* @typedef {{
10+
* NSPrivacyTracking: boolean;
11+
* NSPrivacyTrackingDomains: JSONValue[];
12+
* NSPrivacyCollectedDataTypes: JSONValue[];
13+
* NSPrivacyAccessedAPITypes: JSONValue[];
14+
* }} PrivacyManifest;
15+
*/
16+
17+
// https://developer.apple.com/documentation/bundleresources/privacy_manifest_files
18+
export const PRIVACY_ACCESSED_API_TYPES = "NSPrivacyAccessedAPITypes";
19+
export const PRIVACY_COLLECTED_DATA_TYPES = "NSPrivacyCollectedDataTypes";
20+
export const PRIVACY_TRACKING = "NSPrivacyTracking";
21+
export const PRIVACY_TRACKING_DOMAINS = "NSPrivacyTrackingDomains";
22+
23+
// https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api
24+
export const PRIVACY_ACCESSED_API_TYPE = "NSPrivacyAccessedAPIType";
25+
export const PRIVACY_ACCESSED_API_TYPE_REASONS =
26+
"NSPrivacyAccessedAPITypeReasons";
27+
export const PRIVACY_ACCESSED_API_CATEGORY_FILE_TIMESTAMP =
28+
"NSPrivacyAccessedAPICategoryFileTimestamp";
29+
export const PRIVACY_ACCESSED_API_CATEGORY_SYSTEM_BOOT_TIME =
30+
"NSPrivacyAccessedAPICategorySystemBootTime";
31+
export const PRIVACY_ACCESSED_API_CATEGORY_USER_DEFAULTS =
32+
"NSPrivacyAccessedAPICategoryUserDefaults";
33+
34+
/**
35+
* @param {JSONObject} appConfig
36+
* @param {ApplePlatform} targetPlatform
37+
* @returns {JSONObject | undefined}
38+
*/
39+
function getUserPrivacyManifest(appConfig, targetPlatform) {
40+
const platformConfig = appConfig[targetPlatform];
41+
if (!isObject(platformConfig)) {
42+
return;
43+
}
44+
45+
const userPrivacyManifest = platformConfig["privacyManifest"];
46+
if (!isObject(userPrivacyManifest)) {
47+
return;
48+
}
49+
50+
return userPrivacyManifest;
51+
}
52+
53+
/**
54+
* @param {JSONObject} appConfig
55+
* @param {ApplePlatform} targetPlatform
56+
* @param {string} destination
57+
* @returns {Promise<void>}
58+
*/
59+
export async function generatePrivacyManifest(
60+
appConfig,
61+
targetPlatform,
62+
destination,
63+
fs = nodefs
64+
) {
65+
/** @type {PrivacyManifest} */
66+
const manifest = {
67+
[PRIVACY_TRACKING]: false,
68+
[PRIVACY_TRACKING_DOMAINS]: [],
69+
[PRIVACY_COLLECTED_DATA_TYPES]: [],
70+
[PRIVACY_ACCESSED_API_TYPES]: [
71+
{
72+
[PRIVACY_ACCESSED_API_TYPE]:
73+
PRIVACY_ACCESSED_API_CATEGORY_FILE_TIMESTAMP,
74+
[PRIVACY_ACCESSED_API_TYPE_REASONS]: ["C617.1"],
75+
},
76+
{
77+
[PRIVACY_ACCESSED_API_TYPE]:
78+
PRIVACY_ACCESSED_API_CATEGORY_SYSTEM_BOOT_TIME,
79+
[PRIVACY_ACCESSED_API_TYPE_REASONS]: ["35F9.1"],
80+
},
81+
{
82+
[PRIVACY_ACCESSED_API_TYPE]:
83+
PRIVACY_ACCESSED_API_CATEGORY_USER_DEFAULTS,
84+
[PRIVACY_ACCESSED_API_TYPE_REASONS]: ["CA92.1"],
85+
},
86+
],
87+
};
88+
89+
const userPrivacyManifest = getUserPrivacyManifest(appConfig, targetPlatform);
90+
if (userPrivacyManifest) {
91+
const tracking = userPrivacyManifest[PRIVACY_TRACKING];
92+
if (typeof tracking === "boolean") {
93+
manifest[PRIVACY_TRACKING] = tracking;
94+
}
95+
96+
const fields = /** @type {const} */ ([
97+
PRIVACY_TRACKING_DOMAINS,
98+
PRIVACY_COLLECTED_DATA_TYPES,
99+
PRIVACY_ACCESSED_API_TYPES,
100+
]);
101+
for (const field of fields) {
102+
const value = userPrivacyManifest[field];
103+
if (Array.isArray(value)) {
104+
manifest[field].push(...value);
105+
}
106+
}
107+
}
108+
109+
const xcprivacy = await toPlist(manifest);
110+
fs.writeFileSync(path.join(destination, "PrivacyInfo.xcprivacy"), xcprivacy);
111+
}

ios/utils.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// @ts-check
2+
import { spawn } from "node:child_process";
23
import * as path from "node:path";
34
import { fileURLToPath } from "node:url";
45

@@ -25,3 +26,31 @@ export function projectPath(p, targetPlatform) {
2526
const packageDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
2627
return path.join(packageDir, targetPlatform, p);
2728
}
29+
30+
/**
31+
* @param {JSONObject} source
32+
* @returns {Promise<string>}
33+
*/
34+
export function toPlist(source) {
35+
return new Promise((resolve, reject) => {
36+
const args = ["-convert", "xml1", "-r", "-o", "-", "--", "-"];
37+
const plutil = spawn("/usr/bin/plutil", args, {
38+
stdio: ["pipe", "pipe", "inherit"],
39+
});
40+
41+
/** @type {string[]} */
42+
const data = [];
43+
plutil.stdout.on("data", (chunk) => data.push(chunk.toString()));
44+
45+
plutil.on("exit", (exitCode) => {
46+
if (exitCode !== 0) {
47+
reject(new Error("Failed to generate 'PrivacyInfo.xcprivacy'"));
48+
} else {
49+
resolve(data.join(""));
50+
}
51+
});
52+
53+
plutil.stdin.write(JSON.stringify(source));
54+
plutil.stdin.end();
55+
});
56+
}

test/ios/privacyManifest.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { deepEqual } from "node:assert/strict";
2+
import { afterEach, describe, it } from "node:test";
3+
import { generatePrivacyManifest as generatePrivacyManifestActual } from "../../ios/privacyManifest.mjs";
4+
import type { JSONObject } from "../../scripts/types.ts";
5+
import { fs, setMockFiles } from "../fs.mock.ts";
6+
7+
const macosOnly = { skip: process.platform === "win32" };
8+
9+
describe("generatePrivacyManifest()", macosOnly, () => {
10+
function generatePrivacyManifest(config: JSONObject): Promise<void> {
11+
const destination = ".";
12+
fs.mkdirSync(destination, { recursive: true, mode: 0o755 });
13+
return generatePrivacyManifestActual(config, "ios", destination, fs);
14+
}
15+
16+
function readPrivacyManifest() {
17+
return fs
18+
.readFileSync("PrivacyInfo.xcprivacy", { encoding: "utf-8" })
19+
.split("\n");
20+
}
21+
22+
afterEach(() => {
23+
setMockFiles();
24+
});
25+
26+
it("generates a default manifest", async () => {
27+
await generatePrivacyManifest({});
28+
29+
deepEqual(readPrivacyManifest(), DEFAULT_PRIVACY_MANIFEST);
30+
});
31+
32+
it("handles invalid configuration", async () => {
33+
await generatePrivacyManifest({ ios: { privacyManifest: "YES" } });
34+
35+
deepEqual(readPrivacyManifest(), DEFAULT_PRIVACY_MANIFEST);
36+
});
37+
38+
it("appends to default manifest", async () => {
39+
await generatePrivacyManifest({
40+
ios: {
41+
privacyManifest: {
42+
NSPrivacyTracking: true,
43+
NSPrivacyTrackingDomains: ["test"],
44+
NSPrivacyAccessedAPITypes: ["test"],
45+
},
46+
},
47+
});
48+
49+
deepEqual(readPrivacyManifest(), [
50+
'<?xml version="1.0" encoding="UTF-8"?>',
51+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
52+
'<plist version="1.0">',
53+
"<dict>",
54+
" <key>NSPrivacyAccessedAPITypes</key>",
55+
" <array>",
56+
" <dict>",
57+
" <key>NSPrivacyAccessedAPIType</key>",
58+
" <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>",
59+
" <key>NSPrivacyAccessedAPITypeReasons</key>",
60+
" <array>",
61+
" <string>C617.1</string>",
62+
" </array>",
63+
" </dict>",
64+
" <dict>",
65+
" <key>NSPrivacyAccessedAPIType</key>",
66+
" <string>NSPrivacyAccessedAPICategorySystemBootTime</string>",
67+
" <key>NSPrivacyAccessedAPITypeReasons</key>",
68+
" <array>",
69+
" <string>35F9.1</string>",
70+
" </array>",
71+
" </dict>",
72+
" <dict>",
73+
" <key>NSPrivacyAccessedAPIType</key>",
74+
" <string>NSPrivacyAccessedAPICategoryUserDefaults</string>",
75+
" <key>NSPrivacyAccessedAPITypeReasons</key>",
76+
" <array>",
77+
" <string>CA92.1</string>",
78+
" </array>",
79+
" </dict>",
80+
" <string>test</string>",
81+
" </array>",
82+
" <key>NSPrivacyCollectedDataTypes</key>",
83+
" <array/>",
84+
" <key>NSPrivacyTracking</key>",
85+
" <true/>",
86+
" <key>NSPrivacyTrackingDomains</key>",
87+
" <array>",
88+
" <string>test</string>",
89+
" </array>",
90+
"</dict>",
91+
"</plist>",
92+
"",
93+
]);
94+
});
95+
});
96+
97+
const DEFAULT_PRIVACY_MANIFEST = [
98+
'<?xml version="1.0" encoding="UTF-8"?>',
99+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
100+
'<plist version="1.0">',
101+
"<dict>",
102+
" <key>NSPrivacyAccessedAPITypes</key>",
103+
" <array>",
104+
" <dict>",
105+
" <key>NSPrivacyAccessedAPIType</key>",
106+
" <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>",
107+
" <key>NSPrivacyAccessedAPITypeReasons</key>",
108+
" <array>",
109+
" <string>C617.1</string>",
110+
" </array>",
111+
" </dict>",
112+
" <dict>",
113+
" <key>NSPrivacyAccessedAPIType</key>",
114+
" <string>NSPrivacyAccessedAPICategorySystemBootTime</string>",
115+
" <key>NSPrivacyAccessedAPITypeReasons</key>",
116+
" <array>",
117+
" <string>35F9.1</string>",
118+
" </array>",
119+
" </dict>",
120+
" <dict>",
121+
" <key>NSPrivacyAccessedAPIType</key>",
122+
" <string>NSPrivacyAccessedAPICategoryUserDefaults</string>",
123+
" <key>NSPrivacyAccessedAPITypeReasons</key>",
124+
" <array>",
125+
" <string>CA92.1</string>",
126+
" </array>",
127+
" </dict>",
128+
" </array>",
129+
" <key>NSPrivacyCollectedDataTypes</key>",
130+
" <array/>",
131+
" <key>NSPrivacyTracking</key>",
132+
" <false/>",
133+
" <key>NSPrivacyTrackingDomains</key>",
134+
" <array/>",
135+
"</dict>",
136+
"</plist>",
137+
"",
138+
];

test/pack.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ describe("npm pack", () => {
157157
"ios/info_plist.rb",
158158
"ios/node.rb",
159159
"ios/pod_helpers.rb",
160+
"ios/privacyManifest.mjs",
160161
"ios/privacy_manifest.rb",
161162
"ios/test_app.rb",
162163
"ios/use_react_native-0.70.rb",

0 commit comments

Comments
 (0)