在 Windows 平台上维护大型 Electron 桌面客户端,更新体验往往是一个跨不过去的坎。

最常见的痛点很明确:许多基于 electron-updater 与 NSIS 的桌面客户端,默认将程序安装在 Program Files 目录下。结果就是每次检测到新版本,用户都得面对一次 UAC 提权弹窗、一套完整的安装向导,甚至在点击关闭客户端后还会再弹一次确认。频繁打断工作流不说,更新期间主窗口关闭、向导卡顿,整个体验割裂且沉重。

这一切的一切都得从 Windows 的权限边界说起。

桌面客户端要想实现 VS Code 用户版那样的“静默下载、后台展开、重启秒切”无感更新,核心不是换一个更新库,而是彻底重构 Windows 上的安装根目录布局、权限分层以及进程交接机制。本文就带大家了解一下这套原生启动器与不可变版本目录的设计与落地细节。


架构边界:安装根与系统级目录彻底拆分

要想更新不弹 UAC,最直接的前提是安装根目录必须处于当前用户可写的区域。

我们首先来看整体的目录与进程架构。下这张图展示了运行时各组件的边界关系:

无感更新运行时架构.chart

整个布局被严格拆分成两个完全隔离的层级:

  • 用户级安装根(默认 %LOCALAPPDATA%\Programs\CeraForge\):当前用户拥有完全读写权限。固定的原生启动器、各版本的独立运行时目录、更新暂存区全都放在这里。
  • 系统级目录(%ProgramFiles%\CeraForge\):仅在首次安装时请求一次管理员权限,写入必须具备系统级身份的提权助手 CeraForgeElevate.exe 和资源管理器缩略图 Shell 扩展 DLL。

基于这种划分,我们确立了三条系统不变量:

  1. 版本目录永不改写:每个版本存放在独立的 app-<semver>\ 目录中。目录只整体新增、只整体删除,内部文件一旦就绪便不再发生任何改写。
  2. 启动器路径永久固定:快捷方式、任务栏固定项、注册表文件关联永远指向安装根目录下的启动器 CeraForge.exe,不随版本迭代变动。
  3. 用户数据完全解耦:用户个人数据由独立的数据目录承载,与具体的客户端二进制版本隔离,版本切换或回滚绝不丢失数据。

这一步完成后,日常的版本更新操作全部收敛在当前用户的权限范围内,不需要、也坚决不再请求管理员提权。


启动器为什么必须是轻量原生实现

很多团队在做多进程分发时,会习惯性地用 Electron 或 Node.js 脚本写一个 wrapper 作为入口。但在真实桌面场景中,这是行不通的。

作为常驻桌面的总入口,启动器必须轻量、启动迅速,并且必须在各类老旧 Windows 环境中绝对稳定。 VS Code 团队选择用 Rust 编写其 inno_updater,而我们在评估后选择了 C++ (MSVC) + 静态链接 CRT (/MT)。

原因很现实:

  • Go 自 1.21 起放弃了 Windows 7 支持,标准库存在隐式调用高版本 API 的风险;
  • Rust 的 stable MSVC 目标同样对老旧环境有系统调用依赖;
  • 启动器如果跑不起来,整个应用程序就会直接陷入“双击毫无反应”的瘫痪状态,这条入口通道必须零依赖。

为了确保绝对的系统兼容性,我们给启动器原生工程设立了严格的导入表构建门禁:

  • _WIN32_WINNT=0x0601,PE 头子系统版本强制写入 6.01;
  • 静态检查 PE 导入表,严禁引入 bcrypt.dll、RegDeleteTreeW 等非基础依赖;
  • 严禁使用会把 GetSystemTimePreciseAsFileTime 带入导入表的 std::thread,改用底层的 Win32 API CreateThread。

最终编译出的启动器体积不到 1MB,启动耗时仅数毫秒。


更新管线:三步密码学验签与硬链接展开

