揭秘 electron-builder:macOS 应用打包背后到底发生了什么?

本文首发同名微信公众号:前端徐徐

大家好,我是徐徐。今天我们聊聊 electron-builder 中 macOS 如何打包的。

前言

我在后台收到留言说上一篇关于 electron-builder 解析的文章不够有深度,我就想了一下,要再细致一点就只能把每个平台的打包源码讲一下啰。 这促使我重新审视了这个话题,决定深入源码,为大家揭示 macOS 平台上 Electron 应用打包的全过程。 Electron 应用的 macOS 平台打包是一个涉及多个步骤的复杂过程,包括准备打包配置、执行打包、应用签名、notarization 等。

为什么 macOS 打包如此重要?

在我们开始技术探讨之前,先问问自己:为什么 macOS 平台的打包如此重要?随着 Apple 对安全性要求的不断提高,特别是从 macOS Catalina 开始,如果不正确处理打包和签名,你的应用可能根本无法在用户的 Mac 上运行。想象一下,你辛苦开发的应用被系统拒之门外的沮丧!这就是为什么我们需要深入了解 electron-builder 的工作原理。

准备阶段

在打包之前,需要确保 Electron 应用的 package.json 文件中包含了必要的字段,如 nameversionmain(入口脚本)等。此外,macOS 应用需要一个 build 配置,指定打包选项和签名信息。

配置文件

开发者可以在项目的 package.json 文件中定义 electron-builder 的配置,包括:

  • 图标 (icon):macOS 应用的图标路径。
  • 证书和签名 (mac):包含签名证书的路径和密码。
  • 应用信息 (appId):用于唯一标识应用的字符串。
  • 目标格式 (target):指定打包目标,如 dmgzippkg 等。

打包流程

electron-builder 通过以下步骤完成打包:

创建打包器实例

首先,创建一个 MacPackager 实例,它继承自 PlatformPackager,用于处理 macOS 平台特有的打包逻辑。

export class MacPackager extends PlatformPackager<MacConfiguration> {
  constructor(info: Packager) {
    super(info, Platform.MAC)
  }
  // ...
}

定义默认目标

定义 macOS 平台的默认打包目标,如 dmgzippkg 等。

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)
}

创建打包目标

根据配置中指定的目标格式,创建相应的打包目标。

createTargets(targets: Array<string>mapper: (name: string, factory: (outDir: string) => Target) => void): void {
  for (const name of targets) {
    // ...
  }
}

执行打包

对应用进行实际的打包操作,包括复制文件、合并资源,处理不同架构(如 x64 和 arm64)的打包等逻辑。

protected async doPack(
    outDir: string,
    appOutDir: string,
    platformName: ElectronPlatformName,
    arch: Arch,
    platformSpecificBuildOptions: MacConfiguration,
    targets: Array<Target>
  ): Promise<any> {
    switch (arch) {
      default: {
        return super.doPack(outDir, appOutDir, platformName, arch, platformSpecificBuildOptions, targets)
      }
      case Arch.universal: {
        const outDirName = (arch: Arch) => `${appOutDir}-${Arch[arch]}-temp`

        const x64Arch = Arch.x64
        const x64AppOutDir = outDirName(x64Arch)
        await super.doPack(outDir, x64AppOutDir, platformName, x64Arch, platformSpecificBuildOptions, targets, falsetrue)

        if (this.info.cancellationToken.cancelled) {
          return
        }

        const arm64Arch = Arch.arm64
        const arm64AppOutPath = outDirName(arm64Arch)
        await super.doPack(outDir, arm64AppOutPath, platformName, arm64Arch, platformSpecificBuildOptions, targets, falsetrue)

        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: truemergeASARs: platformSpecificBuildOptions.mergeASARs ?? truesingleArchFiles: platformSpecificBuildOptions.singleArchFiles,
          x64ArchFiles: platformSpecificBuildOptions.x64ArchFiles,
        })
        await fs.rm(x64AppOutDir, { recursive: true, force: true })
        await fs.rm(arm64AppOutPath, { recursive: true, force: true })

        // Give users a final opportunity to perform things on the combined universal package before signing
        const packContext: AfterPackContext = {
          appOutDir,
          outDir,
          arch,
          targets,
          packager: thiselectronPlatformName: platformName,
        }
        await this.info.afterPack(packContext)

        if (this.info.cancellationToken.cancelled) {
          return
        }

        await this.doSignAfterPack(outDir, appOutDir, platformName, arch, platformSpecificBuildOptions, targets)
        break
      }
    }
  }

这个地方需要核心讲解一下,因为它完整的将打包资源变成的一个可执行的文件应用。

入参解析

  • outDir: 最终打包产物的输出目录。
  • appOutDir: 应用输出目录,即应用的临时打包目录。
  • platformName: 目标平台名称,例如 ElectronPlatformName.macOS
  • arch: 目标架构,例如 Arch.x64Arch.arm64
  • platformSpecificBuildOptions: 平台特定的构建选项。
  • targets: 打包目标数组。

