import {
addValue,
Arch,
archFromString,
AsyncTaskManager,
DebugLogger,
deepAssign,
executeFinally,
getArtifactArchName,
InvalidConfigurationError,
log,
MAX_FILE_REQUESTS,
orNullIfFileNotExist,
safeStringifyJson,
serializeToYaml,
TmpDir,
} from "builder-util"
import { CancellationToken } from "builder-util-runtime"
import { chmod, mkdirs, outputFile } from "fs-extra"
import * as isCI from "is-ci"
import { Lazy } from "lazy-val"
import { release as getOsRelease } from "os"
import * as path from "path"
import { AppInfo } from "./appInfo"
import { readAsarJson } from "./asar/asar"
import { AfterExtractContext, AfterPackContext, BeforePackContext, Configuration, Hook } from "./configuration"
import { Platform, SourceRepositoryInfo, Target } from "./core"
import { createElectronFrameworkSupport } from "./electron/ElectronFramework"
import { Framework } from "./Framework"
import { LibUiFramework } from "./frameworks/LibUiFramework"
import { Metadata } from "./options/metadata"
import { ArtifactBuildStarted, ArtifactCreated, PackagerOptions } from "./packagerApi"
import { PlatformPackager } from "./platformPackager"
import { ProtonFramework } from "./ProtonFramework"
import { computeArchToTargetNamesMap, createTargets, NoOpTarget } from "./targets/targetFactory"
import { computeDefaultAppDirectory, getConfig, validateConfiguration } from "./util/config/config"
import { expandMacro } from "./util/macroExpander"
import { createLazyProductionDeps, NodeModuleDirInfo, NodeModuleInfo } from "./util/packageDependencies"
import { checkMetadata, readPackageJson } from "./util/packageMetadata"
import { getRepositoryInfo } from "./util/repositoryInfo"
import { resolveFunction } from "./util/resolve"
import { installOrRebuild, nodeGypRebuild } from "./util/yarn"
import { PACKAGE_VERSION } from "./version"
import { AsyncEventEmitter, HandlerType } from "./util/asyncEventEmitter"
import asyncPool from "tiny-async-pool"
async function createFrameworkInfo(configuration: Configuration, packager: Packager): Promise<Framework> {
let framework = configuration.framework
if (framework != null) {
framework = framework.toLowerCase()
}
let nodeVersion = configuration.nodeVersion
if (framework === "electron" || framework == null) {
return await createElectronFrameworkSupport(configuration, packager)
}
if (nodeVersion == null || nodeVersion === "current") {
nodeVersion = process.versions.node
}
const distMacOsName = `${packager.appInfo.productFilename}.app`
const isUseLaunchUi = configuration.launchUiVersion !== false
if (framework === "proton" || framework === "proton-native") {
return new ProtonFramework(nodeVersion, distMacOsName, isUseLaunchUi)
} else if (framework === "libui") {
return new LibUiFramework(nodeVersion, distMacOsName, isUseLaunchUi)
} else {
throw new InvalidConfigurationError(`Unknown framework: ${framework}`)
}
}
type PackagerEvents = {
artifactBuildStarted: Hook<ArtifactBuildStarted, void>
beforePack: Hook<BeforePackContext, void>
afterExtract: Hook<AfterExtractContext, void>
afterPack: Hook<AfterPackContext, void>
afterSign: Hook<AfterPackContext, void>
artifactBuildCompleted: Hook<ArtifactCreated, void>
msiProjectCreated: Hook<string, void>
appxManifestCreated: Hook<string, void>
artifactCreated: Hook<ArtifactCreated, void>
}
export class Packager {
readonly projectDir: string
private _appDir: string
get appDir(): string {
return this._appDir
}
private _metadata: Metadata | null = null
get metadata(): Metadata {
return this._metadata!
}
private _nodeModulesHandledExternally = false
get areNodeModulesHandledExternally(): boolean {
return this._nodeModulesHandledExternally
}
private _isPrepackedAppAsar = false
get isPrepackedAppAsar(): boolean {
return this._isPrepackedAppAsar
}
private _devMetadata: Metadata | null = null
get devMetadata(): Metadata | null {
return this._devMetadata
}
private _configuration: Configuration | null = null
get config(): Configuration {
return this._configuration!
}
isTwoPackageJsonProjectLayoutUsed = false
private readonly eventEmitter = new AsyncEventEmitter<PackagerEvents>()
_appInfo: AppInfo | null = null
get appInfo(): AppInfo {
return this._appInfo!
}
readonly tempDirManager = new TmpDir("packager")
private _repositoryInfo = new Lazy<SourceRepositoryInfo | null>(() => getRepositoryInfo(this.projectDir, this.metadata, this.devMetadata))
readonly options: PackagerOptions
readonly debugLogger = new DebugLogger(log.isDebugEnabled)
get repositoryInfo(): Promise<SourceRepositoryInfo | null> {
return this._repositoryInfo.value
}
private nodeDependencyInfo = new Map<string, Lazy<Array<any>>>()
getNodeDependencyInfo(platform: Platform | null, flatten: boolean = true): Lazy<Array<NodeModuleInfo | NodeModuleDirInfo>> {
let key = "" + flatten.toString()
let excludedDependencies: Array<string> | null = null
if (platform != null && this.framework.getExcludedDependencies != null) {
excludedDependencies = this.framework.getExcludedDependencies(platform)
if (excludedDependencies != null) {
key += `-${platform.name}`
}
}
let result = this.nodeDependencyInfo.get(key)
if (result == null) {
result = createLazyProductionDeps(this.appDir, excludedDependencies, flatten)
this.nodeDependencyInfo.set(key, result)
}
return result
}
stageDirPathCustomizer: (target: Target, packager: PlatformPackager<any>, arch: Arch) => string = (target, packager, arch) => {
return path.join(target.outDir, `__${target.name}-${getArtifactArchName(arch, target.name)}`)
}
private _buildResourcesDir: string | null = null
get buildResourcesDir(): string {
let result = this._buildResourcesDir
if (result == null) {
result = path.resolve(this.projectDir, this.relativeBuildResourcesDirname)
this._buildResourcesDir = result
}
return result
}
get relativeBuildResourcesDirname(): string {
return this.config.directories!.buildResources!
}
private _framework: Framework | null = null
get framework(): Framework {
return this._framework!
}
private readonly toDispose: Array<() => Promise<void>> = []
disposeOnBuildFinish(disposer: () => Promise<void>) {
this.toDispose.push(disposer)
}
constructor(
options: PackagerOptions,
readonly cancellationToken = new CancellationToken()
) {
if ("devMetadata" in options) {
throw new InvalidConfigurationError("devMetadata in the options is deprecated, please use config instead")
}
if ("extraMetadata" in options) {
throw new InvalidConfigurationError("extraMetadata in the options is deprecated, please use config.extraMetadata instead")
}
const targets = options.targets || new Map<Platform, Map<Arch, Array<string>>>()
if (options.targets == null) {
options.targets = targets
}
function processTargets(platform: Platform, types: Array<string>) {
function commonArch(currentIfNotSpecified: boolean): Array<Arch> {
const result = Array<Arch>()
return result.length === 0 && currentIfNotSpecified ? [archFromString(process.arch)] : result
}
let archToType = targets.get(platform)
if (archToType == null) {
archToType = new Map<Arch, Array<string>>()
targets.set(platform, archToType)
}
if (types.length === 0) {
for (const arch of commonArch(false)) {
archToType.set(arch, [])
}
return
}
for (const type of types) {
const suffixPos = type.lastIndexOf(":")
if (suffixPos > 0) {
addValue(archToType, archFromString(type.substring(suffixPos + 1)), type.substring(0, suffixPos))
} else {
for (const arch of commonArch(true)) {
addValue(archToType, arch, type)
}
}
}
}
if (options.mac != null) {
processTargets(Platform.MAC, options.mac)
}
if (options.linux != null) {
processTargets(Platform.LINUX, options.linux)
}
if (options.win != null) {
processTargets(Platform.WINDOWS, options.win)
}
this.projectDir = options.projectDir == null ? process.cwd() : path.resolve(options.projectDir)
this._appDir = this.projectDir
this.options = {
...options,
prepackaged: options.prepackaged == null ? null : path.resolve(this.projectDir, options.prepackaged),
}
log.info({ version: PACKAGE_VERSION, os: getOsRelease() }, "electron-builder")
}
async addPackagerEventHandlers() {
const { type } = this.appInfo
this.eventEmitter.on("artifactBuildStarted", await resolveFunction(type, this.config.artifactBuildStarted, "artifactBuildStarted"), "user")
this.eventEmitter.on("artifactBuildCompleted", await resolveFunction(type, this.config.artifactBuildCompleted, "artifactBuildCompleted"), "user")
this.eventEmitter.on("appxManifestCreated", await resolveFunction(type, this.config.appxManifestCreated, "appxManifestCreated"), "user")
this.eventEmitter.on("msiProjectCreated", await resolveFunction(type, this.config.msiProjectCreated, "msiProjectCreated"), "user")
this.eventEmitter.on("beforePack", await resolveFunction(type, this.config.beforePack, "beforePack"), "user")
this.eventEmitter.on("afterExtract", await resolveFunction(type, this.config.afterExtract, "afterExtract"), "user")
this.eventEmitter.on("afterPack", await resolveFunction(type, this.config.afterPack, "afterPack"), "user")
this.eventEmitter.on("afterSign", await resolveFunction(type, this.config.afterSign, "afterSign"), "user")
}
onAfterPack(handler: PackagerEvents["afterPack"]): Packager {
this.eventEmitter.on("afterPack", handler)
return this
}
onArtifactCreated(handler: PackagerEvents["artifactCreated"]): Packager {
this.eventEmitter.on("artifactCreated", handler)
return this
}
filterPackagerEventListeners(event: keyof PackagerEvents, type: HandlerType | undefined) {
return this.eventEmitter.filterListeners(event, type)
}
clearPackagerEventListeners() {
this.eventEmitter.clear()
}
async emitArtifactBuildStarted(event: ArtifactBuildStarted, logFields?: any) {
log.info(
logFields || {
target: event.targetPresentableName,
arch: event.arch == null ? null : Arch[event.arch],
file: log.filePath(event.file),
},
"building"
)
await this.eventEmitter.emit("artifactBuildStarted", event)
}
* Only for sub artifacts (update info), for main artifacts use `callArtifactBuildCompleted`.
*/
async emitArtifactCreated(event: ArtifactCreated) {
await this.eventEmitter.emit("artifactCreated", event)
}
async emitArtifactBuildCompleted(event: ArtifactCreated) {
await this.eventEmitter.emit("artifactBuildCompleted", event)
await this.emitArtifactCreated(event)
}
async emitAppxManifestCreated(path: string) {
await this.eventEmitter.emit("appxManifestCreated", path)
}
async emitMsiProjectCreated(path: string) {
await this.eventEmitter.emit("msiProjectCreated", path)
}
async emitBeforePack(context: BeforePackContext) {
await this.eventEmitter.emit("beforePack", context)
}
async emitAfterPack(context: AfterPackContext) {
await this.eventEmitter.emit("afterPack", context)
}
async emitAfterSign(context: AfterPackContext) {
await this.eventEmitter.emit("afterSign", context)
}
async emitAfterExtract(context: AfterPackContext) {
await this.eventEmitter.emit("afterExtract", context)
}
async validateConfig(): Promise<void> {
let configPath: string | null = null
let configFromOptions = this.options.config
if (typeof configFromOptions === "string") {
configPath = configFromOptions
configFromOptions = null
} else if (configFromOptions != null && typeof configFromOptions.extends === "string" && configFromOptions.extends.includes(".")) {
configPath = configFromOptions.extends
delete configFromOptions.extends
}
const projectDir = this.projectDir
const devPackageFile = path.join(projectDir, "package.json")
this._devMetadata = await orNullIfFileNotExist(readPackageJson(devPackageFile))
const devMetadata = this.devMetadata
const configuration = await getConfig(projectDir, configPath, configFromOptions, new Lazy(() => Promise.resolve(devMetadata)))
if (log.isDebugEnabled) {
log.debug({ config: getSafeEffectiveConfig(configuration) }, "effective config")
}
this._appDir = await computeDefaultAppDirectory(projectDir, configuration.directories!.app)
this.isTwoPackageJsonProjectLayoutUsed = this._appDir !== projectDir
const appPackageFile = this.isTwoPackageJsonProjectLayoutUsed ? path.join(this.appDir, "package.json") : devPackageFile
if (this.devMetadata != null && !this.isTwoPackageJsonProjectLayoutUsed) {
this._metadata = this.devMetadata
} else {
this._metadata = await this.readProjectMetadataIfTwoPackageStructureOrPrepacked(appPackageFile)
}
deepAssign(this.metadata, configuration.extraMetadata)
if (this.isTwoPackageJsonProjectLayoutUsed) {
log.debug({ devPackageFile, appPackageFile }, "two package.json structure is used")
}
checkMetadata(this.metadata, this.devMetadata, appPackageFile, devPackageFile)
await validateConfiguration(configuration, this.debugLogger)
this._configuration = configuration
this._devMetadata = devMetadata
}
async build(repositoryInfo?: SourceRepositoryInfo): Promise<BuildResult> {
await this.validateConfig()
if (repositoryInfo != null) {
this._repositoryInfo.value = Promise.resolve(repositoryInfo)
}
this._appInfo = new AppInfo(this, null)
await this.addPackagerEventHandlers()
this._framework = await createFrameworkInfo(this.config, this)
const commonOutDirWithoutPossibleOsMacro = path.resolve(
this.projectDir,
expandMacro(this.config.directories!.output!, null, this._appInfo, {
os: "",
})
)
if (!isCI && (process.stdout as any).isTTY) {
const effectiveConfigFile = path.join(commonOutDirWithoutPossibleOsMacro, "builder-effective-config.yaml")
log.info({ file: log.filePath(effectiveConfigFile) }, "writing effective config")
await outputFile(effectiveConfigFile, getSafeEffectiveConfig(this.config))
}
const artifactPaths = new Set<string>()
this.onArtifactCreated(event => {
if (event.file != null) {
artifactPaths.add(event.file)
}
})
this.disposeOnBuildFinish(() => this.tempDirManager.cleanup())
const platformToTargets = await executeFinally(this.doBuild(), async () => {
if (this.debugLogger.isEnabled) {
await this.debugLogger.save(path.join(commonOutDirWithoutPossibleOsMacro, "builder-debug.yml"))
}
const toDispose = this.toDispose.slice()
this.toDispose.length = 0
for (const disposer of toDispose) {
await disposer().catch((e: any) => {
log.warn({ error: e }, "cannot dispose")
})
}
})
return {
outDir: commonOutDirWithoutPossibleOsMacro,
artifactPaths: Array.from(artifactPaths),
platformToTargets,
configuration: this.config,
}
}
private async readProjectMetadataIfTwoPackageStructureOrPrepacked(appPackageFile: string): Promise<Metadata> {
let data = await orNullIfFileNotExist(readPackageJson(appPackageFile))
if (data != null) {
return data
}
data = await orNullIfFileNotExist(readAsarJson(path.join(this.projectDir, "app.asar"), "package.json"))
if (data != null) {
this._isPrepackedAppAsar = true
return data
}
throw new Error(`Cannot find package.json in the ${path.dirname(appPackageFile)}`)
}
private async doBuild(): Promise<Map<Platform, Map<string, Target>>> {
const taskManager = new AsyncTaskManager(this.cancellationToken)
const syncTargetsIfAny = [] as Target[]
const platformToTarget = new Map<Platform, Map<string, Target>>()
const createdOutDirs = new Set<string>()
for (const [platform, archToType] of this.options.targets!) {
if (this.cancellationToken.cancelled) {
break
}
if (platform === Platform.MAC && process.platform === Platform.WINDOWS.nodeName) {
throw new InvalidConfigurationError("Build for macOS is supported only on macOS, please see https://electron.build/multi-platform-build")
}
const packager = await this.createHelper(platform)
const nameToTarget: Map<string, Target> = new Map()
platformToTarget.set(platform, nameToTarget)
let poolCount = Math.floor(packager.config.concurrency?.jobs || 1)
if (poolCount < 1) {
log.warn({ concurrency: poolCount }, "concurrency is invalid, overriding with job count: 1")
poolCount = 1
} else if (poolCount > MAX_FILE_REQUESTS) {
log.warn(
{ concurrency: poolCount, MAX_FILE_REQUESTS },
`job concurrency is greater than recommended MAX_FILE_REQUESTS, this may lead to File Descriptor errors (too many files open). Proceed with caution (e.g. this is an experimental feature)`
)
}
const packPromises: Promise<any>[] = []
for (const [arch, targetNames] of computeArchToTargetNamesMap(archToType, packager, platform)) {
if (this.cancellationToken.cancelled) {
break
}
const outDir = path.resolve(this.projectDir, packager.expandMacro(this.config.directories!.output!, Arch[arch]))
const targetList = createTargets(nameToTarget, targetNames.length === 0 ? packager.defaultTarget : targetNames, outDir, packager)
await createOutDirIfNeed(targetList, createdOutDirs)
const promise = packager.pack(outDir, arch, targetList, taskManager)
if (poolCount < 2) {
await promise
} else {
packPromises.push(promise)
}
}
await asyncPool(poolCount, packPromises, async it => {
if (this.cancellationToken.cancelled) {
return
}
await it
})
if (this.cancellationToken.cancelled) {
break
}
for (const target of nameToTarget.values()) {
if (target.isAsyncSupported) {
taskManager.addTask(target.finishBuild())
} else {
syncTargetsIfAny.push(target)
}
}
}
await taskManager.awaitTasks()
for (const target of syncTargetsIfAny) {
if (this.cancellationToken.cancelled) {
break
}
await target.finishBuild()
}
return platformToTarget
}
private async createHelper(platform: Platform): Promise<PlatformPackager<any>> {
if (this.options.platformPackagerFactory != null) {
return this.options.platformPackagerFactory(this, platform)
}
switch (platform) {
case Platform.MAC: {
const helperClass = (await import("./macPackager")).MacPackager
return new helperClass(this)
}
case Platform.WINDOWS: {
const helperClass = (await import("./winPackager")).WinPackager
return new helperClass(this)
}
case Platform.LINUX:
return new (await import("./linuxPackager")).LinuxPackager(this)
default:
throw new Error(`Unknown platform: ${platform}`)
}
}
public async installAppDependencies(platform: Platform, arch: Arch): Promise<any> {
if (this.options.prepackaged != null || !this.framework.isNpmRebuildRequired) {
return
}
const frameworkInfo = { version: this.framework.version, useCustomDist: true }
const config = this.config
if (config.nodeGypRebuild === true) {
await nodeGypRebuild(platform.nodeName, Arch[arch], frameworkInfo)
}
if (config.npmRebuild === false) {
log.info({ reason: "npmRebuild is set to false" }, "skipped dependencies rebuild")
return
}
const beforeBuild = await resolveFunction(this.appInfo.type, config.beforeBuild, "beforeBuild")
if (beforeBuild != null) {
const performDependenciesInstallOrRebuild = await beforeBuild({
appDir: this.appDir,
electronVersion: this.config.electronVersion!,
platform,
arch: Arch[arch],
})
this._nodeModulesHandledExternally = !performDependenciesInstallOrRebuild
if (!performDependenciesInstallOrRebuild) {
return
}
}
if (config.buildDependenciesFromSource === true && platform.nodeName !== process.platform) {
log.info({ reason: "platform is different and buildDependenciesFromSource is set to true" }, "skipped dependencies rebuild")
} else {
await installOrRebuild(
config,
{ appDir: this.appDir, projectDir: this.projectDir },
{
frameworkInfo,
platform: platform.nodeName,
arch: Arch[arch],
productionDeps: this.getNodeDependencyInfo(null, false) as Lazy<Array<NodeModuleDirInfo>>,
}
)
}
}
}
function createOutDirIfNeed(targetList: Array<Target>, createdOutDirs: Set<string>): Promise<any> {
const ourDirs = new Set<string>()
for (const target of targetList) {
if (target instanceof NoOpTarget) {
continue
}
const outDir = target.outDir
if (!createdOutDirs.has(outDir)) {
ourDirs.add(outDir)
}
}
if (ourDirs.size === 0) {
return Promise.resolve()
}
return Promise.all(
Array.from(ourDirs)
.sort()
.map(dir => {
return mkdirs(dir)
.then(() => chmod(dir, 0o755) )
.then(() => createdOutDirs.add(dir))
})
)
}
export interface BuildResult {
readonly outDir: string
readonly artifactPaths: Array<string>
readonly platformToTargets: Map<Platform, Map<string, Target>>
readonly configuration: Configuration
}
function getSafeEffectiveConfig(configuration: Configuration): string {
const o = JSON.parse(safeStringifyJson(configuration))
if (o.cscLink != null) {
o.cscLink = "<hidden by builder>"
}
return serializeToYaml(o, true)
}