Electron 打包与自动更新完全指南:electron-builder、latest.yml、app-update.yml 与自建更新服务器实战

本文面向已经能用 electron-builder 打出安装包、正准备把自动更新推上生产的开发者,围绕一条完整链路展开:打包配置 → 更新元数据 → 客户端接线 → 后端托管 → 发布流水线 → 排错验收。研究数据表明,国内社区关于 electron-updater、latest.yml 与 app-update.yml、自建更新后端的内容已经出现,但分散成多篇各讲一段 1234,端到端闭环长期缺失;与此同时,入门教程高度同质化,大量内容止步于"把网页打成 .exe" 10。本文不重复 Hello World,只解决"装完包之后用户能不能收到更新"这一段工程链路。

两点前提先说清楚。其一,本次研究采集到的条目热度指标全部为 0、发布时间字段为空,因此本文不做"热度排序",只依据条目集中度做主题归纳。其二,自动更新相关的字段位置、事件集合、签名配置写法在 electron-builder / electron-updater 的不同大版本之间有过迁移,本文示例统一使用 1.0.0 → 1.0.1 的演练版本,并在可能随版本变化处标注"版本相关";落地时请以你安装版本的 schema 与类型定义为准:

bash 复制代码
npx electron-builder --version   # 查看打包器版本
npm ls electron-updater          # 查看客户端更新库版本

作为版本背景参考:Electron 官方同时维护多条版本线,采集期内已出现 v45.0.0-alpha.11 预发布 11,而第三方项目中 electron 44.x 的依赖升级 PR 仍在持续推送;社区中也存在专门的 Electron 生命周期数据维护 12。这意味着"依赖基线"本身是变量,本文示例中的所有版本号只是演练代号,不代表对某一 Electron 大版本的兼容承诺。

一、一次自动更新到底发生了什么:链路全景

1.1 六个环节:检查 → 比版本 → 下载(差分)→ 校验 → 安装 → 重启

一次成功的自动更新,端到端包含六个环节:

  1. 检查 :客户端向"更新源"发起 HTTP 请求,拉取一个很小的 YAML 元数据文件(本文统称 latest.yml)。
  2. 比版本 :electron-updater 用语义化版本比较元数据里的 version 与当前应用 version,决定是否存在更新。
  3. 下载:若存在更新,按元数据里的文件清单下载安装包;在条件满足时走差分下载,只取新旧包之间差异的数据块。
  4. 校验 :下载完成后对完整文件计算 sha512,与元数据中的 sha512 比对;Windows 上还会额外校验代码签名发布者信息。
  5. 安装 :quitAndInstall 退出应用并调起安装器(或平台等价机制)覆盖安装。
  6. 重启:安装完成后拉起新版本,更新结束。

这六步里,第 3、4 步是最容易被误解的部分:差分下载不是"必须",而是"条件满足时的优化"。一旦服务端不支持字节范围请求、blockmap 缺失或新旧版本差异过大,electron-updater 会退化为全量下载,功能仍然正确,只是带宽成本上升。

1.2 三份关键元数据的分工:构建产物、latest.yml(服务端指针)、app-update.yml(客户端寻址)

理解整条链路的关键,是把"更新信息"拆成三个角色:

载体 由谁生成 放在哪里 被谁消费 回答的问题
安装包 + .blockmap electron-builder 更新服务器/对象存储 客户端下载器 装什么、怎么省流量
latest.yml(含 -mac / -linux 变体) electron-builder 更新服务器 客户端更新器 现在最新版是几、文件叫什么、哈希是多少
app-update.yml electron-builder 打进安装包内部 客户端更新器 我该去哪里找 latest.yml

三者的耦合方向是单向的:app-update.yml 里的 URL 指向一个目录,该目录里的 latest.yml 指向安装包与 blockmap。把这条链路画成一行就是:

app-update.yml.url → latest.yml → 安装包 / blockmap

这个分工解释了一个高频困惑:为什么"改一下更新地址"有时只需要改服务器上的一个文件,有时却必须重新打包。答案取决于坏掉的是"寻址"还是"指针"------第三节会展开。

1.3 更新链路的"延迟爆炸"特性:打包期错误 → 发布期才暴露

自动更新有一个反直觉的工程性质:大部分错误在打包当天不会报错,而是在下一次发版时才暴露。

appId 写错、version 与 package.json 不一致、忘记给 macOS 增加 zip target、发布目录层级与 app-update.yml 中的 URL 不一致------这些配置在 electron-builder 看来都是合法的,构建会成功,安装包可以正常安装。问题要等到 1.0.1 发布之后,1.0.0 的用户点"检查更新"却什么也收不到时才显现;如果产品更新频率低,这个延迟可能是几周甚至几个月。

因此,自动更新不能作为"打包的附属配置"顺手写完,而要当成一条需要单独验收的发布管线。研究素材中的踩坑记录也呈现出同样的分布:打包期问题集中在 ABI 不匹配导致的启动异常 8 与 Vue 项目打包踩坑 5,而更新期问题集中在元数据字段与后端托管 34------两者发生时点完全不同,混在一张排错表里效率很低,这也是本文把它们分章处理的原因。

二、electron-builder 打包配置:为"能更新"而打包

2.1 配置组织方式与必须锁死的三个标识

electron-builder 支持把配置写在 package.json 的 build 字段,也支持独立的 electron-builder.yml(或 .json / .json5 / .toml / .js)。生产项目建议独立文件:配置量大、注释需求强、且要与 package.json 的依赖字段分离评审。社区中的实际项目也倾向于独立配置文件,例如 Gitee 上的 moyin-creator 就维护了独立的 electron-builder.yml 7。

