最近在做 MacOS 端 electron 应用自动更新,顺手把关键设计沉淀下来:下载只是入口,真正要处理的是版本策略、私有分发、数据保障和发布开关。
最近在做一个 MacOS 端 electron 应用的自动更新功能。做完之后,觉得这块值得单独沉淀一下。
一开始它看起来像一个很小的功能:应用启动后检查一下有没有新版本,有就弹窗,用户点更新,进度条跑完,重启。
真拆下去就不是这回事了。
你很快会撞上几个更具体的问题:新版从哪里来?谁有资格下载?下载到的包怎么证明没被篡改?可选更新和强制更新怎么区分?用户正在编辑东西时,强制更新能不能直接重启?CI 跑完包就算发布了吗?如果新版有问题,怎么让还没更新的人先别收到?
所以这篇不写“怎么调一个 API 实现自动更新”。API 反而是最靠后的部分。一个可靠的 MacOS 端 electron 应用自动更新系统,核心要处理三条边界:
- 版本策略边界:客户端要不要更新,是产品策略;下载哪个文件、怎么校验,是更新引擎的元数据。
- 信任边界:客户端不能拿对象存储密钥,服务端也不应该替所有用户转发大文件。
- 用户数据边界:强制更新可以阻断旧版本,但不能拿用户正在编辑的数据当代价。
后文的项目名一律写成“MacOS 端 electron 应用”。所有业务名、仓库、环境、账号、IP 和内部服务名都不出现,只保留架构本身。
一、先别急着写下载按钮
MacOS 端 electron 应用以前靠用户去下载页手动下载 DMG。这个模式在早期能凑合,但应用进入稳定使用后,问题会越来越明显。
用户不会主动更新。很多人不是抗拒更新,只是没有动机去找新版安装包。于是线上永远有一批旧客户端,协议变了、接口变了、漏洞修了,它们还在那里。
更麻烦的是强制升级。服务端如果不再兼容某个旧版本,只在下载页挂一个新版没有用。你需要在应用里告诉用户:这个版本不能继续用了,请先更新。
这时候自动更新就不再是“体验优化”,而是客户端和服务端之间的一份版本契约。
最小范围可以这样定:
| 维度 | 本期做法 |
|---|---|
| 平台 | 先做 macOS,优先 Apple Silicon,Intel 架构预留 |
| 更新粒度 | 先做整包替换,保留 blockmap 增量能力 |
| 更新类型 | 可选更新 + 强制更新 |
| 检测时机 | 启动后延迟检查 + 周期检查 + 手动检查入口 |
| 下载安装 | 用户确认后下载,显示进度,完成后重启安装 |
| 失败处理 | 不破坏现有版本,可重试,可跳转手动下载 |
| 分发 | 私有对象存储 + 服务端鉴权代理 |
这里最容易低估的是最后一行。公开下载很简单,私有分发会把整个方案往后推一层。
二、为什么选 electron-updater
Electron 自带 autoUpdater。官方文档也写得很清楚:macOS 上它基于 Squirrel.Mac,应用必须签名,更新请求还会受 macOS App Transport Security 约束。
但内置 autoUpdater 的控制面比较窄。它适合简单更新,不太适合下面这些要求:
- 下载过程要显示百分比。
- 更新发现后不要自动下载,要等用户点按钮。
- feed 请求要带业务登录态。
- 后续希望保留 blockmap 增量更新。
- 更新 UI 完全由应用自己控制。
electron-updater 更合适。它的官方文档明确列出 download-progress 事件,也支持 autoDownload 控制。需要自定义请求头时,还可以直接实例化 updater,用 generic provider 加 requestHeaders。
这不是说 electron-updater 省事。它省掉的是最危险的那部分:macOS 上的替换、安装、重启。代价也很明确:你要自己补齐发布元数据、CI 产物、私有 feed 和真包 E2E。
还有一个细节很关键:electron-builder 文档提到,macOS 自动更新需要 zip target,否则 latest-mac.yml 无法生成。很多人以为 macOS 只要 DMG 就够了,自动更新场景不是这样。DMG 更像首次安装入口,自动更新链路需要的是 zip 和元数据。
三、把自动更新拆成四块
公开文章里不需要把所有类名和文件路径摊出来。把架构压扁后,其实就是四块:
终端用户
│
▼
MacOS 端 electron 应用
├─ Renderer:弹窗、强制阻断、进度、错误兜底
└─ Main:版本检测、策略判断、IPC、electron-updater 编排
│
▼
应用服务端
├─ version manifest:告诉客户端 latest / minSupported / feedUrl
└─ feed proxy:鉴权后把 yml / zip / blockmap 重定向到预签名 URL
│
├─ 远程配置中心:控制 latest / minSupported
▼
私有对象存储:保存 latest-mac.yml / zip / blockmap
客户端做三件事:
- 拉版本清单,算出
none、optional、forced。 - 用户确认后,把 feed 交给
electron-updater下载。 - 下载完成后,在合适时机触发
quitAndInstall()。
服务端也做三件事:
- 校验登录态。
- 从远程配置中心读当前发布配置。
- 给具体更新文件签发短期下载地址。
这个拆法的好处是边界清楚。客户端不理解对象存储,服务端不理解安装细节,远程配置中心只负责“现在该让谁收到哪个版本”。
四、manifest 和 latest-mac.yml 不要抢活
自动更新里有两个容易混的文件。
第一个是业务自己的 version manifest,大概长这样:
{
"latest": "1.2.3",
"minSupported": "1.1.0",
"releaseNotes": "本次更新说明",
"channels": {
"darwin-arm64": {
"feedUrl": "https://<app-server>/api/v1/update/feed/<app>/darwin-arm64/"
}
}
}
它回答的是产品问题:
- 当前最新版本是什么?
- 低于哪个版本必须强制更新?
- 用户该看到什么更新说明?
- 当前架构应该从哪个 feed 下载?
第二个是 latest-mac.yml,由构建流程生成,给 electron-updater 读。它回答的是下载和校验问题:
- 要下载哪个 zip?
- 文件大小是多少?
- sha512 是多少?
- blockmap 在哪里?
这两个文件最好别互相抢活。manifest 不要带 sha512 和 size,latest-mac.yml 也不要承载强制更新策略。
原因很简单:双真相源一定会漂。manifest 说最新是 1.2.3,latest-mac.yml 指向 1.2.2;manifest 里写了一个 hash,CI 产物里又是另一个 hash。这种问题 dev 环境和单测很难暴露,最后会在真包升级时炸出来。
我更喜欢的边界是:
| 文件 | 管什么 | 不管什么 |
|---|---|---|
| version manifest | latest、minSupported、releaseNotes、feedUrl |
文件 hash、size、具体包名 |
latest-mac.yml |
zip、blockmap、sha512、size | 强制更新阈值、更新弹窗策略 |
这样客户端先用 manifest 决定“要不要更新”,再把下载细节交给 electron-updater。
五、私有分发不要把密钥塞进客户端
如果更新包是公读的,generic HTTP server 就够了。问题是很多桌面应用不希望 release 包裸奔在公网,至少希望下载入口受登录态控制。
这时常见几个方案:
| 方案 | 问题 |
|---|---|
| 公读 CDN | 简单,但没有私有和鉴权 |
| 客户端直接签 S3 请求 | 等于把对象存储凭证下发到客户端 |
| 服务端转发 zip 字节 | 安全,但服务端承担所有下载流量 |
| 服务端 302 到预签名 URL | 客户端无密钥,服务端不转发大文件 |
最后这个方案比较稳。流程是:
electron-updater
│ GET feed/latest-mac.yml,带 Bearer token
▼
应用服务端 feed proxy
│ 校验登录态、校验 app/arch/file 白名单
│ 生成对象存储预签名 URL
▼
302 Location: presigned-url
│
▼
electron-updater 跟随 302,从对象存储下载
AWS S3 的预签名 URL 本质上就是一个限时 bearer token。AWS 文档也明确说,它可以在不修改 bucket policy 的情况下给对象临时访问权限。
这里有几个防线不能省:
{app}、{arch}、{file}必须做白名单,防路径遍历。- 允许的文件类型只应该是
latest-mac.yml、*.zip、*.blockmap。 - 预签名 URL 过期时间要短。
- feed 代理用的版本号必须来自同一个远程配置,而不是另一套逻辑。
- 真包 E2E 要确认 302、zip、blockmap、校验都能跑通。
这个设计看着绕,但它刚好卡住了两个边界:业务鉴权留在应用服务端,对象存储下载留给对象存储。
六、可选更新和强制更新是两种产品状态
版本策略可以写成一个纯函数:
type UpdatePolicy = 'none' | 'optional' | 'forced'
function decideUpdatePolicy(input: {
currentVersion: string
latestVersion: string
minSupportedVersion: string
}): UpdatePolicy
规则很直:
current >= latest:none。minSupported <= current < latest:optional。current < minSupported:forced。
重点不是这几行比较。重点是异常怎么处理。
我的倾向是 fail-safe:版本不可解析、manifest 不完整、接口 401、网络超时、服务端 5xx,统一当作没有可靠更新信息,不弹窗,不强制阻断。下一轮再试。
强制更新尤其不能靠不可靠数据触发。误弹一个可选更新,用户关掉就好;误进强制阻断,用户手里的工作会直接被打断。
渲染端可以围绕这三个状态做 UI:
none
└─ 不提示
optional
├─ 立即更新
└─ 稍后,本会话不再提示
forced
├─ 更新并重启
└─ 退出
forced 的 gate 要真拦住:没有关闭按钮,ESC 不关闭,点击遮罩不关闭。它不是一个普通 modal,是旧版本不可继续使用时的边界。
七、强制更新前先保住用户数据
这是桌面应用和 Web 应用很不一样的地方。
Web 应用刷新页面,最多丢一些未提交表单。桌面应用可能正在编辑一个本地文件、一个工程、一个还没保存的新内容。强制更新如果直接重启安装,用户数据就可能没了。
所以强制更新链路里要多一个握手:
下载完成
│
用户点击“更新并重启”
│
渲染端检查当前工作内容
├─ 有已保存路径:静默保存
├─ 新建未保存:弹 Save As
└─ 没有打开内容:跳过保存
│
保存成功后
│
confirmSafeToRestart()
│
主进程设置 isInstallInProgress = true
│
quitAndInstall()
isInstallInProgress 这个标记很小,但它很重要。很多桌面应用本来就有 before-quit 守卫,用来拦截窗口关闭、提示保存。如果自动更新也触发退出,两个保存流程可能互相打架。
正确的顺序是:业务保存先完成,渲染端明确告诉主进程“可以重启了”,主进程再绕过普通退出守卫,把控制权交给更新引擎。
八、CI 跑完不等于发版完成
自动更新最容易让人误会的一点是:包构建出来、上传到对象存储,就算发布了。
其实还差最后一下。用户能不能收到新版本,取决于远程配置中心里的 latest 指向哪里。
我更愿意把发版拆成两步:
- 包就绪:CI 完成签名、公证、staple、zip、blockmap、
latest-mac.yml,并上传对象存储。 - 对用户放量:远程配置中心把
latest指到新版本。
这个分离很有用。
如果只上传包,不改 latest,用户不会收到更新。你可以先拿真包做内部验证。确认没问题,再改 latest 放量。
如果放量后发现问题,把 latest 指回上一个稳定版本,还没更新的人就不会继续收到坏版本。已经更新的人不会自动降级,真正修复还是要向前发一个 patch,但至少可以先止血。
强制更新也是同一套开关。把 minSupported 抬高,低于它的客户端会进入 forced。这个动作要谨慎,它不是“提醒用户更新”,而是在告诉旧版本:你不能继续用了。
九、这类功能一定要真包 E2E
自动更新有一类 bug,dev 模式和单测很难测出来。
比如:
- release 包里缺
app-update.yml。 latest-mac.yml路径对,但 zip 上传目录不对。- feed 能拉到 yml,blockmap sibling 请求却 404。
- 下载成功了,
before-quit守卫把quitAndInstall()吞了。 - 签名、公证、staple 没串好,下载后装不上。
- 篡改 hash 时没有拒装。
这些问题都在“真实签名包 + 真实 feed + 真实对象存储 + 真实 macOS 安装行为”里才暴露。
所以验收至少要覆盖:
| 用例 | 要看什么 |
|---|---|
| 正向升级 | 旧版检测到新版,下载,重启后版本变新 |
| 私有 feed | yml、zip、blockmap 都能经 302 拉到 |
| 完整性 | 篡改 sha512 后拒绝安装,现有版本不坏 |
| 签名公证 | 升级后仍能通过 macOS 校验 |
| 失败恢复 | 下载失败后能重试,不破坏旧版本 |
| 强制更新保存 | 有路径静默保存,无路径 Save As,保存后再重启 |
自动更新最怕“看起来都通了”。检测通了不代表下载通,下载通了不代表安装通,安装通了不代表签名链路没坏。
十、最后沉淀成几条原则
如果下次再给一个 MacOS 端 electron 应用做自动更新,我会先把这几条写在设计文档最上面:
- 先定版本策略,再接更新引擎。
- manifest 管策略,
latest-mac.yml管下载和校验。 - 私有产物不要公读,也不要把对象存储密钥发给客户端。
- 服务端做鉴权和签名,不转发大文件。
- 强制更新前必须保障保存。
- CI 只负责包就绪,远程配置才是放量开关。
- 真包 E2E 是主测试,不是补充测试。
做到这里,自动更新才不只是一个“下载新版”的按钮。它变成了一套可控的版本分发系统:客户端知道什么时候该更新,服务端知道谁能下载,发布者知道什么时候放量,用户的数据不会因为更新被牺牲。
