From 50dc958cdbb18fc34a419f29ff6527c64f2161a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 08:22:36 +0100 Subject: [PATCH 01/13] Add dependencies and xcode helpers --- package-lock.json | 52 ++++++++ packages/host/package.json | 2 + packages/host/src/node/cli/xcode-helpers.ts | 138 ++++++++++++++++++++ packages/host/tsconfig.node.json | 6 +- packages/host/types/xmldom.d.ts | 43 ++++++ 5 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 packages/host/src/node/cli/xcode-helpers.ts create mode 100644 packages/host/types/xmldom.d.ts diff --git a/package-lock.json b/package-lock.json index 2552398b..0b4f113c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1946,6 +1946,56 @@ "node": ">=6.9.0" } }, + "node_modules/@bacons/xcode": { + "version": "1.0.0-alpha.29", + "resolved": "https://registry.npmjs.org/@bacons/xcode/-/xcode-1.0.0-alpha.29.tgz", + "integrity": "sha512-b2P+Y6ovdlie3A8Tx6QBFlbfQPhXe0vDS83W0DVob6732e3TukgVMbVcE9umn36BaeGzmJVjMFtqEyhWetjRWg==", + "license": "MIT", + "dependencies": { + "@expo/plist": "^0.0.18", + "debug": "^4.3.4", + "uuid": "^8.3.2" + } + }, + "node_modules/@bacons/xcode/node_modules/@expo/plist": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.0.18.tgz", + "integrity": "sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "~0.7.0", + "base64-js": "^1.2.3", + "xmlbuilder": "^14.0.0" + } + }, + "node_modules/@bacons/xcode/node_modules/@xmldom/xmldom": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", + "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", + "deprecated": "this version is no longer supported, please update to at least 0.8.*", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@bacons/xcode/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@bacons/xcode/node_modules/xmlbuilder": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz", + "integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/@changesets/apply-release-plan": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.13.tgz", @@ -15353,8 +15403,10 @@ "version": "1.0.1", "license": "MIT", "dependencies": { + "@bacons/xcode": "^1.0.0-alpha.29", "@expo/plist": "^0.4.7", "@react-native-node-api/cli-utils": "0.1.4", + "@xmldom/xmldom": "^0.8.11", "pkg-dir": "^8.0.0", "read-pkg": "^9.0.1", "zod": "^4.1.11" diff --git a/packages/host/package.json b/packages/host/package.json index 401ba6f1..89b8248a 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -67,7 +67,9 @@ ], "license": "MIT", "dependencies": { + "@bacons/xcode": "^1.0.0-alpha.29", "@expo/plist": "^0.4.7", + "@xmldom/xmldom": "^0.8.11", "@react-native-node-api/cli-utils": "0.1.4", "pkg-dir": "^8.0.0", "read-pkg": "^9.0.1", diff --git a/packages/host/src/node/cli/xcode-helpers.ts b/packages/host/src/node/cli/xcode-helpers.ts new file mode 100644 index 00000000..6e0e53fe --- /dev/null +++ b/packages/host/src/node/cli/xcode-helpers.ts @@ -0,0 +1,138 @@ +import assert from "node:assert"; +import path from "node:path"; +import fs from "node:fs"; +import cp from "node:child_process"; + +// Using xmldom here because this is what @expo/plist uses internally and we might as well re-use it here. +// Types come from packages/host/types/xmldom.d.ts (path mapping in tsconfig.node.json) to avoid pulling in lib "dom". +import { DOMParser } from "@xmldom/xmldom"; +import xcode from "@bacons/xcode"; +import * as zod from "zod"; + +export type XcodeWorkspace = { + version: string; + fileRefs: { + location: string; + }[]; +}; + +export async function readXcodeWorkspace(workspacePath: string) { + const dataFilePath = path.join(workspacePath, "contents.xcworkspacedata"); + assert( + fs.existsSync(dataFilePath), + `Expected a contents.xcworkspacedata file at '${dataFilePath}'`, + ); + const xml = await fs.promises.readFile(dataFilePath, "utf-8"); + const dom = new DOMParser().parseFromString(xml, "application/xml"); + const version = dom.documentElement.getAttribute("version") ?? "1.0"; + assert.equal(version, "1.0", "Unexpected workspace version"); + + const result: XcodeWorkspace = { + version, + fileRefs: [], + }; + const fileRefs = dom.documentElement.getElementsByTagName("FileRef"); + for (let i = 0; i < fileRefs.length; i++) { + const fileRef = fileRefs.item(i); + if (fileRef) { + const location = fileRef.getAttribute("location"); + if (location) { + result.fileRefs.push({ + location, + }); + } + } + } + return result; +} + +export async function findXcodeWorkspace(fromPath: string) { + // Check if the directory contains a Xcode workspace + const xcodeWorkspace = await fs.promises.glob(path.join("*.xcworkspace"), { + cwd: fromPath, + }); + + for await (const workspace of xcodeWorkspace) { + return path.join(fromPath, workspace); + } + + // Check if the directory contain an ios directory and call recursively from that + const iosDirectory = path.join(fromPath, "ios"); + if (fs.existsSync(iosDirectory)) { + return findXcodeWorkspace(iosDirectory); + } + + // TODO: Consider continuing searching in parent directories + throw new Error(`No Xcode workspace found in '${fromPath}'`); +} + +export async function findXcodeProject(fromPath: string) { + // Read the workspace contents to find the first project + const workspacePath = await findXcodeWorkspace(fromPath); + const workspace = await readXcodeWorkspace(workspacePath); + // Resolve the first project location to an absolute path + assert( + workspace.fileRefs.length > 0, + "Expected at least one project in the workspace", + ); + const [firstProject] = workspace.fileRefs; + // Extract the path from the scheme (using a regex) + const match = firstProject.location.match(/^([^:]*):(.*)$/); + assert(match, "Expected a project path in the workspace"); + const [, scheme, projectPath] = match; + assert(scheme, "Expected a scheme in the fileRef location"); + assert(projectPath, "Expected a path in the fileRef location"); + if (scheme === "absolute") { + return projectPath; + } else if (scheme === "group") { + return path.resolve(path.dirname(workspacePath), projectPath); + } else { + throw new Error(`Unexpected scheme: ${scheme}`); + } +} + +const BuildSettingsSchema = zod.array( + zod.object({ + target: zod.string(), + buildSettings: zod.partialRecord(zod.string(), zod.string()), + }), +); + +export function getBuildSettings( + xcodeProjectPath: string, + mainTarget: xcode.PBXNativeTarget, +) { + const result = cp.spawnSync( + "xcodebuild", + [ + "-showBuildSettings", + "-project", + xcodeProjectPath, + "-target", + mainTarget.getDisplayName(), + "-json", + ], + { + cwd: xcodeProjectPath, + encoding: "utf-8", + }, + ); + assert.equal( + result.status, + 0, + `Failed to run xcodebuild -showBuildSettings: ${result.stderr}`, + ); + return BuildSettingsSchema.parse(JSON.parse(result.stdout)); +} + +export function getBuildDirPath( + xcodeProjectPath: string, + mainTarget: xcode.PBXNativeTarget, +) { + const buildSettings = getBuildSettings(xcodeProjectPath, mainTarget); + assert(buildSettings.length === 1, "Expected exactly one build setting"); + const [targetBuildSettings] = buildSettings; + const { BUILD_DIR: buildDirPath } = targetBuildSettings.buildSettings; + assert(buildDirPath, "Expected a build directory"); + return buildDirPath; +} diff --git a/packages/host/tsconfig.node.json b/packages/host/tsconfig.node.json index e0982db2..02a1feaa 100644 --- a/packages/host/tsconfig.node.json +++ b/packages/host/tsconfig.node.json @@ -5,7 +5,11 @@ "declarationMap": true, "outDir": "dist", "rootDir": "src", - "types": ["node"] + "types": ["node"], + "baseUrl": ".", + "paths": { + "@xmldom/xmldom": ["./types/xmldom.d.ts"] + } }, "include": ["src/node/**/*.ts", "types/**/*.d.ts"], "exclude": ["**/*.test.ts"], diff --git a/packages/host/types/xmldom.d.ts b/packages/host/types/xmldom.d.ts new file mode 100644 index 00000000..908d3617 --- /dev/null +++ b/packages/host/types/xmldom.d.ts @@ -0,0 +1,43 @@ +/** + * Local type declaration for @xmldom/xmldom that does not pull in lib "dom". + * Used via path mapping in tsconfig.node.json so we get correct xmldom types + * without bleeding global DOM types (Document, Node, Element, etc.) into the project. + */ +declare module "@xmldom/xmldom" { + interface XmldomDocument { + readonly documentElement: XmldomElement; + } + + interface XmldomElement { + getAttribute(name: string): string | null; + getElementsByTagName(name: string): XmldomNodeList; + } + + interface XmldomNodeList { + readonly length: number; + item(index: number): T | null; + } + + interface XmldomDOMParserOptions { + locator?: unknown; + errorHandler?: + | ((level: string, msg: unknown) => unknown) + | { + warning?: (msg: unknown) => unknown; + error?: (msg: unknown) => unknown; + fatalError?: (msg: unknown) => unknown; + }; + } + + interface XmldomDOMParserInstance { + parseFromString(xmlsource: string, mimeType?: string): XmldomDocument; + } + + interface XmldomDOMParserStatic { + new (options?: XmldomDOMParserOptions): XmldomDOMParserInstance; + } + + export const DOMParser: XmldomDOMParserStatic; + export const XMLSerializer: unknown; + export const DOMImplementation: unknown; +} From 11e6233ac46bde46c10fbb663e24a91116730c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 08:41:31 +0100 Subject: [PATCH 02/13] Enable source maps in host CLI --- packages/host/bin/react-native-node-api.mjs | 2 +- packages/host/tsconfig.node.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/host/bin/react-native-node-api.mjs b/packages/host/bin/react-native-node-api.mjs index e778e210..eb78caa4 100755 --- a/packages/host/bin/react-native-node-api.mjs +++ b/packages/host/bin/react-native-node-api.mjs @@ -1,2 +1,2 @@ -#!/usr/bin/env node +#!/usr/bin/env node --enable-source-maps import "../dist/node/cli/run.js"; diff --git a/packages/host/tsconfig.node.json b/packages/host/tsconfig.node.json index 02a1feaa..5056c8cd 100644 --- a/packages/host/tsconfig.node.json +++ b/packages/host/tsconfig.node.json @@ -3,6 +3,7 @@ "compilerOptions": { "composite": true, "declarationMap": true, + "sourceMap": true, "outDir": "dist", "rootDir": "src", "types": ["node"], From 299ce4d0512539dd02367617f57c9087700fc77a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 08:54:13 +0100 Subject: [PATCH 03/13] Add command to patch xcode project --- packages/host/react-native-node-api.podspec | 24 +--------- packages/host/scripts/patch-xcode-project.rb | 13 +++++ packages/host/src/node/cli/apple.ts | 50 +++++++++++++++++++- packages/host/src/node/cli/program.ts | 23 ++++++++- 4 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 packages/host/scripts/patch-xcode-project.rb diff --git a/packages/host/react-native-node-api.podspec b/packages/host/react-native-node-api.podspec index ab4b0e75..a559c87d 100644 --- a/packages/host/react-native-node-api.podspec +++ b/packages/host/react-native-node-api.podspec @@ -3,19 +3,7 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) require_relative "./scripts/patch-hermes" - -NODE_PATH ||= `which node`.strip -CLI_COMMAND ||= "'#{NODE_PATH}' '#{File.join(__dir__, "dist/node/cli/run.js")}'" -COPY_FRAMEWORKS_COMMAND ||= "#{CLI_COMMAND} link --apple '#{Pod::Config.instance.installation_root}'" - -# We need to run this now to ensure the xcframeworks are copied vendored_frameworks are considered -XCFRAMEWORKS_DIR ||= File.join(__dir__, "xcframeworks") -unless defined?(@xcframeworks_copied) - puts "Executing #{COPY_FRAMEWORKS_COMMAND}" - system(COPY_FRAMEWORKS_COMMAND) or raise "Failed to copy xcframeworks" - # Setting a flag to avoid running this command on every require - @xcframeworks_copied = true -end +require_relative "./scripts/patch-xcode-project" if ENV['RCT_NEW_ARCH_ENABLED'] == '0' Pod::UI.warn "React Native Node-API doesn't support the legacy architecture (but RCT_NEW_ARCH_ENABLED == '0')" @@ -35,16 +23,6 @@ Pod::Spec.new do |s| s.dependency "weak-node-api" - s.vendored_frameworks = "auto-linked/apple/*.xcframework" - s.script_phase = { - :name => 'Copy Node-API xcframeworks', - :execution_position => :before_compile, - :script => <<-CMD - set -e - #{COPY_FRAMEWORKS_COMMAND} - CMD - } - # Use install_modules_dependencies helper to install the dependencies (requires React Native version >=0.71.0). # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79. if respond_to?(:install_modules_dependencies, true) diff --git a/packages/host/scripts/patch-xcode-project.rb b/packages/host/scripts/patch-xcode-project.rb new file mode 100644 index 00000000..5a9adbd0 --- /dev/null +++ b/packages/host/scripts/patch-xcode-project.rb @@ -0,0 +1,13 @@ +unless defined?(@exit_hooks_installed) + # Setting a flag to avoid running this command on every require + @exit_hooks_installed = true + + NODE_PATH ||= `which node`.strip + CLI_COMMAND ||= "'#{NODE_PATH}' '#{File.join(__dir__, "../dist/node/cli/run.js")}'" + PATCH_XCODE_PROJECT_COMMAND ||= "#{CLI_COMMAND} patch-xcode-project '#{Pod::Config.instance.installation_root}'" + + # Using an at_exit hook to ensure the command is executed after the pod install is complete + at_exit do + system(PATCH_XCODE_PROJECT_COMMAND) or raise "Failed to patch the Xcode project" + end +end diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts index 31aa6779..2001fe92 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -3,9 +3,11 @@ import path from "node:path"; import fs from "node:fs"; import plist from "@expo/plist"; +import * as xcode from "@bacons/xcode"; +import * as xcodeJson from "@bacons/xcode/json"; import * as zod from "zod"; -import { spawn } from "@react-native-node-api/cli-utils"; +import { chalk, spawn } from "@react-native-node-api/cli-utils"; import { getLatestMtime, getLibraryName } from "../path-utils.js"; import { @@ -13,6 +15,52 @@ import { LinkModuleOptions, LinkModuleResult, } from "./link-modules.js"; +import { findXcodeProject } from "./xcode-helpers.js"; + +const PACKAGE_ROOT = path.resolve(__dirname, "..", "..", ".."); +const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "react-native-node-api.mjs"); +const BUILD_PHASE_PREFIX = "[Node-API]"; +const BUILD_PHASE_NAME = `${BUILD_PHASE_PREFIX} Copy, rename and sign frameworks`; + +export async function ensureXcodeBuildPhase(fromPath: string) { + // Locate the app's Xcode project + const xcodeProjectPath = await findXcodeProject(fromPath); + const pbxprojPath = path.join(xcodeProjectPath, "project.pbxproj"); + assert( + fs.existsSync(pbxprojPath), + `Expected a project.pbxproj file at '${pbxprojPath}'`, + ); + const xcodeProject = xcode.XcodeProject.open(pbxprojPath); + // Create a build phase on the main target to stage and rename the addon Xcframeworks + const mainTarget = xcodeProject.rootObject.getMainAppTarget(); + assert(mainTarget, "Unable to find a main target"); + + const existingBuildPhases = mainTarget.props.buildPhases.filter((phase) => + phase.getDisplayName().startsWith(BUILD_PHASE_PREFIX), + ); + + for (const existingBuildPhase of existingBuildPhases) { + console.log( + "Removing existing build phase:", + chalk.dim(existingBuildPhase.getDisplayName()), + ); + existingBuildPhase.removeFromProject(); + } + + mainTarget.createBuildPhase(xcode.PBXShellScriptBuildPhase, { + name: BUILD_PHASE_NAME, + shellScript: [ + "set -e", + `'${process.execPath}' '${CLI_PATH}' link --apple '${fromPath}'`, + ].join("\n"), + }); + + await fs.promises.writeFile( + pbxprojPath, + xcodeJson.build(xcodeProject.toJSON()), + "utf8", + ); +} /** * Reads and parses a plist file, converting it to XML format if needed. diff --git a/packages/host/src/node/cli/program.ts b/packages/host/src/node/cli/program.ts index 169ca85b..37839e49 100644 --- a/packages/host/src/node/cli/program.ts +++ b/packages/host/src/node/cli/program.ts @@ -26,7 +26,7 @@ import { import { command as vendorHermes } from "./hermes"; import { packageNameOption, pathSuffixOption } from "./options"; import { linkModules, pruneLinkedModules, ModuleLinker } from "./link-modules"; -import { linkXcframework } from "./apple"; +import { ensureXcodeBuildPhase, linkXcframework } from "./apple"; import { linkAndroidDir } from "./android"; // We're attaching a lot of listeners when spawning in parallel @@ -249,3 +249,24 @@ program }); }), ); + +program + .command("patch-xcode-project") + .description("Patch the Xcode project to include the Node-API build phase") + .argument("[path]", "Some path inside the app package", process.cwd()) + .action( + wrapAction(async (pathInput) => { + const resolvedPath = path.resolve(process.cwd(), pathInput); + console.log( + "Patching Xcode project in", + prettyPath(resolvedPath), + "to include a build phase to copy, rename and sign Node-API frameworks", + ); + assert.equal( + process.platform, + "darwin", + "Patching Xcode project is only supported on macOS", + ); + await ensureXcodeBuildPhase(resolvedPath); + }), + ); From 1e614960ef3e69c6eaa3543e5b4117020435bf99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 10:09:31 +0100 Subject: [PATCH 04/13] Copy and sign framework from xcode build phase --- packages/host/src/node/cli/apple.ts | 232 +++++++++++++++------ packages/host/src/node/cli/link-modules.ts | 2 + packages/host/src/node/cli/program.ts | 35 ++-- 3 files changed, 191 insertions(+), 78 deletions(-) diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts index 2001fe92..bfbbf208 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -7,15 +7,16 @@ import * as xcode from "@bacons/xcode"; import * as xcodeJson from "@bacons/xcode/json"; import * as zod from "zod"; -import { chalk, spawn } from "@react-native-node-api/cli-utils"; +import { assertFixable, chalk, spawn } from "@react-native-node-api/cli-utils"; import { getLatestMtime, getLibraryName } from "../path-utils.js"; import { getLinkedModuleOutputPath, LinkModuleOptions, LinkModuleResult, + ModuleLinker, } from "./link-modules.js"; -import { findXcodeProject } from "./xcode-helpers.js"; +import { findXcodeProject, getBuildDirPath } from "./xcode-helpers.js"; const PACKAGE_ROOT = path.resolve(__dirname, "..", "..", ".."); const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "react-native-node-api.mjs"); @@ -47,6 +48,8 @@ export async function ensureXcodeBuildPhase(fromPath: string) { existingBuildPhase.removeFromProject(); } + // TODO: Declare input and output files to prevent unnecessary runs + mainTarget.createBuildPhase(xcode.PBXShellScriptBuildPhase, { name: BUILD_PHASE_NAME, shellScript: [ @@ -108,6 +111,9 @@ const XcframeworkInfoSchema = zod.looseObject({ LibraryIdentifier: zod.string(), LibraryPath: zod.string(), DebugSymbolsPath: zod.string().optional(), + SupportedArchitectures: zod.array(zod.string()), + SupportedPlatform: zod.string(), + SupportedPlatformVariant: zod.string().optional(), }), ), CFBundlePackageType: zod.literal("XFWK"), @@ -376,84 +382,190 @@ export async function linkVersionedFramework({ ); } +export async function createAppleLinker(): Promise { + assert.equal( + process.platform, + "darwin", + "Linking Apple addons are only supported on macOS", + ); + + const { + TARGET_BUILD_DIR: targetBuildDir, + FRAMEWORKS_FOLDER_PATH: frameworksFolderPath, + } = process.env; + assert(targetBuildDir, "Expected TARGET_BUILD_DIR to be set by Xcodebuild"); + assert( + frameworksFolderPath, + "Expected FRAMEWORKS_FOLDER_PATH to be set by Xcodebuild", + ); + + const outputPath = path.join(targetBuildDir, frameworksFolderPath); + await fs.promises.mkdir(outputPath, { recursive: true }); + + const { + EXPANDED_CODE_SIGN_IDENTITY: signingIdentity = "-", + CODE_SIGNING_REQUIRED: signingRequired, + CODE_SIGNING_ALLOWED: signingAllowed, + } = process.env; + + return (options: LinkModuleOptions) => { + return linkXcframework({ + ...options, + outputPath, + signingIdentity: + signingRequired !== "NO" && signingAllowed !== "NO" + ? signingIdentity + : undefined, + }); + }; +} + +export function determineFrameworkSlice(): { + platform: string; + platformVariant?: string; + architectures: string[]; +} { + const { + PLATFORM_NAME: platformName, + EFFECTIVE_PLATFORM_NAME: effectivePlatformName, + ARCHS: architecturesJoined, + } = process.env; + + assert(platformName, "Expected PLATFORM_NAME to be set by Xcodebuild"); + assert(architecturesJoined, "Expected ARCHS to be set by Xcodebuild"); + const architectures = architecturesJoined.split(" "); + + const simulator = platformName.endsWith("simulator"); + + if (platformName === "iphonesimulator") { + return { + platform: "ios", + platformVariant: simulator ? "simulator" : undefined, + architectures, + }; + } else if (platformName === "macosx") { + return { + platform: "macos", + architectures, + platformVariant: effectivePlatformName?.endsWith("maccatalyst") + ? "maccatalyst" + : undefined, + }; + } + + throw new Error( + `Unsupported platform: ${effectivePlatformName ?? platformName}`, + ); +} + export async function linkXcframework({ platform, modulePath, incremental, naming, -}: LinkModuleOptions): Promise { - assert.equal( - process.platform, - "darwin", - "Linking Apple addons are only supported on macOS", + outputPath: outputParentPath, + signingIdentity, +}: LinkModuleOptions & { + outputPath: string; + signingIdentity?: string; +}): Promise { + assertFixable( + !incremental, + "Incremental linking is not supported for Apple frameworks", + { + instructions: "Run the command with the --force flag", + }, ); // Copy the xcframework to the output directory and rename the framework and binary const newLibraryName = getLibraryName(modulePath, naming); - const outputPath = getLinkedModuleOutputPath(platform, modulePath, naming); - - if (incremental && fs.existsSync(outputPath)) { - const moduleModified = getLatestMtime(modulePath); - const outputModified = getLatestMtime(outputPath); - if (moduleModified < outputModified) { - return { - originalPath: modulePath, - libraryName: newLibraryName, - outputPath, - skipped: true, - }; - } - } + const frameworkOutputPath = path.join( + outputParentPath, + `${newLibraryName}.framework`, + ); // 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, - }); + await fs.promises.rm(frameworkOutputPath, { recursive: true, force: true }); - const info = await readXcframeworkInfo(path.join(outputPath, "Info.plist")); + const info = await readXcframeworkInfo(path.join(modulePath, "Info.plist")); - await Promise.all( - info.AvailableLibraries.map(async (framework) => { - const frameworkPath = path.join( - outputPath, - framework.LibraryIdentifier, - framework.LibraryPath, - ); - await linkFramework({ - frameworkPath, - newLibraryName, - debugSymbolsPath: framework.DebugSymbolsPath - ? path.join( - outputPath, - framework.LibraryIdentifier, - framework.DebugSymbolsPath, - ) - : undefined, - }); - }), + // TODO: Assert the existence of environment variables injected by Xcodebuild + // TODO: Pick and assert the existence of the right framework slice based on the environment variables + // TODO: Link the framework into the output path + + const expectedSlice = determineFrameworkSlice(); + + const framework = info.AvailableLibraries.find((framework) => { + return ( + expectedSlice.platform === framework.SupportedPlatform && + expectedSlice.platformVariant === framework.SupportedPlatformVariant && + expectedSlice.architectures.every((architecture) => + framework.SupportedArchitectures.includes(architecture), + ) + ); + }); + assert( + framework, + `Failed to find a framework slice matching: ${JSON.stringify(expectedSlice)}`, ); - await writeXcframeworkInfo(outputPath, { - ...info, - AvailableLibraries: info.AvailableLibraries.map((library) => { - return { - ...library, - LibraryPath: `${newLibraryName}.framework`, - BinaryPath: `${newLibraryName}.framework/${newLibraryName}`, - }; - }), + const originalFrameworkPath = path.join( + modulePath, + framework.LibraryIdentifier, + framework.LibraryPath, + ); + + // Copy the existing framework to the output path + await fs.promises.cp(originalFrameworkPath, frameworkOutputPath, { + recursive: true, + verbatimSymlinks: true, }); - // Delete any leftover "magic file" - await fs.promises.rm(path.join(outputPath, "react-native-node-api-module"), { - force: true, + await linkFramework({ + frameworkPath: frameworkOutputPath, + newLibraryName, + debugSymbolsPath: framework.DebugSymbolsPath + ? path.join( + modulePath, + framework.LibraryIdentifier, + framework.DebugSymbolsPath, + ) + : undefined, }); + if (signingIdentity) { + await signFramework({ + frameworkPath: frameworkOutputPath, + identity: signingIdentity, + }); + } + return { originalPath: modulePath, libraryName: newLibraryName, - outputPath, + outputPath: frameworkOutputPath, skipped: false, + signed: signingIdentity ? true : false, }; } + +export async function signFramework({ + frameworkPath, + identity, +}: { + frameworkPath: string; + identity: string; +}) { + await spawn( + "codesign", + [ + "--force", + "--sign", + identity, + "--timestamp=none", + "--preserve-metadata=identifier,entitlements,flags", + frameworkPath, + ], + { + outputMode: "buffered", + }, + ); +} diff --git a/packages/host/src/node/cli/link-modules.ts b/packages/host/src/node/cli/link-modules.ts index a2d274f6..66c80cb6 100644 --- a/packages/host/src/node/cli/link-modules.ts +++ b/packages/host/src/node/cli/link-modules.ts @@ -44,11 +44,13 @@ export type ModuleDetails = { export type LinkModuleResult = ModuleDetails & { skipped: boolean; + signed?: boolean; }; export type ModuleOutputBase = { originalPath: string; skipped: boolean; + signed?: boolean; }; type ModuleOutput = ModuleOutputBase & diff --git a/packages/host/src/node/cli/program.ts b/packages/host/src/node/cli/program.ts index 37839e49..c9268975 100644 --- a/packages/host/src/node/cli/program.ts +++ b/packages/host/src/node/cli/program.ts @@ -26,7 +26,7 @@ import { import { command as vendorHermes } from "./hermes"; import { packageNameOption, pathSuffixOption } from "./options"; import { linkModules, pruneLinkedModules, ModuleLinker } from "./link-modules"; -import { ensureXcodeBuildPhase, linkXcframework } from "./apple"; +import { ensureXcodeBuildPhase, createAppleLinker } from "./apple"; import { linkAndroidDir } from "./android"; // We're attaching a lot of listeners when spawning in parallel @@ -36,11 +36,14 @@ export const program = new Command("react-native-node-api").addCommand( vendorHermes, ); -function getLinker(platform: PlatformName): ModuleLinker { +async function createLinker( + platform: PlatformName, + fromPath: string, +): Promise { if (platform === "android") { return linkAndroidDir; } else if (platform === "apple") { - return linkXcframework; + return createAppleLinker(); } else { throw new Error(`Unknown platform: ${platform as string}`); } @@ -99,27 +102,20 @@ program for (const platform of platforms) { const platformDisplayName = getPlatformDisplayName(platform); - const platformOutputPath = getAutolinkPath(platform); const modules = await oraPromise( - () => - linkModules({ + async () => + await linkModules({ platform, fromPath: path.resolve(pathArg), incremental: !force, naming: { packageName, pathSuffix }, - linker: getLinker(platform), + linker: await createLinker(platform, path.resolve(pathArg)), }), { - text: `Linking ${platformDisplayName} Node-API modules into ${prettyPath( - platformOutputPath, - )}`, - successText: `Linked ${platformDisplayName} Node-API modules into ${prettyPath( - platformOutputPath, - )}`, + text: `Linking ${platformDisplayName} Node-API modules`, + successText: `Linked ${platformDisplayName} Node-API modules`, failText: () => - `Failed to link ${platformDisplayName} Node-API modules into ${prettyPath( - platformOutputPath, - )}`, + `Failed to link ${platformDisplayName} Node-API modules`, }, ); @@ -130,16 +126,18 @@ program const failures = modules.filter((result) => "failure" in result); const linked = modules.filter((result) => "outputPath" in result); - for (const { originalPath, outputPath, skipped } of linked) { + for (const { originalPath, outputPath, skipped, signed } of linked) { const prettyOutputPath = outputPath - ? "→ " + prettyPath(path.basename(outputPath)) + ? "→ " + prettyPath(outputPath) : ""; + const signedSuffix = signed ? "🔏" : ""; if (skipped) { console.log( chalk.greenBright("-"), "Skipped", prettyPath(originalPath), prettyOutputPath, + signedSuffix, "(up to date)", ); } else { @@ -148,6 +146,7 @@ program "Linked", prettyPath(originalPath), prettyOutputPath, + signedSuffix, ); } } From bfade25c4dba6718aeff960466a2cefbffb3f989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 10:12:51 +0100 Subject: [PATCH 05/13] Remove incremental linking --- packages/host/src/node/cli/android.ts | 16 +------ packages/host/src/node/cli/apple.ts | 15 ++----- packages/host/src/node/cli/link-modules.ts | 3 -- packages/host/src/node/cli/program.ts | 11 +---- packages/host/src/node/cli/xcode-helpers.ts | 48 --------------------- 5 files changed, 5 insertions(+), 88 deletions(-) diff --git a/packages/host/src/node/cli/android.ts b/packages/host/src/node/cli/android.ts index 2fb2f92a..4af1a109 100644 --- a/packages/host/src/node/cli/android.ts +++ b/packages/host/src/node/cli/android.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -import { getLatestMtime, getLibraryName, MAGIC_FILENAME } from "../path-utils"; +import { getLibraryName, MAGIC_FILENAME } from "../path-utils"; import { getLinkedModuleOutputPath, LinkModuleResult, @@ -17,7 +17,6 @@ const ANDROID_ARCHITECTURES = [ ] as const; export async function linkAndroidDir({ - incremental, modulePath, naming, platform, @@ -25,19 +24,6 @@ export async function linkAndroidDir({ const libraryName = getLibraryName(modulePath, naming); const outputPath = getLinkedModuleOutputPath(platform, modulePath, naming); - if (incremental && fs.existsSync(outputPath)) { - const moduleModified = getLatestMtime(modulePath); - const outputModified = getLatestMtime(outputPath); - if (moduleModified < outputModified) { - return { - originalPath: modulePath, - libraryName, - outputPath, - skipped: true, - }; - } - } - await fs.promises.rm(outputPath, { recursive: true, force: true }); await fs.promises.cp(modulePath, outputPath, { recursive: true }); for (const arch of ANDROID_ARCHITECTURES) { diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts index bfbbf208..71db80b2 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -7,16 +7,15 @@ import * as xcode from "@bacons/xcode"; import * as xcodeJson from "@bacons/xcode/json"; import * as zod from "zod"; -import { assertFixable, chalk, spawn } from "@react-native-node-api/cli-utils"; +import { chalk, spawn } from "@react-native-node-api/cli-utils"; -import { getLatestMtime, getLibraryName } from "../path-utils.js"; +import { getLibraryName } from "../path-utils.js"; import { - getLinkedModuleOutputPath, LinkModuleOptions, LinkModuleResult, ModuleLinker, } from "./link-modules.js"; -import { findXcodeProject, getBuildDirPath } from "./xcode-helpers.js"; +import { findXcodeProject } from "./xcode-helpers.js"; const PACKAGE_ROOT = path.resolve(__dirname, "..", "..", ".."); const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "react-native-node-api.mjs"); @@ -461,7 +460,6 @@ export function determineFrameworkSlice(): { export async function linkXcframework({ platform, modulePath, - incremental, naming, outputPath: outputParentPath, signingIdentity, @@ -469,13 +467,6 @@ export async function linkXcframework({ outputPath: string; signingIdentity?: string; }): Promise { - assertFixable( - !incremental, - "Incremental linking is not supported for Apple frameworks", - { - instructions: "Run the command with the --force flag", - }, - ); // Copy the xcframework to the output directory and rename the framework and binary const newLibraryName = getLibraryName(modulePath, naming); const frameworkOutputPath = path.join( diff --git a/packages/host/src/node/cli/link-modules.ts b/packages/host/src/node/cli/link-modules.ts index 66c80cb6..4fe717fd 100644 --- a/packages/host/src/node/cli/link-modules.ts +++ b/packages/host/src/node/cli/link-modules.ts @@ -23,7 +23,6 @@ export type ModuleLinker = ( export type LinkModulesOptions = { platform: PlatformName; - incremental: boolean; naming: NamingStrategy; fromPath: string; linker: ModuleLinker; @@ -61,7 +60,6 @@ type ModuleOutput = ModuleOutputBase & export async function linkModules({ fromPath, - incremental, naming, platform, linker, @@ -96,7 +94,6 @@ export async function linkModules({ try { return await linker({ modulePath: originalPath, - incremental, naming, platform, }); diff --git a/packages/host/src/node/cli/program.ts b/packages/host/src/node/cli/program.ts index c9268975..861eafa7 100644 --- a/packages/host/src/node/cli/program.ts +++ b/packages/host/src/node/cli/program.ts @@ -62,11 +62,6 @@ function getPlatformDisplayName(platform: PlatformName) { program .command("link") .argument("[path]", "Some path inside the app package", process.cwd()) - .option( - "--force", - "Don't check timestamps of input files to skip unnecessary rebuilds", - false, - ) .option( "--prune", "Delete vendored modules that are no longer auto-linked", @@ -78,10 +73,7 @@ program .addOption(pathSuffixOption) .action( wrapAction( - async ( - pathArg, - { force, prune, pathSuffix, android, apple, packageName }, - ) => { + async (pathArg, { prune, pathSuffix, android, apple, packageName }) => { console.log("Auto-linking Node-API modules from", chalk.dim(pathArg)); const platforms: PlatformName[] = []; if (android) { @@ -107,7 +99,6 @@ program await linkModules({ platform, fromPath: path.resolve(pathArg), - incremental: !force, naming: { packageName, pathSuffix }, linker: await createLinker(platform, path.resolve(pathArg)), }), diff --git a/packages/host/src/node/cli/xcode-helpers.ts b/packages/host/src/node/cli/xcode-helpers.ts index 6e0e53fe..5068a8fd 100644 --- a/packages/host/src/node/cli/xcode-helpers.ts +++ b/packages/host/src/node/cli/xcode-helpers.ts @@ -6,8 +6,6 @@ import cp from "node:child_process"; // Using xmldom here because this is what @expo/plist uses internally and we might as well re-use it here. // Types come from packages/host/types/xmldom.d.ts (path mapping in tsconfig.node.json) to avoid pulling in lib "dom". import { DOMParser } from "@xmldom/xmldom"; -import xcode from "@bacons/xcode"; -import * as zod from "zod"; export type XcodeWorkspace = { version: string; @@ -90,49 +88,3 @@ export async function findXcodeProject(fromPath: string) { throw new Error(`Unexpected scheme: ${scheme}`); } } - -const BuildSettingsSchema = zod.array( - zod.object({ - target: zod.string(), - buildSettings: zod.partialRecord(zod.string(), zod.string()), - }), -); - -export function getBuildSettings( - xcodeProjectPath: string, - mainTarget: xcode.PBXNativeTarget, -) { - const result = cp.spawnSync( - "xcodebuild", - [ - "-showBuildSettings", - "-project", - xcodeProjectPath, - "-target", - mainTarget.getDisplayName(), - "-json", - ], - { - cwd: xcodeProjectPath, - encoding: "utf-8", - }, - ); - assert.equal( - result.status, - 0, - `Failed to run xcodebuild -showBuildSettings: ${result.stderr}`, - ); - return BuildSettingsSchema.parse(JSON.parse(result.stdout)); -} - -export function getBuildDirPath( - xcodeProjectPath: string, - mainTarget: xcode.PBXNativeTarget, -) { - const buildSettings = getBuildSettings(xcodeProjectPath, mainTarget); - assert(buildSettings.length === 1, "Expected exactly one build setting"); - const [targetBuildSettings] = buildSettings; - const { BUILD_DIR: buildDirPath } = targetBuildSettings.buildSettings; - assert(buildDirPath, "Expected a build directory"); - return buildDirPath; -} From df581d9d7acd82a9b3d51a5783a4828fc672d267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 10:28:36 +0100 Subject: [PATCH 06/13] Use bufferred outout when converting plists to xml --- packages/host/src/node/cli/apple.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts index 71db80b2..a377202b 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -83,7 +83,7 @@ export async function readAndParsePlist(plistPath: string): Promise { "Updating Info.plist files are not supported on this platform", ); await spawn("plutil", ["-convert", "xml1", plistPath], { - outputMode: "inherit", + outputMode: "buffered", }); } catch (cause) { throw new Error(`Failed to convert plist to XML: ${plistPath}`, { @@ -323,6 +323,7 @@ export async function linkVersionedFramework({ "Info.plist", ); const frameworkInfo = await readFrameworkInfo(frameworkInfoPath); + // Update install name await spawn( "install_name_tool", From 17394b0be038f66bd20eb8403cf91589043db3d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 10:46:13 +0100 Subject: [PATCH 07/13] Updated determineFrameworkSlice --- packages/host/src/node/cli/apple.ts | 63 ++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts index a377202b..2f978245 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -420,6 +420,12 @@ export async function createAppleLinker(): Promise { }; } +/** + * Maps Xcode PLATFORM_NAME to SupportedPlatform / SupportedPlatformVariant + * as used in xcframework Info.plist (e.g. hello.apple.node/Info.plist). + * PLATFORM_NAME values: iphoneos, iphonesimulator, macosx, appletvos, + * appletvsimulator, xros, xrsimulator. + */ export function determineFrameworkSlice(): { platform: string; platformVariant?: string; @@ -435,27 +441,44 @@ export function determineFrameworkSlice(): { assert(architecturesJoined, "Expected ARCHS to be set by Xcodebuild"); const architectures = architecturesJoined.split(" "); - const simulator = platformName.endsWith("simulator"); - - if (platformName === "iphonesimulator") { - return { - platform: "ios", - platformVariant: simulator ? "simulator" : undefined, - architectures, - }; - } else if (platformName === "macosx") { - return { - platform: "macos", - architectures, - platformVariant: effectivePlatformName?.endsWith("maccatalyst") - ? "maccatalyst" - : undefined, - }; + switch (platformName) { + case "iphoneos": + return { platform: "ios", architectures }; + case "iphonesimulator": + return { + platform: "ios", + platformVariant: "simulator", + architectures, + }; + case "macosx": + return { + platform: "macos", + architectures, + platformVariant: effectivePlatformName?.endsWith("maccatalyst") + ? "maccatalyst" + : undefined, + }; + case "appletvos": + return { platform: "tvos", architectures }; + case "appletvsimulator": + return { + platform: "tvos", + platformVariant: "simulator", + architectures, + }; + case "xros": + return { platform: "xros", architectures }; + case "xrsimulator": + return { + platform: "xros", + platformVariant: "simulator", + architectures, + }; + default: + throw new Error( + `Unsupported platform: ${effectivePlatformName ?? platformName}`, + ); } - - throw new Error( - `Unsupported platform: ${effectivePlatformName ?? platformName}`, - ); } export async function linkXcframework({ From 0740c3c8744fdb19f05a6809b5c25267b3ec3551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 10:48:37 +0100 Subject: [PATCH 08/13] Add changeset --- .changeset/social-peaches-end.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/social-peaches-end.md diff --git a/.changeset/social-peaches-end.md b/.changeset/social-peaches-end.md new file mode 100644 index 00000000..ed873762 --- /dev/null +++ b/.changeset/social-peaches-end.md @@ -0,0 +1,5 @@ +--- +"react-native-node-api": minor +--- + +Modify Xcode project to add a build phase to the main project app to link Node-API frameworks directly From ecff6a6352ce6a6f4c65748f451a0135aeb6bdd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 10:16:04 +0100 Subject: [PATCH 09/13] Cleaning up --- packages/host/src/node/cli/android.ts | 3 +-- packages/host/src/node/cli/apple.ts | 6 ------ packages/host/src/node/cli/link-modules.ts | 3 +-- packages/host/src/node/cli/xcode-helpers.ts | 1 - 4 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/host/src/node/cli/android.ts b/packages/host/src/node/cli/android.ts index 4af1a109..9d733732 100644 --- a/packages/host/src/node/cli/android.ts +++ b/packages/host/src/node/cli/android.ts @@ -19,10 +19,9 @@ const ANDROID_ARCHITECTURES = [ export async function linkAndroidDir({ modulePath, naming, - platform, }: LinkModuleOptions): Promise { const libraryName = getLibraryName(modulePath, naming); - const outputPath = getLinkedModuleOutputPath(platform, modulePath, naming); + const outputPath = getLinkedModuleOutputPath("android", modulePath, naming); await fs.promises.rm(outputPath, { recursive: true, force: true }); await fs.promises.cp(modulePath, outputPath, { recursive: true }); diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts index 2f978245..f8821fbd 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -482,7 +482,6 @@ export function determineFrameworkSlice(): { } export async function linkXcframework({ - platform, modulePath, naming, outputPath: outputParentPath, @@ -502,12 +501,7 @@ export async function linkXcframework({ const info = await readXcframeworkInfo(path.join(modulePath, "Info.plist")); - // TODO: Assert the existence of environment variables injected by Xcodebuild - // TODO: Pick and assert the existence of the right framework slice based on the environment variables - // TODO: Link the framework into the output path - const expectedSlice = determineFrameworkSlice(); - const framework = info.AvailableLibraries.find((framework) => { return ( expectedSlice.platform === framework.SupportedPlatform && diff --git a/packages/host/src/node/cli/link-modules.ts b/packages/host/src/node/cli/link-modules.ts index 4fe717fd..2c2855a0 100644 --- a/packages/host/src/node/cli/link-modules.ts +++ b/packages/host/src/node/cli/link-modules.ts @@ -30,7 +30,7 @@ export type LinkModulesOptions = { export type LinkModuleOptions = Omit< LinkModulesOptions, - "fromPath" | "linker" + "fromPath" | "linker" | "platform" > & { modulePath: string; }; @@ -95,7 +95,6 @@ export async function linkModules({ return await linker({ modulePath: originalPath, naming, - platform, }); } catch (error) { if (error instanceof SpawnFailure) { diff --git a/packages/host/src/node/cli/xcode-helpers.ts b/packages/host/src/node/cli/xcode-helpers.ts index 5068a8fd..c090e859 100644 --- a/packages/host/src/node/cli/xcode-helpers.ts +++ b/packages/host/src/node/cli/xcode-helpers.ts @@ -1,7 +1,6 @@ import assert from "node:assert"; import path from "node:path"; import fs from "node:fs"; -import cp from "node:child_process"; // Using xmldom here because this is what @expo/plist uses internally and we might as well re-use it here. // Types come from packages/host/types/xmldom.d.ts (path mapping in tsconfig.node.json) to avoid pulling in lib "dom". From 132bdb67c977c2b6b5209f19665b5db2936f4652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 11:13:31 +0100 Subject: [PATCH 10/13] Fix lint issues --- packages/host/src/node/cli/program.ts | 8 ++------ packages/host/src/node/cli/xcode-helpers.ts | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/host/src/node/cli/program.ts b/packages/host/src/node/cli/program.ts index 861eafa7..e3c63904 100644 --- a/packages/host/src/node/cli/program.ts +++ b/packages/host/src/node/cli/program.ts @@ -14,7 +14,6 @@ import { import { determineModuleContext, findNodeApiModulePathsByDependency, - getAutolinkPath, getLibraryName, visualizeLibraryMap, normalizeModulePath, @@ -36,10 +35,7 @@ export const program = new Command("react-native-node-api").addCommand( vendorHermes, ); -async function createLinker( - platform: PlatformName, - fromPath: string, -): Promise { +async function createLinker(platform: PlatformName): Promise { if (platform === "android") { return linkAndroidDir; } else if (platform === "apple") { @@ -100,7 +96,7 @@ program platform, fromPath: path.resolve(pathArg), naming: { packageName, pathSuffix }, - linker: await createLinker(platform, path.resolve(pathArg)), + linker: await createLinker(platform), }), { text: `Linking ${platformDisplayName} Node-API modules`, diff --git a/packages/host/src/node/cli/xcode-helpers.ts b/packages/host/src/node/cli/xcode-helpers.ts index c090e859..ea96b943 100644 --- a/packages/host/src/node/cli/xcode-helpers.ts +++ b/packages/host/src/node/cli/xcode-helpers.ts @@ -45,7 +45,7 @@ export async function readXcodeWorkspace(workspacePath: string) { export async function findXcodeWorkspace(fromPath: string) { // Check if the directory contains a Xcode workspace - const xcodeWorkspace = await fs.promises.glob(path.join("*.xcworkspace"), { + const xcodeWorkspace = fs.promises.glob(path.join("*.xcworkspace"), { cwd: fromPath, }); From 234ca1dbb90d271587d65fb67df2d51022cf3c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 11:21:10 +0100 Subject: [PATCH 11/13] Fixing tests --- packages/host/src/node/cli/apple.ts | 74 +++------------------ packages/host/src/node/cli/bin.test.ts | 20 +++++- packages/host/src/node/cli/xcode-helpers.ts | 63 ++++++++++++++++++ 3 files changed, 91 insertions(+), 66 deletions(-) diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts index f8821fbd..06267503 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -15,7 +15,11 @@ import { LinkModuleResult, ModuleLinker, } from "./link-modules.js"; -import { findXcodeProject } from "./xcode-helpers.js"; +import { + determineFrameworkSlice, + ExpectedFrameworkSlice, + findXcodeProject, +} from "./xcode-helpers.js"; const PACKAGE_ROOT = path.resolve(__dirname, "..", "..", ".."); const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "react-native-node-api.mjs"); @@ -408,10 +412,13 @@ export async function createAppleLinker(): Promise { CODE_SIGNING_ALLOWED: signingAllowed, } = process.env; + const expectedSlice = determineFrameworkSlice(); + return (options: LinkModuleOptions) => { return linkXcframework({ ...options, outputPath, + expectedSlice, signingIdentity: signingRequired !== "NO" && signingAllowed !== "NO" ? signingIdentity @@ -420,74 +427,15 @@ export async function createAppleLinker(): Promise { }; } -/** - * Maps Xcode PLATFORM_NAME to SupportedPlatform / SupportedPlatformVariant - * as used in xcframework Info.plist (e.g. hello.apple.node/Info.plist). - * PLATFORM_NAME values: iphoneos, iphonesimulator, macosx, appletvos, - * appletvsimulator, xros, xrsimulator. - */ -export function determineFrameworkSlice(): { - platform: string; - platformVariant?: string; - architectures: string[]; -} { - const { - PLATFORM_NAME: platformName, - EFFECTIVE_PLATFORM_NAME: effectivePlatformName, - ARCHS: architecturesJoined, - } = process.env; - - assert(platformName, "Expected PLATFORM_NAME to be set by Xcodebuild"); - assert(architecturesJoined, "Expected ARCHS to be set by Xcodebuild"); - const architectures = architecturesJoined.split(" "); - - switch (platformName) { - case "iphoneos": - return { platform: "ios", architectures }; - case "iphonesimulator": - return { - platform: "ios", - platformVariant: "simulator", - architectures, - }; - case "macosx": - return { - platform: "macos", - architectures, - platformVariant: effectivePlatformName?.endsWith("maccatalyst") - ? "maccatalyst" - : undefined, - }; - case "appletvos": - return { platform: "tvos", architectures }; - case "appletvsimulator": - return { - platform: "tvos", - platformVariant: "simulator", - architectures, - }; - case "xros": - return { platform: "xros", architectures }; - case "xrsimulator": - return { - platform: "xros", - platformVariant: "simulator", - architectures, - }; - default: - throw new Error( - `Unsupported platform: ${effectivePlatformName ?? platformName}`, - ); - } -} - export async function linkXcframework({ modulePath, naming, outputPath: outputParentPath, + expectedSlice, signingIdentity, }: LinkModuleOptions & { outputPath: string; + expectedSlice: ExpectedFrameworkSlice; signingIdentity?: string; }): Promise { // Copy the xcframework to the output directory and rename the framework and binary @@ -500,8 +448,6 @@ export async function linkXcframework({ await fs.promises.rm(frameworkOutputPath, { recursive: true, force: true }); const info = await readXcframeworkInfo(path.join(modulePath, "Info.plist")); - - const expectedSlice = determineFrameworkSlice(); const framework = info.AvailableLibraries.find((framework) => { return ( expectedSlice.platform === framework.SupportedPlatform && diff --git a/packages/host/src/node/cli/bin.test.ts b/packages/host/src/node/cli/bin.test.ts index f38dead1..5adc185c 100644 --- a/packages/host/src/node/cli/bin.test.ts +++ b/packages/host/src/node/cli/bin.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import cp from "node:child_process"; import path from "node:path"; +import { setupTempDirectory } from "../test-utils"; const PACKAGE_ROOT = path.join(__dirname, "../../.."); const BIN_PATH = path.join(PACKAGE_ROOT, "bin/react-native-node-api.mjs"); @@ -32,13 +33,28 @@ describe("bin", () => { }); describe("link command", () => { - it("should succeed with a mention of Node-API modules", () => { + it("should succeed with a mention of Node-API modules", (context) => { + const targetBuildDir = setupTempDirectory(context, {}); + const { status, stdout, stderr } = cp.spawnSync( process.execPath, - [BIN_PATH, "link", "--android", "--apple"], + [ + BIN_PATH, + "link", + "--android", + // Linking for Apple fails on non-Apple platforms + ...(process.platform === "darwin" ? ["--apple"] : []), + ], { cwd: PACKAGE_ROOT, encoding: "utf8", + env: { + ...process.env, + TARGET_BUILD_DIR: targetBuildDir, + FRAMEWORKS_FOLDER_PATH: "Frameworks", + PLATFORM_NAME: "iphonesimulator", + ARCHS: "arm64", + }, }, ); diff --git a/packages/host/src/node/cli/xcode-helpers.ts b/packages/host/src/node/cli/xcode-helpers.ts index ea96b943..eb5b37f9 100644 --- a/packages/host/src/node/cli/xcode-helpers.ts +++ b/packages/host/src/node/cli/xcode-helpers.ts @@ -87,3 +87,66 @@ export async function findXcodeProject(fromPath: string) { throw new Error(`Unexpected scheme: ${scheme}`); } } + +export type ExpectedFrameworkSlice = { + platform: string; + platformVariant?: string; + architectures: string[]; +}; + +/** + * Maps Xcode PLATFORM_NAME to SupportedPlatform / SupportedPlatformVariant + * as used in xcframework Info.plist (e.g. hello.apple.node/Info.plist). + * PLATFORM_NAME values: iphoneos, iphonesimulator, macosx, appletvos, + * appletvsimulator, xros, xrsimulator. + */ +export function determineFrameworkSlice(): ExpectedFrameworkSlice { + const { + PLATFORM_NAME: platformName, + EFFECTIVE_PLATFORM_NAME: effectivePlatformName, + ARCHS: architecturesJoined, + } = process.env; + + assert(platformName, "Expected PLATFORM_NAME to be set by Xcodebuild"); + assert(architecturesJoined, "Expected ARCHS to be set by Xcodebuild"); + const architectures = architecturesJoined.split(" "); + + switch (platformName) { + case "iphoneos": + return { platform: "ios", architectures }; + case "iphonesimulator": + return { + platform: "ios", + platformVariant: "simulator", + architectures, + }; + case "macosx": + return { + platform: "macos", + architectures, + platformVariant: effectivePlatformName?.endsWith("maccatalyst") + ? "maccatalyst" + : undefined, + }; + case "appletvos": + return { platform: "tvos", architectures }; + case "appletvsimulator": + return { + platform: "tvos", + platformVariant: "simulator", + architectures, + }; + case "xros": + return { platform: "xros", architectures }; + case "xrsimulator": + return { + platform: "xros", + platformVariant: "simulator", + architectures, + }; + default: + throw new Error( + `Unsupported platform: ${effectivePlatformName ?? platformName}`, + ); + } +} From c01e84324c38f4a91e397296de18d6481528effc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 11:27:07 +0100 Subject: [PATCH 12/13] Fix shebang on ubuntu --- packages/host/bin/react-native-node-api.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/bin/react-native-node-api.mjs b/packages/host/bin/react-native-node-api.mjs index eb78caa4..8b762027 100755 --- a/packages/host/bin/react-native-node-api.mjs +++ b/packages/host/bin/react-native-node-api.mjs @@ -1,2 +1,2 @@ -#!/usr/bin/env node --enable-source-maps +#!/usr/bin/env -S node --enable-source-maps import "../dist/node/cli/run.js"; From 6dc2cfb303783b4b15b98e16202f2b1e4d56f486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Tue, 10 Feb 2026 13:15:04 +0100 Subject: [PATCH 13/13] Patch all application targets --- packages/host/src/node/cli/apple.ts | 47 +++++++++++++++++------------ 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/host/src/node/cli/apple.ts b/packages/host/src/node/cli/apple.ts index 06267503..77ca6e5c 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -35,31 +35,38 @@ export async function ensureXcodeBuildPhase(fromPath: string) { `Expected a project.pbxproj file at '${pbxprojPath}'`, ); const xcodeProject = xcode.XcodeProject.open(pbxprojPath); - // Create a build phase on the main target to stage and rename the addon Xcframeworks - const mainTarget = xcodeProject.rootObject.getMainAppTarget(); - assert(mainTarget, "Unable to find a main target"); - - const existingBuildPhases = mainTarget.props.buildPhases.filter((phase) => - phase.getDisplayName().startsWith(BUILD_PHASE_PREFIX), + // Create a build phase in all application targets to stage and rename the addon frameworks + const applicationTargets = xcodeProject.rootObject.props.targets.filter( + (target) => + xcode.PBXNativeTarget.is(target) && + target.props.productType === "com.apple.product-type.application", ); - for (const existingBuildPhase of existingBuildPhases) { - console.log( - "Removing existing build phase:", - chalk.dim(existingBuildPhase.getDisplayName()), + for (const target of applicationTargets) { + console.log(`Patching '${target.getDisplayName()}' target`); + + const existingBuildPhases = target.props.buildPhases.filter((phase) => + phase.getDisplayName().startsWith(BUILD_PHASE_PREFIX), ); - existingBuildPhase.removeFromProject(); - } - // TODO: Declare input and output files to prevent unnecessary runs + for (const existingBuildPhase of existingBuildPhases) { + console.log( + "Removing existing build phase:", + chalk.dim(existingBuildPhase.getDisplayName()), + ); + existingBuildPhase.removeFromProject(); + } - mainTarget.createBuildPhase(xcode.PBXShellScriptBuildPhase, { - name: BUILD_PHASE_NAME, - shellScript: [ - "set -e", - `'${process.execPath}' '${CLI_PATH}' link --apple '${fromPath}'`, - ].join("\n"), - }); + // TODO: Declare input and output files to prevent unnecessary runs + + target.createBuildPhase(xcode.PBXShellScriptBuildPhase, { + name: BUILD_PHASE_NAME, + shellScript: [ + "set -e", + `'${process.execPath}' '${CLI_PATH}' link --apple '${fromPath}'`, + ].join("\n"), + }); + } await fs.promises.writeFile( pbxprojPath,