Files
mqttx/gpui-app/src/update.rs
T
JoeAllen bc49d1839c feat: OTA 改为静默下载 + 安装需用户确认
- 下载落到数据目录 updates/ 暂存区(退出不丢失),校验后等待安装
- 启动自动检查:静默检查+下载,完成后弹「更新就绪」对话框,
  由用户选择 立即安装(重启) 或 下次启动安装
- main 在进入 UI 前检测暂存区并完成自替换(下次启动安装路径)
- 设置对话框:下载完成后进入「待安装」状态,提供两个安装时机按钮
- install_and_restart 拆分为 download_and_stage / load_staged / install_staged
2026-09-29 00:22:35 +08:00

425 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! OTA 自更新:以 Gitea Release 为更新源。
//!
//! 流程:`check_latest`(比较最新 Release 与当前版本)→ `download_and_verify`
//! (下载到 exe 同目录临时文件并校验 sha256)→ `install_and_restart`
//! (改名自替换后拉起新进程)。全程 HTTPS + sha256 校验。
use std::io::Read;
use std::path::PathBuf;
use sha2::Digest as _;
/// 更新源(自建 Gitea,仓库公开、附件可直接下载,无需认证)。
pub const GITEA_URL: &str = "https://gitea.heavenlybook.cn";
pub const PKG_OWNER: &str = "JoeAllen";
pub const PKG_REPO: &str = "mqttx";
const HTTP_TIMEOUT_SECS: u64 = 30;
/// 发现的可用更新。
#[derive(Debug, Clone)]
pub struct UpdateInfo {
/// 新版本号(不含 v 前缀),如 "1.0.1"
pub version: String,
/// Release 说明(Markdown 原文)
pub notes: String,
/// 平台对应产物下载地址
pub asset_url: String,
/// sha256 校验文件地址(若 Release 提供了 `.sha256` 侧车)
pub sha256_url: Option<String>,
/// 产物字节数(API 提供,用于下载进度)
pub size: u64,
}
/// 当前版本。测试可用环境变量 `MQTTX_VERSION_OVERRIDE` 伪装旧版本,
/// 验证"发现新版本"路径。
pub fn current_version() -> &'static str {
static VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
VERSION.get_or_init(|| {
std::env::var("MQTTX_VERSION_OVERRIDE")
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string())
})
}
/// 构建目标的平台三元组,需与 release.yml / publish 脚本的产物命名一致。
pub fn platform_triple() -> &'static str {
if cfg!(windows) {
"x86_64-pc-windows-msvc"
} else if cfg!(target_os = "macos") {
"x86_64-apple-darwin"
} else {
"x86_64-unknown-linux-gnu"
}
}
/// 某版本的产物文件名(与 CI 的命名约定 `mqttx-<ver>-<triple>-mqttx[.exe]` 一致)。
pub fn asset_name_for(version: &str) -> String {
let ext = if cfg!(windows) { ".exe" } else { "" };
format!("mqttx-v{}-{}-mqttx{}", version, platform_triple(), ext)
}
fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.timeout_read(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.build()
}
fn http_get_json(url: &str) -> Result<serde_json::Value, String> {
let resp = agent()
.get(url)
.call()
.map_err(|e| format!("请求失败: {e}"))?;
resp.into_json::<serde_json::Value>()
.map_err(|e| format!("解析响应失败: {e}"))
}
/// 从版本字符串提取数字段(容忍 v 前缀与 -beta 之类后缀)。
pub fn parse_version(v: &str) -> Vec<u32> {
v.trim()
.trim_start_matches(['v', 'V'])
.split(['.', '-'])
.filter_map(|s| s.parse::<u32>().ok())
.collect()
}
/// a 是否比 b 新(按数字段逐位比较,缺失段视为 0)。
pub fn is_newer(a: &str, b: &str) -> bool {
let (a, b) = (parse_version(a), parse_version(b));
let len = a.len().max(b.len());
for i in 0..len {
let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
if x != y {
return x > y;
}
}
false
}
/// 从 Release JSON 里挑选当前平台的产物与 sha256 侧车。
pub fn pick_assets(rel: &serde_json::Value) -> Option<(String, String, u64, Option<String>)> {
let assets = rel.get("assets")?.as_array()?;
let want_ext = if cfg!(windows) { ".exe" } else { "" };
let triple = platform_triple();
for a in assets {
let name = a.get("name")?.as_str()?;
if !name.contains(triple) || !name.ends_with(want_ext) || name.ends_with(".sha256") {
continue;
}
let url = a.get("browser_download_url")?.as_str()?.to_string();
let size = a.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
// 侧车命名:<asset>.sha256
let sha = assets
.iter()
.find(|b| {
b.get("name").and_then(|n| n.as_str()) == Some(&format!("{name}.sha256"))
})
.and_then(|b| b.get("browser_download_url"))
.and_then(|u| u.as_str())
.map(|s| s.to_string());
return Some((name.to_string(), url, size, sha));
}
None
}
/// 检查更新。返回 Ok(None) 表示已是最新。
pub fn check_latest() -> Result<Option<UpdateInfo>, String> {
let base = format!("{GITEA_URL}/api/v1/repos/{PKG_OWNER}/{PKG_REPO}/releases");
// 优先 latest 端点;旧版 Gitea 没有时回退到列表取第一个非草稿/预发布
let rel = match http_get_json(&format!("{base}/latest")) {
Ok(v) => v,
Err(_) => {
let list = http_get_json(&format!("{base}?limit=5"))?;
let first = list
.as_array()
.and_then(|l| l.iter().find(|r| {
r.get("draft").and_then(|d| d.as_bool()) == Some(false)
&& r.get("prerelease").and_then(|d| d.as_bool()) == Some(false)
}))
.ok_or_else(|| "没有可用 Release".to_string())?;
first.clone()
}
};
let tag = rel
.get("tag_name")
.and_then(|t| t.as_str())
.ok_or_else(|| "Release 缺少 tag_name".to_string())?;
if !is_newer(tag, current_version()) {
return Ok(None);
}
let Some((_, url, size, sha)) = pick_assets(&rel) else {
return Err(format!("Release {tag} 没有当前平台({})的产物", platform_triple()));
};
Ok(Some(UpdateInfo {
version: tag.trim_start_matches('v').to_string(),
notes: rel
.get("body")
.and_then(|b| b.as_str())
.unwrap_or_default()
.to_string(),
asset_url: url,
sha256_url: sha,
size,
}))
}
/// 已下载并校验、等待安装的更新包(存放在数据目录的暂存区)。
#[derive(Debug, Clone)]
pub struct StagedUpdate {
pub version: String,
pub path: PathBuf,
}
/// 暂存目录:数据目录下的 `updates/`,重启不丢失。
fn stage_dir() -> Result<PathBuf, String> {
let dir = dirs::data_dir()
.unwrap_or_else(std::env::temp_dir)
.join("MQTTX-GPUI")
.join("updates");
std::fs::create_dir_all(&dir).map_err(|e| format!("创建暂存目录失败: {e}"))?;
Ok(dir)
}
/// 检查更新并静默下载到暂存区(供启动自动检查使用)。
/// 返回 Ok(None) 表示已是最新;错误仅记录、不打扰用户。
pub fn check_and_download() -> Result<Option<StagedUpdate>, String> {
let Some(info) = check_latest()? else {
return Ok(None);
};
// 已暂存同版本则不重复下载
if let Some(staged) = load_staged()
&& staged.version == info.version
{
return Ok(Some(staged));
}
download_and_stage(&info, &|_, _| {}).map(Some)
}
/// 下载并校验到暂存区,返回待安装的更新。
///
/// `on_progress(downloaded, total)` 在阻塞线程上回调,total 为 0 表示未知。
pub fn download_and_stage(
info: &UpdateInfo,
on_progress: &dyn Fn(u64, u64),
) -> Result<StagedUpdate, String> {
let dir = stage_dir()?;
let final_name = asset_name_for(&info.version);
let tmp = dir.join(format!("{}.{}.tmp", final_name, std::process::id()));
let dst = dir.join(&final_name);
let resp = agent()
.get(&info.asset_url)
.call()
.map_err(|e| format!("下载失败: {e}"))?;
let mut reader = resp.into_reader();
let total = if info.size > 0 { info.size } else { 0 };
let mut file = std::fs::File::create(&tmp).map_err(|e| format!("创建临时文件失败: {e}"))?;
let mut buf = [0u8; 64 * 1024];
let mut downloaded: u64 = 0;
let mut hasher = sha2::Sha256::new();
loop {
let n = reader.read(&mut buf).map_err(|e| format!("下载中断: {e}"))?;
if n == 0 {
break;
}
std::io::Write::write_all(&mut file, &buf[..n])
.map_err(|e| format!("写入临时文件失败: {e}"))?;
hasher.update(&buf[..n]);
downloaded += n as u64;
on_progress(downloaded, total);
}
std::io::Write::flush(&mut file).ok();
drop(file);
// sha256 校验
let expect = fetch_expected_sha256(info)?;
if let Some(expect) = expect {
let actual = format!("{:x}", hasher.finalize());
if !actual.eq_ignore_ascii_case(&expect) {
let _ = std::fs::remove_file(&tmp);
return Err(format!(
"校验失败:期望 sha256 {expect},实际 {actual}"
));
}
}
// 覆盖旧暂存(不同版本重名时以新版本为准)
let _ = std::fs::remove_file(&dst);
std::fs::rename(&tmp, &dst).map_err(|e| format!("暂存失败: {e}"))?;
Ok(StagedUpdate {
version: info.version.clone(),
path: dst,
})
}
/// 读取 `.sha256` 侧车内容,返回其中的哈希值。没有侧车时返回 None。
fn fetch_expected_sha256(info: &UpdateInfo) -> Result<Option<String>, String> {
let Some(url) = &info.sha256_url else {
return Ok(None);
};
let text = agent()
.get(url)
.call()
.map_err(|e| format!("获取校验文件失败: {e}"))?
.into_string()
.map_err(|e| format!("读取校验文件失败: {e}"))?;
// 常见格式:<hex> <filename> 或纯 hex
let hex = text
.split_whitespace()
.next()
.unwrap_or_default()
.to_lowercase();
if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(Some(hex))
} else {
Err("校验文件格式异常".into())
}
}
/// 扫描暂存区,返回待安装的更新(文件存在且比当前版本新才算)。
pub fn load_staged() -> Option<StagedUpdate> {
let dir = stage_dir().ok()?;
let ext = if cfg!(windows) { ".exe" } else { "" };
let triple = platform_triple();
for e in std::fs::read_dir(&dir).ok()?.flatten() {
let name = e.file_name();
let name = name.to_string_lossy().to_string();
// 命名:mqttx-v<ver>-<triple>-mqttx[.exe]
let Some(rest) = name.strip_prefix("mqttx-v") else {
continue;
};
if !(name.ends_with(ext) && name.contains(triple)) || name.contains(".tmp") {
continue;
}
let version = rest
.trim_end_matches(ext)
.split('-')
.next()?
.to_string();
if !is_newer(&version, current_version()) {
continue;
}
return Some(StagedUpdate { version, path: e.path() });
}
None
}
/// 安装暂存的更新:替换自身并拉起新进程(旧文件改名 `.old`,
/// 新进程启动时由 [`cleanup_old`] 清理)。成功后调用方应退出当前进程。
pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> {
let cur = std::env::current_exe().map_err(|e| format!("无法定位自身: {e}"))?;
let mut old_name = cur.clone().into_os_string();
old_name.push(".old");
let old = PathBuf::from(old_name);
// 旧残留先清掉,避免改名失败
let _ = std::fs::remove_file(&old);
std::fs::rename(&cur, &old).map_err(|e| format!("替换失败(可能无写入权限): {e}"))?;
if let Err(e) = std::fs::rename(&staged.path, &cur) {
// 回滚,尽量保住当前可执行文件
let _ = std::fs::rename(&old, &cur);
return Err(format!("替换失败: {e}"));
}
std::process::Command::new(&cur)
.spawn()
.map_err(|e| format!("启动新版本失败: {e}"))?;
Ok(())
}
/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。
pub fn cleanup_old() {
if let Ok(cur) = std::env::current_exe()
&& let Some(dir) = cur.parent()
&& let Ok(entries) = std::fs::read_dir(dir)
{
for e in entries.flatten() {
let name = e.file_name();
let name = name.to_string_lossy();
if (name.ends_with(".old") && name.starts_with("mqttx"))
|| name.ends_with(".update.tmp")
{
let _ = std::fs::remove_file(e.path());
}
}
}
// 暂存目录里中断的 .tmp
if let Ok(dir) = stage_dir()
&& let Ok(entries) = std::fs::read_dir(&dir)
{
for e in entries.flatten() {
let name = e.file_name();
if name.to_string_lossy().ends_with(".tmp") {
let _ = std::fs::remove_file(e.path());
}
}
}
}
// ─── 单元测试 ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_compare() {
assert!(is_newer("1.0.1", "1.0.0"));
assert!(is_newer("v1.1.0", "1.0.9"));
assert!(is_newer("2.0", "1.9.9"));
// 预发布后缀不做特殊处理(只比较数字段,1.0.0-beta == 1.0.0)
assert!(!is_newer("1.0.0-beta", "1.0.0"));
assert!(!is_newer("1.0.0", "1.0.0"));
assert!(!is_newer("0.9", "1.0.0"));
assert_eq!(parse_version("v1.2.3-beta"), vec![1, 2, 3]);
}
#[test]
fn asset_naming_matches_ci_convention() {
assert_eq!(
asset_name_for("1.0.1"),
format!("mqttx-v1.0.1-{}-mqttx{}", platform_triple(), if cfg!(windows) { ".exe" } else { "" })
);
}
#[test]
fn pick_assets_selects_platform_and_sidecar() {
let rel: serde_json::Value = serde_json::json!({
"tag_name": "v1.0.1",
"assets": [
{"name": "mqttx-v1.0.1-x86_64-unknown-linux-gnu-mqttx", "size": 1,
"browser_download_url": "https://x/linux"},
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe", "size": 19132928,
"browser_download_url": "https://x/win"},
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65,
"browser_download_url": "https://x/win.sha256"}
]
});
let (name, url, size, sha) = pick_assets(&rel).expect("应选中平台产物");
if cfg!(windows) {
assert_eq!(name, "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe");
assert_eq!(url, "https://x/win");
assert_eq!(size, 19132928);
assert_eq!(sha.as_deref(), Some("https://x/win.sha256"));
} else {
assert_eq!(name, "mqttx-v1.0.1-x86_64-unknown-linux-gnu-mqttx");
assert_eq!(url, "https://x/linux");
assert_eq!(sha, None);
}
}
#[test]
fn pick_assets_skips_sidecars_and_other_platforms() {
let rel: serde_json::Value = serde_json::json!({
"tag_name": "v1.0.1",
"assets": [
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65,
"browser_download_url": "https://x/side"},
]
});
if cfg!(windows) {
// 只有侧车、没有主体 → 不应选中侧车
assert!(pick_assets(&rel).is_none());
}
}
}