diff --git a/.changeset/blue-parts-cheer.md b/.changeset/blue-parts-cheer.md
new file mode 100644
index 00000000..782aa96f
--- /dev/null
+++ b/.changeset/blue-parts-cheer.md
@@ -0,0 +1,5 @@
+---
+"react-native-node-api": patch
+---
+
+Linking Node-API addons for Apple platforms is no longer re-creating Xcframeworks
diff --git a/package-lock.json b/package-lock.json
index 1efe4481..ccc7603b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14597,10 +14597,9 @@
}
},
"node_modules/zod": {
- "version": "3.25.76",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
- "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
- "dev": true,
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
+ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -14824,15 +14823,6 @@
"zod": "^4.1.11"
}
},
- "packages/cmake-file-api/node_modules/zod": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz",
- "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
- },
"packages/cmake-rn": {
"version": "0.4.1",
"dependencies": {
@@ -14849,15 +14839,6 @@
"node-api-headers": "^1.5.0"
}
},
- "packages/cmake-rn/node_modules/zod": {
- "version": "4.1.12",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
- "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
- },
"packages/ferric": {
"name": "ferric-cli",
"version": "0.3.4",
@@ -14897,7 +14878,8 @@
"@expo/plist": "^0.4.7",
"@react-native-node-api/cli-utils": "0.1.0",
"pkg-dir": "^8.0.0",
- "read-pkg": "^9.0.1"
+ "read-pkg": "^9.0.1",
+ "zod": "^4.1.11"
},
"bin": {
"react-native-node-api": "bin/react-native-node-api.mjs"
@@ -14906,8 +14888,7 @@
"@babel/core": "^7.26.10",
"@babel/types": "^7.27.0",
"fswin": "^3.24.829",
- "node-api-headers": "^1.5.0",
- "zod": "^3.24.3"
+ "node-api-headers": "^1.5.0"
},
"peerDependencies": {
"@babel/core": "^7.26.10",
diff --git a/packages/host/package.json b/packages/host/package.json
index caab011d..914a7664 100644
--- a/packages/host/package.json
+++ b/packages/host/package.json
@@ -83,14 +83,14 @@
"@expo/plist": "^0.4.7",
"@react-native-node-api/cli-utils": "0.1.0",
"pkg-dir": "^8.0.0",
- "read-pkg": "^9.0.1"
+ "read-pkg": "^9.0.1",
+ "zod": "^4.1.11"
},
"devDependencies": {
"@babel/core": "^7.26.10",
"@babel/types": "^7.27.0",
"fswin": "^3.24.829",
- "node-api-headers": "^1.5.0",
- "zod": "^3.24.3"
+ "node-api-headers": "^1.5.0"
},
"peerDependencies": {
"@babel/core": "^7.26.10",
diff --git a/packages/host/src/node/cli/apple.test.ts b/packages/host/src/node/cli/apple.test.ts
index 051e7cee..7914a408 100644
--- a/packages/host/src/node/cli/apple.test.ts
+++ b/packages/host/src/node/cli/apple.test.ts
@@ -2,54 +2,19 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import path from "node:path";
import fs from "node:fs";
+import cp from "node:child_process";
import {
- determineInfoPlistPath,
- readInfoPlist,
- updateInfoPlist,
+ linkFlatFramework,
+ readAndParsePlist,
+ readFrameworkInfo,
+ readXcframeworkInfo,
} from "./apple";
import { setupTempDirectory } from "../test-utils";
-describe("apple", () => {
- describe("determineInfoPlistPath", () => {
- it("should find Info.plist files in unversioned frameworks", (context) => {
- const infoPlistContents = `...`;
- const infoPlistSubPath = "Info.plist";
- const tempDirectoryPath = setupTempDirectory(context, {
- [infoPlistSubPath]: infoPlistContents,
- });
-
- assert.strictEqual(
- determineInfoPlistPath(tempDirectoryPath),
- path.join(tempDirectoryPath, infoPlistSubPath),
- );
- });
-
- 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,
- });
-
- assert.strictEqual(
- determineInfoPlistPath(tempDirectoryPath),
- path.join(tempDirectoryPath, infoPlistSubPath),
- );
- });
-
- 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("apple", { skip: process.platform !== "darwin" }, () => {
describe("readInfoPlist", () => {
- it("should read Info.plist contents", async (context) => {
+ it("should read Info.plist contents, plus extra keys not in schema", async (context) => {
const infoPlistContents = `
@@ -57,8 +22,10 @@ describe("apple", () => {
CFBundleExecutable
ExecutableFileName
- CFBundleIconFile
- AppIcon
+ CFBundlePackageType
+ FMWK
+ CFBundleInfoDictionaryVersion
+ 6.0
`;
@@ -68,10 +35,11 @@ describe("apple", () => {
});
const infoPlistPath = path.join(tempDirectoryPath, infoPlistSubPath);
- const contents = await readInfoPlist(infoPlistPath);
+ const contents = await readAndParsePlist(infoPlistPath);
assert.deepEqual(contents, {
CFBundleExecutable: "ExecutableFileName",
- CFBundleIconFile: "AppIcon",
+ CFBundlePackageType: "FMWK",
+ CFBundleInfoDictionaryVersion: "6.0",
});
});
@@ -80,104 +48,246 @@ describe("apple", () => {
const infoPlistPath = path.join(tempDirectoryPath, "Info.plist");
await assert.rejects(
- () => readInfoPlist(infoPlistPath),
- /Unable to read Info.plist at path/,
+ () => readAndParsePlist(infoPlistPath),
+ /Expected an Info.plist/,
);
});
});
- describe("updateInfoPlist", () => {
- it(
- "updates an xml plist",
- { skip: process.platform !== "darwin" },
- async (context) => {
- const infoPlistSubPath = "Info.plist";
- const tempDirectoryPath = setupTempDirectory(context, {
- [infoPlistSubPath]: `
-
-
-
+ describe("readXcframeworkInfo", () => {
+ it("should read xcframework Info.plist contents, plus extra keys not in schema", async (context) => {
+ const tempDirectoryPath = setupTempDirectory(context, {
+ "foo.xcframework": {
+ "Info.plist": `
+
+
+
- CFBundleExecutable
- addon
+ AvailableLibraries
+
+
+ BinaryPath
+ hello.framework/hello
+ LibraryIdentifier
+ tvos-arm64
+ LibraryPath
+ hello.framework
+ SupportedArchitectures
+
+ arm64
+
+ SupportedPlatform
+ tvos
+
+
+ CFBundlePackageType
+ XFWK
+ XCFrameworkFormatVersion
+ 1.0
-
- `,
- });
+
+ `,
+ },
+ });
- await updateInfoPlist({
- frameworkPath: tempDirectoryPath,
- oldLibraryName: "addon",
- newLibraryName: "new-addon-name",
- });
+ const result = await readXcframeworkInfo(
+ path.join(tempDirectoryPath, "foo.xcframework", "Info.plist"),
+ );
- 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>/,
- );
- },
- );
+ assert.deepEqual(result, {
+ AvailableLibraries: [
+ {
+ BinaryPath: "hello.framework/hello",
+ LibraryIdentifier: "tvos-arm64",
+ LibraryPath: "hello.framework",
+ SupportedArchitectures: ["arm64"],
+ SupportedPlatform: "tvos",
+ },
+ ],
+ CFBundlePackageType: "XFWK",
+ XCFrameworkFormatVersion: "1.0",
+ });
+ });
+ });
- 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);
+ describe("readFrameworkInfo", () => {
+ it("should read framework Info.plist contents, plus extra keys not in schema", async (context) => {
+ const tempDirectoryPath = setupTempDirectory(context, {
+ "foo.framework": {
+ "Info.plist": `
+
+
+
+
+ CFBundlePackageType
+ FMWK
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleExecutable
+ example-0--hello
+ CFBundleIdentifier
+ example_0.hello
+ CFBundleSupportedPlatforms
+
+ XRSimulator
+
+
+
+ `,
+ },
+ });
- await updateInfoPlist({
- frameworkPath: tempDirectoryPath,
- oldLibraryName: "addon",
- newLibraryName: "new-addon-name",
- });
+ const result = await readFrameworkInfo(
+ path.join(tempDirectoryPath, "foo.framework", "Info.plist"),
+ );
- 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>/,
- );
- },
- );
+ assert.deepEqual(result, {
+ CFBundlePackageType: "FMWK",
+ CFBundleInfoDictionaryVersion: "6.0",
+ CFBundleExecutable: "example-0--hello",
+ CFBundleIdentifier: "example_0.hello",
+ CFBundleSupportedPlatforms: ["XRSimulator"],
+ });
+ });
+ });
- 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;
- },
+ describe("linkFlatFramework", () => {
+ it("updates an xml plist, preserving extra keys", async (context) => {
+ const infoPlistSubPath = "Info.plist";
+ const tempDirectoryPath = setupTempDirectory(context, {
+ "foo.framework": {
+ [infoPlistSubPath]: `
+
+
+
+
+ CFBundleExecutable
+ addon
+ CFBundlePackageType
+ FMWK
+ CFBundleInfoDictionaryVersion
+ 6.0
+ MyExtraKey
+ MyExtraValue
+
+
+ `,
+ },
+ });
+
+ // Create a dummy binary file
+ cp.spawnSync("clang", [
+ "-dynamiclib",
+ "-o",
+ path.join(tempDirectoryPath, "foo.framework", "addon"),
+ "-xc",
+ "/dev/null",
+ ]);
+
+ await linkFlatFramework({
+ frameworkPath: path.join(tempDirectoryPath, "foo.framework"),
+ newLibraryName: "new-addon-name",
+ });
+
+ const contents = await fs.promises.readFile(
+ path.join(
+ tempDirectoryPath,
+ "new-addon-name.framework",
+ infoPlistSubPath,
+ ),
+ "utf-8",
+ );
+ assert.match(contents, /<\?xml version="1.0" encoding="UTF-8"\?>/);
+ assert.match(
+ contents,
+ /CFBundleExecutable<\/key>\s*new-addon-name<\/string>/,
+ );
+
+ // Assert the install name was updated correctly
+ const { stdout: otoolOutput } = cp.spawnSync(
+ "otool",
+ [
+ "-L",
+ path.join(
+ tempDirectoryPath,
+ "new-addon-name.framework",
+ "new-addon-name",
+ ),
+ ],
+ { encoding: "utf-8" },
+ );
+ assert.match(
+ otoolOutput,
+ /@rpath\/new-addon-name.framework\/new-addon-name/,
+ );
+
+ // It should preserve extra keys
+ assert.match(
+ contents,
+ /MyExtraKey<\/key>\s*MyExtraValue<\/string>/,
+ );
+ });
+
+ it("converts a binary plist to xml", async (context) => {
+ const tempDirectoryPath = setupTempDirectory(context, {});
+ await fs.promises.mkdir(path.join(tempDirectoryPath, "foo.framework"));
+ // 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",
+ );
+ await fs.promises.writeFile(
+ path.join(tempDirectoryPath, "foo.framework", "Info.plist"),
+ binaryPlistContents,
+ );
+
+ // Create a dummy binary file
+ cp.spawnSync("clang", [
+ "-dynamiclib",
+ "-o",
+ path.join(tempDirectoryPath, "foo.framework", "addon"),
+ "-xc",
+ "/dev/null",
+ ]);
+
+ await linkFlatFramework({
+ frameworkPath: path.join(tempDirectoryPath, "foo.framework"),
+ newLibraryName: "new-addon-name",
+ });
+
+ const contents = await fs.promises.readFile(
+ path.join(tempDirectoryPath, "new-addon-name.framework", "Info.plist"),
+ "utf-8",
+ );
+ assert.match(contents, /<\?xml version="1.0" encoding="UTF-8"\?>/);
+ assert.match(
+ contents,
+ /CFBundleExecutable<\/key>\s*new-addon-name<\/string>/,
+ );
+ });
+ });
+});
+
+describe("apple on non-darwin", { skip: process.platform === "darwin" }, () => {
+ it("throws", async (context) => {
+ const tempDirectoryPath = setupTempDirectory(context, {
+ ["Info.plist"]: '',
+ });
+
+ await assert.rejects(
+ () =>
+ linkFlatFramework({
+ frameworkPath: path.join(tempDirectoryPath, "Info.plist"),
+ newLibraryName: "new-addon-name",
+ }),
+ (err) => {
+ assert(err instanceof Error);
+ assert.match(
+ err.message,
+ /Linking Apple addons are only supported on macOS/,
);
+ return true;
},
);
});
diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts
index 687b8ccf..0ccbd82a 100644
--- a/packages/host/src/node/cli/apple.ts
+++ b/packages/host/src/node/cli/apple.ts
@@ -1,9 +1,10 @@
import assert from "node:assert/strict";
import path from "node:path";
import fs from "node:fs";
-import os from "node:os";
import plist from "@expo/plist";
+import * as zod from "zod";
+
import { spawn } from "@react-native-node-api/cli-utils";
import { getLatestMtime, getLibraryName } from "../path-utils.js";
@@ -13,92 +14,228 @@ import {
LinkModuleResult,
} from "./link-modules.js";
-export function determineInfoPlistPath(frameworkPath: string) {
- const checkedPaths = new Array();
+/**
+ * Reads and parses a plist file, converting it to XML format if needed.
+ */
+export async function readAndParsePlist(plistPath: string): Promise {
+ assert(fs.existsSync(plistPath), `Expected an Info.plist: ${plistPath}`);
+ // Try reading the file to see if it is already in XML format
+ try {
+ const contents = await fs.promises.readFile(plistPath, "utf-8");
+ if (contents.startsWith(" `- ${checkedPath}`),
- ].join("\n"),
- );
+export async function writeXcframeworkInfo(
+ xcframeworkPath: string,
+ info: zod.infer,
+) {
+ const infoPlistPath = path.join(xcframeworkPath, "Info.plist");
+ const infoPlistXml = plist.build(info);
+ await fs.promises.writeFile(infoPlistPath, infoPlistXml, "utf-8");
}
-/**
- * Resolves the Info.plist file within a framework and reads its contents.
- */
-export async function readInfoPlist(infoPlistPath: string) {
- try {
- 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 at path "${infoPlistPath}"`, {
- cause,
- });
- }
+const FrameworkInfoSchema = zod.looseObject({
+ CFBundlePackageType: zod.literal("FMWK"),
+ CFBundleInfoDictionaryVersion: zod.literal("6.0"),
+ CFBundleExecutable: zod.string(),
+});
+
+export async function readFrameworkInfo(infoPlistPath: string) {
+ const infoPlist = await readAndParsePlist(infoPlistPath);
+ return FrameworkInfoSchema.parse(infoPlist);
}
-type UpdateInfoPlistOptions = {
+export async function writeFrameworkInfo(
+ infoPlistPath: string,
+ info: zod.infer,
+) {
+ const infoPlistXml = plist.build(info);
+ await fs.promises.writeFile(infoPlistPath, infoPlistXml, "utf-8");
+}
+
+type LinkFrameworkOptions = {
frameworkPath: string;
- oldLibraryName: string;
newLibraryName: string;
};
-/**
- * Update the Info.plist file of an xcframework to use the new library name.
- */
-export async function updateInfoPlist({
+export async function linkFramework({
frameworkPath,
- oldLibraryName,
newLibraryName,
-}: UpdateInfoPlistOptions) {
- 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 },
- );
+}: LinkFrameworkOptions) {
+ assert.equal(
+ process.platform,
+ "darwin",
+ "Linking Apple frameworks are only supported on macOS",
+ );
+ assert(
+ fs.existsSync(frameworkPath),
+ `Expected framework at '${frameworkPath}'`,
+ );
+ if (fs.existsSync(path.join(frameworkPath, "Versions"))) {
+ await linkVersionedFramework({ frameworkPath, newLibraryName });
+ } else {
+ await linkFlatFramework({ frameworkPath, newLibraryName });
}
+}
+
+export async function linkFlatFramework({
+ frameworkPath,
+ newLibraryName,
+}: LinkFrameworkOptions) {
+ assert.equal(
+ process.platform,
+ "darwin",
+ "Linking Apple addons are only supported on macOS",
+ );
+ const frameworkInfoPath = path.join(frameworkPath, "Info.plist");
+ const frameworkInfo = await readFrameworkInfo(frameworkInfoPath);
+ // Update install name
+ await spawn(
+ "install_name_tool",
+ [
+ "-id",
+ `@rpath/${newLibraryName}.framework/${newLibraryName}`,
+ frameworkInfo.CFBundleExecutable,
+ ],
+ {
+ outputMode: "buffered",
+ cwd: frameworkPath,
+ },
+ );
+ await writeFrameworkInfo(frameworkInfoPath, {
+ ...frameworkInfo,
+ CFBundleExecutable: newLibraryName,
+ });
+ // Rename the actual binary
+ await fs.promises.rename(
+ path.join(frameworkPath, frameworkInfo.CFBundleExecutable),
+ path.join(frameworkPath, newLibraryName),
+ );
+ // Rename the framework directory
+ await fs.promises.rename(
+ frameworkPath,
+ path.join(path.dirname(frameworkPath), `${newLibraryName}.framework`),
+ );
+}
- const contents = await readInfoPlist(infoPlistPath);
+export async function linkVersionedFramework({
+ frameworkPath,
+ newLibraryName,
+}: LinkFrameworkOptions) {
assert.equal(
- contents.CFBundleExecutable,
- oldLibraryName,
- "Unexpected CFBundleExecutable value in Info.plist",
+ process.platform,
+ "darwin",
+ "Linking Apple addons are only supported on macOS",
+ );
+ const frameworkInfoPath = path.join(
+ frameworkPath,
+ "Versions",
+ "Current",
+ "Resources",
+ "Info.plist",
+ );
+ const frameworkInfo = await readFrameworkInfo(frameworkInfoPath);
+ // Update install name
+ await spawn(
+ "install_name_tool",
+ [
+ "-id",
+ `@rpath/${newLibraryName}.framework/${newLibraryName}`,
+ frameworkInfo.CFBundleExecutable,
+ ],
+ {
+ outputMode: "buffered",
+ cwd: frameworkPath,
+ },
+ );
+ await writeFrameworkInfo(frameworkInfoPath, {
+ ...frameworkInfo,
+ CFBundleExecutable: newLibraryName,
+ });
+ // Rename the actual binary
+ const existingBinaryPath = path.join(
+ frameworkPath,
+ frameworkInfo.CFBundleExecutable,
+ );
+ const stat = await fs.promises.lstat(existingBinaryPath);
+ assert(
+ stat.isSymbolicLink(),
+ `Expected binary to be a symlink: ${existingBinaryPath}`,
+ );
+ const realBinaryPath = await fs.promises.realpath(existingBinaryPath);
+ const newRealBinaryPath = path.join(
+ path.dirname(realBinaryPath),
+ newLibraryName,
+ );
+ // Rename the real binary file
+ await fs.promises.rename(realBinaryPath, newRealBinaryPath);
+ // Remove the old binary symlink
+ await fs.promises.unlink(existingBinaryPath);
+ // Create a new symlink with the new name
+ const newBinarySymlinkTarget = path.join(
+ "Versions",
+ "Current",
+ newLibraryName,
+ );
+ assert(
+ fs.existsSync(path.join(frameworkPath, newBinarySymlinkTarget)),
+ "Expected new binary to exist",
+ );
+ await fs.promises.symlink(
+ newBinarySymlinkTarget,
+ path.join(frameworkPath, newLibraryName),
+ );
+
+ // Rename the framework directory
+ await fs.promises.rename(
+ frameworkPath,
+ path.join(path.dirname(frameworkPath), `${newLibraryName}.framework`),
);
- contents.CFBundleExecutable = newLibraryName;
- await fs.promises.writeFile(infoPlistPath, plist.build(contents), "utf-8");
}
export async function linkXcframework({
@@ -107,123 +244,68 @@ export async function linkXcframework({
incremental,
naming,
}: LinkModuleOptions): Promise {
+ assert.equal(
+ process.platform,
+ "darwin",
+ "Linking Apple addons are only supported on macOS",
+ );
// Copy the xcframework to the output directory and rename the framework and binary
const newLibraryName = getLibraryName(modulePath, naming);
const outputPath = getLinkedModuleOutputPath(platform, modulePath, naming);
- const tempPath = await fs.promises.mkdtemp(
- path.join(os.tmpdir(), `react-native-node-api-${newLibraryName}-`),
- );
- try {
- if (incremental && fs.existsSync(outputPath)) {
- const moduleModified = getLatestMtime(modulePath);
- const outputModified = getLatestMtime(outputPath);
- if (moduleModified < outputModified) {
- return {
- originalPath: modulePath,
- libraryName: newLibraryName,
- outputPath,
- skipped: true,
- };
- }
- }
- // Delete any existing xcframework (or xcodebuild will try to amend it)
- await fs.promises.rm(outputPath, { recursive: true, force: true });
- await fs.promises.cp(modulePath, tempPath, { recursive: true });
-
- // Following extracted function mimics `glob("*/*.framework/")`
- function globFrameworkDirs(
- startPath: string,
- fn: (parentPath: string, name: string) => Promise,
- ) {
- return fs
- .readdirSync(startPath, { withFileTypes: true })
- .filter((tripletEntry) => tripletEntry.isDirectory())
- .flatMap((tripletEntry) => {
- const tripletPath = path.join(startPath, tripletEntry.name);
- return fs
- .readdirSync(tripletPath, { withFileTypes: true })
- .filter(
- (frameworkEntry) =>
- frameworkEntry.isDirectory() &&
- path.extname(frameworkEntry.name) === ".framework",
- )
- .flatMap(
- async (frameworkEntry) =>
- await fn(tripletPath, frameworkEntry.name),
- );
- });
- }
- const frameworkPaths = await Promise.all(
- globFrameworkDirs(tempPath, async (tripletPath, frameworkEntryName) => {
- const frameworkPath = path.join(tripletPath, frameworkEntryName);
- const oldLibraryName = path.basename(frameworkEntryName, ".framework");
- const oldLibraryPath = path.join(frameworkPath, oldLibraryName);
- const newFrameworkPath = path.join(
- tripletPath,
- `${newLibraryName}.framework`,
- );
- const newLibraryPath = path.join(newFrameworkPath, newLibraryName);
- assert(
- fs.existsSync(oldLibraryPath),
- `Expected a library at '${oldLibraryPath}'`,
- );
- // Rename the library
- await fs.promises.rename(
- oldLibraryPath,
- // Cannot use newLibraryPath here, because the framework isn't renamed yet
- path.join(frameworkPath, newLibraryName),
- );
- // Rename the framework
- await fs.promises.rename(frameworkPath, newFrameworkPath);
- // Expect the library in the new location
- assert(fs.existsSync(newLibraryPath));
- // Update the binary
- await spawn(
- "install_name_tool",
- [
- "-id",
- `@rpath/${newLibraryName}.framework/${newLibraryName}`,
- newLibraryPath,
- ],
- {
- outputMode: "buffered",
- },
- );
- // Update the Info.plist file for the framework
- await updateInfoPlist({
- frameworkPath: newFrameworkPath,
- oldLibraryName,
- newLibraryName,
- });
- return newFrameworkPath;
- }),
- );
-
- // Create a new xcframework from the renamed frameworks
- await spawn(
- "xcodebuild",
- [
- "-create-xcframework",
- ...frameworkPaths.flatMap((frameworkPath) => [
- "-framework",
- frameworkPath,
- ]),
- "-output",
+ if (incremental && fs.existsSync(outputPath)) {
+ const moduleModified = getLatestMtime(modulePath);
+ const outputModified = getLatestMtime(outputPath);
+ if (moduleModified < outputModified) {
+ return {
+ originalPath: modulePath,
+ libraryName: newLibraryName,
outputPath,
- ],
- {
- outputMode: "buffered",
- },
- );
-
- return {
- originalPath: modulePath,
- libraryName: newLibraryName,
- outputPath,
- skipped: false,
- };
- } finally {
- await fs.promises.rm(tempPath, { recursive: true, force: true });
+ skipped: true,
+ };
+ }
}
+ // Delete any existing xcframework (or xcodebuild will try to amend it)
+ await fs.promises.rm(outputPath, { recursive: true, force: true });
+ // Copy the existing xcframework to the output path
+ await fs.promises.cp(modulePath, outputPath, {
+ recursive: true,
+ verbatimSymlinks: true,
+ });
+
+ const info = await readXcframeworkInfo(path.join(outputPath, "Info.plist"));
+
+ await Promise.all(
+ info.AvailableLibraries.map(async (framework) => {
+ const frameworkPath = path.join(
+ outputPath,
+ framework.LibraryIdentifier,
+ framework.LibraryPath,
+ );
+ await linkFramework({ frameworkPath, newLibraryName });
+ }),
+ );
+
+ await writeXcframeworkInfo(outputPath, {
+ ...info,
+ AvailableLibraries: info.AvailableLibraries.map((library) => {
+ return {
+ ...library,
+ LibraryPath: `${newLibraryName}.framework`,
+ BinaryPath: `${newLibraryName}.framework/${newLibraryName}`,
+ };
+ }),
+ });
+
+ // Delete any leftover "magic file"
+ await fs.promises.rm(path.join(outputPath, "react-native-node-api-module"), {
+ force: true,
+ });
+
+ return {
+ originalPath: modulePath,
+ libraryName: newLibraryName,
+ outputPath,
+ skipped: false,
+ };
}