From b67127807116e824d2c3f06513115d44bb0e31fc Mon Sep 17 00:00:00 2001 From: Tommy Nguyen <4123478+tido64@users.noreply.github.com> Date: Wed, 19 Mar 2025 14:14:36 +0100 Subject: [PATCH 1/2] refactor(apple): port 'entitlements.rb' to JS --- ios/entitlements.mjs | 70 +++++++++++++++ ios/privacyManifest.mjs | 5 +- ios/utils.mjs | 5 +- test/ios/entitlements.test.ts | 165 ++++++++++++++++++++++++++++++++++ test/pack.test.ts | 1 + 5 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 ios/entitlements.mjs create mode 100644 test/ios/entitlements.test.ts diff --git a/ios/entitlements.mjs b/ios/entitlements.mjs new file mode 100644 index 000000000..38553e33f --- /dev/null +++ b/ios/entitlements.mjs @@ -0,0 +1,70 @@ +// @ts-check +import * as nodefs from "node:fs"; +import * as path from "node:path"; +import { isObject, toPlist } from "./utils.mjs"; + +/** @import { ApplePlatform, JSONObject } from "../scripts/types.js"; */ + +const DEFAULT_IOS_ENTITLEMENTS = { + "keychain-access-groups": ["$(AppIdentifierPrefix)com.microsoft.adalcache"], +}; + +const DEFAULT_MACOS_ENTITLEMENTS = { + "com.apple.security.app-sandbox": true, + "com.apple.security.files.user-selected.read-only": true, + "com.apple.security.network.client": true, +}; + +/** + * @param {JSONObject} appConfig + * @param {ApplePlatform} targetPlatform + * @returns {string | JSONObject | undefined} + */ +function getCodeSignEntitlements(appConfig, targetPlatform) { + const platformConfig = appConfig[targetPlatform]; + if (!isObject(platformConfig)) { + return; + } + + const codeSignEntitlements = platformConfig["codeSignEntitlements"]; + if ( + typeof codeSignEntitlements !== "string" && + !isObject(codeSignEntitlements) + ) { + return; + } + + return codeSignEntitlements; +} + +/** + * @param {JSONObject} appConfig + * @param {ApplePlatform} targetPlatform + * @param {string} destination + * @returns {Promise} + */ +export async function generateEntitlements( + appConfig, + targetPlatform, + destination, + fs = nodefs +) { + // If `codeSignEntitlements` is a string, set `CODE_SIGN_ENTITLEMENTS` instead + const userEntitlements = getCodeSignEntitlements(appConfig, targetPlatform); + if (typeof userEntitlements === "string") { + return; + } + + const filename = "App.entitlements"; + const entitlements = await toPlist( + { + ...(targetPlatform === "macos" + ? DEFAULT_MACOS_ENTITLEMENTS + : DEFAULT_IOS_ENTITLEMENTS), + ...userEntitlements, + }, + filename + ); + + fs.writeFileSync(path.join(destination, filename), entitlements); +} diff --git a/ios/privacyManifest.mjs b/ios/privacyManifest.mjs index 6b24f5d6e..086c0d8c4 100644 --- a/ios/privacyManifest.mjs +++ b/ios/privacyManifest.mjs @@ -106,6 +106,7 @@ export async function generatePrivacyManifest( } } - const xcprivacy = await toPlist(manifest); - fs.writeFileSync(path.join(destination, "PrivacyInfo.xcprivacy"), xcprivacy); + const filename = "PrivacyInfo.xcprivacy"; + const xcprivacy = await toPlist(manifest, filename); + fs.writeFileSync(path.join(destination, filename), xcprivacy); } diff --git a/ios/utils.mjs b/ios/utils.mjs index 89032f8de..2da16d1c3 100644 --- a/ios/utils.mjs +++ b/ios/utils.mjs @@ -29,9 +29,10 @@ export function projectPath(p, targetPlatform) { /** * @param {JSONObject} source + * @param {string} filename * @returns {Promise} */ -export function toPlist(source) { +export function toPlist(source, filename) { return new Promise((resolve, reject) => { const args = ["-convert", "xml1", "-r", "-o", "-", "--", "-"]; const plutil = spawn("/usr/bin/plutil", args, { @@ -44,7 +45,7 @@ export function toPlist(source) { plutil.on("exit", (exitCode) => { if (exitCode !== 0) { - reject(new Error("Failed to generate 'PrivacyInfo.xcprivacy'")); + reject(new Error(`Failed to generate '${filename}'`)); } else { resolve(data.join("")); } diff --git a/test/ios/entitlements.test.ts b/test/ios/entitlements.test.ts new file mode 100644 index 000000000..ff88be859 --- /dev/null +++ b/test/ios/entitlements.test.ts @@ -0,0 +1,165 @@ +import { deepEqual, throws } from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { generateEntitlements as generateEntitlementsActual } from "../../ios/entitlements.mjs"; +import type { JSONObject } from "../../scripts/types.ts"; +import { fs, setMockFiles } from "../fs.mock.ts"; + +const macosOnly = { skip: process.platform === "win32" }; + +describe("generatePrivacyManifest()", macosOnly, () => { + const targetPlatforms = ["ios", "macos", "visionos"] as const; + + function generateEntitlements( + config: JSONObject, + platform: (typeof targetPlatforms)[number] + ): Promise { + const destination = "."; + fs.mkdirSync(destination, { recursive: true, mode: 0o755 }); + return generateEntitlementsActual(config, platform, destination, fs); + } + + function readEntitlements() { + return fs + .readFileSync("App.entitlements", { encoding: "utf-8" }) + .split("\n"); + } + + afterEach(() => { + setMockFiles(); + }); + + for (const platform of targetPlatforms) { + it(`[${platform}] generates a default manifest`, async () => { + await generateEntitlements({}, platform); + + deepEqual(readEntitlements(), DEFAULT_ENTITLEMENTS[platform]); + }); + + it(`[${platform}] handles invalid manifest`, async () => { + await generateEntitlements( + { [platform]: { codeSignEntitlements: false } }, + platform + ); + + deepEqual(readEntitlements(), DEFAULT_ENTITLEMENTS[platform]); + }); + + it(`[${platform}] does not generate a manifest when a path is specified`, async () => { + await generateEntitlements( + { [platform]: { codeSignEntitlements: "App.entitlements" } }, + platform + ); + + throws(readEntitlements); + }); + + it(`[${platform}] appends to default manifest`, async () => { + await generateEntitlements( + { + [platform]: { + codeSignEntitlements: { + "com.apple.developer.game-center": true, + }, + }, + }, + platform + ); + + deepEqual(readEntitlements(), APP_ENTITLEMENTS[platform]); + }); + } +}); + +const DEFAULT_ENTITLEMENTS = { + ios: [ + '', + '', + '', + "", + " keychain-access-groups", + " ", + " $(AppIdentifierPrefix)com.microsoft.adalcache", + " ", + "", + "", + "", + ], + macos: [ + '', + '', + '', + "", + " com.apple.security.app-sandbox", + " ", + " com.apple.security.files.user-selected.read-only", + " ", + " com.apple.security.network.client", + " ", + "", + "", + "", + ], + visionos: [ + '', + '', + '', + "", + " keychain-access-groups", + " ", + " $(AppIdentifierPrefix)com.microsoft.adalcache", + " ", + "", + "", + "", + ], +}; + +const APP_ENTITLEMENTS = { + ios: [ + '', + '', + '', + "", + " com.apple.developer.game-center", + " ", + " keychain-access-groups", + " ", + " $(AppIdentifierPrefix)com.microsoft.adalcache", + " ", + "", + "", + "", + ], + macos: [ + '', + '', + '', + "", + " com.apple.developer.game-center", + " ", + " com.apple.security.app-sandbox", + " ", + " com.apple.security.files.user-selected.read-only", + " ", + " com.apple.security.network.client", + " ", + "", + "", + "", + ], + visionos: [ + '', + '', + '', + "", + " com.apple.developer.game-center", + " ", + " keychain-access-groups", + " ", + " $(AppIdentifierPrefix)com.microsoft.adalcache", + " ", + "", + "", + "", + ], +}; diff --git a/test/pack.test.ts b/test/pack.test.ts index e12580605..02e016e0a 100644 --- a/test/pack.test.ts +++ b/test/pack.test.ts @@ -153,6 +153,7 @@ describe("npm pack", () => { "ios/ReactTestAppUITests/ReactTestAppUITests.swift", "ios/assetsCatalog.mjs", "ios/assets_catalog.rb", + "ios/entitlements.mjs", "ios/entitlements.rb", "ios/info_plist.rb", "ios/node.rb", From 4e29279a52b9ff3aa9c48832c578d5f57e0eaddb Mon Sep 17 00:00:00 2001 From: Tommy Nguyen <4123478+tido64@users.noreply.github.com> Date: Wed, 19 Mar 2025 15:11:28 +0100 Subject: [PATCH 2/2] make `toPlist` synchronous --- ios/entitlements.mjs | 6 +++--- ios/privacyManifest.mjs | 6 +++--- ios/utils.mjs | 32 +++++++++++--------------------- test/ios/entitlements.test.ts | 20 ++++++++++---------- test/ios/privacyManifest.test.ts | 16 ++++++++-------- 5 files changed, 35 insertions(+), 45 deletions(-) diff --git a/ios/entitlements.mjs b/ios/entitlements.mjs index 38553e33f..8f43d2cb1 100644 --- a/ios/entitlements.mjs +++ b/ios/entitlements.mjs @@ -41,9 +41,9 @@ function getCodeSignEntitlements(appConfig, targetPlatform) { * @param {JSONObject} appConfig * @param {ApplePlatform} targetPlatform * @param {string} destination - * @returns {Promise} + * @returns {void} */ -export async function generateEntitlements( +export function generateEntitlements( appConfig, targetPlatform, destination, @@ -56,7 +56,7 @@ export async function generateEntitlements( } const filename = "App.entitlements"; - const entitlements = await toPlist( + const entitlements = toPlist( { ...(targetPlatform === "macos" ? DEFAULT_MACOS_ENTITLEMENTS diff --git a/ios/privacyManifest.mjs b/ios/privacyManifest.mjs index 086c0d8c4..04e9d9441 100644 --- a/ios/privacyManifest.mjs +++ b/ios/privacyManifest.mjs @@ -54,9 +54,9 @@ function getUserPrivacyManifest(appConfig, targetPlatform) { * @param {JSONObject} appConfig * @param {ApplePlatform} targetPlatform * @param {string} destination - * @returns {Promise} + * @returns {void} */ -export async function generatePrivacyManifest( +export function generatePrivacyManifest( appConfig, targetPlatform, destination, @@ -107,6 +107,6 @@ export async function generatePrivacyManifest( } const filename = "PrivacyInfo.xcprivacy"; - const xcprivacy = await toPlist(manifest, filename); + const xcprivacy = toPlist(manifest, filename); fs.writeFileSync(path.join(destination, filename), xcprivacy); } diff --git a/ios/utils.mjs b/ios/utils.mjs index 2da16d1c3..d6be217f1 100644 --- a/ios/utils.mjs +++ b/ios/utils.mjs @@ -1,5 +1,5 @@ // @ts-check -import { spawn } from "node:child_process"; +import { spawnSync } from "node:child_process"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; @@ -30,28 +30,18 @@ export function projectPath(p, targetPlatform) { /** * @param {JSONObject} source * @param {string} filename - * @returns {Promise} + * @returns {string} */ export function toPlist(source, filename) { - return new Promise((resolve, reject) => { - const args = ["-convert", "xml1", "-r", "-o", "-", "--", "-"]; - const plutil = spawn("/usr/bin/plutil", args, { - stdio: ["pipe", "pipe", "inherit"], - }); - - /** @type {string[]} */ - const data = []; - plutil.stdout.on("data", (chunk) => data.push(chunk.toString())); + const args = ["-convert", "xml1", "-r", "-o", "-", "--", "-"]; + const plutil = spawnSync("/usr/bin/plutil", args, { + stdio: ["pipe", "pipe", "inherit"], + input: JSON.stringify(source), + }); - plutil.on("exit", (exitCode) => { - if (exitCode !== 0) { - reject(new Error(`Failed to generate '${filename}'`)); - } else { - resolve(data.join("")); - } - }); + if (plutil.status !== 0) { + throw new Error(`Failed to generate '${filename}'`); + } - plutil.stdin.write(JSON.stringify(source)); - plutil.stdin.end(); - }); + return plutil.stdout.toString(); } diff --git a/test/ios/entitlements.test.ts b/test/ios/entitlements.test.ts index ff88be859..99ef5c10c 100644 --- a/test/ios/entitlements.test.ts +++ b/test/ios/entitlements.test.ts @@ -12,10 +12,10 @@ describe("generatePrivacyManifest()", macosOnly, () => { function generateEntitlements( config: JSONObject, platform: (typeof targetPlatforms)[number] - ): Promise { + ): void { const destination = "."; fs.mkdirSync(destination, { recursive: true, mode: 0o755 }); - return generateEntitlementsActual(config, platform, destination, fs); + generateEntitlementsActual(config, platform, destination, fs); } function readEntitlements() { @@ -29,14 +29,14 @@ describe("generatePrivacyManifest()", macosOnly, () => { }); for (const platform of targetPlatforms) { - it(`[${platform}] generates a default manifest`, async () => { - await generateEntitlements({}, platform); + it(`[${platform}] generates a default manifest`, () => { + generateEntitlements({}, platform); deepEqual(readEntitlements(), DEFAULT_ENTITLEMENTS[platform]); }); - it(`[${platform}] handles invalid manifest`, async () => { - await generateEntitlements( + it(`[${platform}] handles invalid manifest`, () => { + generateEntitlements( { [platform]: { codeSignEntitlements: false } }, platform ); @@ -44,8 +44,8 @@ describe("generatePrivacyManifest()", macosOnly, () => { deepEqual(readEntitlements(), DEFAULT_ENTITLEMENTS[platform]); }); - it(`[${platform}] does not generate a manifest when a path is specified`, async () => { - await generateEntitlements( + it(`[${platform}] does not generate a manifest when a path is specified`, () => { + generateEntitlements( { [platform]: { codeSignEntitlements: "App.entitlements" } }, platform ); @@ -53,8 +53,8 @@ describe("generatePrivacyManifest()", macosOnly, () => { throws(readEntitlements); }); - it(`[${platform}] appends to default manifest`, async () => { - await generateEntitlements( + it(`[${platform}] appends to default manifest`, () => { + generateEntitlements( { [platform]: { codeSignEntitlements: { diff --git a/test/ios/privacyManifest.test.ts b/test/ios/privacyManifest.test.ts index 7a44de4a0..ce28a2ecb 100644 --- a/test/ios/privacyManifest.test.ts +++ b/test/ios/privacyManifest.test.ts @@ -7,10 +7,10 @@ import { fs, setMockFiles } from "../fs.mock.ts"; const macosOnly = { skip: process.platform === "win32" }; describe("generatePrivacyManifest()", macosOnly, () => { - function generatePrivacyManifest(config: JSONObject): Promise { + function generatePrivacyManifest(config: JSONObject): void { const destination = "."; fs.mkdirSync(destination, { recursive: true, mode: 0o755 }); - return generatePrivacyManifestActual(config, "ios", destination, fs); + generatePrivacyManifestActual(config, "ios", destination, fs); } function readPrivacyManifest() { @@ -23,20 +23,20 @@ describe("generatePrivacyManifest()", macosOnly, () => { setMockFiles(); }); - it("generates a default manifest", async () => { - await generatePrivacyManifest({}); + it("generates a default manifest", () => { + generatePrivacyManifest({}); deepEqual(readPrivacyManifest(), DEFAULT_PRIVACY_MANIFEST); }); - it("handles invalid configuration", async () => { - await generatePrivacyManifest({ ios: { privacyManifest: "YES" } }); + it("handles invalid configuration", () => { + generatePrivacyManifest({ ios: { privacyManifest: "YES" } }); deepEqual(readPrivacyManifest(), DEFAULT_PRIVACY_MANIFEST); }); - it("appends to default manifest", async () => { - await generatePrivacyManifest({ + it("appends to default manifest", () => { + generatePrivacyManifest({ ios: { privacyManifest: { NSPrivacyTracking: true,