Electron自动更新全攻略:electron-builder配置与自建更新服务器实战

Electron自动更新electron-builder
于 2026-10-02 04:54:38 修改
·本内容遵循CC 4.0 BY-SA版权协议

写 Electron 客户端最绕不开的坎,就是打包和自动更新。本地跑得再欢,一到分发环节,各种签名、产物路径、更新机制的问题全冒出来了。很多团队做到一半卡住,不是卡在 electron-builder 配置上,就是卡在自建更新服务器和 latest.yml、app-update.yml 这两个文件的配合上。这篇文章就把这条链路完整捋一遍,从打包配置到自建更新服务,再到客户端集成,全部基于我在生产环境里实际跑通的方案,适合正在维护 Electron 桌面应用、以及准备把自动更新从第三方托管迁到自己服务器上的开发者参考。

1. 方案整体设计与选型拆解

1.1 为什么选 electron-builder 而不是其他方案

Electron 打包工具目前主流的有 electron-builder、electron-packager、Electron Forge。Electron Forge 是官方推荐的,集成了打包和发布流程,但它对自动更新的支持更偏商业化托管,自建服务器的配置不如 electron-builder 灵活。electron-packager 则只是个裸打包工具,产物连安装包都不是,更别提生成更新元数据了。

electron-builder 的优势是它把打包、签名、生成更新元数据(latest.yml、latest-mac.yml)一条龙处理了。只要在配置文件里写好 publish 相关设置,构建完成后自动生成对应的更新描述文件,这对于做自动更新来说是决定性的效率提升。我在多个项目里对比过,electron-builder 生成的 NSIS 安装包、dmg、AppImage 产物都很标准,配合 electron-updater 使用几乎零阻力。

1.2 自建更新服务器 vs 第三方托管

Electron 应用默认可以用 GitHub Releases 做更新源,但你得考虑国内网络环境的下载速度。一个 80MB 的安装包走 GitHub 不定时抽风,用户那边更新失败率会直接让产品口碑崩掉。自建更新服务器最大优势是可控:

  • 更新包放在自己的 CDN 或内网服务器,下载速度快且稳定
  • 更新策略可以自己控制,比如强制更新、灰度发布
  • 不依赖第三方平台的接口稳定性
  • 企业内网环境部署时,自建服务器是唯一可选方案

自建服务器的形态也很灵活,可以是一个 Nginx 静态目录,也可以是一个带鉴权的 Node.js 服务。关键是让 electron-updater 能通过 HTTP 请求拿到 latest.yml 和安装包文件。

1.3 latest.yml 与 app-update.yml 到底什么关系

很多人被这两个文件绕晕,我直接说结论:

  • latest.yml 是构建时生成的更新元数据文件,放在你的更新服务器上,客户端每次检查更新时请求的就是它
  • app-update.yml 是打包时嵌入到安装包(其实是 resources 目录)里的本地配置文件,告诉客户端应该去哪查更新、查哪个文件

app-update.yml 的内容会包含你配置的 publish 地址。如果用 electron-builder 的 electron-updater,它启动时会读这个内嵌文件,然后远程请求 latest.yml 对比版本号。

有一点要强调:老版本 electron-updater 有时需要你手动在 dev-app-update.yml 里配置 url 才能在开发环境调试更新,但在打包产物里并不需要单独创建 app-update.yml,electron-builder 会自动把它打进 resources 目录。这里只要保证 build 配置正确即可,不要手动干预 app-update.yml 的内容。

2. electron-builder 打包配置实战

2.1 一份可直接落地的 electron-builder.yml

直接给一份我常用的配置,对应的 electron-builder 版本是 24.x 以上。

YAML
appId: com.example.desktop
productName: ExampleApp
directories:
output: release
buildResources: build
files:
- dist/**/*
- package.json
- "!node_modules/**/*"
asar: true
win:
target:
- target: nsis
arch:
- x64
artifactName: ${productName}-${version}-${arch}.${ext}
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
perMachine: false
deleteAppDataOnUninstall: false
createDesktopShortcut: true
mac:
target:
- target: dmg
arch:
- x64
- arm64
artifactName: ${productName}-${version}-${arch}.${ext}
category: public.app-category.developer-tools
linux:
target:
- target: AppImage
arch:
- x64
artifactName: ${productName}-${version}-${arch}.${ext}
category: Development
publish:
provider: generic
url: https://update.example.com/releases