三个标识一旦发布就必须保持稳定:

  • appId :应用身份。Windows 上关联卸载记录与安装目录,macOS 上关联 Bundle ID。改 appId 等于换了一个应用,老用户的更新链路随之失效。
  • productName:展示名与默认安装目录名。改名会让 Windows 的安装路径、开始菜单项、卸载项同时变化,更新结果通常是"多出一个新应用"而不是"旧应用被升级"。
  • version :electron-builder 默认读取 package.json 的 version,也可在配置中显式指定。它是 latest.yml 中版本指针的来源,必须严格单调递增。

下面是一份面向生产的最小配置骨架,注释区分了"影响更新"与"只影响外观/体积"的字段:

yaml 复制代码
# electron-builder.yml
appId: com.example.notes          # 【影响更新】发布后冻结
productName: Notes                # 【影响更新】发布后尽量冻结
copyright: Copyright © 2026 Example

directories:
  output: release/${version}      # 仅影响产物落盘位置
  buildResources: build           # 图标、entitlements 等资源目录

files:
  - dist/**                       # 渲染层构建产物
  - package.json
  - "!**/*.map"                   # 仅影响体积

asar: true                        # 仅影响体积与加载方式

# ------ 更新相关的核心配置 ------
publish:
  provider: generic               # 【影响更新】决定 app-update.yml 的 provider 字段
  url: https://update.example.com/notes   # 【影响更新】写入 app-update.yml 的 url
  channel: latest                 # 【影响更新】决定请求哪个 yml 文件名

win:
  target:
    - target: nsis                # 【影响更新】NSIS 是 Windows 自更新的成熟路径
      arch: [x64]
  artifactName: ${productName}-Setup-${version}-${arch}.${ext}
  signtoolOptions:                # 【影响更新】Windows 更新会校验发布者信息
    certificateSubjectName: "Example Technology Co., Ltd."
  # 使用文件证书的旧写法为 win.certificateFile / certificatePassword,字段位置版本相关

nsis:
  oneClick: false                 # 仅影响安装体验
  perMachine: false               # 【影响更新】perMachine 需要管理员权限才能静默升级
  allowToChangeInstallationDirectory: true
  differentialPackage: true       # 【影响更新】生成 blockmap 的前提之一

mac:
  category: public.app-category.productivity
  target:
    - target: dmg                 # 仅负责首次安装
      arch: [x64, arm64]
    - target: zip                 # 【影响更新】mac 自更新实际使用 zip 及其 blockmap
      arch: [x64, arm64]
  artifactName: ${productName}-${version}-${arch}.${ext}
  hardenedRuntime: true           # 【影响更新】公证的前置要求
  entitlements: build/entitlements.mac.plist
  entitlementsInherit: build/entitlements.mac.plist
  notarize: true                  # 【影响更新】写法随大版本变化,旧版为 notarize: { teamId } 或 afterSign 钩子

linux:
  target:
    - target: AppImage            # 【影响更新】AppImage 是 Linux 自更新的主要路径
      arch: [x64]
  category: Utility
  artifactName: ${productName}-${version}-${arch}.${ext}

artifactName 里的 ${version}、${arch}、${ext} 等宏是可验证的命名机制,务必把版本号编进文件名:同名覆盖会让 CDN 缓存、blockmap 与包体错配,而带版本号的文件名天然不可变,可以放心长缓存。

2.2 target 选型与更新能力对照

不同 target 的"能打包"与"能自更新"是两件事:

平台 target 首次安装用途 自动更新能力 说明
Windows NSIS(-Setup.exe) 是 成熟支持,含差分 electron-updater 的主要 Windows 路径
Windows NSIS Web 是 支持 安装器在线拉取资源,网络要求更高
Windows MSI 是 支持程度依版本而定 传统 MSI 部署场景多走组态/SCCM,自更新不是其主路径
Windows portable 免安装 不适用 无安装器可覆盖
macOS dmg 是 否 只用于分发与首次安装
macOS zip 否(更新载体) 成熟支持 缺少 zip 时 macOS 更新链路直接断掉
Linux AppImage 是 支持 单文件自替换,要求文件位于可写路径
Linux deb / rpm 是 通常走系统包管理器 传统包管理方式更新,不依赖 electron-updater

"版本相关"提示:electron-updater 在较新版本中扩展了对部分 Linux 包格式的处理,具体到 deb / rpm 是否具备自更新能力,请以你所用版本的类型定义与实测为准;在未验证前,把 deb / rpm 的升级交给系统包管理器是更稳妥的假设。Windows 的 MSI 同理。

macOS 是最容易踩空的一端:只打 dmg 是不够的 。electron-updater 的 macOS 链路使用平台自带的更新机制替换应用包,更新载体是 zip;同时要求应用有有效代码签名,否则会出现形如"Could not get code signature for running application"的失败(具体措辞随版本略有差异)。公证(notarization)则关系到 Gatekeeper 是否允许新版本被正常打开。签名主体变更还会带来更隐蔽的问题:Windows 侧 electron-updater 会把下载到的安装包签名发布者与 app-update.yml 中的 publisherName 比对,换证书等于把更新校验链打断,必须同步规划。

2.3 publish 配置:generic、GitHub、S3 等 provider 的字段与差异

publish 决定两件事:构建时把产物推到哪里(如果启用 --publish),以及把"去哪里找更新"写进 app-update.yml。常见 provider:

provider 核心字段 优点 边界
generic url、channel 可托管到任意 HTTP 服务,控制力最强 无内建灰度、鉴权、统计
github owner、repo、provider 与 GitHub Releases 天然集成 分发策略受平台约束,国内访问不稳定
s3 / 对象存储类 bucket、region、凭据 与 CDN 组合弹性好 缓存一致性、Range 行为需自己验证
专用更新服务 服务方定义 可能自带灰度/统计 依赖第三方可用性与协议稳定性

