diff --git a/ios/entitlements.mjs b/ios/entitlements.mjs new file mode 100644 index 000000000..8f43d2cb1 --- /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 {void} + */ +export 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 = 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..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, @@ -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 = toPlist(manifest, filename); + fs.writeFileSync(path.join(destination, filename), xcprivacy); } diff --git a/ios/utils.mjs b/ios/utils.mjs index 89032f8de..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"; @@ -29,28 +29,19 @@ export function projectPath(p, targetPlatform) { /** * @param {JSONObject} source - * @returns {Promise} + * @param {string} filename + * @returns {string} */ -export function toPlist(source) { - 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())); +export function toPlist(source, filename) { + 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 'PrivacyInfo.xcprivacy'")); - } 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 new file mode 100644 index 000000000..99ef5c10c --- /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] + ): void { + const destination = "."; + fs.mkdirSync(destination, { recursive: true, mode: 0o755 }); + 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`, () => { + generateEntitlements({}, platform); + + deepEqual(readEntitlements(), DEFAULT_ENTITLEMENTS[platform]); + }); + + it(`[${platform}] handles invalid manifest`, () => { + generateEntitlements( + { [platform]: { codeSignEntitlements: false } }, + platform + ); + + deepEqual(readEntitlements(), DEFAULT_ENTITLEMENTS[platform]); + }); + + it(`[${platform}] does not generate a manifest when a path is specified`, () => { + generateEntitlements( + { [platform]: { codeSignEntitlements: "App.entitlements" } }, + platform + ); + + throws(readEntitlements); + }); + + it(`[${platform}] appends to default manifest`, () => { + 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/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, 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",