重点说几个配置:

  • publish.provider 设为 generic,这是 electron-updater 支持自建服务器的关键。url 直接指向你更新服务器的根地址,后面客户端请求 latest.yml 时会拼成 https://update.example.com/releases/latest.yml
  • artifactName 里的 ${version} 占位符很重要,如果你不给它定义,electron-builder 默认的命名里有 ${channel},在非 stable 分支下可能会生成带 channel 的安装包文件名,导致 latest.yml 里记录的 url 和你服务器上实际的文件名对不上
  • directories.output 我习惯统一输出到 release 目录,方便 CI 打包后直接上传整个目录

2.2 Windows NSIS 打包与代码签名

Windows 平台的自动更新绕不开 NSIS 安装器。electron-updater 在 Windows 下更新时,依赖安装包去覆盖旧的安装目录,所以产物必须是 NSIS 安装包。不要考虑用 portable 目标做自动更新,免安装版没有注册安装路径,更新完没法覆盖旧版本。

NSIS 的几个关键参数:

  • oneClick: false 让用户可以看到安装路径选择,对国内用户更友好
  • perMachine: false 安装到当前用户目录,避免权限问题。如果做企业级软件需要全机器安装,再改成 true,但那样安装时需要管理员权限,更新时也会有 UAC 弹窗
  • allowToChangeInstallationDirectory: true 允许改安装目录,但有一个副作用,如果用户改过安装目录,electron-updater 需要注册表里的 InstallLocation 或 UninstallString 来定位安装路径,否则更新会找不到旧版本。electron-builder 默认会写这些注册表项,正常使用没问题

代码签名这块我要专门强调:Windows 上如果不签名,electron-updater 更新时会触发 SmartScreen 拦截,用户还得额外点击“仍要运行”。企业环境里更麻烦,直接可能被安全策略拦截。签名证书建议用正规机构签发的 EV 或 OV 代码签名证书,有条件的走一下微软的签名认证减少误报。签名操作是在打包时自动做的,配置里加:

YAML
win:
certificateFile: cert.pfx
certificatePassword: ${env.CERT_PASSWORD}

实际打包时用环境变量传密码,不要硬编码进配置文件。

2.3 构建产物里哪些文件需要上传

构建完成之后,release 目录下会生成一堆文件。以 Windows 为例,典型的产物有:

  • ExampleApp-1.0.0-x64.exe(实际安装包)
  • latest.yml
  • builder-effective-config.yaml(调试用)
  • win-unpacked/(免安装目录,开发调试用)

上传到更新服务器时,只需要传安装包和对应的 yml 文件。win-unpacked 不用传,那只是未打包的调试目录。

macOS 构建时生成 latest-mac.yml,Linux 生成 latest-linux.yml。这是因为不同平台 electron-updater 会请求不同名字的元数据文件,Windows 固定请求 latest.yml。

3. latest.yml 与 app-update.yml 文件结构深入

3.1 文件内容逐字段解析

先看一份真实的 latest.yml:

YAML
version: 1.0.1
files:
- url: ExampleApp-1.0.1-x64.exe
sha512: a3f4c9f6d8e1...(Base64 编码的 SHA512)
size: 87654321
releaseDate: '2024-11-20T10:30:00.000Z'
path: ExampleApp-1.0.1-x64.exe
sha512: a3f4c9f6d8e1...(同上)

字段说明:

  • version 是安装包的版本号,严格对应 package.json 里的 version。electron-updater 会比较这个版本和本地 app.getVersion() 的结果,语义化版本比较用的是 electron-builder 内部的 semver 逻辑
  • files 数组里的 url 是文件名(相对路径),electron-updater 会基于你配置的 provider url 拼出完整下载地址
  • sha512 是 Base64 编码的 SHA512 哈希,用于校验下载的安装包是否完整。这是自动更新安全性的重要一环,防的就是下载文件被中间人篡改或损坏
  • releaseDate 是发布时间,electron-updater 在某些场景下会用它在本地缓存更新信息

