diff --git a/package-lock.json b/package-lock.json index 48911022..1efe4481 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14834,11 +14834,12 @@ } }, "packages/cmake-rn": { - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "@react-native-node-api/cli-utils": "0.1.0", "cmake-file-api": "0.1.0", - "react-native-node-api": "0.5.1" + "react-native-node-api": "0.5.2", + "zod": "^4.1.11" }, "bin": { "cmake-rn": "bin/cmake-rn.js" @@ -14848,13 +14849,22 @@ "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.3", + "version": "0.3.4", "dependencies": { "@napi-rs/cli": "~3.0.3", "@react-native-node-api/cli-utils": "0.1.0", - "react-native-node-api": "0.5.1" + "react-native-node-api": "0.5.2" }, "bin": { "ferric": "bin/ferric.js" @@ -14871,7 +14881,9 @@ "version": "0.3.0", "dependencies": { "@react-native-node-api/cli-utils": "0.1.0", - "gyp-parser": "^1.0.4" + "gyp-parser": "^1.0.4", + "pkg-dir": "^8.0.0", + "read-pkg": "^9.0.1" }, "bin": { "gyp-to-cmake": "bin/gyp-to-cmake.js" @@ -14879,7 +14891,7 @@ }, "packages/host": { "name": "react-native-node-api", - "version": "0.5.1", + "version": "0.5.2", "license": "MIT", "dependencies": { "@expo/plist": "^0.4.7", @@ -14920,7 +14932,7 @@ "cmake-rn": "*", "gyp-to-cmake": "*", "prebuildify": "^6.0.1", - "react-native-node-api": "^0.5.1", + "react-native-node-api": "^0.5.2", "read-pkg": "^9.0.1", "rolldown": "1.0.0-beta.29" } diff --git a/packages/cmake-rn/README.md b/packages/cmake-rn/README.md index 6b6aa888..ba41aecb 100644 --- a/packages/cmake-rn/README.md +++ b/packages/cmake-rn/README.md @@ -16,11 +16,28 @@ To link against `weak-node-api` just include the CMake config exposed through `W cmake_minimum_required(VERSION 3.15...3.31) project(tests-buffers) +# Defines the "weak-node-api" target include(${WEAK_NODE_API_CONFIG}) add_library(addon SHARED addon.c) target_link_libraries(addon PRIVATE weak-node-api) target_compile_features(addon PRIVATE cxx_std_20) + +if(APPLE) + # Build frameworks when building for Apple (optional) + set_target_properties(addon PROPERTIES + FRAMEWORK TRUE + MACOSX_FRAMEWORK_IDENTIFIER async_test.addon + MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0 + MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0 + XCODE_ATTRIBUTE_SKIP_INSTALL NO + ) +else() + set_target_properties(addon PROPERTIES + PREFIX "" + SUFFIX .node + ) +endif() ``` This is different from how `cmake-js` "injects" the Node-API for linking (via `${CMAKE_JS_INC}`, `${CMAKE_JS_SRC}` and `${CMAKE_JS_LIB}`). To allow for interoperability between these tools, we inject these when you pass `--cmake-js` to `cmake-rn`. diff --git a/packages/cmake-rn/package.json b/packages/cmake-rn/package.json index cd81d9d5..107c6962 100644 --- a/packages/cmake-rn/package.json +++ b/packages/cmake-rn/package.json @@ -26,7 +26,8 @@ "dependencies": { "@react-native-node-api/cli-utils": "0.1.0", "cmake-file-api": "0.1.0", - "react-native-node-api": "0.5.2" + "react-native-node-api": "0.5.2", + "zod": "^4.1.11" }, "peerDependencies": { "node-addon-api": "^8.3.1", diff --git a/packages/cmake-rn/src/cli.ts b/packages/cmake-rn/src/cli.ts index 10bf9f63..2639c13d 100644 --- a/packages/cmake-rn/src/cli.ts +++ b/packages/cmake-rn/src/cli.ts @@ -12,20 +12,14 @@ import { assertFixable, wrapAction, } from "@react-native-node-api/cli-utils"; -import { isSupportedTriplet } from "react-native-node-api"; -import * as cmakeFileApi from "cmake-file-api"; -import { - getCmakeJSVariables, - getWeakNodeApiVariables, -} from "./weak-node-api.js"; import { platforms, allTriplets as allTriplets, findPlatformForTriplet, platformHasTriplet, } from "./platforms.js"; -import { BaseOpts, TripletContext, Platform } from "./platforms/types.js"; +import { Platform } from "./platforms/types.js"; // We're attaching a lot of listeners when spawning in parallel EventEmitter.defaultMaxListeners = 100; @@ -170,17 +164,17 @@ program = program.action( process.cwd(), expandTemplate(baseOptions.out, baseOptions), ); - const { out, build: buildPath } = baseOptions; + const { verbose, clean, source, out, build: buildPath } = baseOptions; assertFixable( - fs.existsSync(path.join(baseOptions.source, "CMakeLists.txt")), - `No CMakeLists.txt found in source directory: ${chalk.dim(baseOptions.source)}`, + fs.existsSync(path.join(source, "CMakeLists.txt")), + `No CMakeLists.txt found in source directory: ${chalk.dim(source)}`, { instructions: `Change working directory into a directory with a CMakeLists.txt, create one or specify the correct source directory using --source`, }, ); - if (baseOptions.clean) { + if (clean) { await fs.promises.rm(buildPath, { recursive: true, force: true }); } const triplets = new Set(requestedTriplets); @@ -217,13 +211,17 @@ program = program.action( const tripletContexts = [...triplets].map((triplet) => { const platform = findPlatformForTriplet(triplet); - const tripletBuildPath = getTripletBuildPath(buildPath, triplet); + return { triplet, platform, - buildPath: tripletBuildPath, - outputPath: path.join(tripletBuildPath, "out"), - options: baseOptions, + async spawn(command: string, args: string[], cwd?: string) { + await spawn(command, args, { + outputMode: verbose ? "inherit" : "buffered", + outputPrefix: verbose ? chalk.dim(`[${triplet}] `) : undefined, + cwd, + }); + }, }; }); @@ -231,11 +229,29 @@ program = program.action( const tripletsSummary = chalk.dim( `(${getTripletsSummary(tripletContexts)})`, ); + + // Perform configure steps for each platform in sequence await oraPromise( Promise.all( - tripletContexts.map(({ platform, ...context }) => - configureProject(platform, context, baseOptions), - ), + platforms.map(async (platform) => { + const relevantTriplets = tripletContexts.filter(({ triplet }) => + platformHasTriplet(platform, triplet), + ); + if (relevantTriplets.length > 0) { + await platform.configure( + relevantTriplets, + baseOptions, + (command, args, cwd) => + spawn(command, args, { + outputMode: verbose ? "inherit" : "buffered", + outputPrefix: verbose + ? chalk.dim(`[${platform.name}] `) + : undefined, + cwd, + }), + ); + } + }), ), { text: `Configuring projects ${tripletsSummary}`, @@ -249,13 +265,14 @@ program = program.action( await oraPromise( Promise.all( tripletContexts.map(async ({ platform, ...context }) => { - // Delete any stale build artifacts before building - // This is important, since we might rename the output files - await fs.promises.rm(context.outputPath, { - recursive: true, - force: true, - }); - await buildProject(platform, context, baseOptions); + // TODO: Consider if this is still important 😬 + // // Delete any stale build artifacts before building + // // This is important, since we might rename the output files + // await fs.promises.rm(context.outputPath, { + // recursive: true, + // force: true, + // }); + await platform.build(context, baseOptions); }), ), { @@ -274,13 +291,7 @@ program = program.action( if (relevantTriplets.length == 0) { continue; } - await platform.postBuild( - { - outputPath: out, - triplets: relevantTriplets, - }, - baseOptions, - ); + await platform.postBuild(out, relevantTriplets, baseOptions); } }), ); @@ -302,92 +313,4 @@ function getTripletsSummary( .join(" / "); } -/** - * Namespaces the output path with a triplet name - */ -function getTripletBuildPath(buildPath: string, triplet: unknown) { - assert(typeof triplet === "string", "Expected triplet to be a string"); - return path.join(buildPath, triplet.replace(/;/g, "_")); -} - -async function configureProject( - platform: Platform>, - context: TripletContext, - options: BaseOpts, -) { - const { triplet, buildPath, outputPath } = context; - const { verbose, source, weakNodeApiLinkage, cmakeJs } = options; - - // TODO: Make the two following definitions a part of the platform definition - - const nodeApiDefinitions = - weakNodeApiLinkage && isSupportedTriplet(triplet) - ? [getWeakNodeApiVariables(triplet)] - : []; - - const cmakeJsDefinitions = - cmakeJs && isSupportedTriplet(triplet) - ? [getCmakeJSVariables(triplet)] - : []; - - const definitions = [ - ...nodeApiDefinitions, - ...cmakeJsDefinitions, - ...options.define, - { CMAKE_LIBRARY_OUTPUT_DIRECTORY: outputPath }, - ]; - - await cmakeFileApi.createSharedStatelessQuery(buildPath, "codemodel", "2"); - - await spawn( - "cmake", - [ - "-S", - source, - "-B", - buildPath, - ...platform.configureArgs(context, options), - ...toDefineArguments(definitions), - ], - { - outputMode: verbose ? "inherit" : "buffered", - outputPrefix: verbose ? chalk.dim(`[${triplet}] `) : undefined, - }, - ); -} - -async function buildProject( - platform: Platform>, - context: TripletContext, - options: BaseOpts, -) { - const { triplet, buildPath } = context; - const { verbose, configuration } = options; - await spawn( - "cmake", - [ - "--build", - buildPath, - "--config", - configuration, - ...(options.target.length > 0 ? ["--target", ...options.target] : []), - "--", - ...platform.buildArgs(context, options), - ], - { - outputMode: verbose ? "inherit" : "buffered", - outputPrefix: verbose ? chalk.dim(`[${triplet}] `) : undefined, - }, - ); -} - -function toDefineArguments(declarations: Array>) { - return declarations.flatMap((values) => - Object.entries(values).flatMap(([key, definition]) => [ - "-D", - `${key}=${definition}`, - ]), - ); -} - export { program }; diff --git a/packages/cmake-rn/src/helpers.ts b/packages/cmake-rn/src/helpers.ts new file mode 100644 index 00000000..ac88cc92 --- /dev/null +++ b/packages/cmake-rn/src/helpers.ts @@ -0,0 +1,8 @@ +export function toDefineArguments(declarations: Array>) { + return declarations.flatMap((values) => + Object.entries(values).flatMap(([key, definition]) => [ + "-D", + `${key}=${definition}`, + ]), + ); +} diff --git a/packages/cmake-rn/src/platforms/android.ts b/packages/cmake-rn/src/platforms/android.ts index b7b758c3..479fdc92 100644 --- a/packages/cmake-rn/src/platforms/android.ts +++ b/packages/cmake-rn/src/platforms/android.ts @@ -14,6 +14,11 @@ import { import * as cmakeFileApi from "cmake-file-api"; import type { Platform } from "./types.js"; +import { toDefineArguments } from "../helpers.js"; +import { + getCmakeJSVariables, + getWeakNodeApiVariables, +} from "../weak-node-api.js"; // This should match https://github.com/react-native-community/template/blob/main/template/android/build.gradle#L7 const DEFAULT_NDK_VERSION = "27.1.12297006"; @@ -40,6 +45,10 @@ const androidSdkVersionOption = new Option( type AndroidOpts = { ndkVersion: string; androidSdkVersion: string }; +function getBuildPath(baseBuildPath: string, triplet: Triplet) { + return path.join(baseBuildPath, triplet); +} + export const platform: Platform = { id: "android", name: "Android", @@ -63,7 +72,19 @@ export const platform: Platform = { .addOption(ndkVersionOption) .addOption(androidSdkVersionOption); }, - configureArgs({ triplet }, { ndkVersion, androidSdkVersion }) { + async configure( + triplets, + { + configuration, + ndkVersion, + androidSdkVersion, + source, + define, + build, + weakNodeApiLinkage, + cmakeJs, + }, + ) { const { ANDROID_HOME } = process.env; assert( typeof ANDROID_HOME === "string", @@ -84,57 +105,84 @@ export const platform: Platform = { ndkPath, "build/cmake/android.toolchain.cmake", ); - const architecture = ANDROID_ARCHITECTURES[triplet]; - - return [ - "-G", - "Ninja", - "--toolchain", - toolchainPath, - "-D", - "CMAKE_SYSTEM_NAME=Android", - // "-D", - // `CPACK_SYSTEM_NAME=Android-${architecture}`, - // "-D", - // `CMAKE_INSTALL_PREFIX=${installPath}`, - // "-D", - // `CMAKE_BUILD_TYPE=${configuration}`, - "-D", - "CMAKE_MAKE_PROGRAM=ninja", - // "-D", - // "CMAKE_C_COMPILER_LAUNCHER=ccache", - // "-D", - // "CMAKE_CXX_COMPILER_LAUNCHER=ccache", - "-D", - `ANDROID_NDK=${ndkPath}`, - "-D", - `ANDROID_ABI=${architecture}`, - "-D", - "ANDROID_TOOLCHAIN=clang", - "-D", - `ANDROID_PLATFORM=${androidSdkVersion}`, - "-D", - // TODO: Make this configurable - "ANDROID_STL=c++_shared", + + const commonDefinitions = [ + ...define, + { + CMAKE_BUILD_TYPE: configuration, + CMAKE_SYSTEM_NAME: "Android", + // "CMAKE_INSTALL_PREFIX": installPath, + CMAKE_MAKE_PROGRAM: "ninja", + // "-D", + // "CMAKE_C_COMPILER_LAUNCHER=ccache", + // "-D", + // "CMAKE_CXX_COMPILER_LAUNCHER=ccache", + ANDROID_NDK: ndkPath, + ANDROID_TOOLCHAIN: "clang", + ANDROID_PLATFORM: androidSdkVersion, + // TODO: Make this configurable + ANDROID_STL: "c++_shared", + }, ]; + + await Promise.all( + triplets.map(async ({ triplet, spawn }) => { + const buildPath = getBuildPath(build, triplet); + const outputPath = path.join(buildPath, "out"); + // We want to use the CMake File API to query information later + await cmakeFileApi.createSharedStatelessQuery( + buildPath, + "codemodel", + "2", + ); + + await spawn("cmake", [ + "-S", + source, + "-B", + buildPath, + "-G", + "Ninja", + "--toolchain", + toolchainPath, + ...toDefineArguments([ + ...(weakNodeApiLinkage ? [getWeakNodeApiVariables(triplet)] : []), + ...(cmakeJs ? [getCmakeJSVariables(triplet)] : []), + ...commonDefinitions, + { + // "CPACK_SYSTEM_NAME": `Android-${architecture}`, + CMAKE_LIBRARY_OUTPUT_DIRECTORY: outputPath, + ANDROID_ABI: ANDROID_ARCHITECTURES[triplet], + }, + ]), + ]); + }), + ); }, - buildArgs() { - return []; + async build({ triplet, spawn }, { target, build }) { + const buildPath = getBuildPath(build, triplet); + await spawn("cmake", [ + "--build", + buildPath, + ...(target.length > 0 ? ["--target", ...target] : []), + ]); }, isSupportedByHost() { const { ANDROID_HOME } = process.env; return typeof ANDROID_HOME === "string" && fs.existsSync(ANDROID_HOME); }, async postBuild( - { outputPath, triplets }, - { autoLink, configuration, target }, + outputPath, + triplets, + { autoLink, configuration, target, build }, ) { const prebuilds: Record< string, { triplet: Triplet; libraryPath: string }[] > = {}; - for (const { triplet, buildPath } of triplets) { + for (const { triplet } of triplets) { + const buildPath = getBuildPath(build, triplet); assert(fs.existsSync(buildPath), `Expected a directory at ${buildPath}`); const targets = await cmakeFileApi.readCurrentTargetsDeep( buildPath, diff --git a/packages/cmake-rn/src/platforms/apple.ts b/packages/cmake-rn/src/platforms/apple.ts index 91f5e7fd..6edc6065 100644 --- a/packages/cmake-rn/src/platforms/apple.ts +++ b/packages/cmake-rn/src/platforms/apple.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs"; +import cp from "node:child_process"; import { Option, @@ -11,10 +12,41 @@ import { AppleTriplet as Triplet, createAppleFramework, createXCframework, + dereferenceDirectory, } from "react-native-node-api"; import type { Platform } from "./types.js"; import * as cmakeFileApi from "cmake-file-api"; +import { toDefineArguments } from "../helpers.js"; +import { + getCmakeJSVariables, + getWeakNodeApiVariables, +} from "../weak-node-api.js"; + +import * as z from "zod"; + +const XcodeListOutput = z.object({ + project: z.object({ + configurations: z.array(z.string()), + name: z.string(), + schemes: z.array(z.string()), + targets: z.array(z.string()), + }), +}); + +function listXcodeProject(cwd: string): z.infer { + const result = cp.spawnSync("xcodebuild", ["-list", "-json"], { + encoding: "utf-8", + cwd, + }); + assert.equal( + result.status, + 0, + `Failed to run xcodebuild -list: ${result.stderr}`, + ); + const parsed = JSON.parse(result.stdout) as unknown; + return XcodeListOutput.parse(parsed); +} type XcodeSDKName = | "iphoneos" @@ -54,6 +86,20 @@ const CMAKE_SYSTEM_NAMES = { "arm64-apple-visionos-sim": "visionOS", } satisfies Record; +const DESTINATION_BY_TRIPLET = { + "arm64-apple-ios": "generic/platform=iOS", + "arm64-apple-ios-sim": "generic/platform=iOS Simulator", + "arm64-apple-tvos": "generic/platform=tvOS", + // "x86_64-apple-tvos": "generic/platform=tvOS", + "arm64-apple-tvos-sim": "generic/platform=tvOS Simulator", + "arm64-apple-visionos": "generic/platform=visionOS", + "arm64-apple-visionos-sim": "generic/platform=visionOS Simulator", + // TODO: Verify that the three following destinations are correct and actually work + "x86_64-apple-darwin": "generic/platform=macOS,arch=x86_64", + "arm64-apple-darwin": "generic/platform=macOS,arch=arm64", + "arm64;x86_64-apple-darwin": "generic/platform=macOS", +} satisfies Record; + type AppleArchitecture = "arm64" | "x86_64" | "arm64;x86_64"; export const APPLE_ARCHITECTURES = { @@ -84,11 +130,6 @@ export function createPlistContent(values: Record) { ].join("\n"); } -export function getAppleBuildArgs() { - // We expect the final application to sign these binaries - return ["CODE_SIGNING_ALLOWED=NO"]; -} - const xcframeworkExtensionOption = new Option( "--xcframework-extension", "Don't rename the xcframework to .apple.node", @@ -98,6 +139,34 @@ type AppleOpts = { xcframeworkExtension: boolean; }; +function getBuildPath(baseBuildPath: string, triplet: Triplet) { + return path.join(baseBuildPath, triplet.replace(/;/g, "_")); +} + +async function readCmakeSharedLibraryTarget( + buildPath: string, + configuration: string, + target: string[], +) { + const targets = await cmakeFileApi.readCurrentTargetsDeep( + buildPath, + configuration, + "2.0", + ); + const sharedLibraries = targets.filter( + ({ type, name }) => + type === "SHARED_LIBRARY" && + (target.length === 0 || target.includes(name)), + ); + assert.equal( + sharedLibraries.length, + 1, + "Expected exactly one shared library", + ); + const [sharedLibrary] = sharedLibraries; + return sharedLibrary; +} + export const platform: Platform = { id: "apple", name: "Apple", @@ -116,86 +185,229 @@ export const platform: Platform = { amendCommand(command) { return command.addOption(xcframeworkExtensionOption); }, - configureArgs({ triplet }) { - return [ - "-G", - "Xcode", - "-D", - `CMAKE_SYSTEM_NAME=${CMAKE_SYSTEM_NAMES[triplet]}`, - "-D", - `CMAKE_OSX_SYSROOT=${XCODE_SDK_NAMES[triplet]}`, - "-D", - `CMAKE_OSX_ARCHITECTURES=${APPLE_ARCHITECTURES[triplet]}`, - ]; + async configure( + triplets, + { source, build, define, weakNodeApiLinkage, cmakeJs }, + spawn, + ) { + // Ideally, we would generate a single Xcode project supporting all architectures / platforms + // However, CMake's Xcode generator does not support that well, so we generate one project per triplet + // Specifically, the linking of weak-node-api breaks, since the sdk / arch specific framework + // from the xcframework is picked at configure time, not at build time. + // See https://gitlab.kitware.com/cmake/cmake/-/issues/21752#note_1717047 for more information. + await Promise.all( + triplets.map(async ({ triplet }) => { + const buildPath = getBuildPath(build, triplet); + // We want to use the CMake File API to query information later + // TODO: Or do we? + await cmakeFileApi.createSharedStatelessQuery( + buildPath, + "codemodel", + "2", + ); + await spawn("cmake", [ + "-S", + source, + "-B", + buildPath, + "-G", + "Xcode", + ...toDefineArguments([ + ...define, + ...(weakNodeApiLinkage ? [getWeakNodeApiVariables("apple")] : []), + ...(cmakeJs ? [getCmakeJSVariables("apple")] : []), + { + CMAKE_SYSTEM_NAME: CMAKE_SYSTEM_NAMES[triplet], + CMAKE_OSX_SYSROOT: XCODE_SDK_NAMES[triplet], + CMAKE_OSX_ARCHITECTURES: APPLE_ARCHITECTURES[triplet], + }, + { + // Setting the output directories works around an issue with Xcode generator + // where an unexpanded variable would emitted in the artifact paths. + // This is okay, since we're generating per triplet build directories anyway. + // https://gitlab.kitware.com/cmake/cmake/-/issues/24161 + CMAKE_LIBRARY_OUTPUT_DIRECTORY: path.join(buildPath, "out"), + CMAKE_ARCHIVE_OUTPUT_DIRECTORY: path.join(buildPath, "out"), + }, + ]), + ]); + }), + ); }, - buildArgs() { + async build({ spawn, triplet }, { build, target, configuration }) { // We expect the final application to sign these binaries - return ["CODE_SIGNING_ALLOWED=NO"]; + if (target.length > 1) { + throw new Error("Building for multiple targets is not supported yet"); + } + + const buildPath = getBuildPath(build, triplet); + + const sharedLibrary = await readCmakeSharedLibraryTarget( + buildPath, + configuration, + target, + ); + + const isFramework = sharedLibrary.nameOnDisk?.includes(".framework/"); + + if (isFramework) { + const { project } = listXcodeProject(buildPath); + + const schemes = project.schemes.filter( + (scheme) => scheme !== "ALL_BUILD" && scheme !== "ZERO_CHECK", + ); + + assert( + schemes.length === 1, + `Expected exactly one buildable scheme, got ${schemes.join(", ")}`, + ); + + const [scheme] = schemes; + + if (target.length === 1) { + assert.equal( + scheme, + target[0], + "Expected the only scheme to match the requested target", + ); + } + + await spawn( + "xcodebuild", + [ + "archive", + "-scheme", + scheme, + "-configuration", + configuration, + "-destination", + DESTINATION_BY_TRIPLET[triplet], + ], + buildPath, + ); + await spawn( + "xcodebuild", + [ + "install", + "-scheme", + scheme, + "-configuration", + configuration, + "-destination", + DESTINATION_BY_TRIPLET[triplet], + ], + buildPath, + ); + } else { + await spawn("cmake", [ + "--build", + buildPath, + "--config", + configuration, + ...(target.length > 0 ? ["--target", ...target] : []), + "--", + + // Skip code-signing (needed when building free dynamic libraries) + // TODO: Make this configurable + "CODE_SIGNING_ALLOWED=NO", + ]); + // Create a framework + const { artifacts } = sharedLibrary; + assert( + artifacts && artifacts.length === 1, + "Expected exactly one artifact", + ); + const [artifact] = artifacts; + await createAppleFramework( + path.join(buildPath, artifact.path), + triplet.endsWith("-darwin"), + ); + } }, isSupportedByHost: function (): boolean | Promise { return process.platform === "darwin"; }, async postBuild( - { outputPath, triplets }, - { configuration, autoLink, xcframeworkExtension, target }, + outputPath, + triplets, + { configuration, autoLink, xcframeworkExtension, target, build }, ) { - const prebuilds: Record = {}; - for (const { buildPath } of triplets) { + const libraryNames = new Set(); + const frameworkPaths: string[] = []; + for (const { triplet } of triplets) { + const buildPath = getBuildPath(build, triplet); assert(fs.existsSync(buildPath), `Expected a directory at ${buildPath}`); - const targets = await cmakeFileApi.readCurrentTargetsDeep( + const sharedLibrary = await readCmakeSharedLibraryTarget( buildPath, configuration, - "2.0", - ); - const sharedLibraries = targets.filter( - ({ type, name }) => - type === "SHARED_LIBRARY" && - (target.length === 0 || target.includes(name)), - ); - assert.equal( - sharedLibraries.length, - 1, - "Expected exactly one shared library", + target, ); - const [sharedLibrary] = sharedLibraries; const { artifacts } = sharedLibrary; assert( artifacts && artifacts.length === 1, "Expected exactly one artifact", ); const [artifact] = artifacts; - // Add prebuild entry, creating a new entry if needed - if (!(sharedLibrary.name in prebuilds)) { - prebuilds[sharedLibrary.name] = []; + libraryNames.add(sharedLibrary.name); + // Locate the path of the framework, if a free dynamic library was built + if (artifact.path.includes(".framework/")) { + frameworkPaths.push(path.dirname(path.join(buildPath, artifact.path))); + } else { + const libraryName = path.basename( + artifact.path, + path.extname(artifact.path), + ); + const frameworkPath = path.join( + buildPath, + path.dirname(artifact.path), + `${libraryName}.framework`, + ); + assert( + fs.existsSync(frameworkPath), + `Expected to find a framework at: ${frameworkPath}`, + ); + frameworkPaths.push(frameworkPath); } - prebuilds[sharedLibrary.name].push(path.join(buildPath, artifact.path)); } + // Make sure none of the frameworks are symlinks + // We do this before creating an xcframework to avoid symlink paths being invalidated + // as the xcframework might be moved to a different location + await Promise.all( + frameworkPaths.map(async (frameworkPath) => { + const stat = await fs.promises.lstat(frameworkPath); + if (stat.isSymbolicLink()) { + await dereferenceDirectory(frameworkPath); + } + }), + ); + const extension = xcframeworkExtension ? ".xcframework" : ".apple.node"; - for (const [libraryName, libraryPaths] of Object.entries(prebuilds)) { - const frameworkPaths = await Promise.all( - libraryPaths.map(createAppleFramework), - ); - // Create the xcframework - const xcframeworkOutputPath = path.resolve( - outputPath, - `${libraryName}${extension}`, - ); + assert( + libraryNames.size === 1, + "Expected all libraries to have the same name", + ); + const [libraryName] = libraryNames; - await oraPromise( - createXCframework({ - outputPath: xcframeworkOutputPath, - frameworkPaths, - autoLink, - }), - { - text: `Assembling XCFramework (${libraryName})`, - successText: `XCFramework (${libraryName}) assembled into ${prettyPath(xcframeworkOutputPath)}`, - failText: ({ message }) => - `Failed to assemble XCFramework (${libraryName}): ${message}`, - }, - ); - } + // Create the xcframework + const xcframeworkOutputPath = path.resolve( + outputPath, + `${libraryName}${extension}`, + ); + + await oraPromise( + createXCframework({ + outputPath: xcframeworkOutputPath, + frameworkPaths, + autoLink, + }), + { + text: `Assembling XCFramework (${libraryName})`, + successText: `XCFramework (${libraryName}) assembled into ${prettyPath(xcframeworkOutputPath)}`, + failText: ({ message }) => + `Failed to assemble XCFramework (${libraryName}): ${message}`, + }, + ); }, }; diff --git a/packages/cmake-rn/src/platforms/types.ts b/packages/cmake-rn/src/platforms/types.ts index 7f4a78da..d3cd9b9f 100644 --- a/packages/cmake-rn/src/platforms/types.ts +++ b/packages/cmake-rn/src/platforms/types.ts @@ -17,10 +17,18 @@ export type BaseOpts = Omit, "triplet">; export type TripletContext = { triplet: Triplet; - buildPath: string; - outputPath: string; + /** + * Spawn a command in the context of this triplet + */ + spawn: Spawn; }; +export type Spawn = ( + command: string, + args: string[], + cwd?: string, +) => Promise; + export type Platform< Triplets extends string[] = string[], Opts extends cli.OptionValues = Record, @@ -51,30 +59,29 @@ export type Platform< */ isSupportedByHost(): boolean | Promise; /** - * Platform specific arguments passed to CMake to configure a triplet project. + * Configure all projects for this platform. */ - configureArgs( - context: TripletContext, + configure( + triplets: TripletContext[], options: BaseOpts & Opts, - ): string[]; + spawn: Spawn, + ): Promise; /** - * Platform specific arguments passed to CMake to build a triplet project. + * Platform specific command to build a triplet project. */ - buildArgs( + build( context: TripletContext, options: BaseOpts & Opts, - ): string[]; + ): Promise; /** * Called to combine multiple triplets into a single prebuilt artefact. */ postBuild( - context: { - /** - * Location of the final prebuilt artefact. - */ - outputPath: string; - triplets: TripletContext[]; - }, + /** + * Location of the final prebuilt artefact. + */ + outputPath: string, + triplets: TripletContext[], options: BaseOpts & Opts, ): Promise; }; diff --git a/packages/cmake-rn/src/weak-node-api.ts b/packages/cmake-rn/src/weak-node-api.ts index bc8a7407..ee6cb154 100644 --- a/packages/cmake-rn/src/weak-node-api.ts +++ b/packages/cmake-rn/src/weak-node-api.ts @@ -16,8 +16,10 @@ export function toCmakePath(input: string) { return input.split(path.win32.sep).join(path.posix.sep); } -export function getWeakNodeApiPath(triplet: SupportedTriplet): string { - if (isAppleTriplet(triplet)) { +export function getWeakNodeApiPath( + triplet: SupportedTriplet | "apple", +): string { + if (triplet === "apple" || isAppleTriplet(triplet)) { const xcframeworkPath = path.join( weakNodeApiPath, "weak-node-api.xcframework", @@ -53,7 +55,7 @@ function getNodeApiIncludePaths() { } export function getWeakNodeApiVariables( - triplet: SupportedTriplet, + triplet: SupportedTriplet | "apple", ): Record { return { // Expose an includable CMake config file declaring the weak-node-api target @@ -67,7 +69,7 @@ export function getWeakNodeApiVariables( * For compatibility with cmake-js */ export function getCmakeJSVariables( - triplet: SupportedTriplet, + triplet: SupportedTriplet | "apple", ): Record { return { CMAKE_JS_INC: getNodeApiIncludePaths().join(";"), diff --git a/packages/ferric/src/build.ts b/packages/ferric/src/build.ts index fdf961e3..a3eb1684 100644 --- a/packages/ferric/src/build.ts +++ b/packages/ferric/src/build.ts @@ -237,7 +237,10 @@ export const buildCommand = new Command("build") if (appleLibraries.length > 0) { const libraryPaths = await combineLibraries(appleLibraries); const frameworkPaths = await Promise.all( - libraryPaths.map(createAppleFramework), + libraryPaths.map((libraryPath) => + // TODO: Pass true as `versioned` argument for -darwin targets + createAppleFramework(libraryPath), + ), ); const xcframeworkFilename = determineXCFrameworkFilename( frameworkPaths, diff --git a/packages/gyp-to-cmake/package.json b/packages/gyp-to-cmake/package.json index 702a1d1f..415fe62b 100644 --- a/packages/gyp-to-cmake/package.json +++ b/packages/gyp-to-cmake/package.json @@ -23,6 +23,8 @@ }, "dependencies": { "@react-native-node-api/cli-utils": "0.1.0", - "gyp-parser": "^1.0.4" + "gyp-parser": "^1.0.4", + "pkg-dir": "^8.0.0", + "read-pkg": "^9.0.1" } } diff --git a/packages/gyp-to-cmake/src/cli.ts b/packages/gyp-to-cmake/src/cli.ts index 1045ba45..45cefbaa 100644 --- a/packages/gyp-to-cmake/src/cli.ts +++ b/packages/gyp-to-cmake/src/cli.ts @@ -1,7 +1,12 @@ +import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; +import { packageDirectorySync } from "pkg-dir"; +import { readPackageSync } from "read-pkg"; + import { Command, + Option, prettyPath, wrapAction, } from "@react-native-node-api/cli-utils"; @@ -12,23 +17,25 @@ import { type GypToCmakeListsOptions, } from "./transformer.js"; -export type TransformOptions = Omit< - GypToCmakeListsOptions, - "gyp" | "projectName" -> & { +export type TransformOptions = Omit & { disallowUnknownProperties: boolean; - projectName?: string; }; export function generateProjectName(gypPath: string) { - return path.dirname(gypPath).replaceAll(path.sep, "-"); + const packagePath = packageDirectorySync({ cwd: path.dirname(gypPath) }); + assert(packagePath, "Expected the binding.gyp file to be inside a package"); + const { name } = readPackageSync({ cwd: packagePath }); + return name + .replace(/^@/g, "") + .replace(/\//g, "--") + .replace(/[^a-zA-Z0-9_-]/g, "_"); } export function transformBindingGypFile( gypPath: string, { disallowUnknownProperties, - projectName = generateProjectName(gypPath), + projectName, ...restOfOptions }: TransformOptions, ) { @@ -49,7 +56,7 @@ export function transformBindingGypFile( export function transformBindingGypsRecursively( directoryPath: string, - options: TransformOptions, + options: Omit, ) { const entries = fs.readdirSync(directoryPath, { withFileTypes: true }); for (const entry of entries) { @@ -57,11 +64,19 @@ export function transformBindingGypsRecursively( if (entry.isDirectory()) { transformBindingGypsRecursively(fullPath, options); } else if (entry.isFile() && entry.name === "binding.gyp") { - transformBindingGypFile(fullPath, options); + transformBindingGypFile(fullPath, { + ...options, + projectName: generateProjectName(fullPath), + }); } } } +const projectNameOption = new Option( + "--project-name ", + "Project name to use in CMakeLists.txt", +).default(undefined, "Uses name from the surrounding package.json"); + export const program = new Command("gyp-to-cmake") .description("Transform binding.gyp to CMakeLists.txt") .option( @@ -70,7 +85,12 @@ export const program = new Command("gyp-to-cmake") ) .option("--weak-node-api", "Link against the weak-node-api library", false) .option("--define-napi-version", "Define NAPI_VERSION for all targets", false) + .option( + "--no-apple-framework", + "Disable emitting target properties to produce Apple frameworks", + ) .option("--cpp ", "C++ standard version", "17") + .addOption(projectNameOption) .argument( "[path]", "Path to the binding.gyp file or directory to traverse recursively", @@ -80,19 +100,30 @@ export const program = new Command("gyp-to-cmake") wrapAction( ( targetPath: string, - { pathTransforms, cpp, defineNapiVersion, weakNodeApi }, + { + pathTransforms, + cpp, + defineNapiVersion, + weakNodeApi, + appleFramework, + projectName, + }, ) => { - const options: TransformOptions = { + const options: Omit = { unsupportedBehaviour: "throw", disallowUnknownProperties: false, transformWinPathsToPosix: pathTransforms, compileFeatures: cpp ? [`cxx_std_${cpp}`] : [], defineNapiVersion, weakNodeApi, + appleFramework, }; const stat = fs.statSync(targetPath); if (stat.isFile()) { - transformBindingGypFile(targetPath, options); + transformBindingGypFile(targetPath, { + ...options, + projectName: projectName ?? generateProjectName(targetPath), + }); } else if (stat.isDirectory()) { transformBindingGypsRecursively(targetPath, options); } else { diff --git a/packages/gyp-to-cmake/src/transformer.ts b/packages/gyp-to-cmake/src/transformer.ts index fdf5c604..3c9b65d8 100644 --- a/packages/gyp-to-cmake/src/transformer.ts +++ b/packages/gyp-to-cmake/src/transformer.ts @@ -15,6 +15,7 @@ export type GypToCmakeListsOptions = { compileFeatures?: string[]; defineNapiVersion?: boolean; weakNodeApi?: boolean; + appleFramework?: boolean; }; function isCmdExpansion(value: string) { @@ -26,6 +27,10 @@ function escapeSpaces(source: string) { return source.replace(/ /g, "\\ "); } +function escapeBundleIdentifier(identifier: string) { + return identifier.replaceAll("__", ".").replace(/[^A-Za-z0-9.-_]/g, "-"); +} + /** * @see {@link https://github.com/cmake-js/cmake-js?tab=readme-ov-file#usage} for details on the template used * @returns The contents of a CMakeLists.txt file @@ -39,6 +44,7 @@ export function bindingGypToCmakeLists({ transformWinPathsToPosix = true, defineNapiVersion = true, weakNodeApi = false, + appleFramework = true, compileFeatures = [], }: GypToCmakeListsOptions): string { function mapExpansion(value: string): string[] { @@ -113,10 +119,58 @@ export function bindingGypToCmakeLists({ escapedIncludes.push("${CMAKE_JS_INC}"); } - lines.push( - `add_library(${targetName} SHARED ${escapedSources.join(" ")})`, - `set_target_properties(${targetName} PROPERTIES PREFIX "" SUFFIX ".node")`, - ); + function setTargetPropertiesLines( + properties: Record, + indent = "", + ): string[] { + return [ + `${indent}set_target_properties(${targetName} PROPERTIES`, + ...Object.entries(properties).map( + ([key, value]) => `${indent} ${key} ${value ? value : '""'}`, + ), + `${indent} )`, + ]; + } + + lines.push(`add_library(${targetName} SHARED ${escapedSources.join(" ")})`); + + if (appleFramework) { + lines.push( + "", + 'option(BUILD_APPLE_FRAMEWORK "Wrap addon in an Apple framework" ON)', + "", + "if(APPLE AND BUILD_APPLE_FRAMEWORK)", + ...setTargetPropertiesLines( + { + FRAMEWORK: "TRUE", + MACOSX_FRAMEWORK_IDENTIFIER: escapeBundleIdentifier( + `${projectName}.${targetName}`, + ), + MACOSX_FRAMEWORK_SHORT_VERSION_STRING: "1.0", + MACOSX_FRAMEWORK_BUNDLE_VERSION: "1.0", + XCODE_ATTRIBUTE_SKIP_INSTALL: "NO", + }, + " ", + ), + "else()", + ...setTargetPropertiesLines( + { + PREFIX: "", + SUFFIX: ".node", + }, + " ", + ), + "endif()", + "", + ); + } else { + lines.push( + ...setTargetPropertiesLines({ + PREFIX: "", + SUFFIX: ".node", + }), + ); + } if (libraries.length > 0) { lines.push( diff --git a/packages/host/src/node/index.ts b/packages/host/src/node/index.ts index dd914402..3071f233 100644 --- a/packages/host/src/node/index.ts +++ b/packages/host/src/node/index.ts @@ -22,6 +22,9 @@ export { determineXCFrameworkFilename, } from "./prebuilds/apple.js"; -export { determineLibraryBasename } from "./path-utils.js"; +export { + determineLibraryBasename, + dereferenceDirectory, +} from "./path-utils.js"; export { weakNodeApiPath } from "./weak-node-api.js"; diff --git a/packages/host/src/node/path-utils.test.ts b/packages/host/src/node/path-utils.test.ts index e780f802..7a65147b 100644 --- a/packages/host/src/node/path-utils.test.ts +++ b/packages/host/src/node/path-utils.test.ts @@ -13,6 +13,7 @@ import { isNodeApiModule, stripExtension, findNodeApiModulePathsByDependency, + dereferenceDirectory, } from "./path-utils.js"; import { setupTempDirectory } from "./test-utils.js"; @@ -550,3 +551,45 @@ describe("findNodeAddonForBindings()", () => { }); } }); + +describe("dereferenceDirectory", () => { + describe("when directory contains symlinks", () => { + it("should dereference symlinks", async (context) => { + // Create a temp directory with a symlink + const tempDir = setupTempDirectory(context, { + "original/file.txt": "Hello, world!", + }); + const originalPath = path.join(tempDir, "original"); + const symlinkPath = path.join(tempDir, "link"); + // Create a link to the original directory + fs.symlinkSync(originalPath, symlinkPath, "dir"); + // And an internal link + fs.symlinkSync( + path.join(originalPath, "file.txt"), + path.join(originalPath, "linked-file.txt"), + "file", + ); + + { + // Verify that outer link is no longer a link + const stat = await fs.promises.lstat(symlinkPath); + assert(stat.isSymbolicLink()); + } + + await dereferenceDirectory(symlinkPath); + + { + // Verify that outer link is no longer a link + const stat = await fs.promises.lstat(symlinkPath); + assert(!stat.isSymbolicLink()); + } + + // Verify that the internal link is still a link to a readable file + const internalLinkPath = path.join(symlinkPath, "linked-file.txt"); + const internalLinkStat = await fs.promises.lstat(internalLinkPath); + assert(internalLinkStat.isSymbolicLink()); + const content = await fs.promises.readFile(internalLinkPath, "utf8"); + assert.equal(content, "Hello, world!"); + }); + }); +}); diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index b5cc9c7d..f898d566 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -543,3 +543,18 @@ export function findNodeAddonForBindings(id: string, fromDir: string) { } return undefined; } + +export async function dereferenceDirectory(dirPath: string) { + const tempPath = dirPath + ".tmp"; + const stat = await fs.promises.lstat(dirPath); + assert(stat.isSymbolicLink(), `Expected a symbolic link at: ${dirPath}`); + // Move the existing framework out of the way + await fs.promises.rename(dirPath, tempPath); + // Only dereference the symlink at tempPath (not recursively) + const realPath = await fs.promises.realpath(tempPath); + await fs.promises.cp(realPath, dirPath, { + recursive: true, + verbatimSymlinks: true, + }); + await fs.promises.unlink(tempPath); +} diff --git a/packages/host/src/node/prebuilds/apple.ts b/packages/host/src/node/prebuilds/apple.ts index 33422b85..dce6353b 100644 --- a/packages/host/src/node/prebuilds/apple.ts +++ b/packages/host/src/node/prebuilds/apple.ts @@ -30,7 +30,14 @@ type XCframeworkOptions = { autoLink: boolean; }; -export async function createAppleFramework(libraryPath: string) { +export async function createAppleFramework( + libraryPath: string, + versioned = false, +) { + if (versioned) { + // TODO: Add support for generating a Versions/Current/Resources/Info.plist convention framework + throw new Error("Creating versioned frameworks is not supported yet"); + } assert(fs.existsSync(libraryPath), `Library not found: ${libraryPath}`); // Write a info.plist file to the framework const libraryName = path.basename(libraryPath, path.extname(libraryPath)); diff --git a/packages/host/weak-node-api/CMakeLists.txt b/packages/host/weak-node-api/CMakeLists.txt index 49e9bf85..08422d62 100644 --- a/packages/host/weak-node-api/CMakeLists.txt +++ b/packages/host/weak-node-api/CMakeLists.txt @@ -3,13 +3,18 @@ project(weak-node-api) add_library(${PROJECT_NAME} SHARED weak_node_api.cpp - ${CMAKE_JS_SRC} ) # Stripping the prefix from the library name # to make sure the name of the XCFramework will match the name of the library if(APPLE) - set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "") + set_target_properties(${PROJECT_NAME} PROPERTIES + FRAMEWORK TRUE + MACOSX_FRAMEWORK_IDENTIFIER com.callstack.${PROJECT_NAME} + MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0 + MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0 + XCODE_ATTRIBUTE_SKIP_INSTALL NO + ) endif() target_include_directories(${PROJECT_NAME} diff --git a/packages/node-addon-examples/tests/async/CMakeLists.txt b/packages/node-addon-examples/tests/async/CMakeLists.txt index a94a7716..659e3461 100644 --- a/packages/node-addon-examples/tests/async/CMakeLists.txt +++ b/packages/node-addon-examples/tests/async/CMakeLists.txt @@ -1,9 +1,26 @@ cmake_minimum_required(VERSION 3.15...3.31) -project(tests-async) +project(async_test) include(${WEAK_NODE_API_CONFIG}) add_library(addon SHARED addon.c) -set_target_properties(addon PROPERTIES PREFIX "" SUFFIX ".node") + +option(BUILD_APPLE_FRAMEWORK "Wrap addon in an Apple framework" ON) + +if(APPLE AND BUILD_APPLE_FRAMEWORK) + set_target_properties(addon PROPERTIES + FRAMEWORK TRUE + MACOSX_FRAMEWORK_IDENTIFIER async_test.addon + MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0 + MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0 + XCODE_ATTRIBUTE_SKIP_INSTALL NO + ) +elseif(APPLE) + set_target_properties(addon PROPERTIES + PREFIX "" + SUFFIX .node + ) +endif() + target_link_libraries(addon PRIVATE weak-node-api) target_compile_features(addon PRIVATE cxx_std_17) \ No newline at end of file diff --git a/packages/node-addon-examples/tests/buffers/CMakeLists.txt b/packages/node-addon-examples/tests/buffers/CMakeLists.txt index 837359fc..bca19bce 100644 --- a/packages/node-addon-examples/tests/buffers/CMakeLists.txt +++ b/packages/node-addon-examples/tests/buffers/CMakeLists.txt @@ -1,9 +1,26 @@ cmake_minimum_required(VERSION 3.15...3.31) -project(tests-buffers) +project(buffers_test) include(${WEAK_NODE_API_CONFIG}) add_library(addon SHARED addon.c) -set_target_properties(addon PROPERTIES PREFIX "" SUFFIX ".node") + +option(BUILD_APPLE_FRAMEWORK "Wrap addon in an Apple framework" ON) + +if(APPLE AND BUILD_APPLE_FRAMEWORK) + set_target_properties(addon PROPERTIES + FRAMEWORK TRUE + MACOSX_FRAMEWORK_IDENTIFIER buffers_test.addon + MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0 + MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0 + XCODE_ATTRIBUTE_SKIP_INSTALL NO + ) +elseif(APPLE) + set_target_properties(addon PROPERTIES + PREFIX "" + SUFFIX .node + ) +endif() + target_link_libraries(addon PRIVATE weak-node-api) target_compile_features(addon PRIVATE cxx_std_17) \ No newline at end of file