import path from "node:path" import fs from "node:fs" import { fileExists, LogWriter, removeNpmNamespacePrefix } from "./buildUtils.js" import { createRequire } from "node:module" import { getElectronVersion, getInstalledModuleVersion } from "./getInstalledModuleVersion.js" import { spawn } from "node:child_process" /** * @typedef {(...args: string[]) => void} Logger */ /** * @typedef {"nodeGyp"|"copyFromDist"} BuildStrategy */ /** * @typedef {"arm64"|"x64"|"universal"} InputArch */ /** * @typedef {"arm64"|"x64"} BuildArch */ /** * @typedef {"node"|"electron"} Environment */ /** * @typedef {"win32"|"linux"|"darwin"} Platform */ /** * Rebuild native lib for either current node version or for electron version and * cache in the "native-cache" directory. * ABI for nodejs and for electron differs and we need to build it differently for each. We do caching * to avoid rebuilding between different invocations (e.g. running desktop and running tests). * @param params {object} * @param params.environment {Environment} * @param params.platform {Platform} platform to compile for in case of cross compilation * @param params.architecture {InputArch} the instruction set used in the built desktop binary * @param params.rootDir {string} path to the root of the project * @param params.nodeModule {string} name of the npm module to rebuild * @param params.log {Logger} * @param params.copyTarget {string | undefined} Which node-gyp target (specified in binding.gyp) to copy the output of. Defaults to the same name as the module * @returns {Promise>>} paths to cached native module by architecture */ export async function getNativeLibModulePaths({ environment, platform, architecture, rootDir, nodeModule, log, copyTarget }) { const namespaceTrimmedNodeModule = removeNpmNamespacePrefix(nodeModule) const libPaths = await getCachedLibPaths({ rootDir, nodeModule: namespaceTrimmedNodeModule, environment, platform, architecture }, log) const isCrossCompilation = checkIsCrossCompilation(platform) for (/** @type {[BuildArch, string]} */ const entry of Object.entries(libPaths)) { const architecture = /** @type BuildArch */ (entry[0]) const libPath = entry[1] if (await fileExists(libPath)) { log(`Using cached ${nodeModule} at`, libPath) } else { if (isCrossCompilation) { throw new Error(`Cannot cross-compile for ${platform} from ${process.platform}`) } else { log(`Compiling ${nodeModule} for ${platform}...`) const artifactPath = await buildNativeModule({ environment, platform, rootDir, log, nodeModule, copyTarget, architecture, }) await fs.promises.copyFile(artifactPath, libPath) } } } return libPaths } function checkIsCrossCompilation(platform) { if (platform === "win32" && process.platform !== "win32") { return true } else if (platform !== process.platform) { // We only care about cross compiling the app when building for windows from linux // since it's only possible to build for mac from mac, // and there's no reason to build for linux from anything but linux // Consider it an here error since if you're doing it it's probably a mistake // And it's more effort than it's worth to allow arbitrary configurations throw new Error(`Invalid cross compilation ${process.platform} => ${platform}. only * => win32 is allowed`) } return false } /** * Build a native module using node-gyp * Runs `node-gyp rebuild ...` from within `node_modules//` * @param params {object} * @param params.nodeModule {string} the node module being built. Must be installed, and must be a native module project with a `binding.gyp` at the root * @param params.copyTarget {string} * @param params.platform {Platform} the platform to build the binary for * @param params.environment {Environment} Used to determine which node version to use * @param params.rootDir {string} the root dir of the project * @param params.architecture {BuildArch} the architecture to build for: "x64" | "arm64" * @param params.log {Logger} * @returns {Promise} the path to the binary that was ordered */ export async function buildNativeModule({ nodeModule, copyTarget, environment, platform, rootDir, architecture, log }) { const moduleDir = await getModuleDir(rootDir, nodeModule) const allowedArch = ["x64", "arm64"].includes(architecture) if (!allowedArch) { throw new Error("this should not have been called with universal architecture since we're not using lipo anymore.") } return await buildWithGyp(architecture, environment, platform, moduleDir, copyTarget, log) } /** * @typedef {(arch: BuildArch, environment: Environment, platform: Platform, moduleDir: string, copyTarget: string, log: Logger) => Promise} Builder */ /** * rebuild a native module to avoid using the prebuilt version * @type Builder */ async function buildWithGyp(arch, environment, platform, moduleDir, copyTarget, log) { await callProgram({ command: "npm exec", args: [ "--", "node-gyp", "rebuild", "--release", "--build-from-source", `--arch=${arch}`, ...(environment === "electron" ? ["--runtime=electron", "--dist-url=https://www.electronjs.org/headers", `--target=${await getElectronVersion(log)}`] : []), ], cwd: moduleDir, log, }) const gypResult = path.join(moduleDir, "build", "Release", `${copyTarget}.node`) // we're building two archs one after another and gyp nukes the output folder before starting a build const resultWithArch = path.join(moduleDir, `${copyTarget}-${arch}.node`) await fs.promises.copyFile(gypResult, resultWithArch) return resultWithArch } /** * Get a prebuilt version of a node native module * * we can't cross-compile with node-gyp, so we need to use the prebuilt version when building a desktop client for windows on linux * * @param params {object} * @param params.nodeModule {string} * @param params.rootDir {string} * @param params.platform: {"win32" | "darwin" | "linux"} * @param params.log {Logger} * @returns {Promise} */ export async function getPrebuiltNativeModuleForWindows({ nodeModule, rootDir, platform, log }) { // We never want to use prebuilt native modules when building on jenkins, so it is considered an error as a safeguard if (process.env.JENKINS_HOME) { throw new Error("Should not be getting prebuilt native modules in CI") } const target = await getPrebuildConfiguration(nodeModule, platform, log) await callProgram({ command: "npm exec", args: [ "--", "prebuild-install", `--platform=win32`, "--tag-prefix=v", ...(target != null ? [`--runtime=${target.runtime}`, `--target=${target.version}`] : []), "--verbose", ], cwd: await getModuleDir(rootDir, nodeModule), log, }) } /** * prebuild-install {runtime, target} configurations are a pain to maintain because they are specific for whichever native module you want to get a prebuild for, * So we just define them here and throw an error if we try to obtain a configuration for an unknown module * @param nodeModule {string} * @param platform {"electron"|"node"} * @param log {Logger} * @return {Promise<{ runtime: string, version: string} | null>} */ async function getPrebuildConfiguration(nodeModule, platform, log) { if (nodeModule === "@signalapp/sqlcipher") { return null } else { throw new Error(`Unknown prebuild-configuration for node module ${nodeModule}, requires a definition`) } } /** * Call a program, piping stdout and stderr to log, and resolves when the process exits * @returns {Promise} */ function callProgram({ command, args, cwd, log }) { const process = spawn(command, args, { stdio: [null, "pipe", "pipe"], shell: true, cwd, }) const logStream = new LogWriter(log) process.stdout.pipe(logStream) process.stderr.pipe(logStream) return new Promise((resolve, reject) => { process.on("exit", (code) => { if (code === 0) { resolve() } else { reject(new Error(`command "${command}" failed with error code: ${code}`)) } }) }) } /** * Get the target name for the built native library when cached * @param params {object} * @param params.rootDir {string} * @param params.nodeModule {string} * @param params.environment {Environment} * @param params.platform {Platform} * @param params.architecture {InputArch} the instruction set used in the built desktop binary * @param log {Logger} * @returns {Promise>>} map of the location of the built binaries for each architecture that needs to be built */ export async function getCachedLibPaths({ rootDir, nodeModule, environment, platform, architecture }, log) { const libraryVersion = await getInstalledModuleVersion(nodeModule, log) let versionedEnvironment if (environment === "electron") { versionedEnvironment = `electron-${await getInstalledModuleVersion("electron", log)}` } else { // process.versions.modules is an ABI version. It is not significant for modules that use new ABI but still matters for those we use versionedEnvironment = `node-${process.versions.modules}` } return await buildCachedLibPaths({ rootDir, nodeModule, environment, versionedEnvironment, platform, libraryVersion, architecture }) } /** * * @param params {object} * @param params.rootDir {string} * @param params.nodeModule {string} * @param params.environment {Environment} * @param params.versionedEnvironment {string} * @param params.platform {Platform} * @param params.libraryVersion {string} * @param params.architecture {InputArch} the instruction set used in the built desktop binary * @return {Promise>>} */ export async function buildCachedLibPaths({ rootDir, nodeModule, environment, versionedEnvironment, platform, libraryVersion, architecture }) { const dir = path.join(rootDir, "native-cache", environment) await fs.promises.mkdir(dir, { recursive: true }) if (architecture === "universal") { return { x64: path.resolve(dir, `${nodeModule}-${libraryVersion}-${versionedEnvironment}-${platform}-x64.node`), arm64: path.resolve(dir, `${nodeModule}-${libraryVersion}-${versionedEnvironment}-${platform}-arm64.node`), } } else { return { [architecture]: path.resolve(dir, `${nodeModule}-${libraryVersion}-${versionedEnvironment}-${platform}-${architecture}.node`), } } } async function getModuleDir(rootDir, nodeModule) { // We resolve relative to the rootDir passed to us // however, if we just use rootDir as the base for require() it doesn't work: node_modules must be at the directory up from yours (for whatever reason). // so we provide a directory one level deeper. Practically it doesn't matter if "src" subdirectory exists or not, this is just to give node some // subdirectory to work against. const someChild = path.resolve(path.join(rootDir, "src")).toString() const filePath = createRequire(someChild).resolve(nodeModule) const pathEnd = path.join("node_modules", nodeModule) const endIndex = filePath.lastIndexOf(pathEnd) return path.join(filePath.substring(0, endIndex), pathEnd) }