path 字段和顶层 sha512 是兼容旧版本的冗余字段,新版本 Electron 用 files 数组里的信息,但生成时都会带上。

3.2 app-update.yml 在打包后去哪了

打包完成后,resources/app-update.yml 内容大概是这样:

YAML
provider: generic
url: https://update.example.com/releases
updaterCacheDirName: exampleapp-updater

这个文件是 electron-builder 根据你的 publish 配置自动生成的。客户端运行时,electron-updater 会读它拿到 provider 和 url,然后去请求 latest.yml。

这里有个细节:updaterCacheDirName 决定了更新缓存的目录名,在 Windows 上是 %LOCALAPPDATA%/<updaterCacheDirName>,macOS 上是 ~/Library/Caches/<updaterCacheDirName>。如果你发现更新下载到一半的临时文件一直占着磁盘空间,就是从这个目录找。想手动清除,把对应目录删掉即可。

3.3 手动生成 latest.yml 的两种方式

正常情况下 electron-builder 构建完成后会自动生成 latest.yml,不需要手动维护。但有一些场景需要你手动处理:

场景一:服务端已经有安装包,想补一份更新元数据。可以用 Node.js 计算 sha512:

JAVASCRIPT
const crypto = require('crypto');
const fs = require('fs');
 
const fileBuffer = fs.readFileSync('ExampleApp-1.0.1-x64.exe');
const hash = crypto.createHash('sha512').update(fileBuffer).digest('base64');
console.log(hash);

然后拼一个 yaml 文件,注意 version、path、sha512 三个字段必须准确。

场景二:你的更新服务器需要动态生成 latest.yml(比如做灰度发布时给不同用户返回不同版本),这时服务端放一个模板,根据请求参数动态渲染 yaml 返回即可,但要注意 Content-Type 必须能被 electron-updater 正确解析。

3.4 sha512 校验的防篡改原理

electron-updater 下载完安装包后,会先计算本地文件的 SHA512,再和 latest.yml 里的值比对,不一致就直接判更新失败。这个机制是为了防止中间人攻击和下载损坏。

实际中常见的问题是:Nginx 开启 gzip 后,如果配置错误导致动态请求被压缩,可能影响校验。不过静态文件一般不会被 gzip 处理,所以问题更多出现在自建 Node 服务没正确返回文件流,或 CDN 回源超时导致文件截断。后面排查章节细讲。

4. 自建更新服务器完整实现

4.1 纯静态 Nginx 部署方案

如果更新包是固定的、不需要动态分发,最省事的方案是直接用 Nginx 托管 release 目录。下面是一个我实测可用的配置:

NGINX
server {
listen 80;
server_name update.example.com;
 
root /var/www/electron-releases;
index index.html;
 
location / {
autoindex off;
}
 
location ~ \.yml$ {
default_type text/yaml;
add_header Cache-Control "no-cache";
}
 
location ~ \.(exe|dmg|AppImage|zip|blockmap)$ {
add_header Cache-Control "public, max-age=604800";
}
}

几个关键点:

  • default_type text/yaml 要刻意设置,Nginx 默认对 yml 后缀可能返回 application/octet-stream,某些网络环境下可能导致客户端解析失败
  • yml 文件要禁用缓存(no-cache),这样客户端每轮检查更新时能拿到最新的版本信息。安装包这类大文件则可以开长缓存,因为文件名里带版本号,新版本自然会生成新 URL,不存在缓存污染问题
  • autoindex off 关闭目录列表,避免敏感文件被扫描
  • 如果更新服务器走 HTTPS,electron-updater 对证书校验很严格,自签名证书会导致请求直接失败。要么用正式证书,要么在代码里临时关掉校验(生产环境不建议)

4.2 Node.js 极简更新服务器

