本文面向已经能用 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 六个环节:检查 → 比版本 → 下载(差分)→ 校验 → 安装 → 重启
一次成功的自动更新,端到端包含六个环节:
- 检查 :客户端向"更新源"发起 HTTP 请求,拉取一个很小的 YAML 元数据文件(本文统称
latest.yml)。 - 比版本 :electron-updater 用语义化版本比较元数据里的
version与当前应用version,决定是否存在更新。 - 下载:若存在更新,按元数据里的文件清单下载安装包;在条件满足时走差分下载,只取新旧包之间差异的数据块。
- 校验 :下载完成后对完整文件计算 sha512,与元数据中的
sha512比对;Windows 上还会额外校验代码签名发布者信息。 - 安装 :
quitAndInstall退出应用并调起安装器(或平台等价机制)覆盖安装。 - 重启:安装完成后拉起新版本,更新结束。
这六步里,第 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 展示 | 只影响体验 |
三条关键推论:
latest.yml是校验链的根。它本身没有签名机制,其可信度完全依赖传输通道与托管方------这正是第五章强调"元数据与包必须同源可信"的原因。- sha512 是整包哈希,不是差分哈希。差分下载拼装完成后,仍然要对最终整包计算 sha512 并比对,因此差分不会削弱完整性校验。
- 顶层字段与
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
}
})
三条安全与健壮性要求:
- 只暴露白名单方法 ,不要把
ipcRenderer整体塞给渲染层;更新动作属于高权限动作。 - 主进程推送的 payload 只作展示数据使用,渲染层不要据其拼接路径或发起网络请求。
- 订阅必须可退订,窗口重建或热更新后否则会累积监听器,导致同一进度被重复渲染。
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
}
三条上传纪律:
- 上传顺序:先包体与 blockmap,最后覆盖 yml。yml 是切换开关,早一步上传就会让客户端去下载还不存在的文件。
- 原子覆盖 :把 yml 先上传为
latest.yml.tmp再原子改名,避免客户端读到半个文件。 - 旧产物保留至少一个版本周期:否则刚升级到 1.0.1 的用户在下载 1.0.2 时拿不到 1.0.1 的 blockmap,差分必然退化。
5.3 差分下载的工程条件:blockmap 原理与 Range 兼容性
差分下载要同时满足四个条件:
- 新版本产物随包发布了
.blockmap; - 服务端能提供旧版本的
.blockmap(或客户端缓存中仍有); - 服务端支持 HTTP 字节范围请求(响应含
Accept-Ranges: bytes,且对Range请求返回206 Partial Content); - 中间层(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、校验链、元数据与包同源可信
自动更新是最高权限的代码分发通道,四条底线不可妥协:
- 全程 HTTPS ,并且不要在客户端关闭证书校验。
latest.yml被篡改即可指向任意安装包。 - sha512 校验必须保留。它防的是"包被替换或损坏",与 HTTPS 防的"通道被窃听/篡改"互补。
- 元数据与包同源可信。如果 yml 在 A 域、包在 B 域,攻击面与运维面同时翻倍;确需分离时,两处都必须同等可信。
- 代码签名不轻易变更 。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 |
三条硬约束:
- semver 单调递增。electron-updater 默认拒绝降级,版本回退会被视为"没有更新"。
- 预发布版本号用
-beta.N形式 ,不要用1.0.1b2这类非法 semver,否则比较结果不可预期。 - 同一个 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.0.0打包并安装到一台干净测试机; - 把
1.0.0的产物上传到本地静态服务(含 blockmap 与latest.yml); - 把
package.json升到1.0.1,重新打包并上传,最后 覆盖latest.yml; - 在客户端点击"检查更新",同时打开服务端访问日志;
- 用日志证据核对请求序列:
latest.yml→*.blockmap→ 安装包(差分生效时安装包请求的字节范围是稀疏的); - 重启验证新版本号,并重复一次以确认
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...