Lionel · 李豪个人兴趣、文章、摄影、视频和音乐
Article · 文章

MacOS 端 electron 应用自动更新初探

Lionel
2026/06/24 · 12 min read

最近在做 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

客户端做三件事:

  1. 拉版本清单,算出 noneoptionalforced
  2. 用户确认后,把 feed 交给 electron-updater 下载。
  3. 下载完成后,在合适时机触发 quitAndInstall()

服务端也做三件事:

  1. 校验登录态。
  2. 从远程配置中心读当前发布配置。
  3. 给具体更新文件签发短期下载地址。

这个拆法的好处是边界清楚。客户端不理解对象存储,服务端不理解安装细节,远程配置中心只负责“现在该让谁收到哪个版本”。

四、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 latestminSupportedreleaseNotesfeedUrl 文件 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 >= latestnone
  • minSupported <= current < latestoptional
  • current < minSupportedforced

重点不是这几行比较。重点是异常怎么处理。

我的倾向是 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 指向哪里。

我更愿意把发版拆成两步:

  1. 包就绪:CI 完成签名、公证、staple、zip、blockmap、latest-mac.yml,并上传对象存储。
  2. 对用户放量:远程配置中心把 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 应用做自动更新,我会先把这几条写在设计文档最上面:

  1. 先定版本策略,再接更新引擎。
  2. manifest 管策略,latest-mac.yml 管下载和校验。
  3. 私有产物不要公读,也不要把对象存储密钥发给客户端。
  4. 服务端做鉴权和签名,不转发大文件。
  5. 强制更新前必须保障保存。
  6. CI 只负责包就绪,远程配置才是放量开关。
  7. 真包 E2E 是主测试,不是补充测试。

做到这里,自动更新才不只是一个“下载新版”的按钮。它变成了一套可控的版本分发系统:客户端知道什么时候该更新,服务端知道谁能下载,发布者知道什么时候放量,用户的数据不会因为更新被牺牲。

参考资料

#文章
Lionel Written by
我叫李豪,来自云南昭通,现居杭州 喜欢摄影、足球 、Vibe Coding 和老婆一起养了两只猫
Scinentistcoldplay