本文第五章的自建后端基于 generic:它是唯一一个"服务端只是一个 HTTP 静态目录也成立"的 provider,最适合用来理解链路本身。真实项目的多平台配置也可以参考开源仓库中的 electron-builder.yml 写法 7。

2.4 构建产物清单与命名规则

一次典型构建的产物目录(版本 1.0.1)大致如下:

python 复制代码
release/1.0.1/
├── win-unpacked/                       # Windows 绿色目录,本地验证用,不用于分发
│   └── resources/
│       └── app-update.yml              # 打进安装包的客户端寻址文件
├── mac/
│   └── Notes.app/Contents/Resources/
│       └── app-update.yml              # macOS 侧的客户端寻址文件
├── Notes-Setup-1.0.1-x64.exe           # Windows 安装包(NSIS)
├── Notes-Setup-1.0.1-x64.exe.blockmap  # 差分下载所需的块映射
├── latest.yml                          # Windows 更新指针
├── Notes-1.0.1-arm64.dmg               # macOS 首次安装载体
├── Notes-1.0.1-arm64.zip               # macOS 更新载体
├── Notes-1.0.1-arm64.zip.blockmap      # macOS 差分块映射
├── latest-mac.yml                      # macOS 更新指针
├── Notes-1.0.1-x64.AppImage            # Linux 更新载体
└── latest-linux.yml                    # Linux 更新指针

