Rust 桌面应用自托管自动更新:latest.json 清单 + minisign 签名校验的完整做法
学习目标:学完这篇,你会给自己的 Rust 桌面应用接上一套不依赖 Tauri updater / electron-updater 的自托管自动更新------清单轮询、下载、验签、换包、重启,全链路自己可控,还能防 CDN 投毒。步骤给全,可直接照抄。
0. 先说问题
我的 boundless 是个 Rust 写的桌面白板(GPUI 框架),发版后用户手里的版本就凝固了。靠用户手动下载新包,更新率惨不忍睹。Tauri 和 Electron 都有官方 updater,但纯 Rust 写的桌面应用没有现成轮子------那就自己搭一条链路。
boundless 的做法是:一个 latest.json 清单 + minisign 签名,src/updater.rs 几百行 Rust 全链路打通,外加一份 139 行的配置手册 UPDATER_CONFIG.md。今天把这套做法拆开讲。
1. 前置知识:更新链路只有五步
动手前先把链路想清楚,自动更新归根结底是五步:查 (有没有新版)→ 下 (下载产物)→ 验 (签名对不对)→ 换 (替换旧文件)→ 起(重启)。每一步的失败处理想清楚了,链路就稳了。
再科普一个概念「minisign」:轻量签名工具,一对密钥,签名就是 4 行文本的 .sig 文件。公钥可以编译进二进制------这是整套方案信任锚的关键。
2. 第一步:生成密钥对,公钥 bake 进二进制
用仓库自带的 examples/gen_minisign_keypair.rs 生成密钥对。注意一个坑:必须用这个 helper 生成,别用 minisign CLI ------两者密钥 KDF 不一致,用 CLI 生成的密钥,签名端(examples/sign_release.rs,同一个 minisign crate)解不开。
公钥写成常量编译进二进制:
rust
pub const MANIFEST_URL: &str = "https://boundlesscdn.xi-n.com/boundless-latest.json";
pub const MINISIGN_PUBKEY: &str = "RWTgy8P9e5EU/l+TFMe/JOCA/Dkn+5dDYdQgQySZUl959/zDM+NnbK9X";
私钥只存在 CI 的 secret 里,这辈子不出 CI。还有个守卫函数:
rust
pub fn signing_configured() -> bool {
!MINISIGN_PUBKEY.contains("REPLACE_ME")
}
公钥还是占位符 REPLACE_ME 时,验签直接拒绝------没配好密钥的构建,更新功能就当不存在。
翻译成人话:验签的"根信任"钉死在二进制里,不经过网络。CDN 被投毒也装不上坏包。
3. 第二步:CI 签名产物,生成 latest.json
发版时 CI 跑 examples/sign_release.rs:用私钥给产物签名,生成 <产物>.sig,把签名内容嵌进 latest.json:
json
{
"version": "0.4.8",
"notes": "...",
"pubdate": "...",
"platforms": {
"windows-x86_64": { "signature": "<.sig 的 4 行文本>", "url": "https://..." },
"darwin-aarch64": { "signature": "<.sig 的 4 行文本>", "url": "https://..." }
}
}
对应代码里的 Manifest / PlatformEntry 结构(src/updater.rs)。schema 兼容 Tauri updater 那套(version、notes、pubdate、按平台分的 signature + url),latest.json 托管在 CDN 上。平台 key 按系统区分:windows-x86_64、darwin-aarch64、darwin-x86_64,不支持的平台直接标 unsupported。
4. 第三步:客户端轮询清单,比版本
rust
pub async fn fetch_manifest() -> Result<Manifest>
拉 latest.json 时,URL 后面拼 ?t={时间戳} 做缓存 bust------不然 CDN 缓存会让你"永远没新版"。is_newer() 做 semver 版本比较,current_version() 取编译进二进制的 CARGO_PKG_VERSION。
调试技巧:debug 构建认 BOUNDLESS_FAKE_VERSION 环境变量,可以假装自己是旧版本,把"标题栏图标 → 更新弹窗 → 重启提示"整条 UI 链路走一遍,不用真发版。
5. 第四步:下载到临时文件,带进度
rust
pub async fn download<F>(url: &str, dest: &Path, progress: F) -> Result<()>
异步下载,带进度回调(给进度条用)。关键:先下到临时文件,不直接覆盖运行中的程序。下载和换包是两个阶段,中间隔着验签。
6. 第五步:验签,不通过绝不应用
rust
pub fn verify(artifact: &Path, signature: &str) -> Result<()>
用编译进二进制的公钥验 .sig。签名不对、公钥是占位符,一律拒绝,更新流程到此为止,旧版本继续跑。
翻译成人话:这是整条链路最重要的一行纪律------验签失败就当没这回事,绝不"先装上再说"。自动更新最大的风险不是"更不上",是"更上了坏东西"。
7. 第六步:平台差异化换包
rust
pub fn apply(artifact: &Path) -> Result<()>
- Windows :运行中的 exe 不能被覆盖,但可以被改名。做法:从 zip 里解出新 exe 放到临时位置 → 把运行中的 exe 改名成
.old→ 把新 exe 移进来 → 移动失败就回滚(把.old移回来,保证应用还能启动)。.old下次启动时由cleanup_old()清理。 - macOS :不能替换运行中的文件,那就整包 swap------把新的
Boundless.app解到同级临时目录,整个目录换掉旧包。 - 其他平台:直接 bail "not implemented"。不支持就是不支持,不装假。
8. 第七步:重启,退出旧进程
rust
pub fn restart() -> Result<()>
起新进程,然后 std::process::exit(0)。新旧交接完成,用户下次看到的就是新版本。
9. 为什么这样设计
- 公钥编译进二进制:信任锚不经过网络,这是防投毒的根本。
- macOS 整包 swap:系统限制绕不过去,就顺着它------换整个 .app 而不是单个文件。
- Windows 两步换包 + 回滚:rename 旧文件、move 新文件,中间失败能回到旧版本,应用永远可启动。
- 占位符守卫:密钥没配好的构建,更新功能静默不存在,而不是带着假信任跑。
10. 避坑清单
- 密钥用配套 helper 生成,别用 minisign CLI(KDF 不一致,签名端解不开)。
- latest.json 一定要加缓存 bust,不然 CDN 缓存让你"永远没新版"。
- 验签失败的处理是"当没这回事",不是重试------签名不对重试 100 次也不对。
- Windows 换包顺序别反:先 rename 旧的,再 move 新的,失败回滚。直接覆盖运行中的 exe 会失败。
11. 总结
自托管自动更新七步:密钥对(公钥 bake 进二进制)→ CI 签名 + latest.json → 客户端轮询比版本 → 下载到临时文件 → 验签(不过就停)→ 平台差异化换包 → 重启。核心思想一句话:信任锚钉死在二进制里,失败时永远能回到可启动的旧版本。
给开发者的建议:如果你也在用 Rust 写桌面应用(GPUI、egui、iced 都一样),这套链路可以直接抄------updater.rs 的结构是平台无关的,换掉产物格式和平台分支就能用。
项目开源在 GitHub:tsingliuwin/boundless,觉得有用欢迎点个 star。