FusionKit 的 Windows 应用内更新:检查、下载与一键重启安装
FusionKit 在 Windows 上的更新流程,用户看到的只有三步:弹窗告诉你有新版本,点「下载并安装」看着进度条走完,再点一次「重启安装」,几秒后新版本自己打开。
这套交互用 electron-updater 几行代码就能搭出雏形,但真正放到用户手里,会遇到几个教程里很少提的问题:
- GitHub 在部分网络下连不上,需要走镜像;可镜像是第三方,它给的安装包能不能信?
- 下载可能卡在 37% 不动,既不报错也不结束。
- 应用退出时还有子进程在跑(FusionKit 的本地转写服务),安装程序覆盖文件时会撞上占用。
- 安装没能接管时,应用已经停掉了一半服务,用户该怎么办?
这篇文章按发布产物、主进程架构、检查、下载、安装、界面的顺序,把 FusionKit 的实现拆开来看。macOS 版因为还没有签名,只提供 DMG 下载链接,不在本文讨论范围内。
本文代码均为 FusionKit 源码的节选与简化,完整实现见仓库的
electron/main/app-update/目录。依赖版本为 electron-updater 6.8。
整体结构
先看全貌。更新能力完全由主进程持有,渲染进程只能通过 preload 暴露的 window.appUpdate 发起六个动作,并订阅一份状态。
职责的划分大致是:
发布侧:一次 Release 里有什么
electron-updater 的工作基础是 electron-builder 产出的三类文件。FusionKit 的 Windows 构建用 NSIS 打包,按用户安装:
perMachine: false 很关键:应用装在用户目录下,更新时不需要管理员权限,也就不会弹 UAC。后面的 Provider 会依赖这个前提。
一次构建会产出:
FusionKit_0.5.2_x64.exe:NSIS 安装包。FusionKit_0.5.2_x64.exe.blockmap:安装包的分块哈希表,用于增量下载。latest.yml:更新清单,客户端检查更新时读的就是它。
files 里的 sha512 是整套安全模型的锚点:electron-updater 下载完安装包后,会用它做完整性校验,不匹配就拒绝安装。记住这一点,后面讲镜像信任时会回到这里。
主进程入口:先决定「要不要更新」
update.ts 在主窗口创建后调用一次。它先判断当前是不是可以更新的环境,再决定用哪个 updater:
几个配置值得说明:
autoDownload = false:electron-updater 默认检查到新版本就开始下载。FusionKit 的安装包有两百多 MB,下载必须由用户点击触发。allowDowngrade = false:即便某个源返回了旧版本的latest.yml,也不会「更新」回去。- 开发构建用一个空实现
INACTIVE_UPDATER:服务照常创建,界面照常工作,但永远不会真的发请求。UI 测试则换成指向本地服务器的测试 updater,能完整跑通下载与安装流程。 relaunch里关掉autoInstallOnAppQuit:这是安装失败后的兜底路径,后文细讲。
IPC 注册时,每个 handler 都会先校验调用方:
「下载并运行一个可执行文件」是应用里权限最高的操作之一,只允许应用自己窗口的主 frame 调用,任何 iframe 或其他 webContents 都会被拒绝。preload 层也做了配合:通用的 IPC 桥会拦截所有 app-update: 前缀的频道,渲染进程只能走 window.appUpdate 这一个入口。
一份状态,单向流动
主进程与渲染进程之间不传零散的事件,而是传一份完整的、冻结的状态对象:
AppUpdateService 每次变更都生成一份新的 Object.freeze 对象,通过 app-update:state 推给窗口。渲染进程挂载时会先 getState() 拿一次快照,之后只听推送;如果推送比快照先到,就以推送为准。这样即使用户关掉弹窗、过一会儿再打开,看到的进度也是对的。
状态之间的流转如下:
每个公开方法都先检查当前阶段,不合法的调用直接返回当前状态,不抛错。比如下载过程中再点一次「检查更新」,check() 会原样返回 downloading 状态,不会打断下载。
检查更新:自定义 Provider
electron-updater 内置的 GitHub Provider 只认 github.com。FusionKit 需要在官方源和镜像之间轮换,所以通过 setFeedURL 注入了一个 custom 类型的 Provider:
plan 是服务持有的「源计划」对象,会随 setFeedURL 的参数一起传进 Provider 的构造函数。Provider 通过它读取本次要尝试的源,并把每个源的结果回报给服务。
下载源从哪来
源的列表来自应用里统一的下载源表。GitHub Release 的地址会映射出两个镜像:
用户在设置里选择下载源模式:「仅官方」只用 github.com,「自动」先官方后镜像,「镜像优先」先镜像后官方。Whisper 模型、CUDA 运行库的下载也走同一张表,所以一处设置对整个应用生效。
逐个尝试,每个源独立计时
readChannelFile 给每个源单独设了 15 秒上限,超时就用 CancellationToken 取消请求,接着试下一个。github.com 被阻断时,常见的表现是连接一直挂着不返回,如果所有源共用一个总超时,镜像根本轮不到。请求还会加上 noCache 查询参数,避免 CDN 或镜像返回缓存的旧清单。
关键问题:镜像给的安装包能信吗
回到 latest.yml 里的 sha512。electron-updater 会校验下载的安装包是否与清单里的哈希一致,但清单和安装包如果来自同一个镜像,这个校验就形同虚设:镜像可以同时改掉清单里的哈希和安装包本身,校验照样通过,应用随后会静默运行一个任意程序。
FusionKit 目前还没有代码签名,无法从安装包本身验证来源,所以在清单层面做了一道「互证」:
指纹只比较真正影响下载的字段:版本号,以及每个文件的名字、SHA-512 和大小。发布时间、更新说明、YAML 格式和文件顺序都不参与比较:
互证时,剩下的源会被并发询问,第一个足够的答案就结束检查,其余请求随即取消。只要 github.com 的清单读到了,无论镜像怎么说,都以官方为准。
还有一个细节:互证需要两个不同主机的镜像。下载源表里只有两个镜像,任何一个宕机,互证就做不成了。于是 FusionKit 额外加了一个只做「见证」的主机 gh-proxy.com:它不支持 Range 请求,不适合下载大文件和 blockmap,但读一个几百字节的 latest.yml 完全没问题。见证主机只参与确认,永远不会成为下载源。
最终在服务层,下载入口直接拒绝未经确认的清单:
这时用户在弹窗里仍然能看到新版本,只是按钮从「下载并安装」变成了 GitHub 与镜像的手动下载链接,并附一句说明。安全上不妥协,体验上也不至于什么都不给。
解析下载地址:固定版本号、去掉提权
检查完成后,electron-updater 会调用 Provider 的 resolveFiles 获取实际下载地址:
这里做了四件事:
- 没有 SHA-512 就拒绝。没有哈希就没有完整性校验,宁可失败。
- 文件名必须是单个路径段。
latest.yml里的url如果是../xxx或完整的https://…,说明清单有问题,直接拒绝。 - 强制
isAdminRightsRequired: false。翻看 electron-updater 的NsisUpdater.doInstall可以看到,这个字段为true时,安装包会通过elevate.exe以管理员身份启动。FusionKit 是按用户安装的,正常构建永远不会写出这个字段;它如果出现,说明清单不是 FusionKit 构建出来的。 - 用版本号固定的地址下载,而不是
releases/latest/download/…。
第 4 点有两个好处。一是下载过程中如果恰好发布了更新的版本,latest 会指向新文件,固定版本号就不会被半路换掉。二是增量下载:electron-updater 会把下载地址里的新版本号替换成当前版本号,去找旧版本的 .blockmap,比对两份分块哈希后只下载变化的块。地址里没有版本号,这个替换就无从做起,每次只能全量下载。
此外,Provider 还重写了 setRequestHeaders,去掉 electron-updater 默认附带的 x-user-staging-id 请求头。这个 ID 是本机持久化的灰度标识,没有必要发给第三方镜像。
下载:换源与卡顿看门狗
用户点击「下载并安装」后,runDownload 进入一个循环,从检查时回答的那个源开始尝试:
换源顺序:从回答检查的源开始,依次试它之后的源,最后再回头试它之前的源。之前的源可能只是检查时超时,下载时未必连不上。
卡顿看门狗:每次收到 download-progress 事件都会重置一个 60 秒的计时器。60 秒内没有任何新数据,就把 stalled 标为 true 并取消当前下载,随后按超时处理并换到下一个源。如果没有这一层,用户面对的就是一根永远停在 37% 的进度条。
哪些错误不换源:磁盘满、目录无写权限这类错误,换哪个源结果都一样,立即停止并告诉用户原因。用户主动取消时回到 available,可以随时重新开始。
所有源都失败时报哪个错:不是简单地报最后一个。mostTellingFailure 会优先选择「对方明确回答了」的失败(404 文件不存在、SHA-512 不匹配),其中 github.com 自己的回答优先级最高;只有所有源都连不上时,才报网络问题。比如发布刚创建、安装包还在上传,官方会返回 404,而最后一个镜像恰好超时,这时用户看到的是「新版本的文件还没发布完」,而不是「检查网络」。
一键重启安装
下载完成后,点击「重启安装」调用 install()。这一步要处理的是「退出」本身:
第一步:先停掉自己的子进程
FusionKit 的本地转写会启动一个推理服务子进程。正常退出时,主进程在 before-quit 里调用 preventDefault(),等服务优雅关闭后再真正退出。但 quitAndInstall 会在安装程序启动后立刻调用 app.quit(),如果这时还在等子进程,安装程序就可能在覆盖文件时撞上占用。
所以安装前先显式关闭:
关闭完成后打开 #allowQuit,之后的 before-quit 就不再拦截。关闭本身有超时上限(各阶段宽限期之和再加 5 秒),超时则抛错,进入 failInstall('prepare')。
第二步:静默安装并自动重启
quitAndInstall(true, true) 的两个参数分别是 isSilent 和 isForceRunAfter。在 electron-updater 6.8 的 NsisUpdater 里,它们会被翻译成安装程序的命令行参数:
--updated:告诉 NSIS 这是一次更新,安装脚本里可以用${isUpdated}判断。/S:静默安装,不显示安装向导。--force-run:安装完成后自动启动新版本。
installer.nsh 就用到了 ${isUpdated}。本地转写依赖 VC++ 运行库,安装脚本会在缺失时调用微软的安装器,这一步会触发一次 UAC 确认。如果用户在某次更新里拒绝过,后续更新就不再询问,改由应用内提供修复按钮。静默更新时突然弹出系统对话框,体验很差,这里的处理是:问一次,用户拒绝后就记住。
第三步:安装程序没接管怎么办
quitAndInstall 只负责启动安装程序,它不会告诉你安装程序有没有真的跑起来。FusionKit 在这里加了两层保护:
- 20 秒超时:调用后 20 秒应用还活着,说明安装程序没有接管,进入
failInstall('start')。 installerStarted标志:electron-updater 在一次quitAndInstall之后会忽略后续的启动请求,所以这一阶段失败后不再提供「重试」,只提供「重启 FusionKit」。
「重启」走的就是前面 update.ts 里定义的 relaunch:
这里要关掉 autoInstallOnAppQuit。electron-updater 在下载完成后会注册一个退出钩子,应用退出时自动运行安装程序。如果不关,app.quit() 会再次触发安装,安装程序就会和 app.relaunch() 拉起的新进程抢文件。关掉之后,已下载的安装包仍然保留在缓存里,重启后再检查一次,就能直接从「已下载」状态重新开始安装。
为什么失败后必须重启?因为 prepareInstall 已经把转写服务停掉了,应用处于「半关闭」状态,继续使用只会遇到更多莫名其妙的错误。错误引导因此对安装失败统一给出「重启 FusionKit」作为首选操作。
渲染进程:安静的启动检查
弹窗组件在应用启动 1.5 秒后做一次自动检查,并区分两种来源:
- 启动检查:只有发现新版本才弹窗;检查失败、已是最新都保持安静。用户每次打开应用都看到「网络错误」,只会让人烦。
- 手动检查:无论结果如何都打开弹窗,并给出明确反馈。
- 下载结束时用户已关闭弹窗:下载完成或失败后,弹窗会自己重新打开。下载是用户发起的,结果应该告诉他。如果此时新手引导正在进行,自动弹窗会排在引导之后。
错误界面不展示原始报错,而是按 during(检查、下载、安装)和 kind(网络、文件不存在、校验失败、磁盘、未知)组合出原因、下一步建议和操作按钮。比如下载阶段的网络错误,会提供「重试」「网络设置」「打开发布页」「镜像下载」和「帮助」。原始信息放在「复制诊断信息」里:每个尝试过的源各占一行,GitHub 重定向地址中带签名的查询参数会被截掉。
小结
把 FusionKit 的实现收拢成一份清单,基本也适用于其他用 electron-updater 做 Windows 更新的应用:
- 关掉
autoDownload,下载由用户触发;开发构建用空实现,不访问网络。 - IPC 只对主窗口的主 frame 开放,通用 IPC 桥屏蔽更新频道。
- 主进程维护一份完整状态并推送给渲染进程,非法调用返回当前状态而不是抛错。
- 需要镜像时写自定义 Provider,每个源独立超时,请求带防缓存参数。
- 镜像的清单要有第二个来源佐证,否则 SHA-512 校验没有意义;无法佐证时退回手动下载。
- 下载地址带上版本号,既防止半路换文件,又能用上 blockmap 增量下载。
- 按用户安装,并在 Provider 里强制
isAdminRightsRequired: false。 - 下载要有卡顿看门狗,失败时按「哪个答案信息量最大」来报告原因。
- 安装前先停掉自己的子进程;
quitAndInstall之后设超时,失败时用关闭了autoInstallOnAppQuit的方式重启。 - 启动检查保持安静,只在有新版本时打扰用户。
这些处理单独看都不复杂。它们要解决的问题是同一个:在网络、文件系统和进程都不完全可控的环境里,更新不应该把用户卡在一个说不清楚的状态里。