import { notarize } from "@electron/notarize"
import { NotarizeOptionsNotaryTool, NotaryToolKeychainCredentials } from "@electron/notarize/lib/types"
import { PerFileSignOptions, SignOptions } from "@electron/osx-sign/dist/cjs/types"
import { Identity } from "@electron/osx-sign/dist/cjs/util-identities"
import { Arch, AsyncTaskManager, copyFile, deepAssign, exec, getArchSuffix, InvalidConfigurationError, log, orIfFileNotExist, statOrNull, unlinkIfExists, use } from "builder-util"
import { MemoLazy, Nullish } from "builder-util-runtime"
import * as fs from "fs/promises"
import { mkdir, readdir } from "fs/promises"
import { Lazy } from "lazy-val"
import * as path from "path"
import { AppInfo } from "./appInfo"
import { CertType, CodeSigningInfo, createKeychain, CreateKeychainOptions, findIdentity, isSignAllowed, removeKeychain, reportError, sign } from "./codeSign/macCodeSign"
import { DIR_TARGET, Platform, Target } from "./core"
import { AfterPackContext, ElectronPlatformName } from "./index"
import { MacConfiguration, MasConfiguration } from "./options/macOptions"
import { Packager } from "./packager"
import { chooseNotNull, DoPackOptions, PlatformPackager } from "./platformPackager"
import { ArchiveTarget } from "./targets/ArchiveTarget"
import { PkgTarget, prepareProductBuildArgs } from "./targets/pkg"
import { createCommonTarget, NoOpTarget } from "./targets/targetFactory"
import { isMacOsHighSierra } from "./util/macosVersion"
import { getTemplatePath } from "./util/pathManager"
import { resolveFunction } from "./util/resolve"
export type CustomMacSignOptions = SignOptions
export type CustomMacSign = (configuration: CustomMacSignOptions, packager: MacPackager) => Promise<void>
export class MacPackager extends PlatformPackager<MacConfiguration> {
readonly codeSigningInfo = new MemoLazy<CreateKeychainOptions | null, CodeSigningInfo>(
() => {
const cscLink = this.getCscLink()
if (cscLink == null || process.platform !== "darwin") {
return null
}
const selected = {
tmpDir: this.info.tempDirManager,
cscLink,
cscKeyPassword: this.getCscPassword(),
cscILink: chooseNotNull(this.platformSpecificBuildOptions.cscInstallerLink, process.env.CSC_INSTALLER_LINK),
cscIKeyPassword: chooseNotNull(this.platformSpecificBuildOptions.cscInstallerKeyPassword, process.env.CSC_INSTALLER_KEY_PASSWORD),
currentDir: this.projectDir,
}
return selected
},
async selected => {
if (selected) {
return createKeychain(selected).then(result => {
const keychainFile = result.keychainFile
if (keychainFile != null) {
this.info.disposeOnBuildFinish(() => removeKeychain(keychainFile))
}
return result
})
}
return Promise.resolve({ keychainFile: process.env.CSC_KEYCHAIN || null })
}
)
private _iconPath = new Lazy(() => this.getOrConvertIcon("icns"))
constructor(info: Packager) {
super(info, Platform.MAC)
}
get defaultTarget(): Array<string> {
return this.info.framework.macOsDefaultTargets
}
protected prepareAppInfo(appInfo: AppInfo): AppInfo {
return new AppInfo(this.info, this.platformSpecificBuildOptions.bundleVersion, this.platformSpecificBuildOptions, true)
}
async getIconPath(): Promise<string | null> {
return this._iconPath.value
}
createTargets(targets: Array<string>, mapper: (name: string, factory: (outDir: string) => Target) => void): void {
for (const name of targets) {
switch (name) {
case DIR_TARGET:
break
case "dmg": {
const { DmgTarget } = require("dmg-builder")
mapper(name, outDir => new DmgTarget(this, outDir))
break
}
case "zip":
mapper(name, outDir => new ArchiveTarget(name, outDir, this, true))
break
case "pkg":
mapper(name, outDir => new PkgTarget(this, outDir))
break
default:
mapper(name, outDir => (name === "mas" || name === "mas-dev" ? new NoOpTarget(name) : createCommonTarget(name, outDir, this)))
break
}
}
}
protected async doPack(config: DoPackOptions<MacConfiguration>): Promise<any> {
const { outDir, appOutDir, platformName, arch, platformSpecificBuildOptions, targets } = config
switch (arch) {
default: {
return super.doPack(config)
}
case Arch.universal: {
const outDirName = (arch: Arch) => `${appOutDir}-${Arch[arch]}-temp`
const options = {
...config,
options: {
sign: false,
disableAsarIntegrity: true,
disableFuses: true,
},
}
const x64Arch = Arch.x64
const x64AppOutDir = outDirName(x64Arch)
await super.doPack({ ...options, appOutDir: x64AppOutDir, arch: x64Arch })
if (this.info.cancellationToken.cancelled) {
return
}
const arm64Arch = Arch.arm64
const arm64AppOutPath = outDirName(arm64Arch)
await super.doPack({ ...options, appOutDir: arm64AppOutPath, arch: arm64Arch })
if (this.info.cancellationToken.cancelled) {
return
}
const framework = this.info.framework
log.info(
{
platform: platformName,
arch: Arch[arch],
[`${framework.name}`]: framework.version,
appOutDir: log.filePath(appOutDir),
},
`packaging`
)
const appFile = `${this.appInfo.productFilename}.app`
const { makeUniversalApp } = require("@electron/universal")
await makeUniversalApp({
x64AppPath: path.join(x64AppOutDir, appFile),
arm64AppPath: path.join(arm64AppOutPath, appFile),
outAppPath: path.join(appOutDir, appFile),
force: true,
mergeASARs: platformSpecificBuildOptions.mergeASARs ?? true,
singleArchFiles: platformSpecificBuildOptions.singleArchFiles,
x64ArchFiles: platformSpecificBuildOptions.x64ArchFiles,
})
await fs.rm(x64AppOutDir, { recursive: true, force: true })
await fs.rm(arm64AppOutPath, { recursive: true, force: true })
const packContext: AfterPackContext = {
appOutDir,
outDir,
arch,
targets,
packager: this,
electronPlatformName: platformName,
}
await this.info.emitAfterPack(packContext)
if (this.info.cancellationToken.cancelled) {
return
}
await this.doAddElectronFuses(packContext)
await this.doSignAfterPack(outDir, appOutDir, platformName, arch, platformSpecificBuildOptions, targets)
break
}
}
}
async pack(outDir: string, arch: Arch, targets: Array<Target>, taskManager: AsyncTaskManager): Promise<void> {
const hasMas = targets.length !== 0 && targets.some(it => it.name === "mas" || it.name === "mas-dev")
const prepackaged = this.packagerOptions.prepackaged
for (const target of targets) {
const targetName = target.name
if (!(targetName === "mas" || targetName === "mas-dev")) {
continue
}
const masBuildOptions = deepAssign({}, this.platformSpecificBuildOptions, this.config.mas)
if (targetName === "mas-dev") {
deepAssign(masBuildOptions, this.config.masDev, {
type: "development",
})
}
const targetOutDir = path.join(outDir, `${targetName}${getArchSuffix(arch, this.platformSpecificBuildOptions.defaultArch)}`)
if (prepackaged == null) {
await this.doPack({ outDir, appOutDir: targetOutDir, platformName: "mas", arch, platformSpecificBuildOptions: masBuildOptions, targets: [target] })
await this.sign(path.join(targetOutDir, `${this.appInfo.productFilename}.app`), targetOutDir, masBuildOptions, arch)
} else {
await this.sign(prepackaged, targetOutDir, masBuildOptions, arch)
}
}
if (!hasMas || targets.length > 1) {
const appPath = prepackaged == null ? path.join(this.computeAppOutDir(outDir, arch), `${this.appInfo.productFilename}.app`) : prepackaged
if (prepackaged == null) {
await this.doPack({
outDir,
appOutDir: path.dirname(appPath),
platformName: this.platform.nodeName as ElectronPlatformName,
arch,
platformSpecificBuildOptions: this.platformSpecificBuildOptions,
targets,
})
}
this.packageInDistributableFormat(appPath, arch, targets, taskManager)
}
}
private async sign(appPath: string, outDir: string | null, masOptions: MasConfiguration | null, arch: Arch): Promise<boolean> {
if (!isSignAllowed()) {
return false
}
const isMas = masOptions != null
const options = masOptions == null ? this.platformSpecificBuildOptions : masOptions
const qualifier = options.identity
const fallBackToAdhoc = (arch === Arch.arm64 || arch === Arch.universal) && !this.forceCodeSigning
if (qualifier === null) {
if (this.forceCodeSigning) {
throw new InvalidConfigurationError("identity explicitly is set to null, but forceCodeSigning is set to true")
}
log.info({ reason: "identity explicitly is set to null" }, "skipped macOS code signing")
if (fallBackToAdhoc) {
log.warn("arm64 requires signing, but identity is set to null and signing is being skipped")
}
return false
}
const keychainFile = (await this.codeSigningInfo.value).keychainFile
const explicitType = options.type
const type = explicitType || "distribution"
const isDevelopment = type === "development"
const certificateTypes = getCertificateTypes(isMas, isDevelopment)
let identity = null
for (const certificateType of certificateTypes) {
identity = await findIdentity(certificateType, qualifier, keychainFile)
if (identity != null) {
break
}
}
if (identity == null) {
if (!isMas && !isDevelopment && explicitType !== "distribution") {
identity = await findIdentity("Mac Developer", qualifier, keychainFile)
if (identity != null) {
log.warn("Mac Developer is used to sign app — it is only for development and testing, not for production")
}
}
const noIdentity = !options.sign && identity == null
if (qualifier === "-") {
identity = new Identity("-", undefined)
} else if (noIdentity && fallBackToAdhoc) {
log.warn(null, "falling back to ad-hoc signature for macOS application code signing")
identity = new Identity("-", undefined)
} else if (noIdentity) {
await reportError(isMas, certificateTypes, qualifier, keychainFile, this.forceCodeSigning)
return false
}
}
if (!isMacOsHighSierra()) {
throw new InvalidConfigurationError("macOS High Sierra 10.13.6 is required to sign")
}
let filter = options.signIgnore
if (Array.isArray(filter)) {
if (filter.length == 0) {
filter = null
}
} else if (filter != null) {
filter = filter.length === 0 ? null : [filter]
}
const filterRe = filter == null ? null : filter.map(it => new RegExp(it))
let binaries = options.binaries || undefined
if (binaries) {
binaries = await Promise.all(
binaries.map(async destination => {
if (await statOrNull(destination)) {
return destination
}
return path.resolve(appPath, destination)
})
)
log.info({ binaries }, "signing additional user-defined binaries")
}
const customSignOptions: MasConfiguration | MacConfiguration = (isMas ? masOptions : this.platformSpecificBuildOptions) || this.platformSpecificBuildOptions
const signOptions: SignOptions = {
identityValidation: false,
ignore: (file: string) => {
if (filterRe != null) {
for (const regExp of filterRe) {
if (regExp.test(file)) {
return true
}
}
}
return (
file.endsWith(".kext") ||
file.startsWith("/Contents/PlugIns", appPath.length) ||
file.includes("/node_modules/puppeteer/.local-chromium") ||
file.includes("/node_modules/playwright-firefox/.local-browsers") ||
file.includes("/node_modules/playwright/.local-browsers")
)
https://github.com/electron-userland/electron-builder/issues/2010
https://github.com/electron-userland/electron-builder/issues/5383
*/
},
identity: identity ? identity.hash || identity.name : undefined,
type,
platform: isMas ? "mas" : "darwin",
version: this.config.electronVersion || undefined,
app: appPath,
keychain: keychainFile || undefined,
binaries,
strictVerify: options.strictVerify,
preAutoEntitlements: options.preAutoEntitlements,
optionsForFile: await this.getOptionsForFile(appPath, isMas, customSignOptions),
provisioningProfile: customSignOptions.provisioningProfile || undefined,
}
await this.doSign(signOptions, customSignOptions, identity)
if (masOptions != null && !isDevelopment) {
const certType = isDevelopment ? "Mac Developer" : "3rd Party Mac Developer Installer"
const masInstallerIdentity = await findIdentity(certType, masOptions.identity, keychainFile)
if (masInstallerIdentity == null) {
throw new InvalidConfigurationError(`Cannot find valid "${certType}" identity to sign MAS installer, please see https://electron.build/code-signing`)
}
const artifactName = this.expandArtifactNamePattern(masOptions, "pkg", arch)
const artifactPath = path.join(outDir!, artifactName)
await this.doFlat(appPath, artifactPath, masInstallerIdentity, keychainFile)
await this.info.emitArtifactBuildCompleted({
file: artifactPath,
target: null,
arch: Arch.x64,
safeArtifactName: this.computeSafeArtifactName(artifactName, "pkg", arch, true, this.platformSpecificBuildOptions.defaultArch),
packager: this,
})
}
if (!isMas) {
await this.notarizeIfProvided(appPath)
}
return true
}
private async getOptionsForFile(appPath: string, isMas: boolean, customSignOptions: MacConfiguration) {
const resourceList = await this.resourceList
const entitlementsSuffix = isMas ? "mas" : "mac"
const getEntitlements = (filePath: string) => {
if (filePath === appPath) {
if (customSignOptions.entitlements) {
return customSignOptions.entitlements
}
const p = `entitlements.${entitlementsSuffix}.plist`
if (resourceList.includes(p)) {
return path.join(this.info.buildResourcesDir, p)
} else {
return getTemplatePath("entitlements.mac.plist")
}
}
if (filePath.includes("Library/LoginItems")) {
return customSignOptions.entitlementsLoginHelper
}
if (customSignOptions.entitlementsInherit) {
return customSignOptions.entitlementsInherit
}
const p = `entitlements.${entitlementsSuffix}.inherit.plist`
if (resourceList.includes(p)) {
return path.join(this.info.buildResourcesDir, p)
} else {
return getTemplatePath("entitlements.mac.plist")
}
}
const requirements = isMas || this.platformSpecificBuildOptions.requirements == null ? undefined : await this.getResource(this.platformSpecificBuildOptions.requirements)
const hardenedRuntime = isMas ? customSignOptions.hardenedRuntime === true : customSignOptions.hardenedRuntime !== false
const optionsForFile: (filePath: string) => PerFileSignOptions = filePath => {
const entitlements = getEntitlements(filePath)
const args = {
entitlements: entitlements || undefined,
hardenedRuntime: hardenedRuntime ?? undefined,
timestamp: customSignOptions.timestamp || undefined,
requirements: requirements || undefined,
additionalArguments: customSignOptions.additionalArguments || [],
}
log.debug({ file: log.filePath(filePath), ...args }, "selecting signing options")
return args
}
return optionsForFile
}
protected async doSign(opts: SignOptions, customSignOptions: MacConfiguration, identity: Identity | null): Promise<void> {
const customSign = await resolveFunction(this.appInfo.type, customSignOptions.sign, "sign")
const { app, platform, type, provisioningProfile } = opts
log.info(
{
file: log.filePath(app),
platform,
type,
identityName: identity?.name || "none",
identityHash: identity?.hash || "none",
provisioningProfile: provisioningProfile || "none",
},
customSign ? "executing custom sign" : "signing"
)
return customSign ? Promise.resolve(customSign(opts, this)) : sign({ ...opts, identity: identity ? identity.name : undefined })
}
protected async doFlat(appPath: string, outFile: string, identity: Identity, keychain: string | Nullish): Promise<any> {
await mkdir(path.dirname(outFile), { recursive: true })
const args = prepareProductBuildArgs(identity, keychain)
args.push("--component", appPath, "/Applications")
args.push(outFile)
return await exec("productbuild", args)
}
public getElectronSrcDir(dist: string) {
return path.resolve(this.projectDir, dist, this.info.framework.distMacOsAppName)
}
public getElectronDestinationDir(appOutDir: string) {
return path.join(appOutDir, this.info.framework.distMacOsAppName)
}
async applyCommonInfo(appPlist: any, contentsPath: string) {
const appInfo = this.appInfo
const appFilename = appInfo.productFilename
appPlist.CFBundleExecutable = appFilename.endsWith(" Helper") ? appFilename.substring(0, appFilename.length - " Helper".length) : appFilename
const icon = await this.getIconPath()
if (icon != null) {
const oldIcon = appPlist.CFBundleIconFile
const resourcesPath = path.join(contentsPath, "Resources")
if (oldIcon != null) {
await unlinkIfExists(path.join(resourcesPath, oldIcon))
}
const iconFileName = "icon.icns"
appPlist.CFBundleIconFile = iconFileName
await copyFile(icon, path.join(resourcesPath, iconFileName))
}
appPlist.CFBundleName = appInfo.productName
appPlist.CFBundleDisplayName = appInfo.productName
const minimumSystemVersion = this.platformSpecificBuildOptions.minimumSystemVersion
if (minimumSystemVersion != null) {
appPlist.LSMinimumSystemVersion = minimumSystemVersion
}
appPlist.CFBundleShortVersionString = this.platformSpecificBuildOptions.bundleShortVersion || appInfo.version
appPlist.CFBundleVersion = appInfo.buildVersion
use(this.platformSpecificBuildOptions.category || (this.config as any).category, it => (appPlist.LSApplicationCategoryType = it))
appPlist.NSHumanReadableCopyright = appInfo.copyright
if (this.platformSpecificBuildOptions.darkModeSupport) {
appPlist.NSRequiresAquaSystemAppearance = false
}
const extendInfo = this.platformSpecificBuildOptions.extendInfo
if (extendInfo != null) {
Object.assign(appPlist, extendInfo)
}
}
protected async signApp(packContext: AfterPackContext, isAsar: boolean): Promise<boolean> {
const readDirectoryAndSign = async (sourceDirectory: string, directories: string[], shouldSign: (file: string) => boolean): Promise<boolean> => {
await Promise.all(
directories.map(async (file: string) => {
if (shouldSign(file)) {
await this.sign(path.join(sourceDirectory, file), null, null, packContext.arch)
}
})
)
return true
}
const appFileName = `${this.appInfo.productFilename}.app`
await readDirectoryAndSign(packContext.appOutDir, await readdir(packContext.appOutDir), file => file === appFileName)
if (!isAsar) {
return true
}
const outResourcesDir = path.join(packContext.appOutDir, "resources", "app.asar.unpacked")
await readDirectoryAndSign(outResourcesDir, await orIfFileNotExist(readdir(outResourcesDir), []), file => file.endsWith(".app"))
return true
}
async notarizeIfProvided(appPath: string) {
const notarizeOptions = this.platformSpecificBuildOptions.notarize
if (notarizeOptions === false) {
log.info({ reason: "`notarize` options were set explicitly `false`" }, "skipped macOS notarization")
return
}
const options = this.getNotarizeOptions(appPath)
if (!options) {
log.warn({ reason: "`notarize` options were unable to be generated" }, "skipped macOS notarization")
return
}
await notarize(options)
log.info(null, "notarization successful")
}
private getNotarizeOptions(appPath: string): NotarizeOptionsNotaryTool | undefined {
const teamId = process.env.APPLE_TEAM_ID
const appleId = process.env.APPLE_ID
const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD
const tool = "notarytool"
if (appleId || appleIdPassword) {
if (!appleId) {
throw new InvalidConfigurationError(`APPLE_ID env var needs to be set`)
}
if (!appleIdPassword) {
throw new InvalidConfigurationError(`APPLE_APP_SPECIFIC_PASSWORD env var needs to be set`)
}
if (!teamId) {
throw new InvalidConfigurationError(`APPLE_TEAM_ID env var needs to be set`)
}
return { tool, appPath, appleId, appleIdPassword, teamId }
}
const appleApiKey = process.env.APPLE_API_KEY
const appleApiKeyId = process.env.APPLE_API_KEY_ID
const appleApiIssuer = process.env.APPLE_API_ISSUER
if (appleApiKey || appleApiKeyId || appleApiIssuer) {
if (!appleApiKey || !appleApiKeyId || !appleApiIssuer) {
throw new InvalidConfigurationError(`Env vars APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER need to be set`)
}
return { tool, appPath, appleApiKey, appleApiKeyId, appleApiIssuer }
}
const keychain = process.env.APPLE_KEYCHAIN
const keychainProfile = process.env.APPLE_KEYCHAIN_PROFILE
if (keychainProfile) {
let args: NotaryToolKeychainCredentials = { keychainProfile }
if (keychain) {
args = { ...args, keychain }
}
return { tool, appPath, ...args }
}
return undefined
}
}
function getCertificateTypes(isMas: boolean, isDevelopment: boolean): CertType[] {
if (isDevelopment) {
return isMas ? ["Mac Developer", "Apple Development"] : ["Mac Developer", "Developer ID Application"]
}
return isMas ? ["Apple Distribution", "3rd Party Mac Developer Application"] : ["Developer ID Application"]
}