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 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/bin/react-native-node-api.mjs b/packages/host/bin/react-native-node-api.mjs index e778e210..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 +#!/usr/bin/env -S node --enable-source-maps import "../dist/node/cli/run.js"; 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/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/android.ts b/packages/host/src/node/cli/android.ts index 2fb2f92a..9d733732 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,26 +17,11 @@ const ANDROID_ARCHITECTURES = [ ] as const; export async function linkAndroidDir({ - incremental, modulePath, naming, - platform, }: LinkModuleOptions): Promise { 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, - }; - } - } + 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 31aa6779..77ca6e5c 100644 --- a/packages/host/src/node/cli/apple.ts +++ b/packages/host/src/node/cli/apple.ts @@ -3,16 +3,77 @@ 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 { getLibraryName } from "../path-utils.js"; import { - getLinkedModuleOutputPath, LinkModuleOptions, LinkModuleResult, + ModuleLinker, } from "./link-modules.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"); +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 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 target of applicationTargets) { + console.log(`Patching '${target.getDisplayName()}' target`); + + const existingBuildPhases = target.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(); + } + + // 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, + xcodeJson.build(xcodeProject.toJSON()), + "utf8", + ); +} /** * Reads and parses a plist file, converting it to XML format if needed. @@ -33,7 +94,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}`, { @@ -60,6 +121,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"), @@ -270,6 +334,7 @@ export async function linkVersionedFramework({ "Info.plist", ); const frameworkInfo = await readFrameworkInfo(frameworkInfoPath); + // Update install name await spawn( "install_name_tool", @@ -328,84 +393,141 @@ export async function linkVersionedFramework({ ); } -export async function linkXcframework({ - platform, - modulePath, - incremental, - naming, -}: LinkModuleOptions): Promise { +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; + + const expectedSlice = determineFrameworkSlice(); + + return (options: LinkModuleOptions) => { + return linkXcframework({ + ...options, + outputPath, + expectedSlice, + signingIdentity: + signingRequired !== "NO" && signingAllowed !== "NO" + ? signingIdentity + : undefined, + }); + }; +} + +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 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(modulePath, "Info.plist")); + 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)}`, + ); - const info = await readXcframeworkInfo(path.join(outputPath, "Info.plist")); - - await Promise.all( - info.AvailableLibraries.map(async (framework) => { - const frameworkPath = path.join( - outputPath, - framework.LibraryIdentifier, - framework.LibraryPath, - ); - await linkFramework({ - frameworkPath, - newLibraryName, - debugSymbolsPath: framework.DebugSymbolsPath - ? path.join( - outputPath, - framework.LibraryIdentifier, - framework.DebugSymbolsPath, - ) - : undefined, - }); - }), + const originalFrameworkPath = path.join( + modulePath, + framework.LibraryIdentifier, + framework.LibraryPath, ); - await writeXcframeworkInfo(outputPath, { - ...info, - AvailableLibraries: info.AvailableLibraries.map((library) => { - return { - ...library, - LibraryPath: `${newLibraryName}.framework`, - BinaryPath: `${newLibraryName}.framework/${newLibraryName}`, - }; - }), + // 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/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/link-modules.ts b/packages/host/src/node/cli/link-modules.ts index a2d274f6..2c2855a0 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; @@ -31,7 +30,7 @@ export type LinkModulesOptions = { export type LinkModuleOptions = Omit< LinkModulesOptions, - "fromPath" | "linker" + "fromPath" | "linker" | "platform" > & { modulePath: string; }; @@ -44,11 +43,13 @@ export type ModuleDetails = { export type LinkModuleResult = ModuleDetails & { skipped: boolean; + signed?: boolean; }; export type ModuleOutputBase = { originalPath: string; skipped: boolean; + signed?: boolean; }; type ModuleOutput = ModuleOutputBase & @@ -59,7 +60,6 @@ type ModuleOutput = ModuleOutputBase & export async function linkModules({ fromPath, - incremental, naming, platform, linker, @@ -94,9 +94,7 @@ export async function linkModules({ try { return await linker({ modulePath: originalPath, - incremental, naming, - platform, }); } catch (error) { if (error instanceof SpawnFailure) { diff --git a/packages/host/src/node/cli/program.ts b/packages/host/src/node/cli/program.ts index 169ca85b..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, @@ -26,7 +25,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, createAppleLinker } from "./apple"; import { linkAndroidDir } from "./android"; // We're attaching a lot of listeners when spawning in parallel @@ -36,11 +35,11 @@ export const program = new Command("react-native-node-api").addCommand( vendorHermes, ); -function getLinker(platform: PlatformName): ModuleLinker { +async function createLinker(platform: PlatformName): Promise { if (platform === "android") { return linkAndroidDir; } else if (platform === "apple") { - return linkXcframework; + return createAppleLinker(); } else { throw new Error(`Unknown platform: ${platform as string}`); } @@ -59,11 +58,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", @@ -75,10 +69,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) { @@ -99,27 +90,19 @@ 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), }), { - 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 +113,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 +133,7 @@ program "Linked", prettyPath(originalPath), prettyOutputPath, + signedSuffix, ); } } @@ -249,3 +235,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); + }), + ); 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..eb5b37f9 --- /dev/null +++ b/packages/host/src/node/cli/xcode-helpers.ts @@ -0,0 +1,152 @@ +import assert from "node:assert"; +import path from "node:path"; +import fs from "node:fs"; + +// 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"; + +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 = 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}`); + } +} + +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}`, + ); + } +} diff --git a/packages/host/tsconfig.node.json b/packages/host/tsconfig.node.json index e0982db2..5056c8cd 100644 --- a/packages/host/tsconfig.node.json +++ b/packages/host/tsconfig.node.json @@ -3,9 +3,14 @@ "compilerOptions": { "composite": true, "declarationMap": true, + "sourceMap": 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; +}