四个疑问一次回答:

  • blockmap 是什么:把文件切块后生成的块级哈希索引。下载新版本时,客户端同时拿到新旧两份 blockmap,计算出公共块,只下载差异块,公共块从本地旧版本文件中就地取用。
  • 为什么安装包名里必须有版本号:blockmap 与 yml 都要精确指向某一个确定的文件;同名覆盖会破坏这种对应关系。
  • win-unpacked / mac/*.app 要不要传 :不要。它们是给本地排查用的中间产物,包含完整的 app-update.yml,可用来核对寻址配置。
  • 要不要同时保留旧版本产物:要。差分下载需要服务端能提供旧版本的 blockmap,新旧任一缺失都会退化为全量下载。

三、更新元数据解剖:latest.yml 与 app-update.yml

3.1 latest.yml 逐字段拆解:版本指针、文件清单与 sha512 校验链

latest.yml 由 electron-builder 在构建末期生成,是整个更新链路里唯一的"真值来源"。下面是一份结构示例,其中哈希与体积为占位符,字段结构以你实际构建产物为准:

yaml 复制代码
version: 1.0.1
files:
  - url: Notes-Setup-1.0.1-x64.exe
    sha512: <base64 编码的 sha512>
    size: 78341122
path: Notes-Setup-1.0.1-x64.exe
sha512: <base64 编码的 sha512>
size: 78341122
releaseDate: '2026-02-18T08:31:04.221Z'
releaseName: 1.0.1          # 可选
releaseNotes: |             # 可选,取决于构建配置与变更日志来源
  - 修复检查更新无响应的问题

字段语义:

字段 写入者 消费者与作用 写错的后果
version electron-builder(取自构建版本) 客户端做 semver 比较 未递增则永远"已是最新版本";乱序会导致拒绝降级
files[].url electron-builder 下载器据此拼接下载地址(相对 app-update.yml.url) 404,或下载到错误文件
files[].sha512 electron-builder 下载完成后整体校验 校验失败,更新中止
files[].size electron-builder 进度计算与完整性辅助 进度显示异常,极端情况影响断点续传判断
path / 顶层 sha512 / size electron-builder 主文件的兼容性冗余字段(与 files[0] 对应) 与 files 不一致会引发难以定位的校验错误
releaseDate electron-builder 更新时间展示、部分策略判断 一般不影响更新成立
releaseName / releaseNotes 构建配置与变更日志来源 UI 展示 只影响体验

三条关键推论:

  1. latest.yml 是校验链的根。它本身没有签名机制,其可信度完全依赖传输通道与托管方------这正是第五章强调"元数据与包必须同源可信"的原因。
  2. sha512 是整包哈希,不是差分哈希。差分下载拼装完成后,仍然要对最终整包计算 sha512 并比对,因此差分不会削弱完整性校验。
  3. 顶层字段与 files 数组必须一致。手工编辑 yml 做回滚或改名时,只改一处会造成下载与校验取到不同文件。

一个值得亲手做一次的破坏性实验:把 latest.yml 的 sha512 任意改一个字符,触发一次更新,记录报错原文。这一步能把"校验失败"从抽象概念变成可以在日志里识别的特征。

3.2 多平台元数据的命名规律:Windows / macOS / Linux 各自请求哪个 yml

electron-updater 按平台请求不同文件,这是"同一个 URL 目录服务三端"的前提:

运行平台 默认 channel(latest) 自定义 channel(如 beta)
Windows latest.yml beta.yml
macOS latest-mac.yml beta-mac.yml
Linux latest-linux.yml beta-linux.yml

命名规律可以概括为"channel 前缀 + 平台后缀":默认 channel 的 Windows 文件不带后缀,macOS 与 Linux 分别带 -mac / -linux。请以实际构建产物的文件名为准 :若你的 electron-builder 版本产出的文件名与此表不符,直接按产物配置服务端目录与 channel 字段,不要反过来改文件名。

另外两个容易混淆的点:

  • 同一次构建只产出"本平台"的 yml。跨平台发布时,服务器目录里会同时存在三个 yml,它们的版本号应当一致,否则三端用户会拿到不一致的版本。
  • channel 不是"beta 包名里带 beta"这么简单,而是"请求另一个 yml 文件"。因此 beta 与 stable 可以共存于同一个目录,代价是产物数量翻倍。

3.3 app-update.yml 逐字段拆解:客户端到底去哪里找更新

app-update.yml 由 electron-builder 在构建时生成,并写入安装包的资源目录:

  • Windows:{安装目录}/resources/app-update.yml(per-user 安装通常在 %LOCALAPPDATA%\Programs\<productName>\resources\);
  • macOS:<App>.app/Contents/Resources/app-update.yml。

它不在 asar 内,因此可以被直接查看。典型内容:

yaml 复制代码
provider: generic
url: 'https://update.example.com/notes'
channel: latest
updaterCacheDirName: notes-updater
publisherName:
  - 'Example Technology Co., Ltd.'
useMultipleRangeRequest: true
字段 作用 关键提示
provider 选择更新协议实现 与 publish.provider 保持一致
url 更新源基地址,客户端据此拼接 yml 与安装包地址 路径层级必须与服务端目录完全一致
channel 决定请求 latest.yml 还是 beta.yml 与 publish.channel 一致
updaterCacheDirName 客户端更新缓存目录名 改名会丢弃旧缓存,可用于排障,不宜频繁变动
publisherName Windows 安装包签名发布者白名单 换代码签名证书时必须同步更新
useMultipleRangeRequest 控制差分下载是否使用多段 Range 请求 中间层对多段 Range 支持不佳时可关闭(字段与默认值版本相关)

为什么必须拆成两个文件?因为它们的变更频率和信任边界不同 :app-update.yml 随安装包走,属于客户端配置,改一次就要重分发;latest.yml 随每次发版走,属于服务端状态,可以随时替换。把"去哪里找"和"现在是什么版本"混在一个文件里,会导致每次发版都要动客户端,链路立刻退化成半自动。

3.4 实践推论:哪些问题可以只换文件,哪些必须重新打包

故障 能否只改服务器上的 latest.yml 能否只改 app-update.yml 必须重新打包
更新地址写错(打错域名/路径) 否 是(重打包或运行时 setFeedURL) 推荐
忘记上传安装包/哈希写错 是 否 否
版本号未递增 是(但这等于伪造版本,不推荐) 否 是,重新出包
channel 配置错 否 是 推荐
代码签名主体变更 否 是(更新 publisherName) 是
macOS 缺少 zip 产物 否 否 是
文件名模板变更 是(需同步所有文件名) 否 是,避免历史混乱

补充一个运行时手段:electron-updater 允许在主进程用 setFeedURL 覆盖寻址配置,因此"更新地址写错"理论上不必为老用户重新发包------前提是首次更新仍需一次成功。这是重要的兜底能力,但不应成为常规手段:把更新源逻辑硬编码进客户端,会让多环境(测试/预发/生产)管理迅速失控。反过来,直接篡改已安装应用里的 app-update.yml 属于应避免的做法:Windows 上或许可行,macOS 上会破坏签名完整性,而且没有任何版本管理可言。

四、electron-updater 客户端接线

4.1 最小闭环:checkForUpdates → downloadUpdate → quitAndInstall 与事件模型

客户端的职责是把三个动作和一组事件翻译成产品语义。下面是主进程的更新服务模块骨架:

ts 复制代码
// src/main/update-service.ts
import { BrowserWindow, app } from 'electron'
import { autoUpdater } from 'electron-updater'
import type { ProgressInfo, UpdateInfo } from 'electron-updater'

export type UpdateStatus =
  | { state: 'idle' }
  | { state: 'checking' }
  | { state: 'available'; info: UpdateInfo }
  | { state: 'not-available'; version: string }
  | { state: 'downloading'; percent: number; transferred: number; total: number; bytesPerSecond: number }
  | { state: 'downloaded'; version: string }
  | { state: 'error'; message: string }

let status: UpdateStatus = { state: 'idle' }

function broadcast(next: UpdateStatus): void {
  status = next
  for (const win of BrowserWindow.getAllWindows()) {
    if (!win.isDestroyed()) win.webContents.send('updater:event', next)
  }
}

export function initUpdater(): void {
  if (!app.isPackaged) {
    // 开发模式默认跳过更新检查;强制使用 dev-app-update.yml 才会走真实链路
    autoUpdater.forceDevUpdateConfig = true
  }

  autoUpdater.autoDownload = false        // 由用户显式确认后再下载
  autoUpdater.autoInstallOnAppQuit = true // 退出应用时自动完成安装
  // autoUpdater.logger = console         // 生产环境建议接入 electron-log 并落盘
  // 需要鉴权或灰度标识时注入请求头(字段以所用版本类型定义为准)
  // autoUpdater.requestHeaders = { Authorization: `Bearer ${token}` }

  autoUpdater.on('checking-for-update', () => broadcast({ state: 'checking' }))
  autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ state: 'available', info }))
  autoUpdater.on('update-not-available', (info: UpdateInfo) =>
    broadcast({ state: 'not-available', version: info.version }),
  )
  autoUpdater.on('download-progress', (p: ProgressInfo) =>
    broadcast({
      state: 'downloading',
      percent: p.percent,
      transferred: p.transferred,
      total: p.total,
      bytesPerSecond: p.bytesPerSecond,
    }),
  )
  autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ state: 'downloaded', version: info.version }))
  autoUpdater.on('error', (err: unknown) =>
    broadcast({ state: 'error', message: err instanceof Error ? err.message : String(err) }),
  )
}

export async function check(): Promise<UpdateStatus> {
  try {
    await autoUpdater.checkForUpdates()
  } catch (err) {
    broadcast({ state: 'error', message: err instanceof Error ? err.message : String(err) })
  }
  return status
}

export async function download(): Promise<void> {
  await autoUpdater.downloadUpdate()
}

export function install(): void {
  // isSilent:NSIS 静默安装;isForceRunAfter:安装完成后拉起新版本
  autoUpdater.quitAndInstall(true, true)
}

export function getStatus(): UpdateStatus {
  return status
}

状态机与事件的对应关系如下:

scss 复制代码
idle ──check()──▶ checking ──update-available──▶ available ──download()──▶ downloading
                    │                                                            │
                    └──update-not-available──▶ not-available                     └──update-downloaded──▶ downloaded
                                                                                                             │
                                                                                                          install()
                                                                                                             ▼
                                                                                                         installing
任意状态 ──error──▶ error

事件集合属于"版本相关"信息:上述六个事件是长期稳定的核心集合,个别版本还提供取消下载、AppImage 文件名修正等附加事件,应以 node_modules/electron-updater 中的类型定义为准,不要凭记忆扩写 UI 逻辑。

4.2 关键配置项取舍:什么时候必须关掉 autoDownload

配置 默认倾向 建议 理由
autoDownload 开启 关闭 让用户决定何时消耗流量,避免移动网络/计费网络下的隐性下载
autoInstallOnAppQuit 开启 保持开启 已下载的更新在退出时装完,用户感知最好
allowPrerelease 关闭 按 channel 策略 与 channel 配合做 beta 分流,不要全局开启
allowDowngrade 关闭 保持关闭 开启会允许版本回退,破坏 semver 单调性与缓存语义
channel latest 与服务端文件名一致 写错等于请求一个不存在的 yml
forceDevUpdateConfig 关闭 仅开发调试开启 让开发模式也走真实更新链路
updateConfigPath 资源目录默认路径 仅排障使用 可指向自定义 app-update.yml,便于本地演练

autoDownload 何时必须关闭:当你的应用体积较大(数百 MB 级)、用户网络环境复杂、或需要在下载前展示更新内容与合规提示时。配合"检查更新 → 展示更新说明 → 用户确认 → 下载并显示进度 → 提示重启"这条交互主线,用户体验明显好于静默下载。

4.3 主进程 ↔ 渲染层:进度条与"检查更新"按钮的 IPC 接线

渲染层只应拿到最小能力集合。preload 用白名单暴露三个动作与一个事件订阅:

ts 复制代码
// src/preload/updater.ts
import { contextBridge, ipcRenderer } from 'electron'

export const updaterBridge = {
  check: () => ipcRenderer.invoke('updater:check'),
  download: () => ipcRenderer.invoke('updater:download'),
  install: () => ipcRenderer.invoke('updater:install'),
  getStatus: () => ipcRenderer.invoke('updater:status'),
  onEvent: (handler: (payload: unknown) => void) => {
    const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => handler(payload)
    ipcRenderer.on('updater:event', listener)
    return () => ipcRenderer.removeListener('updater:event', listener)
  },
}

contextBridge.exposeInMainWorld('updater', updaterBridge)
ts 复制代码
// src/main/ipc.ts
import { ipcMain } from 'electron'
import { check, download, install, getStatus } from './update-service'

export function registerUpdaterIpc(): void {
  ipcMain.handle('updater:check', () => check())
  ipcMain.handle('updater:download', () => download())
  ipcMain.handle('updater:install', () => install())
  ipcMain.handle('updater:status', () => getStatus())
}

渲染层只需要把状态映射成 UI:

ts 复制代码
window.updater.onEvent((payload: any) => {
  switch (payload.state) {
    case 'checking':      setBanner('正在检查更新...'); break
    case 'available':     setBanner(`发现新版本 ${payload.info.version}`); enable('download'); break
    case 'not-available': setBanner(`已是最新版本(${payload.version})`); break
    case 'downloading':   setProgress(payload.percent); break
    case 'downloaded':    setBanner('更新已就绪,重启后生效'); enable('install'); break
    case 'error':         setBanner(`更新失败:${payload.message}`); break
  }
})

三条安全与健壮性要求:

  1. 只暴露白名单方法 ,不要把 ipcRenderer 整体塞给渲染层;更新动作属于高权限动作。
  2. 主进程推送的 payload 只作展示数据使用,渲染层不要据其拼接路径或发起网络请求。
  3. 订阅必须可退订,窗口重建或热更新后否则会累积监听器,导致同一进度被重复渲染。

Electron 官方近期仍在持续修复 IPC 与 contextIsolation 相关的崩溃与发送方校验问题(如渲染进程崩溃修复回移 #53538、42-x-y 分支上的发送 frame 校验 #53727),这从侧面说明"信任渲染层传来的任何东西"依然是不成立的假设。

4.4 平台差异陷阱

Windows(NSIS) :支持差分下载,条件是新旧版本的 blockmap 都可获取、服务端支持字节范围请求;perMachine: true 时静默升级需要管理员权限,交互不佳时会出现"点了重启却弹出 UAC"。签名主体必须与 app-update.yml 的 publisherName 匹配。

macOS :更新载体是 zip,不是 dmg;缺少 zip target 会导致 macOS 完全不更新。应用必须有效签名,推荐完成公证。quitAndInstall 的实际表现与 macOS 自带更新机制相关,重启时机由系统控制,UI 上应提示"即将重启"而不是承诺秒级重启。

Linux(AppImage):更新是"下载新 AppImage 并替换原文件",因此要求 AppImage 位于可写路径;从只读介质运行、或把 AppImage 放在系统保护目录时更新会失败。个别版本会在替换时规范化文件名并发出附加事件,UI 不必为此特殊处理。

五、自建更新后端:从静态托管到动态分发

5.1 方案选型矩阵

方案 成本 控制力 灰度能力 运维复杂度 适用阶段
静态托管(Nginx / Caddy) 低 中 需自行实现 低 早期、内网、私有化交付
对象存储 + CDN 按量 中 需自行实现 中 公网大规模分发
GitHub Releases 免费 低 弱 低 开源项目
自建 API 网关 高 高 可实现 高 企业级、需审计与配额

generic provider 的本质是"客户端按约定路径 GET 几个文件",因此前三种方案都可以用它承载。研究素材中出现了基于 electron-autoupdate-server 搭建更新后端的实践文章 4,但本次采集只拿到文章标题与片段,无法核实其仓库地址、维护状态与真实能力边界,因此本文不对其是否具备灰度、统计或回滚能力作任何断言;若要引入第三方更新服务组件,请自行核对仓库与版本。本文给出的是可独立落地的最小实现。

5.2 最小静态后端:目录布局与缓存策略

服务端目录布局与构建产物保持同构,避免任何重命名:

python 复制代码
/var/www/update/notes/
├── latest.yml
├── latest-mac.yml
├── latest-linux.yml
├── Notes-Setup-1.0.1-x64.exe
├── Notes-Setup-1.0.1-x64.exe.blockmap
├── Notes-1.0.1-arm64.dmg
├── Notes-1.0.1-arm64.zip
├── Notes-1.0.1-arm64.zip.blockmap
└── Notes-1.0.1-x64.AppImage

Nginx 配置要点是"yml 不缓存、包体长缓存、Range 不要被关掉":

nginx 复制代码
server {
  listen 443 ssl http2;
  server_name update.example.com;
  root /var/www/update;

  # 指针文件必须实时生效
  location ~* \.yml$ {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    add_header Access-Control-Allow-Origin *;
  }

  # 带版本号的包体与 blockmap 可以长期缓存
  location ~* \.(exe|msi|zip|dmg|AppImage|blockmap)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin *;
  }

  # 静态文件默认支持单段 Range;多段 Range 行为必须实测
  # curl -I  -H "Range: bytes=0-99"   https://update.example.com/notes/Notes-Setup-1.0.1-x64.exe
  # curl -I  -H "Range: bytes=0-99,200-299" https://update.example.com/notes/Notes-Setup-1.0.1-x64.exe
}

Caddy 的等价写法:

javascript 复制代码
update.example.com {
  root * /var/www/update
  header *.yml Cache-Control "no-cache, no-store, must-revalidate"
  header *.blockmap Cache-Control "public, max-age=31536000, immutable"
  file_server
}

三条上传纪律:

  1. 上传顺序:先包体与 blockmap,最后覆盖 yml。yml 是切换开关,早一步上传就会让客户端去下载还不存在的文件。
  2. 原子覆盖 :把 yml 先上传为 latest.yml.tmp 再原子改名,避免客户端读到半个文件。
  3. 旧产物保留至少一个版本周期:否则刚升级到 1.0.1 的用户在下载 1.0.2 时拿不到 1.0.1 的 blockmap,差分必然退化。

5.3 差分下载的工程条件:blockmap 原理与 Range 兼容性

差分下载要同时满足四个条件:

  1. 新版本产物随包发布了 .blockmap;
  2. 服务端能提供旧版本的 .blockmap(或客户端缓存中仍有);
  3. 服务端支持 HTTP 字节范围请求(响应含 Accept-Ranges: bytes,且对 Range 请求返回 206 Partial Content);
  4. 中间层(CDN、反向代理、WAF)没有禁用或改写 Range 请求。

差分的实现方式是"多段 Range 拼装":客户端根据新旧 blockmap 算出公共块,用一个多段 Range 请求把缺失的若干片段一次性取回,再与本地旧文件的公共块拼成完整新包,最后做整包 sha512 校验。因此多段 Range 的支持质量直接决定差分是否生效 。部分 CDN/网关对 multipart/byteranges 支持不完整,此时应关闭 useMultipleRangeRequest(该字段会写入 app-update.yml,字段与默认值版本相关),让客户端退化为单段请求或全量下载,而不是间歇性失败。

务必用日志验证差分是否真的生效:客户端日志中出现形如"Cannot download differentially, fallback to full download"的记录(措辞随版本略有差异),就说明上述条件有缺失。不要假设差分默认生效。

5.4 动态后端:灰度、鉴权、审计的实现路径

generic provider 没有内建灰度协议,但它有一个关键性质:请求路径固定,服务端可以按请求特征返回不同内容。由此可以搭出三类动态能力:

  • 鉴权 :在客户端用 requestHeaders(或等价机制,字段版本相关)注入令牌,服务端校验后放行;也可以把令牌编进 setFeedURL 的 query 参数。注意令牌会被写进日志,需设置有效期与轮换。
  • 灰度 :服务端按用户 ID 哈希、租户号或请求头中的灰度标识,返回不同版本的 latest.yml。客户端无需改造,但服务端必须维护分桶规则并保证同一用户的分桶稳定。
  • 审计与统计:在 yml 请求与安装包下载两个环节记录用户、版本、时间与结果码,形成"谁升到了哪一版"的可追溯记录。

实现形态通常是"静态存储 + 一层薄网关":网关只负责鉴权、灰度选路与日志,包体仍由静态服务/CDN 直出,避免大文件经过应用服务器。把整条链路做成自建 API 服务固然可控,但要自行承担 Range 转发、断点续传与大文件并发的复杂度,收益通常不成正比。

5.5 安全底线:HTTPS、校验链、元数据与包同源可信

自动更新是最高权限的代码分发通道,四条底线不可妥协:

  1. 全程 HTTPS ,并且不要在客户端关闭证书校验。latest.yml 被篡改即可指向任意安装包。
  2. sha512 校验必须保留。它防的是"包被替换或损坏",与 HTTPS 防的"通道被窃听/篡改"互补。
  3. 元数据与包同源可信。如果 yml 在 A 域、包在 B 域,攻击面与运维面同时翻倍;确需分离时,两处都必须同等可信。
  4. 代码签名不轻易变更 。Windows 的 publisherName 校验、macOS 的签名与公证,都是更新信任链的一部分;换证书要有明确的迁移方案。

六、发布流水线与版本策略

6.1 CI 矩阵构建:平台 runner 分工与签名凭据注入

跨平台产物必须由对应平台构建(尤其是 macOS 的签名与公证),因此用矩阵拆分 runner:

yaml 复制代码
name: release
on:
  push:
    tags: ['v*']

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: windows-latest
            args: --win
          - os: macos-latest
            args: --mac
          - os: ubuntu-latest
            args: --linux
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build:renderer
      - name: 构建并签名
        shell: bash
        env:
          GH_TOKEN: ${{ secrets.GH_TOKEN }}
          # 证书与公证凭据:环境变量命名随 electron-builder 大版本变化,以下为常见组合
          WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
          WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
          CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
          CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
        run: npx electron-builder ${{ matrix.args }} --config electron-builder.yml --publish never
      - uses: actions/upload-artifact@v4
        with:
          name: dist-${{ matrix.os }}
          path: |
            release/**/*.exe
            release/**/*.blockmap
            release/**/*.zip
            release/**/*.dmg
            release/**/*.AppImage
            release/**/*.yml

  publish:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          path: dist
      # 此处执行"先包体、后 yml"的上传脚本,见 6.3

