From 696a2cc178bd2dda8b5219f43215bd1755a14c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 21 Oct 2025 21:20:41 +0200 Subject: [PATCH 1/7] Update existing xcframework instead of re-creating it when linking --- package-lock.json | 31 +--- packages/host/package.json | 6 +- packages/host/src/node/cli/apple.ts | 270 ++++++++++++++++------------ 3 files changed, 167 insertions(+), 140 deletions(-) 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.ts b/packages/host/src/node/cli/apple.ts index 687b8ccf..93061256 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,6 +14,86 @@ import { LinkModuleResult, } from "./link-modules.js"; +/** + * Reads and parses a plist file, converting it to XML format if needed. + */ +async function readAndParsePlist(plistPath: string): Promise { + try { + // Convert to XML format if needed + assert( + process.platform === "darwin", + "Updating Info.plist files are not supported on this platform", + ); + // Try reading the file to see if it is already in XML format + const contents = await fs.promises.readFile(plistPath, "utf-8"); + if (contents.startsWith(", +) { + const infoPlistPath = path.join(xcframeworkPath, "Info.plist"); + const infoPlistXml = plist.build(info); + await fs.promises.writeFile(infoPlistPath, infoPlistXml, "utf-8"); +} + +const FrameworkInfoSchema = zod.looseObject({ + CFBundlePackageType: zod.literal("FMWK"), + CFBundleInfoDictionaryVersion: zod.literal("6.0"), + CFBundleExecutable: zod.string(), +}); + +export async function readFrameworkInfo(frameworkPath: string) { + const infoPlistPath = path.join(frameworkPath, "Info.plist"); + const infoPlist = await readAndParsePlist(infoPlistPath); + return FrameworkInfoSchema.parse(infoPlist); +} + +export async function writeFrameworkInfo( + frameworkPath: string, + info: zod.infer, +) { + const infoPlistPath = path.join(frameworkPath, "Info.plist"); + const infoPlistXml = plist.build(info); + await fs.promises.writeFile(infoPlistPath, infoPlistXml, "utf-8"); +} + export function determineInfoPlistPath(frameworkPath: string) { const checkedPaths = new Array(); @@ -109,121 +190,86 @@ export async function linkXcframework({ }: LinkModuleOptions): Promise { // Copy the xcframework to the output directory and rename the framework and binary const newLibraryName = getLibraryName(modulePath, naming); + const newFrameworkRelativePath = `${newLibraryName}.framework`; + const newBinaryRelativePath = `${newFrameworkRelativePath}/${newLibraryName}`; 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), - ); - }); + + 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 }); + // Copy the existing xcframework to the output path + await fs.promises.cp(modulePath, outputPath, { recursive: true }); - 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; - }), - ); + const info = await readXcframeworkInfo(outputPath); - // Create a new xcframework from the renamed frameworks - await spawn( - "xcodebuild", - [ - "-create-xcframework", - ...frameworkPaths.flatMap((frameworkPath) => [ - "-framework", - frameworkPath, - ]), - "-output", + await Promise.all( + info.AvailableLibraries.map(async (framework) => { + const frameworkPath = path.join( outputPath, - ], - { - outputMode: "buffered", - }, - ); + framework.LibraryIdentifier, + framework.LibraryPath, + ); + assert( + fs.existsSync(frameworkPath), + `Expected framework at '${frameworkPath}'`, + ); + const frameworkInfo = await readFrameworkInfo(frameworkPath); + // Update install name + await spawn( + "install_name_tool", + [ + "-id", + `@rpath/${newBinaryRelativePath}`, + frameworkInfo.CFBundleExecutable, + ], + { + outputMode: "buffered", + cwd: frameworkPath, + }, + ); + await writeFrameworkInfo(frameworkPath, { + ...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), newFrameworkRelativePath), + ); + }), + ); - return { - originalPath: modulePath, - libraryName: newLibraryName, - outputPath, - skipped: false, - }; - } finally { - await fs.promises.rm(tempPath, { recursive: true, force: true }); - } + await writeXcframeworkInfo(outputPath, { + ...info, + AvailableLibraries: info.AvailableLibraries.map((library) => { + return { + ...library, + BinaryPath: newBinaryRelativePath, + LibraryPath: newFrameworkRelativePath, + }; + }), + }); + + return { + originalPath: modulePath, + libraryName: newLibraryName, + outputPath, + skipped: false, + }; } From c56a1151d2292c067e360117e01759c620abf596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 21 Oct 2025 21:26:52 +0200 Subject: [PATCH 2/7] Add changeset --- .changeset/blue-parts-cheer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/blue-parts-cheer.md 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 From 87a20f87a45f232bb759266f268fb8a70ca65f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 21 Oct 2025 23:05:25 +0200 Subject: [PATCH 3/7] Support linking of both flat and versioned frameworks --- packages/host/src/node/cli/apple.test.ts | 278 ++++++++++----------- packages/host/src/node/cli/apple.ts | 295 ++++++++++++----------- 2 files changed, 303 insertions(+), 270 deletions(-) diff --git a/packages/host/src/node/cli/apple.test.ts b/packages/host/src/node/cli/apple.test.ts index 051e7cee..3db4fdf9 100644 --- a/packages/host/src/node/cli/apple.test.ts +++ b/packages/host/src/node/cli/apple.test.ts @@ -2,54 +2,14 @@ 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, -} from "./apple"; +import { linkFlatFramework, readAndParsePlist } 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 +17,10 @@ describe("apple", () => { CFBundleExecutable ExecutableFileName - CFBundleIconFile - AppIcon + CFBundlePackageType + FMWK + CFBundleInfoDictionaryVersion + 6.0 `; @@ -68,10 +30,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 +43,149 @@ 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, { + 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 - - - `, - }); - - await updateInfoPlist({ - frameworkPath: tempDirectoryPath, - oldLibraryName: "addon", - newLibraryName: "new-addon-name", - }); + + + + + CFBundleExecutable + addon + CFBundlePackageType + FMWK + CFBundleInfoDictionaryVersion + 6.0 + MyExtraKey + MyExtraValue + + + `, + }, + }); - 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>/, - ); - }, - ); + // 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", + }); - 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); + 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>/, + ); - await updateInfoPlist({ - frameworkPath: tempDirectoryPath, - oldLibraryName: "addon", - newLibraryName: "new-addon-name", - }); + // 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/, + ); - 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 should preserve extra keys + assert.match( + contents, + /MyExtraKey<\/key>\s*MyExtraValue<\/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; - }, + 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 93061256..6f7efd6b 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -17,31 +17,38 @@ import { /** * Reads and parses a plist file, converting it to XML format if needed. */ -async function readAndParsePlist(plistPath: string): Promise { +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 { - // Convert to XML format if needed - assert( - process.platform === "darwin", - "Updating Info.plist files are not supported on this platform", - ); - // Try reading the file to see if it is already in XML format const contents = await fs.promises.readFile(plistPath, "utf-8"); if (contents.startsWith(", ) { - const infoPlistPath = path.join(frameworkPath, "Info.plist"); const infoPlistXml = plist.build(info); await fs.promises.writeFile(infoPlistPath, infoPlistXml, "utf-8"); } -export function determineInfoPlistPath(frameworkPath: string) { - const checkedPaths = new Array(); - - // First, assume it is an "unversioned" framework that keeps its Info.plist in - // the root. This is the convention for iOS, tvOS, and friends. - let infoPlistPath = path.join(frameworkPath, "Info.plist"); - - if (fs.existsSync(infoPlistPath)) { - return infoPlistPath; - } - checkedPaths.push(infoPlistPath); +type LinkFrameworkOptions = { + frameworkPath: string; + newLibraryName: string; +}; - // Next, assume it is a "versioned" framework that keeps its Info.plist - // under a subdirectory. This is the convention for macOS. - infoPlistPath = path.join( - frameworkPath, - "Versions/Current/Resources/Info.plist", +export async function linkFramework({ + frameworkPath, + newLibraryName, +}: LinkFrameworkOptions) { + assert.equal( + process.platform, + "darwin", + "Linking Apple frameworks are only supported on macOS", ); - - if (fs.existsSync(infoPlistPath)) { - return infoPlistPath; + assert( + fs.existsSync(frameworkPath), + `Expected framework at '${frameworkPath}'`, + ); + if (fs.existsSync(path.join(frameworkPath, "Versions"))) { + await linkVersionedFramework({ frameworkPath, newLibraryName }); + } else { + await linkFlatFramework({ frameworkPath, newLibraryName }); } - checkedPaths.push(infoPlistPath); +} - throw new Error( +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", [ - `Unable to locate an Info.plist file within framework. Checked the following paths:`, - ...checkedPaths.map((checkedPath) => `- ${checkedPath}`), - ].join("\n"), + "-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`), ); } -/** - * 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, - }); - } -} - -type UpdateInfoPlistOptions = { - 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 linkVersionedFramework({ 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 }, - ); - } - - const contents = await readInfoPlist(infoPlistPath); +}: 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, "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({ @@ -188,10 +238,13 @@ 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 newFrameworkRelativePath = `${newLibraryName}.framework`; - const newBinaryRelativePath = `${newFrameworkRelativePath}/${newLibraryName}`; const outputPath = getLinkedModuleOutputPath(platform, modulePath, naming); if (incremental && fs.existsSync(outputPath)) { @@ -209,9 +262,12 @@ export async function linkXcframework({ // 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 }); + await fs.promises.cp(modulePath, outputPath, { + recursive: true, + verbatimSymlinks: true, + }); - const info = await readXcframeworkInfo(outputPath); + const info = await readXcframeworkInfo(path.join(outputPath, "Info.plist")); await Promise.all( info.AvailableLibraries.map(async (framework) => { @@ -220,38 +276,7 @@ export async function linkXcframework({ framework.LibraryIdentifier, framework.LibraryPath, ); - assert( - fs.existsSync(frameworkPath), - `Expected framework at '${frameworkPath}'`, - ); - const frameworkInfo = await readFrameworkInfo(frameworkPath); - // Update install name - await spawn( - "install_name_tool", - [ - "-id", - `@rpath/${newBinaryRelativePath}`, - frameworkInfo.CFBundleExecutable, - ], - { - outputMode: "buffered", - cwd: frameworkPath, - }, - ); - await writeFrameworkInfo(frameworkPath, { - ...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), newFrameworkRelativePath), - ); + await linkFramework({ frameworkPath, newLibraryName }); }), ); @@ -260,8 +285,8 @@ export async function linkXcframework({ AvailableLibraries: info.AvailableLibraries.map((library) => { return { ...library, - BinaryPath: newBinaryRelativePath, - LibraryPath: newFrameworkRelativePath, + LibraryPath: `${newLibraryName}.framework`, + BinaryPath: `${newLibraryName}.framework/${newLibraryName}`, }; }), }); From 42b716802b3e037b2645708c2e446fa0e7b8ee3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 21 Oct 2025 23:09:16 +0200 Subject: [PATCH 4/7] Delete leftover magic files --- packages/host/src/node/cli/apple.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts index 6f7efd6b..b5ed6024 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -291,6 +291,11 @@ export async function linkXcframework({ }), }); + // Delete any leftover "magic file" + await fs.promises.rm(path.join(outputPath, "react-native-node-api-module"), { + force: true, + }); + return { originalPath: modulePath, libraryName: newLibraryName, From f848a93ba766ed9f0d0d9f96ed85b861ac70013b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 21 Oct 2025 23:11:51 +0200 Subject: [PATCH 5/7] Trigger CI again From dbab660532aaadf80050588ac416d60778112c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Wed, 22 Oct 2025 06:47:23 +0200 Subject: [PATCH 6/7] Fix parsing of xcframework info and add tests --- packages/host/src/node/cli/apple.test.ts | 104 ++++++++++++++++++++++- packages/host/src/node/cli/apple.ts | 2 +- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/packages/host/src/node/cli/apple.test.ts b/packages/host/src/node/cli/apple.test.ts index 3db4fdf9..7914a408 100644 --- a/packages/host/src/node/cli/apple.test.ts +++ b/packages/host/src/node/cli/apple.test.ts @@ -4,7 +4,12 @@ import path from "node:path"; import fs from "node:fs"; import cp from "node:child_process"; -import { linkFlatFramework, readAndParsePlist } from "./apple"; +import { + linkFlatFramework, + readAndParsePlist, + readFrameworkInfo, + readXcframeworkInfo, +} from "./apple"; import { setupTempDirectory } from "../test-utils"; describe("apple", { skip: process.platform !== "darwin" }, () => { @@ -49,6 +54,103 @@ describe("apple", { skip: process.platform !== "darwin" }, () => { }); }); + 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": ` + + + + + AvailableLibraries + + + BinaryPath + hello.framework/hello + LibraryIdentifier + tvos-arm64 + LibraryPath + hello.framework + SupportedArchitectures + + arm64 + + SupportedPlatform + tvos + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + + `, + }, + }); + + const result = await readXcframeworkInfo( + path.join(tempDirectoryPath, "foo.xcframework", "Info.plist"), + ); + + assert.deepEqual(result, { + AvailableLibraries: [ + { + BinaryPath: "hello.framework/hello", + LibraryIdentifier: "tvos-arm64", + LibraryPath: "hello.framework", + SupportedArchitectures: ["arm64"], + SupportedPlatform: "tvos", + }, + ], + CFBundlePackageType: "XFWK", + XCFrameworkFormatVersion: "1.0", + }); + }); + }); + + 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 + + + + `, + }, + }); + + const result = await readFrameworkInfo( + path.join(tempDirectoryPath, "foo.framework", "Info.plist"), + ); + + assert.deepEqual(result, { + CFBundlePackageType: "FMWK", + CFBundleInfoDictionaryVersion: "6.0", + CFBundleExecutable: "example-0--hello", + CFBundleIdentifier: "example_0.hello", + CFBundleSupportedPlatforms: ["XRSimulator"], + }); + }); + }); + describe("linkFlatFramework", () => { it("updates an xml plist, preserving extra keys", async (context) => { const infoPlistSubPath = "Info.plist"; diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts index b5ed6024..56c6ca78 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -55,7 +55,7 @@ export async function readAndParsePlist(plistPath: string): Promise { // Using a looseObject to allow additional fields that we don't know about const XcframeworkInfoSchema = zod.looseObject({ AvailableLibraries: zod.array( - zod.object({ + zod.looseObject({ BinaryPath: zod.string(), LibraryIdentifier: zod.string(), LibraryPath: zod.string(), From 3cdec20461cd0f1447d332f6dc135995158e5215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Thu, 23 Oct 2025 06:39:30 +0200 Subject: [PATCH 7/7] Use Versions/Current/Resources as suggested Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/host/src/node/cli/apple.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts index 56c6ca78..0ccbd82a 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -172,7 +172,13 @@ export async function linkVersionedFramework({ "darwin", "Linking Apple addons are only supported on macOS", ); - const frameworkInfoPath = path.join(frameworkPath, "Resources", "Info.plist"); + const frameworkInfoPath = path.join( + frameworkPath, + "Versions", + "Current", + "Resources", + "Info.plist", + ); const frameworkInfo = await readFrameworkInfo(frameworkInfoPath); // Update install name await spawn(