🏡 index : quine-global-boneyard/electron-builder.git

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
    // tslint:disable-next-line:no-invalid-template-strings
    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()
    // https://github.com/electron-userland/electron-builder/issues/2115
    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
    }

    // dmg file must not exist otherwise hdiutil failed (https://github.com/electron-userland/electron-builder/issues/1308#issuecomment-282847594), so, -ov must be specified
    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
    // explicitly disabled if set to null
    if (qualifier === null) {
      // macPackager already somehow handle this situation, so, here just return
      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)
  }

  // public to test
  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) {
  //noinspection SpellCheckingInspection
  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}`]))
    }
    // use c.path instead of entryPath (to be sure that this logic is not applied to .app bundle) https://github.com/electron-userland/electron-builder/issues/2147
    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
}