签名与公证的 secret 命名属于版本相关且必须核对的项 :electron-builder 的签名字段在不同大版本中从 win.certificateFile 等顶层字段迁移到 win.signtoolOptions,macOS 公证也从外部 afterSign 钩子演进到内置 notarize 配置,环境变量组合(Apple ID + 应用专用密码 + Team ID,或 App Store Connect API Key 一套)应以你安装版本的文档为准。本文 workflow 中的命名是常见形态,不是对某一版本的权威清单。

6.2 版本号与 channel 策略

场景 package.json version publish.channel 产出的 yml 客户端请求
正式发布 1.0.1 latest latest.yml 等 默认
公测 1.1.0-beta.2 beta beta.yml 等 channel = 'beta'
内测 1.1.0-alpha.5 alpha alpha.yml 等 channel = 'alpha' + allowPrerelease

三条硬约束:

  1. semver 单调递增。electron-updater 默认拒绝降级,版本回退会被视为"没有更新"。
  2. 预发布版本号用 -beta.N 形式 ,不要用 1.0.1b2 这类非法 semver,否则比较结果不可预期。
  3. 同一个 yml 指针只指向一个确定的包。beta 与 stable 分目录、分文件名,不要让两个 channel 共享一份 yml。

6.3 发布原子性与回滚:为什么必须"先包后 yml"