在后台静默下载并构建新版本目录时,不能只图速度而牺牲完整性校验。

下图详细展示了更新器客户端在后台处理更新包的五阶段管线:

更新展开校验流程.chart

1. 传输校验与断点续传

更新器后台向服务端发起 resolve 请求获取版本信息。下载采用 HTTP Range 请求,支持断点续传。

这里有一个关键细节:只有当服务端返回状态码明确为 206 Partial Content,且响应头 Content-Range 的起始偏移量与本地 .partial 文件现有大小完全吻合时,才允许续传。如果服务端返回 200,说明代理或对象存储忽略了 Range 请求,必须丢弃旧文件全量重下;若起始位置不一致,说明服务端远端文件已变更,同样必须清理后重新开始。如果不严格拦截这些情况,就会组装出“大小吻合但数据错位”的坏包。

2. Ed25519 签名与清单验证

下载完成后,更新器首先根据 resolve 元数据校验 zip 整包的 SHA-256。

随后读取包内清单,使用客户端预埋的 Ed25519 公钥对清单签名进行验证。这里严格遵循先验签名、再信任清单的原则,防止攻击者篡改清单中的文件哈希。清单通过校验后,逐项核对产品名称、产品线分支、架构标识与 SemVer 版本号。

3. Staging 展开与文件级硬链接

为了减少更新时的磁盘 I/O 和展开耗时,更新器采用文件级增量复用。

更新器在 staging\ 临时目录中展开新版本:

  • 比对当前运行中版本与新版本的清单;
  • 对于内容哈希一致的未修改文件,直接调用 Windows 系统的 fs.link (CreateHardLink) 从当前版本目录硬链接到新目录;
  • 当遇到跨盘符或不支持硬链接的文件系统时,平滑降级为常规复制;
  • 只有发生变动的代码和资源文件才会从下载的 zip 包中解压。

4. 严苛的全量哈希复算与原子提交

很多更新器在硬链接复用后就直接跳过了哈希检验,这是巨大的安全隐患——旧版本目录中的文件可能在运行时被杀毒软件误杀、篡改或发生坏道损坏。

因此,更新器会在展开完毕后,对照清单逐一复算 staging\ 下每个文件的 SHA-256(包括刚刚硬链接复用的文件)。一旦发现哈希不符,或者目录中多出了清单未列出的未知文件、少掉了必要文件,立即中断提交流程并退回全量清理。

只有全部文件哈希百分之百通过后,更新器才会向目录写入 .ready 标记文件,最后通过 MoveFileEx 将 staging\ 原子重命名为 app-<semver>。


优雅交接:重启接力与进程清理陷阱

新版本在后台就绪后,客户端界面仅在设置页或右下角安静地展示一条提示:“新版本已就绪,重启以更新”。用户随时可以继续工作,无需中断手头操作。

当用户主动点击“立即重启”时,进程交接并不是调用简单的 app.relaunch() 即可。

如果直接调用 app.relaunch(),Electron 拉起的是当前旧版本目录下的自身可执行文件,再次启动时只会绕回旧版本,不仅无法立即切换,还会触发多实例互斥抢占。

正确的交接方式是:

  1. 主进程以独立分离(detached)模式拉起安装根下的启动器:CeraForge.exe --wait-pid <自身PID>;
  2. Electron 应用立即执行 app.quit() 自行退场;
  3. 启动器在后台通过 Windows OpenProcess 和 WaitForSingleObject 监听旧进程句柄,直到旧进程彻底释放文件锁(设置 30 秒超时熔断);
  4. 旧进程完全退出后,启动器枚举所有携带 .ready 凭证的 app-* 目录,挑选 SemVer 版本号最高的就绪版本,调用 CreateProcess 启动新客户端。

在版本目录清理上,也有极易踩坑的工程细节。