打包流程

  • 架构判断:使用 switch 语句根据 arch 参数来判断当前的架构类型。
  • 默认情况:如果 arch 不是 universal,则调用父类的 doPack 方法进行打包。
  • 通用应用(Universal):如果 archuniversal,则需要创建一个支持多种架构(如 x64 和 arm64)的应用包。

通用应用打包步骤

  • 定义临时目录:定义一个函数 outDirName,用于生成基于架构的临时输出目录名称。
  • 打包 x64 架构
    • 调用父类的 doPack 方法,为 x64 架构打包应用。
    • 使用 x64AppOutDir 作为临时输出目录。
  • 检查取消状态:如果构建过程被取消,则退出方法。
  • 打包 arm64 架构
    • 调用父类的 doPack 方法,为 arm64 架构打包应用。
    • 使用 arm64AppOutPath 作为临时输出目录。
  • 记录日志:记录打包信息,包括平台、架构和应用输出目录。
  • 合并应用:调用 makeUniversalApp 方法,合并 x64 和 arm64 架构的应用包为一个通用应用包,makeUniversalApp 这个方法源码在这里:https://github1s.com/electron/universal/blob/main/src/index.ts,主要是做一个通用应用包。
  • 清理临时目录:删除临时构建目录。
  • 执行用户自定义的打包后操作:如果提供了 afterPack 钩子,则执行它。
  • 检查取消状态:再次检查构建过程是否被取消。
  • 签名应用:调用 doSignAfterPack 方法对合并后的应用进行签名

关键点

  • 合并 ASAR 文件:如果 mergeASARs 选项为 true,则合并架构相关的 ASAR 文件。
  • 单架构文件singleArchFiles 选项允许指定仅包含在一个架构中的应用文件。
  • x64 架构文件x64ArchFiles 选项允许指定 x64 架构特有的文件。

上面这个方法展示了如何为 macOS 平台打包一个 Electron 应用,特别是如何创建一个支持多架构的通用应用包。它涵盖了从打包、合并到签名的整个流程,并提供了对构建过程的细粒度控制。

签名流程

macOS 应用需要进行代码签名,以确保应用的安全性和在 macOS Catalina 及更高版本上的运行。

准备签名信息

使用开发者的证书和密钥,准备签名所需的信息。

readonly codeSigningInfo = new MemoLazy<CreateKeychainOptions | null, CodeSigningInfo>(
  // ...
)

执行签名

调用签名函数,对应用进行签名,<font style="color:rgb(6, 6, 7);">sign</font> 方法包含了应用签名的逻辑,包括查找合适的身份(identity)、设置签名选项、调用签名函数等。

private async sign(appPath: string, outDir: string | null, masOptions: MasConfiguration | null, arch: Arch | null): Promise<boolean> {
  // ...
}

<font style="color:rgb(6, 6, 7);">signApp</font> 方法用于签名应用的各个部分,包括主应用和可能的 ASAR 解包文件。

protected async signApp(packContext: AfterPackContext, isAsar: boolean): Promise<boolean> {
// ...
}

Notarization

从 macOS Catalina 开始,苹果要求所有应用在分发前必须通过苹果的 notarization (苹果的官方认证过程,俗称公证) 流程。详情可看这里:https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution

private async notarizeIfProvided(appPath: string, buildOptions: MacConfiguration) {
  // ...
}

打包完成

完成打包和签名后,electron-builder 会生成指定格式的打包文件,如 dmgzippkg,并根据配置进行后续的发布操作。

错误处理和日志

在整个打包和签名过程中,electron-builder 提供了详细的错误处理和日志记录,帮助开发者诊断和解决问题。

其他高级特性

electron-builder 还支持许多高级特性,如:

  • 代码压缩:减少应用的大小。
  • 自动更新:集成自动更新支持。
  • 配置脚本:在打包前后执行自定义脚本。

总结

Electron 应用在 macOS 平台上的打包过程是一个多层面、复杂的任务,涉及诸多关键步骤,包括准备打包配置、执行打包、应用签名和 notarization 等。electron-builder 的出现大大简化了这一过程,它通过自动化这些繁琐的步骤,使开发者能够将精力更多地集中在应用本身的开发上,而无需深究打包的技术细节。

然而,在实际操作中,开发者仍可能遇到各种打包相关的错误和问题。在这种情况下,对打包原理的深入理解以及对相关源码的熟悉就显得尤为重要。这些知识不仅能帮助开发者更快速地定位问题,还能提供更清晰的解决思路。

我理解很多小伙伴可能更关心如何在实战中解决具体的打包问题。请大家放心,这正是我们接下来要深入探讨的内容。本系列文章的编排是经过精心规划的,我们会先奠定必要的理论基础,然后逐步过渡到实际应用的教程中。

事实上,Mac Electron 打包确实存在许多棘手的问题,比如以下几个方面:

  1. 应用签名和验证流程

  2. 特定 PKG 格式的制作

  3. 应用的升级更新

  4. 原生包的资源处理

这些都是极其细节且关键的问题,每一个都值得我们深入探讨。在接下来的文章中,我们将逐一解析这些问题,并提供实用的解决方案。

通过这个系列,我希望能够帮助大家不仅理解 Electron 应用打包的原理,更能够在实际项目中熟练应用这些知识,解决各种复杂的打包问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值