diff --git a/package-lock.json b/package-lock.json
index 16c9a3d5..48911022 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2914,6 +2914,17 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@expo/plist": {
+ "version": "0.4.7",
+ "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.7.tgz",
+ "integrity": "sha512-dGxqHPvCZKeRKDU1sJZMmuyVtcASuSYh1LPFVaM1DuffqPL36n6FMEL0iUqq2Tx3xhWk8wCnWl34IKplUjJDdA==",
+ "license": "MIT",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.8.8",
+ "base64-js": "^1.2.3",
+ "xmlbuilder": "^15.1.1"
+ }
+ },
"node_modules/@fastify/busboy": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
@@ -6879,6 +6890,15 @@
"integrity": "sha512-9ORTwwS74VaTn38tNbQhsA5U44zkJfcb0BdTSyyG6frP4e8KMtHuTXYmwefe5dpL8XB1aGSIVTaLjD3BbWb5iA==",
"license": "MIT"
},
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.11",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
+ "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -14474,6 +14494,15 @@
"async-limiter": "~1.0.0"
}
},
+ "node_modules/xmlbuilder": {
+ "version": "15.1.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
+ "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -14853,6 +14882,7 @@
"version": "0.5.1",
"license": "MIT",
"dependencies": {
+ "@expo/plist": "^0.4.7",
"@react-native-node-api/cli-utils": "0.1.0",
"pkg-dir": "^8.0.0",
"read-pkg": "^9.0.1"
diff --git a/packages/host/package.json b/packages/host/package.json
index 8031a554..caab011d 100644
--- a/packages/host/package.json
+++ b/packages/host/package.json
@@ -80,6 +80,7 @@
],
"license": "MIT",
"dependencies": {
+ "@expo/plist": "^0.4.7",
"@react-native-node-api/cli-utils": "0.1.0",
"pkg-dir": "^8.0.0",
"read-pkg": "^9.0.1"
diff --git a/packages/host/src/node/cli/apple.test.ts b/packages/host/src/node/cli/apple.test.ts
index 191baafe..051e7cee 100644
--- a/packages/host/src/node/cli/apple.test.ts
+++ b/packages/host/src/node/cli/apple.test.ts
@@ -1,50 +1,184 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import path from "node:path";
-import { readInfoPlist } from "./apple";
+import fs from "node:fs";
+
+import {
+ determineInfoPlistPath,
+ readInfoPlist,
+ updateInfoPlist,
+} from "./apple";
import { setupTempDirectory } from "../test-utils";
describe("apple", () => {
- describe("Info.plist lookup", () => {
- it("should find Info.plist files in unversioned frameworks", async (context) => {
+ describe("determineInfoPlistPath", () => {
+ it("should find Info.plist files in unversioned frameworks", (context) => {
const infoPlistContents = `...`;
const infoPlistSubPath = "Info.plist";
const tempDirectoryPath = setupTempDirectory(context, {
[infoPlistSubPath]: infoPlistContents,
});
- const result = await readInfoPlist(tempDirectoryPath);
-
- assert.strictEqual(result.contents, infoPlistContents);
assert.strictEqual(
- result.infoPlistPath,
+ determineInfoPlistPath(tempDirectoryPath),
path.join(tempDirectoryPath, infoPlistSubPath),
);
});
- it("should find Info.plist files in versioned frameworks", async (context) => {
+ it("should find Info.plist files in versioned frameworks", (context) => {
const infoPlistContents = `...`;
const infoPlistSubPath = "Versions/Current/Resources/Info.plist";
const tempDirectoryPath = setupTempDirectory(context, {
[infoPlistSubPath]: infoPlistContents,
});
- const result = await readInfoPlist(tempDirectoryPath);
-
- assert.strictEqual(result.contents, infoPlistContents);
assert.strictEqual(
- result.infoPlistPath,
+ determineInfoPlistPath(tempDirectoryPath),
path.join(tempDirectoryPath, infoPlistSubPath),
);
});
- it("should throw if Info.plist is missing from framework", async (context) => {
+ it("should throw if Info.plist is missing from framework", (context) => {
+ const tempDirectoryPath = setupTempDirectory(context, {});
+
+ assert.throws(
+ () => determineInfoPlistPath(tempDirectoryPath),
+ /Unable to locate an Info.plist file within framework./,
+ );
+ });
+ });
+
+ describe("readInfoPlist", () => {
+ it("should read Info.plist contents", async (context) => {
+ const infoPlistContents = `
+
+
+
+
+ CFBundleExecutable
+ ExecutableFileName
+ CFBundleIconFile
+ AppIcon
+
+
+ `;
+ const infoPlistSubPath = "Info.plist";
+ const tempDirectoryPath = setupTempDirectory(context, {
+ [infoPlistSubPath]: infoPlistContents,
+ });
+ const infoPlistPath = path.join(tempDirectoryPath, infoPlistSubPath);
+
+ const contents = await readInfoPlist(infoPlistPath);
+ assert.deepEqual(contents, {
+ CFBundleExecutable: "ExecutableFileName",
+ CFBundleIconFile: "AppIcon",
+ });
+ });
+
+ it("should throw if Info.plist doesn't exist", async (context) => {
const tempDirectoryPath = setupTempDirectory(context, {});
+ const infoPlistPath = path.join(tempDirectoryPath, "Info.plist");
await assert.rejects(
- async () => readInfoPlist(tempDirectoryPath),
- /Unable to read Info.plist for framework at path ".*?", as an Info.plist file couldn't be found./,
+ () => readInfoPlist(infoPlistPath),
+ /Unable to read Info.plist at path/,
);
});
});
+
+ describe("updateInfoPlist", () => {
+ it(
+ "updates an xml plist",
+ { skip: process.platform !== "darwin" },
+ async (context) => {
+ const infoPlistSubPath = "Info.plist";
+ const tempDirectoryPath = setupTempDirectory(context, {
+ [infoPlistSubPath]: `
+
+
+
+
+ CFBundleExecutable
+ addon
+
+
+ `,
+ });
+
+ await updateInfoPlist({
+ frameworkPath: tempDirectoryPath,
+ oldLibraryName: "addon",
+ newLibraryName: "new-addon-name",
+ });
+
+ const contents = await fs.promises.readFile(
+ path.join(tempDirectoryPath, infoPlistSubPath),
+ "utf-8",
+ );
+ assert.match(contents, /<\?xml version="1.0" encoding="UTF-8"\?>/);
+ assert.match(
+ contents,
+ /CFBundleExecutable<\/key>\s*new-addon-name<\/string>/,
+ );
+ },
+ );
+
+ it(
+ "converts a binary plist to xml",
+ { skip: process.platform !== "darwin" },
+ async (context) => {
+ const tempDirectoryPath = setupTempDirectory(context, {});
+ // Write a binary plist file
+ const binaryPlistContents = Buffer.from(
+ // Generated running "base64 -i " on a plist file from a framework in the node-examples package
+ "YnBsaXN0MDDfEBUBAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4cICEiIyQiJSYnJChfEBNCdWlsZE1hY2hpbmVPU0J1aWxkXxAZQ0ZCdW5kbGVEZXZlbG9wbWVudFJlZ2lvbl8QEkNGQnVuZGxlRXhlY3V0YWJsZV8QEkNGQnVuZGxlSWRlbnRpZmllcl8QHUNGQnVuZGxlSW5mb0RpY3Rpb25hcnlWZXJzaW9uXxATQ0ZCdW5kbGVQYWNrYWdlVHlwZV8QGkNGQnVuZGxlU2hvcnRWZXJzaW9uU3RyaW5nXxARQ0ZCdW5kbGVTaWduYXR1cmVfEBpDRkJ1bmRsZVN1cHBvcnRlZFBsYXRmb3Jtc18QD0NGQnVuZGxlVmVyc2lvbl8QFUNTUmVzb3VyY2VzRmlsZU1hcHBlZFpEVENvbXBpbGVyXxAPRFRQbGF0Zm9ybUJ1aWxkXkRUUGxhdGZvcm1OYW1lXxARRFRQbGF0Zm9ybVZlcnNpb25aRFRTREtCdWlsZFlEVFNES05hbWVXRFRYY29kZVxEVFhjb2RlQnVpbGRfEBBNaW5pbXVtT1NWZXJzaW9uXlVJRGV2aWNlRmFtaWx5VjI0RzIzMVdFbmdsaXNoVWFkZG9uXxAPZXhhbXBsZV82LmFkZG9uUzYuMFRGTVdLUzEuMFQ/Pz8/oR9fEA9pUGhvbmVTaW11bGF0b3IJXxAiY29tLmFwcGxlLmNvbXBpbGVycy5sbHZtLmNsYW5nLjFfMFYyMkMxNDZfEA9pcGhvbmVzaW11bGF0b3JUMTguMl8QE2lwaG9uZXNpbXVsYXRvcjE4LjJUMTYyMFgxNkM1MDMyYaEpEAEACAA1AEsAZwB8AJEAsQDHAOQA+AEVAScBPwFKAVwBawF/AYoBlAGcAakBvAHLAdIB2gHgAfIB9gH7Af8CBAIGAhgCGQI+AkUCVwJcAnICdwKAAoIAAAAAAAACAQAAAAAAAAAqAAAAAAAAAAAAAAAAAAAChA==",
+ "base64",
+ );
+ const binaryPlistPath = path.join(tempDirectoryPath, "Info.plist");
+ await fs.promises.writeFile(binaryPlistPath, binaryPlistContents);
+
+ await updateInfoPlist({
+ frameworkPath: tempDirectoryPath,
+ oldLibraryName: "addon",
+ newLibraryName: "new-addon-name",
+ });
+
+ const contents = await fs.promises.readFile(binaryPlistPath, "utf-8");
+ assert.match(contents, /<\?xml version="1.0" encoding="UTF-8"\?>/);
+ assert.match(
+ contents,
+ /CFBundleExecutable<\/key>\s*new-addon-name<\/string>/,
+ );
+ },
+ );
+
+ it(
+ "throws when not on darwin",
+ { skip: process.platform === "darwin" },
+ async (context) => {
+ const tempDirectoryPath = setupTempDirectory(context, {
+ ["Info.plist"]: '',
+ });
+
+ await assert.rejects(
+ () =>
+ updateInfoPlist({
+ frameworkPath: tempDirectoryPath,
+ oldLibraryName: "addon",
+ newLibraryName: "new-addon-name",
+ }),
+ (err) => {
+ assert(err instanceof Error);
+ assert.match(err.message, /Failed to convert Info.plist at path/);
+ assert(err.cause instanceof Error);
+ assert.match(
+ err.cause.message,
+ /Updating Info.plist files are not supported on this platform/,
+ );
+ return true;
+ },
+ );
+ },
+ );
+ });
});
diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts
index 20a9065c..687b8ccf 100644
--- a/packages/host/src/node/cli/apple.ts
+++ b/packages/host/src/node/cli/apple.ts
@@ -3,6 +3,7 @@ import path from "node:path";
import fs from "node:fs";
import os from "node:os";
+import plist from "@expo/plist";
import { spawn } from "@react-native-node-api/cli-utils";
import { getLatestMtime, getLibraryName } from "../path-utils.js";
@@ -12,7 +13,7 @@ import {
LinkModuleResult,
} from "./link-modules.js";
-function determineInfoPlistPath(frameworkPath: string) {
+export function determineInfoPlistPath(frameworkPath: string) {
const checkedPaths = new Array();
// First, assume it is an "unversioned" framework that keeps its Info.plist in
@@ -47,28 +48,15 @@ function determineInfoPlistPath(frameworkPath: string) {
/**
* Resolves the Info.plist file within a framework and reads its contents.
*/
-export async function readInfoPlist(frameworkPath: string) {
- let infoPlistPath: string;
+export async function readInfoPlist(infoPlistPath: string) {
try {
- infoPlistPath = determineInfoPlistPath(frameworkPath);
+ const contents = await fs.promises.readFile(infoPlistPath, "utf-8");
+ return plist.parse(contents) as Record;
} catch (cause) {
- throw new Error(
- `Unable to read Info.plist for framework at path "${frameworkPath}", as an Info.plist file couldn't be found.`,
- { cause },
- );
- }
-
- let contents: string;
- try {
- contents = await fs.promises.readFile(infoPlistPath, "utf-8");
- } catch (cause) {
- throw new Error(
- `Unable to read Info.plist for framework at path "${frameworkPath}", due to a file system error.`,
- { cause },
- );
+ throw new Error(`Unable to read Info.plist at path "${infoPlistPath}"`, {
+ cause,
+ });
}
-
- return { infoPlistPath, contents };
}
type UpdateInfoPlistOptions = {
@@ -85,11 +73,32 @@ export async function updateInfoPlist({
oldLibraryName,
newLibraryName,
}: UpdateInfoPlistOptions) {
- const { infoPlistPath, contents } = await readInfoPlist(frameworkPath);
+ const infoPlistPath = determineInfoPlistPath(frameworkPath);
+
+ // Convert to XML format if needed
+ try {
+ assert(
+ process.platform === "darwin",
+ "Updating Info.plist files are not supported on this platform",
+ );
+ await spawn("plutil", ["-convert", "xml1", infoPlistPath], {
+ outputMode: "inherit",
+ });
+ } catch (error) {
+ throw new Error(
+ `Failed to convert Info.plist at path "${infoPlistPath}" to XML format`,
+ { cause: error },
+ );
+ }
- // TODO: Use a proper plist parser
- const updatedContents = contents.replaceAll(oldLibraryName, newLibraryName);
- await fs.promises.writeFile(infoPlistPath, updatedContents, "utf-8");
+ const contents = await readInfoPlist(infoPlistPath);
+ assert.equal(
+ contents.CFBundleExecutable,
+ oldLibraryName,
+ "Unexpected CFBundleExecutable value in Info.plist",
+ );
+ contents.CFBundleExecutable = newLibraryName;
+ await fs.promises.writeFile(infoPlistPath, plist.build(contents), "utf-8");
}
export async function linkXcframework({
diff --git a/packages/host/src/node/prebuilds/apple.ts b/packages/host/src/node/prebuilds/apple.ts
index f3b1efde..33422b85 100644
--- a/packages/host/src/node/prebuilds/apple.ts
+++ b/packages/host/src/node/prebuilds/apple.ts
@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import os from "node:os";
+import plist from "@expo/plist";
import { spawn } from "@react-native-node-api/cli-utils";
import { AppleTriplet } from "./triplets.js";
@@ -23,21 +24,6 @@ export const APPLE_ARCHITECTURES = {
"arm64-apple-visionos-sim": "arm64",
} satisfies Record;
-export function createPlistContent(values: Record) {
- return [
- '',
- '',
- '',
- " ",
- ...Object.entries(values).flatMap(([key, value]) => [
- ` ${key}`,
- ` ${value}`,
- ]),
- " ",
- "",
- ].join("\n");
-}
-
type XCframeworkOptions = {
frameworkPaths: string[];
outputPath: string;
@@ -59,7 +45,7 @@ export async function createAppleFramework(libraryPath: string) {
// Create an empty Info.plist file
await fs.promises.writeFile(
path.join(frameworkPath, "Info.plist"),
- createPlistContent({
+ plist.build({
CFBundleDevelopmentRegion: "en",
CFBundleExecutable: libraryName,
CFBundleIdentifier: `com.callstackincubator.node-api.${libraryName}`,