import { DmgOptions, Target } from "app-builder-lib"
import { findIdentity, isSignAllowed } from "app-builder-lib/out/codeSign/macCodeSign"
import { MacPackager } from "app-builder-lib/out/macPackager"
import { createBlockmap } from "app-builder-lib/out/targets/differentialUpdateInfoBuilder"
import { executeAppBuilderAsJson } from "app-builder-lib/out/util/appBuilder"
import { Arch, AsyncTaskManager, copyDir, copyFile, exec, exists, getArchSuffix, InvalidConfigurationError, isEmptyOrSpaces, log, statOrNull } from "builder-util"
import { CancellationToken, Nullish } from "builder-util-runtime"
import { sanitizeFileName } from "builder-util/out/filename"
import { stat } from "fs-extra"
import { release as getOsRelease } from "os"
import * as path from "path"
import { TmpDir } from "temp-file"
import { addLicenseToDmg } from "./dmgLicense"
import { attachAndExecute, computeBackground, detach, getDmgVendorPath } from "./dmgUtil"
import { hdiUtil } from "./hdiuil"
export class DmgTarget extends Target {
readonly options: DmgOptions = this.packager.config.dmg || Object.create(null)
isAsyncSupported = false
constructor(
private readonly packager: MacPackager,
readonly outDir: string
) {
super("dmg")
}
async build(appPath: string, arch: Arch) {
const packager = this.packager
const artifactName = packager.expandArtifactNamePattern(
this.options,
"dmg",
arch,
"${productName}-" + (packager.platformSpecificBuildOptions.bundleShortVersion || "${version}") + "-${arch}.${ext}",
true,
packager.platformSpecificBuildOptions.defaultArch
)
const artifactPath = path.join(this.outDir, artifactName)
await packager.info.emitArtifactBuildStarted({
targetPresentableName: "DMG",
file: artifactPath,
arch,
})
const volumeName = sanitizeFileName(this.computeVolumeName(arch, this.options.title))
const tempDmg = await createStageDmg(await packager.getTempFile(".dmg"), appPath, volumeName)
const specification = await this.computeDmgOptions()
const backgroundFile = specification.background == null ? null : await transformBackgroundFileIfNeed(specification.background, packager.info.tempDirManager)
const finalSize = await computeAssetSize(packager.info.cancellationToken, tempDmg, specification, backgroundFile)
const expandingFinalSize = finalSize * 0.1 + finalSize
await hdiUtil(["resize", "-size", expandingFinalSize.toString(), tempDmg])
const volumePath = path.join("/Volumes", volumeName)
if (await exists(volumePath)) {
log.debug({ volumePath }, "unmounting previous disk image")
await detach(volumePath)
}
if (!(await attachAndExecute(tempDmg, true, () => customizeDmg(volumePath, specification, packager, backgroundFile)))) {
return
}
const args = ["convert", tempDmg, "-ov", "-format", specification.format!, "-o", artifactPath]
if (specification.format === "UDZO") {
args.push("-imagekey", `zlib-level=${process.env.ELECTRON_BUILDER_COMPRESSION_LEVEL || "9"}`)
}
await hdiUtil(addLogLevel(args))
if (this.options.internetEnabled && parseInt(getOsRelease().split(".")[0], 10) < 19) {
await hdiUtil(addLogLevel(["internet-enable"]).concat(artifactPath))
}
const licenseData = await addLicenseToDmg(packager, artifactPath)
if (packager.packagerOptions.effectiveOptionComputed != null) {
await packager.packagerOptions.effectiveOptionComputed({ licenseData })
}
if (this.options.sign === true) {
await this.signDmg(artifactPath)
}
const safeArtifactName = packager.computeSafeArtifactName(artifactName, "dmg")
const updateInfo = this.options.writeUpdateInfo === false ? null : await createBlockmap(artifactPath, this, packager, safeArtifactName)
await packager.info.emitArtifactBuildCompleted({
file: artifactPath,
safeArtifactName,
target: this,
arch,
packager,
isWriteUpdateInfo: updateInfo != null,
updateInfo,
})
}
private async signDmg(artifactPath: string) {
if (!isSignAllowed(false)) {
return
}
const packager = this.packager
const qualifier = packager.platformSpecificBuildOptions.identity
if (qualifier === null) {
return
}
const keychainFile = (await packager.codeSigningInfo.value).keychainFile
const certificateType = "Developer ID Application"
let identity = await findIdentity(certificateType, qualifier, keychainFile)
if (identity == null) {
identity = await findIdentity("Mac Developer", qualifier, keychainFile)
if (identity == null) {
return
}
}
const args = ["--sign", identity.hash!]
if (keychainFile != null) {
args.push("--keychain", keychainFile)
}
args.push(artifactPath)
await exec("codesign", args)
}
computeVolumeName(arch: Arch, custom?: string | null): string {
const appInfo = this.packager.appInfo
const shortVersion = this.packager.platformSpecificBuildOptions.bundleShortVersion || appInfo.version
const archString = getArchSuffix(arch, this.packager.platformSpecificBuildOptions.defaultArch)
if (custom == null) {
return `${appInfo.productFilename} ${shortVersion}${archString}`
}
return custom
.replace(/\${arch}/g, archString)
.replace(/\${shortVersion}/g, shortVersion)
.replace(/\${version}/g, appInfo.version)
.replace(/\${name}/g, appInfo.name)
.replace(/\${productName}/g, appInfo.productName)
}
async computeDmgOptions(): Promise<DmgOptions> {
const packager = this.packager
const specification: DmgOptions = { ...this.options }
if (specification.icon == null && specification.icon !== null) {
specification.icon = await packager.getIconPath()
}
if (specification.icon != null && isEmptyOrSpaces(specification.icon)) {
throw new InvalidConfigurationError("dmg.icon cannot be specified as empty string")
}
const background = specification.background
if (specification.backgroundColor != null) {
if (background != null) {
throw new InvalidConfigurationError("Both dmg.backgroundColor and dmg.background are specified — please set the only one")
}
} else if (background == null) {
specification.background = await computeBackground(packager)
} else {
specification.background = path.resolve(packager.info.projectDir, background)
}
if (specification.format == null) {
if (process.env.ELECTRON_BUILDER_COMPRESSION_LEVEL != null) {
;(specification as any).format = "UDZO"
} else if (packager.compression === "store") {
specification.format = "UDRO"
} else {
specification.format = packager.compression === "maximum" ? "UDBZ" : "UDZO"
}
}
if (specification.contents == null) {
specification.contents = [
{
x: 130,
y: 220,
},
{
x: 410,
y: 220,
type: "link",
path: "/Applications",
},
]
}
return specification
}
}
async function createStageDmg(tempDmg: string, appPath: string, volumeName: string) {
const imageArgs = addLogLevel(["create", "-srcfolder", appPath, "-volname", volumeName, "-anyowners", "-nospotlight", "-format", "UDRW"])
if (log.isDebugEnabled) {
imageArgs.push("-debug")
}
imageArgs.push("-fs", "APFS")
imageArgs.push(tempDmg)
await hdiUtil(imageArgs)
return tempDmg
}
function addLogLevel(args: Array<string>): Array<string> {
args.push(process.env.DEBUG_DMG === "true" ? "-verbose" : "-quiet")
return args
}
async function computeAssetSize(cancellationToken: CancellationToken, dmgFile: string, specification: DmgOptions, backgroundFile: string | Nullish) {
const asyncTaskManager = new AsyncTaskManager(cancellationToken)
asyncTaskManager.addTask(stat(dmgFile))
if (specification.icon != null) {
asyncTaskManager.addTask(statOrNull(specification.icon))
}
if (backgroundFile != null) {
asyncTaskManager.addTask(stat(backgroundFile))
}
let result = 32 * 1024
for (const stat of await asyncTaskManager.awaitTasks()) {
if (stat != null) {
result += stat.size
}
}
return result
}
async function customizeDmg(volumePath: string, specification: DmgOptions, packager: MacPackager, backgroundFile: string | Nullish) {
const window = specification.window
const isValidIconTextSize = !!specification.iconTextSize && specification.iconTextSize >= 10 && specification.iconTextSize <= 16
const iconTextSize = isValidIconTextSize ? specification.iconTextSize : 12
const env: any = {
...process.env,
volumePath,
appFileName: `${packager.appInfo.productFilename}.app`,
iconSize: specification.iconSize || 80,
iconTextSize,
PYTHONIOENCODING: "utf8",
}
if (specification.backgroundColor != null || specification.background == null) {
env.backgroundColor = specification.backgroundColor || "#ffffff"
if (window != null) {
env.windowX = (window.x == null ? 100 : window.x).toString()
env.windowY = (window.y == null ? 400 : window.y).toString()
env.windowWidth = (window.width || 540).toString()
env.windowHeight = (window.height || 380).toString()
}
} else {
delete env.backgroundColor
}
const args = ["dmg", "--volume", volumePath]
if (specification.icon != null) {
args.push("--icon", (await packager.getResource(specification.icon))!)
}
if (backgroundFile != null) {
args.push("--background", backgroundFile)
}
const data: any = await executeAppBuilderAsJson(args)
if (data.backgroundWidth != null) {
env.windowWidth = window == null ? null : window.width
env.windowHeight = window == null ? null : window.height
if (env.windowWidth == null) {
env.windowWidth = data.backgroundWidth.toString()
}
if (env.windowHeight == null) {
env.windowHeight = data.backgroundHeight.toString()
}
if (env.windowX == null) {
env.windowX = 400
}
if (env.windowY == null) {
env.windowY = Math.round((1440 - env.windowHeight) / 2).toString()
}
}
Object.assign(env, data)
const asyncTaskManager = new AsyncTaskManager(packager.info.cancellationToken)
env.iconLocations = await computeDmgEntries(specification, volumePath, packager, asyncTaskManager)
await asyncTaskManager.awaitTasks()
const executePython = async (execName: string) => {
let pythonPath = process.env.PYTHON_PATH
if (!pythonPath) {
pythonPath = (await exec("which", [execName])).trim()
}
await exec(pythonPath, [path.join(getDmgVendorPath(), "dmgbuild/core.py")], {
cwd: getDmgVendorPath(),
env,
})
}
try {
await executePython("python3")
} catch (_error: any) {
await executePython("python")
}
return packager.packagerOptions.effectiveOptionComputed == null || !(await packager.packagerOptions.effectiveOptionComputed({ volumePath, specification, packager }))
}
async function computeDmgEntries(specification: DmgOptions, volumePath: string, packager: MacPackager, asyncTaskManager: AsyncTaskManager): Promise<string> {
let result = ""
for (const c of specification.contents!) {
if (c.path != null && c.path.endsWith(".app") && c.type !== "link") {
log.warn({ path: c.path, reason: "actual path to app will be used instead" }, "do not specify path for application")
}
const entryPath = c.path || `${packager.appInfo.productFilename}.app`
const entryName = c.name || path.basename(entryPath)
const escapedEntryName = entryName.replace(/['\\]/g, match => `\\${match}`)
if (result.length !== 0) {
result += ",\n"
}
result += `'${escapedEntryName}': (${c.x}, ${c.y})`
if (c.type === "link") {
asyncTaskManager.addTask(exec("ln", ["-s", `/${entryPath.startsWith("/") ? entryPath.substring(1) : entryPath}`, `${volumePath}/${entryName}`]))
}
else if (!isEmptyOrSpaces(c.path) && (c.type === "file" || c.type === "dir")) {
const source = await packager.getResource(c.path)
if (source == null) {
log.warn({ entryPath, reason: "doesn't exist" }, "skipped DMG item copying")
continue
}
const destination = `${volumePath}/${entryName}`
asyncTaskManager.addTask(c.type === "dir" || (await stat(source)).isDirectory() ? copyDir(source, destination) : copyFile(source, destination))
}
}
return result
}
async function transformBackgroundFileIfNeed(file: string, tmpDir: TmpDir): Promise<string> {
if (file.endsWith(".tiff") || file.endsWith(".TIFF")) {
return file
}
const retinaFile = file.replace(/\.([a-z]+)$/, "@2x.$1")
if (await exists(retinaFile)) {
const tiffFile = await tmpDir.getTempFile({ suffix: ".tiff" })
await exec("tiffutil", ["-cathidpicheck", file, retinaFile, "-out", tiffFile])
return tiffFile
}
return file
}