- 下载落到数据目录 updates/ 暂存区(退出不丢失),校验后等待安装 - 启动自动检查:静默检查+下载,完成后弹「更新就绪」对话框, 由用户选择 立即安装(重启) 或 下次启动安装 - main 在进入 UI 前检测暂存区并完成自替换(下次启动安装路径) - 设置对话框:下载完成后进入「待安装」状态,提供两个安装时机按钮 - install_and_restart 拆分为 download_and_stage / load_staged / install_staged
425 lines
15 KiB
Rust
425 lines
15 KiB
Rust
//! 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());
|
||
}
|
||
}
|
||
}
|