如果后续要做灰度发布、限量放量、鉴权,静态 Nginx 就不够用了。这里给一个基于 Express 的最小实现:

JAVASCRIPT
const express = require('express');
const path = require('path');
const fs = require('fs');
 
const app = express();
const RELEASE_DIR = '/var/www/electron-releases';
 
// 返回 latest.yml
app.get('/releases/latest.yml', (req, res) => {
res.type('text/yaml');
res.setHeader('Cache-Control', 'no-cache');
fs.createReadStream(path.join(RELEASE_DIR, 'latest.yml')).pipe(res);
});
 
// 返回安装包,支持 Range 断点下载
app.get('/releases/download/:filename', (req, res) => {
const filePath = path.join(RELEASE_DIR, req.params.filename);
res.setHeader('Content-Type', 'application/octet-stream');
res.sendFile(filePath);
});
 
app.listen(3000);

express 的 sendFile 天然支持 Range 请求,electron-updater 下载大安装包时分段拉取,不支持 Range 的服务器会导致下载中断后无法续传,用户看着进度条卡住然后失败。这一点在自建服务器时最容易踩坑。

生产环境建议加一层鉴权,比如在 yml 接口加签名校验参数,或者用 IP 白名单控制灰度用户范围。

4.3 electron-updater 客户端集成

主进程引入 electron-updater:

JAVASCRIPT
const { app, BrowserWindow, dialog, ipcMain } = require('electron');
const { autoUpdater } = require('electron-updater');
 
autoUpdater.logger = console;
autoUpdater.autoDownload = false;
 
function sendStatusToWindow(text) {
const win = BrowserWindow.getAllWindows()[0];
win.webContents.send('update-status', text);
}
 
// 监听更新状态
autoUpdater.on('checking-for-update', () => {
sendStatusToWindow('checking');
});
 
autoUpdater.on('update-available', (info) => {
sendStatusToWindow('available');
// 这里可以弹出对话框,询问用户是否下载
autoUpdater.downloadUpdate();
});
 
autoUpdater.on('update-not-available', (info) => {
sendStatusToWindow('up-to-date');
});
 
autoUpdater.on('download-progress', (progress) => {
const percent = Math.round(progress.percent);
sendStatusToWindow(`下载进度:${percent}%`);
});
 
autoUpdater.on('update-downloaded', (info) => {
sendStatusToWindow('downloaded');
// 给用户一个“立即重启”按钮,点击后调用
// autoUpdater.quitAndInstall();
});
 
app.whenReady().then(() => {
// 延迟几秒再检查,避免应用刚启动时卡顿
setTimeout(() => {
autoUpdater.checkForUpdates();
}, 5000);
});

几个设计决策:

  • autoDownload = false 的目的是把“是否更新”的决策权交给用户。先收到 update-available 事件,弹窗提示,用户确认后才调用 downloadUpdate()。对于流量敏感的小团队产品,避免用户一进应用就被迫下载大更新包
  • 不要把 checkForUpdates 放在 ready 里立刻执行,等几秒让 UI 先起来,否则更新检查可能阻塞启动流程
  • update-downloaded 之后不要立刻 quitAndInstall(),应该提示用户保存工作。electron-updater 的 quitAndInstall 默认 isSilent=false,会弹系统安装界面做正式安装

4.4 强制更新与灰度发布设计

生产环境经常会遇到“旧版本必须升级才能继续用”的需求。我通常的做法是写一个版本策略接口:

JAVASCRIPT
// 服务端返回
{
"latest": "2.0.0",
"minimum": "1.8.0"
}

客户端检查更新的流程变为:

  1. 启动时请求版本策略接口
  2. 如果本地版本低于 latest,走正常更新流程,非强制
  3. 如果本地版本低于 minimum,标记为强制更新。用户在对话框里只有“立即更新”按钮,没有“跳过”选项,更新的过程中 UI 提示不能关闭应用