发布的一致性完全由上传顺序决定:

bash 复制代码
# 1) 先上传所有不可变产物:安装包 + blockmap
rsync -av --partial \
  release/1.0.1/*.exe release/1.0.1/*.blockmap \
  release/1.0.1/*.zip release/1.0.1/*.dmg \
  release/1.0.1/*.AppImage \
  deploy@update.example.com:/var/www/update/notes/

# 2) 最后覆盖指针文件,这一步才是"切换版本"
rsync -av --partial \
  release/1.0.1/latest.yml release/1.0.1/latest-mac.yml release/1.0.1/latest-linux.yml \
  deploy@update.example.com:/var/www/update/notes/

回滚的正确语义需要特别强调:electron-updater 默认拒绝降级,把 latest.yml 的版本改回去并不能让已升级用户回退 。可行做法只有一种------发布一个更高版本号的修复版(例如 1.0.2),内容可以是 1.0.0 的代码。虽然 allowDowngrade 提供了技术上的降级开关,但它会破坏版本单调性、缓存语义与用户预期,只应在封闭内网、可控设备的应急场景下临时启用。

七、排错手册与上线验收清单

7.1 高频错误对照表

下表中的报错文本为常见形态,具体措辞随 electron-updater 版本略有差异,应以日志实际输出为准:

现象 / 典型报错 根因 修复动作
请求 latest.yml 返回 404 app-update.yml.url 与服务端目录层级不一致;或 yml 未上传 用 curl 直接请求完整 URL 核对路径;统一目录布局
一直提示"已是最新版本" version 未递增;或 yml 被 CDN/浏览器缓存 递增 semver;yml 加 Cache-Control: no-cache
sha512 校验失败 上传被改写/截断;CDN 返回旧包;yml 与包不配套 重新上传并核对哈希;包体与 yml 同批次发布
日志提示差分失败、退化全量下载 服务端/CDN 不支持单段或多段 Range;blockmap 缺失 验证 Accept-Ranges 与 206 响应;保留新旧 blockmap
Windows 更新后仍校验失败 代码签名主体与 publisherName 不一致 同步更新签名证书与 publisherName,或沿用原证书
macOS 完全不检查/不更新 只有 dmg 没有 zip;或应用未有效签名 增加 zip target 并上传;完成签名与公证
macOS 提示无法获取代码签名 应用未签名、签名损坏或被重新打包 重新签名公证,禁止对 .app 二次打包破坏签名
Linux 更新失败 AppImage 位于只读路径或被移动 安装到用户可写目录后再更新
开发模式下永远不更新 非打包环境默认跳过更新检查 使用 dev-app-update.yml 并开启 forceDevUpdateConfig
更新后再次提示更新同一版本 yml 版本与包内版本不一致;旧缓存未失效 校对 package.json 与 latest.yml 的 version;清理 updaterCacheDirName 缓存
静默安装弹出 UAC 或失败 perMachine: true 需要提权 改为 per-user 安装,或在 UI 中明确提示需要管理员权限

7.2 测试环境搭建:本地静态服务 + 两次发布演练

开发模式下让更新链路跑通,只需一个本地静态目录与一份开发配置:

yaml 复制代码
# 项目根目录 dev-app-update.yml
provider: generic
url: 'http://127.0.0.1:8080/notes'
channel: latest
updaterCacheDirName: notes-updater-dev

配合主进程中的 forceDevUpdateConfig = true(见 4.1),即可让开发构建走真实链路。注意开发模式下 quitAndInstall 并不会真的覆盖安装一个"已安装"的应用,因此最终验收仍必须用真实安装包完成。推荐的完整演练流程:

  1. 用 1.0.0 打包并安装到一台干净测试机;
  2. 把 1.0.0 的产物上传到本地静态服务(含 blockmap 与 latest.yml);
  3. 把 package.json 升到 1.0.1,重新打包并上传,最后 覆盖 latest.yml;
  4. 在客户端点击"检查更新",同时打开服务端访问日志;
  5. 用日志证据核对请求序列:latest.yml → *.blockmap → 安装包(差分生效时安装包请求的字节范围是稀疏的);
  6. 重启验证新版本号,并重复一次以确认 1.0.2 可继续升级。

服务端访问日志是这条链路最可靠的验收证据:它能直接回答"客户端到底请求了什么、返回了什么、是否走了差分"。

7.3 上线前检查清单

打包期 发布期
appId / productName 与上一版一致 安装包与 blockmap 全部上传完成
package.json 的 version 已递增且为合法 semver 旧版本产物与 blockmap 仍在服务器上
publish.url 与生产域名、路径一致 三个平台的 yml 版本号一致
Windows 含 NSIS 产物与 .blockmap yml 最后上传,且已原子覆盖
macOS 同时含 dmg 与 zip,zip 含 .blockmap yml 响应头为 no-cache
Linux AppImage 已产出 服务端 Accept-Ranges: bytes 可用,Range 请求返回 206
签名与公证通过,publisherName 与证书主体一致 CDN 已刷新或确认 yml 未被缓存
app-update.yml 内容(provider / url / channel)已核对 用 curl 验证 yml 与包体 URL 均可访问
更新代码已走完一次本地双版本演练 至少一台真实安装的测试机完成 1.0.0 → 1.0.1 升级

八、结语:把更新链路当成一条产品特性来维护

自动更新不是一次性工程。它的故障半径覆盖全体已安装用户,而它的故障窗口又天然滞后------今天写错的一个字段,可能在下个月发版时才爆炸。要让它长期可用,需要给五层结构各安排明确的维护责任:

层次 维护内容 巡检频率
打包配置 appId / productName 稳定性、target 完整性、签名配置 每次大版本或证书变更
更新元数据 yml 字段结构、命名规律、版本号一致性 每次发版
客户端代码 状态机、IPC 白名单、事件集合与所用版本匹配 每次升级 electron-updater
更新后端 HTTPS、Range 支持、缓存头、产物留存 每月 + 每次架构变更
发布流水线 上传顺序、secret 轮换、失败告警 每季度演练一次回滚/热修

最低限度的维护节奏只有三条:版本号只增不减 、产物与 blockmap 至少保留一个版本周期 、更新失败率纳入监控。做到这三点,绝大多数"用户收不到更新"的事故都可以被提前发现或快速定位。至于灰度、审计、多 channel 这些进阶能力,它们都建立在这条最小闭环之上------先把 1.0.0 到 1.0.1 的链路真正跑通并留下日志证据,再谈更复杂的分发策略。

参考资料

1 Electron 自动更新详解---包教会版,CSDN,blog.csdn.net/weixin_7132...

2 16 Electron 应用自动更新方案:electron-updater 完整指南,CSDN,blog.csdn.net/techLaoLi/a...

3 Electron 应用打包配置实战:latest.yml 与 app-update.yml 详解,CSDN,blog.csdn.net/moss5/artic...

4 基于 electron-autoupdate-server 的 Electron 应用自动更新后端搭建与实战,CSDN,blog.csdn.net/weixin_4251...

5 【记录 63】electron 打包 vue 项目之踩坑,CSDN,blog.csdn.net/weixin_4552...

6 项目打包部署说明.md · egetech_frontend/device-detection-system,Gitee,gitee.com/egetech_fro...

7 electron-builder.yml · magicxie/moyin-creator,Gitee,gitee.com/magicxie/mo...

8 Electron 打包后窗口 30 秒不出现:一个 ABI 不匹配的血案,掘金,juejin.cn/post/768757...

9 Electron 踩坑及优化记录,掘金,juejin.cn/post/750856...

10 Ama_tor/electron-hello-world:创建 Hello World 桌面应用并打包为 Windows 可执行文件,Gitee,gitee.com/ama_tor/ele...

11 Release electron v45.0.0-alpha.11 · electron/electron,GitHub,github.com/electron/el...

12 endoflife.date/products/electron.md(Electron 生命周期数据),GitHub,github.com/endoflife-d...

13 fix: renderer crash after IPC to a window.open() child with its own contextIsolation · PR #53538,electron/electron,GitHub,github.com/electron/el...

14 fix: check the sending frame for internal guest, window and reply IPCs (42-x-y) · PR #53727,electron/electron,GitHub,github.com/electron/el...

相关推荐
仿生狮子1 小时前
实现近乎免费之后,设计工程师还剩什么
前端·后端·设计
小凯在掘金1 小时前
为什么要有访问器? 你不知道的对象属性
前端·javascript
OpenTiny社区1 小时前
HC 2026 回顾|OpenTiny NEXT 解锁 Web 应用智能化新范式
前端·开源·github
时光少年1 小时前
Android HWC退化与防治方法
前端
涛涛ing1 小时前
Remix 3 RC 发布:一个不再依赖 React 的全栈框架,正在重新定义“元框架”的边界
前端
闪耀之光M781 小时前
Vite配置文件解析
前端
PC2005_cloud1 小时前
RabbitMQ 在 Spring Boot 中的完整使用
前端·后端
掘金挖土1 小时前
前端手摸手跑路之 AI 应用开发(八)
前端·后端
风骏时光牛马1 小时前
产品功能可用性测试报告
前端