在 Windows 下,运行中的 exe、dll 和 asar 文件会被系统强制加锁,不能被直接删除。我们曾经尝试在卸载或清理时用通配符 RMDir /r "$INSTDIR\app-*",结果发现一个版本都没删掉——因为通配符在底层路径 API 里被直接当成非法字符剥离。

最终收敛出的稳妥方案是先改名探测、再原子清理:

  • 当需要清理老旧版本时,先尝试将其重命名为 app-<semver>.pending-delete;
  • 如果目录内有文件被占用,Windows 会立即返回重命名拒绝,启动器借此能够无副作用地探测出版本占用状态,不误伤运行中的实例;
  • 如果重命名成功,说明无进程占用,随后启动异步线程进行全量删除;即便中途断电或中断,残余的也只是带标记的废弃目录,启动器在下一次运行时会统一扫描清理,绝不残留假死版本。

避坑经验:主进程 asar 补丁与健康标记误杀

在这套无感更新系统落地的过程中,我们踩过两处极其隐蔽的深水坑。

1. 更新器撞上 Electron 的 asar 文件系统补丁

更新系统上线初期,线上偶现报错:更新失败:Invalid package ...\resources\app.asar。

排查后发现,Electron 为了支持直接读取 asar 包内的脚本,在主进程的 Node.js fs 模块上打了一层深度补丁。只要传入的路径字符串包含 .asar,fs 就会自动将其当成虚拟归档目录来解析。

当更新器在解压新版本时,新写入的 staging/app-<ver>/resources/app.asar 是一个刚创建的普通磁盘文件。但更新器的 fs.existsSync 和 fs.readdir 统统被重定向进了 Electron 补丁层:

  • 展开过程中的临时 asar 被当作损坏的归档抛出 Invalid package;
  • readdir 把包内代码列成目录项,与更新清单比对时报出成百上千个“未知多余文件”;
  • 增量硬链接调用直接崩溃。

解法:在更新器模块中,彻底禁止 import fs from 'node:fs',强制全链路切换为 Electron 暴露的未打补丁的原生文件系统:

import originalFs from 'original-fs';
// 所有版本目录的解压、校验、硬链接操作均使用 originalFs

同时严禁使用全局 process.noAsar = true,因为那是进程维度的粗暴开关,会直接破坏主进程正常加载其他业务模块的能力。

2. 缩略图后台调用引发的版本回退事故

早期为了实现容灾,我们在启动器里设计了“启动健康检查机制”:新版本启动后必须稳定运行 15 秒并写出 .healthy 标记;如果连续 2 次启动未写出标记,启动器便将该版本标记为 .bad 并自动回退到上一个版本。

结果上线后引发了一场严重故障:部分用户的客户端在升级到新版后,桌面上原有的旧工程文件触发了 Windows 资源管理器缩略图提取。资源管理器调用启动器后台提取缩略图,该命令行模式在窗口渲染前就极速执行完毕并退出,根本不可能等待 15 秒写入 .healthy。

两次缩略图提取一触发,启动器直接将刚刚升级的新版本判为损坏,将其彻底回退并删除了新版本目录。而旧版本打开已经被新架构升级过的数据库时,触发版本保护弹窗拦截,用户陷入死循环。

这次事故给我们的教训非常深刻:在桌面复杂环境里,不要盲目引入自以为聪明的自动回退逻辑。

客户端最终彻底移除了自动回退机制。启动器只认经过密码学与哈希全量验收的 .ready 凭证,始终坚定拉起最高可用版本;真正的容灾交由清晰的错误展示和手动的修复向导去兜底。


结语

把 Windows 桌面客户端的更新做成“无感”,本质上是在 Windows 独特的权限模型与文件锁机制下,把每一层边界和状态流转梳理到极致。

当安装根目录与管理员提权彻底解耦、版本目录具备不可变性、更新包拥有全链路端到端密码学防护时,客户端更新就不再是一次需要用户严阵以待的“系统级大手术”,而是像浏览器加载新资源一样平滑自如。