强制更新时,update-downloaded 后直接 autoUpdater.quitAndInstall(true, true),静默安装并自动重启。注意这里有个坑:NSIS 安装器在静默安装时,如果旧进程没有完全退出,安装会失败。稳妥做法是安装前先让应用退出所有子进程。

灰度发布可以通过服务端做:不同 user agent 或请求 IP 返回不同版本的 latest.yml。在 Node 更新服务器里加一层判断即可。

5. 常见问题与排查技巧实录

5.1 更新请求 404 或报错网络异常

现象:客户端日志显示访问 latest.yml 返回 404,或者 getLatestVersion 处抛 Network Error。

排查:

  1. 浏览器直接访问 https://你的域名/releases/latest.yml,看能不能正常返回内容
  2. 检查 app-update.yml 里的 url 是否和实际服务器路径一致。这块不一致的原因多半是在 electron-builder 配置里改了 publish.url 但没重新打包
  3. 服务器路径大小写问题。Linux 服务器要严格区分文件名大小写,Latest.yml 和 latest.yml 是两个文件

5.2 sha512 mismatch 更新永远失败

现象:下载完成后报 ERR_UPDATER_SHA512_MISMATCH。

常见原因:服务器开启了响应压缩导致文件流被修改;或 Nginx 的 gzip_static 配置错误返回了 .gz 文件;或 CDN 回源时做了缓存版本冲突。

解决办法:先在服务器上手动下载安装包,本地计算 sha512 再和 latest.yml 对比。如果一致但客户端仍然报错,重点检查反代层是否对 .exe 文件做了额外的编码转换。给下载路径加上 proxy_buffering off; 或等效配置,让文件流直通。

5.3 下载到一半失败,重新下载还是失败

现象:更新下载进度走到 40% 中断,重试依旧。

绝大多数情况是更新服务器不支持 Range 请求。验证方式很简单:

BASH
curl -I -H "Range: bytes=0-100" https://update.example.com/releases/ExampleApp-1.0.1-x64.exe

如果响应头里没有 Accept-Ranges: bytes 或 Content-Range,说明服务端没有正确处理分片请求。Nginx 默认支持 Range,Node 的 res.sendFile 也支持,CDN 需要确认配置里开启了分片回源。

5.4 Windows 更新后旧进程还在,安装失败

现象:提示安装失败,或更新完成后应用没有重启,进程还留在任务管理器里。

NSIS 在覆盖安装前要求目标进程退出。Electron 主进程在 update-downloaded 后还有子进程在跑(比如渲染进程、GPU 进程、某些辅助进程),quitAndInstall 会尝试关闭主进程,但如果应用里有守护进程或托盘进程没有退出,就会卡住。

我的做法是:在 quitAndInstall 前先主动退出所有子进程,然后延迟 300ms 再调用安装函数。同时监听 window-all-closed 事件,确保 app.quit() 真正让主进程退出。

5.5 更新包下载慢

如果 Nginx 部署在国内服务器,跨地域访问确实会慢,毕竟大文件传输的瓶颈在带宽。排查方法:先 ping 看延迟,再确认运营商带宽。小团队建议直接套 CDN,只对 yml 文件禁用缓存,exe 文件走 CDN 加速。也可以在客户端做流量控制,比如把 autoUpdater 的下载并发连接数调低,避免用户网络的带宽被占满。

5.6 macOS 提示无法验证开发者

macOS 上如果应用没做公证(Notarization),用户打开更新后的应用会被 Gatekeeper 拦截。electron-builder 配置里需要开启 notarize:

YAML
mac:
notarize:
teamId: ${env.APPLE_TEAM_ID}

注意 macOS 更新包下载完成后会先挂载 dmg 再提取 app,如果签名或公证不过,安装过程会静默失败,用户看起来像是更新了但版本没变。这个坑排查时很难发现,需要打开控制台看具体错误。

5.7 强制更新时应用无法正常重启

Electron 的 quitAndInstall 在某些 Windows 环境下不会立刻安装,而是等应用退出后才触发安装器。如果业务里有“保存用户数据”的需求,一定要在安装前完成数据提交和持久化,否则数据丢失的锅就扣到更新头上了。我把这个流程统一封装成一个 restartAfterUpdated() 函数,先 flush 本地数据,再退出。

