2023-04-20 17:14:30 +02:00
import path from "node:path"
import fs from "node:fs"
2024-12-20 19:48:47 +01:00
import { fileExists , LogWriter , removeNpmNamespacePrefix } from "./buildUtils.js"
2023-04-20 17:14:30 +02:00
import { createRequire } from "node:module"
2023-04-19 13:58:49 +02:00
import { getElectronVersion , getInstalledModuleVersion } from "./getInstalledModuleVersion.js"
2024-12-20 19:48:47 +01:00
import { spawn } from "node:child_process"
2022-01-12 14:43:01 +01:00
2024-07-05 19:02:03 +02:00
/ * *
* @ typedef { ( ... args : string [ ] ) => void } Logger
* /
2025-01-02 18:47:57 +01:00
/ * *
* @ typedef { "nodeGyp" | "copyFromDist" } BuildStrategy
* /
/ * *
* @ typedef { "arm64" | "x64" | "universal" } InputArch
* /
/ * *
* @ typedef { "arm64" | "x64" } BuildArch
* /
/ * *
* @ typedef { "node" | "electron" } Environment
* /
/ * *
* @ typedef { "win32" | "linux" | "darwin" } Platform
* /
2022-01-12 14:43:01 +01:00
/ * *
2022-02-21 17:48:12 +01:00
* Rebuild native lib for either current node version or for electron version and
2022-01-12 14:43:01 +01:00
* 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 ) .
2024-07-05 19:02:03 +02:00
* @ param params { object }
2025-01-02 18:47:57 +01:00
* @ 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
2024-07-05 19:02:03 +02:00
* @ 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
2025-04-25 13:43:07 +02:00
* @ returns { Promise < Partial < Record < BuildArch , string >>> } paths to cached native module by architecture
2022-01-12 14:43:01 +01:00
* /
2024-12-20 19:48:47 +01:00
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 )
2022-03-02 18:21:01 +01:00
2024-12-20 19:48:47 +01:00
const isCrossCompilation = checkIsCrossCompilation ( platform )
2025-01-02 18:47:57 +01:00
for ( /** @type {[BuildArch, string]} */ const entry of Object . entries ( libPaths ) ) {
2025-04-25 13:43:07 +02:00
const architecture = /** @type BuildArch */ ( entry [ 0 ] )
2025-01-02 18:47:57 +01:00
const libPath = entry [ 1 ]
2024-12-20 19:48:47 +01:00
if ( await fileExists ( libPath ) ) {
log ( ` Using cached ${ nodeModule } at ` , libPath )
2022-03-02 18:21:01 +01:00
} else {
2024-12-20 19:48:47 +01:00
if ( isCrossCompilation ) {
2025-04-25 13:43:07 +02:00
throw new Error ( ` Cannot cross-compile for ${ platform } from ${ process . platform } ` )
2024-12-20 19:48:47 +01:00
} else {
log ( ` Compiling ${ nodeModule } for ${ platform } ... ` )
const artifactPath = await buildNativeModule ( {
environment ,
platform ,
rootDir ,
log ,
nodeModule ,
copyTarget ,
architecture ,
} )
await fs . promises . copyFile ( artifactPath , libPath )
}
2022-03-02 18:21:01 +01:00
}
2024-12-20 19:48:47 +01:00
}
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 ` )
2022-01-12 14:43:01 +01:00
}
2022-03-02 18:21:01 +01:00
2024-12-20 19:48:47 +01:00
return false
2022-01-12 14:43:01 +01:00
}
2022-03-02 18:21:01 +01:00
/ * *
* Build a native module using node - gyp
* Runs ` node-gyp rebuild ... ` from within ` node_modules/<nodeModule>/ `
2024-07-05 19:02:03 +02:00
* @ 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 }
2025-01-02 18:47:57 +01:00
* @ param params . platform { Platform } the platform to build the binary for
* @ param params . environment { Environment } Used to determine which node version to use
2024-07-05 19:02:03 +02:00
* @ param params . rootDir { string } the root dir of the project
2025-01-02 18:47:57 +01:00
* @ param params . architecture { BuildArch } the architecture to build for : "x64" | "arm64"
2024-07-05 19:02:03 +02:00
* @ param params . log { Logger }
2024-12-20 19:48:47 +01:00
* @ returns { Promise < string > } the path to the binary that was ordered
2022-03-02 18:21:01 +01:00
* /
2024-12-20 19:48:47 +01:00
export async function buildNativeModule ( { nodeModule , copyTarget , environment , platform , rootDir , architecture , log } ) {
2023-11-20 17:47:22 +01:00
const moduleDir = await getModuleDir ( rootDir , nodeModule )
2025-01-02 18:47:57 +01:00
const allowedArch = [ "x64" , "arm64" ] . includes ( architecture )
if ( ! allowedArch ) {
2024-12-20 19:48:47 +01:00
throw new Error ( "this should not have been called with universal architecture since we're not using lipo anymore." )
2023-11-20 17:47:22 +01:00
}
2025-01-02 18:47:57 +01:00
return await buildWithGyp ( architecture , environment , platform , moduleDir , copyTarget , log )
2022-03-02 18:21:01 +01:00
}
2024-12-20 19:48:47 +01:00
/ * *
2025-01-02 18:47:57 +01:00
* @ typedef { ( arch : BuildArch , environment : Environment , platform : Platform , moduleDir : string , copyTarget : string , log : Logger ) => Promise < string > } Builder
2024-12-20 19:48:47 +01:00
* /
/ * *
* rebuild a native module to avoid using the prebuilt version
2025-01-02 18:47:57 +01:00
* @ type Builder
2024-12-20 19:48:47 +01:00
* /
2025-01-02 18:47:57 +01:00
async function buildWithGyp ( arch , environment , platform , moduleDir , copyTarget , log ) {
2024-12-20 19:48:47 +01:00
await callProgram ( {
command : "npm exec" ,
args : [
"--" ,
"node-gyp" ,
"rebuild" ,
"--release" ,
"--build-from-source" ,
` --arch= ${ arch } ` ,
2025-01-02 18:47:57 +01:00
... ( environment === "electron"
? [ "--runtime=electron" , "--dist-url=https://www.electronjs.org/headers" , ` --target= ${ await getElectronVersion ( log ) } ` ]
: [ ] ) ,
2024-12-20 19:48:47 +01:00
] ,
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
}
2022-03-02 18:21:01 +01:00
/ * *
* 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
*
2024-07-05 19:02:03 +02:00
* @ param params { object }
* @ param params . nodeModule { string }
* @ param params . rootDir { string }
* @ param params . platform : { "win32" | "darwin" | "linux" }
* @ param params . log { Logger }
2022-03-02 18:21:01 +01:00
* @ returns { Promise < void > }
* /
2024-07-05 19:02:03 +02:00
export async function getPrebuiltNativeModuleForWindows ( { nodeModule , rootDir , platform , log } ) {
2022-03-02 18:21:01 +01:00
// We never want to use prebuilt native modules when building on jenkins, so it is considered an error as a safeguard
2022-07-29 10:19:32 +02:00
if ( process . env . JENKINS _HOME ) {
2022-03-02 18:21:01 +01:00
throw new Error ( "Should not be getting prebuilt native modules in CI" )
}
2024-07-05 19:02:03 +02:00
const target = await getPrebuildConfiguration ( nodeModule , platform , log )
2022-03-02 18:21:01 +01:00
await callProgram ( {
2022-02-10 16:32:47 +01:00
command : "npm exec" ,
2022-03-02 18:21:01 +01:00
args : [
2022-01-12 14:43:01 +01:00
"--" ,
2022-03-02 18:21:01 +01:00
"prebuild-install" ,
` --platform=win32 ` ,
"--tag-prefix=v" ,
2022-12-27 15:37:40 +01:00
... ( target != null ? [ ` --runtime= ${ target . runtime } ` , ` --target= ${ target . version } ` ] : [ ] ) ,
"--verbose" ,
2022-01-12 14:43:01 +01:00
] ,
2022-03-02 13:49:49 +01:00
cwd : await getModuleDir ( rootDir , nodeModule ) ,
2022-12-27 15:37:40 +01:00
log ,
2022-03-02 18:21:01 +01:00
} )
}
/ * *
* 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
2024-07-05 19:02:03 +02:00
* @ param nodeModule { string }
* @ param platform { "electron" | "node" }
* @ param log { Logger }
2024-12-20 19:48:47 +01:00
* @ return { Promise < { runtime : string , version : string } | null > }
2022-03-02 18:21:01 +01:00
* /
2024-07-05 19:02:03 +02:00
async function getPrebuildConfiguration ( nodeModule , platform , log ) {
2025-04-25 13:43:07 +02:00
if ( nodeModule === "@signalapp/sqlcipher" ) {
return null
2024-01-10 16:11:26 +01:00
} else {
throw new Error ( ` Unknown prebuild-configuration for node module ${ nodeModule } , requires a definition ` )
2022-03-02 18:21:01 +01:00
}
}
/ * *
* Call a program , piping stdout and stderr to log , and resolves when the process exits
* @ returns { Promise < void > }
* /
2022-12-27 15:37:40 +01:00
function callProgram ( { command , args , cwd , log } ) {
const process = spawn ( command , args , {
stdio : [ null , "pipe" , "pipe" ] ,
shell : true ,
cwd ,
} )
2022-03-02 18:21:01 +01:00
const logStream = new LogWriter ( log )
process . stdout . pipe ( logStream )
process . stderr . pipe ( logStream )
2022-01-12 14:43:01 +01:00
return new Promise ( ( resolve , reject ) => {
2022-12-27 15:37:40 +01:00
process . on ( "exit" , ( code ) => {
2022-01-12 14:43:01 +01:00
if ( code === 0 ) {
resolve ( )
} else {
2022-03-02 18:21:01 +01:00
reject ( new Error ( ` command " ${ command } " failed with error code: ${ code } ` ) )
2022-01-12 14:43:01 +01:00
}
} )
} )
}
2022-03-02 18:21:01 +01:00
/ * *
* Get the target name for the built native library when cached
2025-01-02 18:47:57 +01:00
* @ 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 < Partial < Record < BuildArch , string >>> } map of the location of the built binaries for each architecture that needs to be built
2022-03-02 18:21:01 +01:00
* /
2024-12-20 19:48:47 +01:00
export async function getCachedLibPaths ( { rootDir , nodeModule , environment , platform , architecture } , log ) {
2022-05-02 17:17:33 +02:00
const libraryVersion = await getInstalledModuleVersion ( nodeModule , log )
2022-03-02 18:21:01 +01:00
2023-04-19 13:58:49 +02:00
let versionedEnvironment
2022-03-02 18:21:01 +01:00
if ( environment === "electron" ) {
2023-04-19 13:58:49 +02:00
versionedEnvironment = ` electron- ${ await getInstalledModuleVersion ( "electron" , log ) } `
2022-03-02 18:21:01 +01:00
} else {
2024-02-23 14:03:19 +01:00
// 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 } `
2022-03-02 18:21:01 +01:00
}
2024-12-20 19:48:47 +01:00
return await buildCachedLibPaths ( { rootDir , nodeModule , environment , versionedEnvironment , platform , libraryVersion , architecture } )
2023-04-19 13:58:49 +02:00
}
2025-01-02 18:47:57 +01:00
/ * *
*
* @ 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 < Partial < Record < BuildArch , string >>> }
* /
2024-12-20 19:48:47 +01:00
export async function buildCachedLibPaths ( { rootDir , nodeModule , environment , versionedEnvironment , platform , libraryVersion , architecture } ) {
2023-04-19 13:58:49 +02:00
const dir = path . join ( rootDir , "native-cache" , environment )
await fs . promises . mkdir ( dir , { recursive : true } )
2024-12-20 19:48:47 +01:00
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 {
2025-01-02 18:47:57 +01:00
return {
[ architecture ] : path . resolve ( dir , ` ${ nodeModule } - ${ libraryVersion } - ${ versionedEnvironment } - ${ platform } - ${ architecture } .node ` ) ,
}
2024-12-20 19:48:47 +01:00
}
2022-03-02 18:21:01 +01:00
}
2022-02-24 16:01:20 +01:00
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.
2022-02-10 16:32:47 +01:00
const someChild = path . resolve ( path . join ( rootDir , "src" ) ) . toString ( )
const filePath = createRequire ( someChild ) . resolve ( nodeModule )
2022-02-24 16:01:20 +01:00
const pathEnd = path . join ( "node_modules" , nodeModule )
const endIndex = filePath . lastIndexOf ( pathEnd )
return path . join ( filePath . substring ( 0 , endIndex ) , pathEnd )
2022-12-27 15:37:40 +01:00
}