JAVASCRIPT
function restartAfterUpdated() {
// 确保主进程释放所有资源
app.releaseSingleInstanceLock();
autoUpdater.quitAndInstall(true, true);
}

releaseSingleInstanceLock 解决的是单实例锁冲突问题。Electron 单实例锁在更新安装时会让安装器卡住,因为系统发现应用还在运行,锁没有被释放。

5.8 如何快速定位问题:electron-updater 日志

electron-updater 会把日志打到 stderr,但默认不开启详情。我在集成时把日志转发到一个本地文件:

JAVASCRIPT
autoUpdater.logger = {
info: (msg) => fs.appendFileSync(path.join(app.getPath('userData'), 'updater.log'), msg + '\n'),
warn: (msg) => fs.appendFileSync(path.join(app.getPath('userData'), 'updater.log'), '[warn] ' + msg + '\n'),
error: (msg) => fs.appendFileSync(path.join(app.getPath('userData'), 'updater.log'), '[error] ' + msg + '\n'),
};

排查时直接看用户目录下的 updater.log,能定位绝大部分请求、响应、校验过程的问题。

5.9 版本号回退的坑

不要在生产环境把版本号从 2.0.0 回退到 1.9.9。electron-updater 用 semver 比较,本地版本高于服务器版本时会在 update-not-available 分支直接结束,不会再有后续更新。一旦发出版本号倒挂,所有 2.0.0 用户会永久停留在这个版本,只能手动覆盖安装。发布流程里记得加一道校验,deny 版本号递减的 release。

6. 更新策略与实操体验补充

6.1 增量更新不是必需品

很多团队一上来就想要增量更新,控制更新包体积。但 electron-builder 的 NSIS 安装包本身已经基于 zip 压缩,内部文件变化较小时,新安装包和旧安装包之间其实存在可复用块。electron-updater 支持 blockmap 差分更新,通过 electron-builder 构建时会默认生成 latest.yml 同名的 .blockmap 文件。如果构建输出里能看到 ExampleApp-1.0.1-x64.exe.blockmap,上传服务器时把它一并带上,客户端就能自动尝试增量下载,下载量会显著减小。

blockmap 的匹配机制是基于文件内容的,不适合跨大版本发布。如果你每次更新都改了大量依赖,增量效率低,客户端会自动回退到全量下载,不需要额外处理。

6.2 发布流程的规范化

更新服务器的文件结构建议保持版本化目录:

TEXT
releases/
latest.yml
ExampleApp-1.0.0-x64.exe
ExampleApp-1.0.0-x64.exe.blockmap
ExampleApp-1.0.1-x64.exe
ExampleApp-1.0.1-x64.exe.blockmap

旧版本安装包不要急着删。用户可能停留在多个旧版本,某些场景需要手动回滚时,旧包还在服务器上就有兜底。我一般只保留最近 5 个版本的安装包,历史包放到冷存储。

CI 里做好流水线后,每次打 tag 自动触发构建、签名、上传、覆盖 latest.yml。人工操作环节越多,出错概率越大,更新服务器这种安全敏感链路上要用自动化消除变量。

6.3 关于灰度发布的优先级

灰度发布确实能让事故影响面变小,但代价是维护一套分发规则。小团队产品不必一上来就做灰度,先保证“全量发布快速回滚”的能力更重要。回滚比灰度等级高。需要灰度时,用 request header 或 query 参数里带灰度分组 id,服务端逻辑简单可控。

6.4 最后说点实在的

我在实际跑更新链路时最深的体会是:更新服务器的稳定性比功能复杂度重要得多。你既想做得“智能”,又要防住网络的不确定性,最后发现真正能救命的还是可靠的文件托管、合理的缓存控制、以及完整的日志。用好 xx 文件、把下载路径的 Range 支持验证到位,已经能解决 80% 的更新问题。剩下的,就是发布前手动刷一次全流程,用真实安装包从旧版本升到新版本,别偷懒。