fix: OTA 更新需用户同意、下载完整性与暂存区管理

- 安装同意标记:仅「下次启动安装」写入,main.rs 凭标记才自动安装,
  跳过确认对话框不会再被强制升级;对话框新增「暂不更新」(删除暂存包)
- 下载完整性:API 提供大小却未下满/超量时报错(read 返回 0 无法
  区分干净结束与连接截断);失败路径统一清理 tmp 文件
- 版本比较遵循 semver 预发布语义(1.0.0-beta.2 不比 1.0.0 新);
  暂存文件名按尾部锚点解析,预发布后缀不再丢失导致重复下载
- 多版本暂存并存时取最新(readdir 顺序无序);下载完成后清理旧版本残留
- install_staged:spawn 失败(杀毒锁文件)自动恢复旧版本并如实报告回滚结果
- agent 加整体 10 分钟超时;cleanup_old 大小写不敏感、跳过近期 tmp
  避免误删并行实例正在写入的文件
- 设置对话框:下载进度条传百分比(原 0~1 小数只显示 1%);
  立即安装失败显示具体错误;Sentry release 与更新检查同源版本号
This commit is contained in:
2026-09-29 10:33:28 +08:00
parent f1a7c9eb45
commit 5dc1a8cc0b
3 changed files with 259 additions and 62 deletions
+21 -8
View File
@@ -13,19 +13,32 @@ fn init_sentry() -> sentry::ClientInitGuard {
sentry::init(
sentry::ClientOptions::new()
.dsn(SENTRY_DSN)
.release(format!("mqttx@{}", env!("CARGO_PKG_VERSION"))),
// 与更新检查用同一版本来源(尊重 MQTTX_VERSION_OVERRIDE),保持一致
.release(format!("mqttx@{}", update::current_version())),
)
}
fn main() {
// OTA 收尾:清理遗留文件;若暂存区有待安装的新版本(用户上次同意安装),
// 在进入 UI 前完成自替换并重启。
// OTA 收尾:清理遗留文件;若暂存区有待安装的新版本且用户已同意
// (「下次启动安装」),在进入 UI 前完成自替换并重启。
update::cleanup_old();
if let Some(staged) = update::load_staged()
&& update::install_staged(&staged).is_ok()
{
// 新进程已拉起,当前进程立即退出
std::process::exit(0);
if let Some(staged) = update::load_staged() {
if update::install_consent_matches(&staged.version) {
match update::install_staged(&staged) {
Ok(()) => {
// 新进程已拉起,当前进程立即退出
std::process::exit(0);
}
Err(_) => {
// 安装失败:清除同意标记,继续正常启动,
// 由应用内的更新检查重新弹窗询问。
update::clear_install_consent();
}
}
} else {
// 暂存包存在但用户未同意安装(例如跳过了确认对话框):
// 不做任何替换,等待应用内更新流程再次询问。
}
}
let _sentry_guard = init_sentry();
+28 -7
View File
@@ -233,15 +233,15 @@ impl SettingsDialog {
.primary()
.xsmall()
.on_click(move |_, window, cx| {
if update::install_staged(&staged_now).is_ok() {
cx.quit();
} else {
if let Err(e) = update::install_staged(&staged_now) {
window.push_notification(
Notification::error(
"安装失败,请重试",
),
Notification::error(format!(
"安装失败:{e}"
)),
cx,
);
} else {
cx.quit();
}
}),
)
@@ -251,6 +251,10 @@ impl SettingsDialog {
.outline()
.xsmall()
.on_click(cx.listener(|this, _, window, cx| {
// 写入同意标记:下次启动据此自动安装
if let UpdateUi::ReadyToInstall(staged) = &this.update {
update::mark_install_consent(&staged.version);
}
this.update = UpdateUi::Idle;
window.push_notification(
Notification::info(
@@ -260,6 +264,23 @@ impl SettingsDialog {
);
cx.notify();
})),
)
.child(
Button::new("upd-discard")
.label("放弃此次更新")
.outline()
.xsmall()
.on_click(cx.listener(|this, _, window, cx| {
if let UpdateUi::ReadyToInstall(staged) = &this.update {
update::discard_staged(staged);
}
this.update = UpdateUi::Idle;
window.push_notification(
Notification::info("已删除暂存的更新包"),
cx,
);
cx.notify();
})),
),
)
}
@@ -283,7 +304,7 @@ impl SettingsDialog {
.gap_1()
.child(
gpui_kit::component::progress::Progress::new("update-progress")
.value(pct),
.value(pct * 100.),
)
.child(
div()
+210 -47
View File
@@ -60,6 +60,8 @@ pub fn asset_name_for(version: &str) -> String {
fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
// 整体上限:防止慢速滴流的下载无限占用阻塞线程
.timeout(std::time::Duration::from_secs(10 * 60))
.timeout_connect(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.timeout_read(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.build()
@@ -74,16 +76,25 @@ fn http_get_json(url: &str) -> Result<serde_json::Value, String> {
.map_err(|e| format!("解析响应失败: {e}"))
}
/// 从版本字符串提取数字段(容忍 v 前缀与 -beta 之类后缀)。
/// 去掉 v 前缀与预发布后缀(`-` 及之后),返回数字主体。
fn release_part(v: &str) -> &str {
let v = v.trim().trim_start_matches(['v', 'V']);
match v.find('-') {
Some(i) => &v[..i],
None => v,
}
}
/// 从版本字符串提取数字段(容忍 v 前缀;预发布后缀(`-` 之后)不参与比较)。
pub fn parse_version(v: &str) -> Vec<u32> {
v.trim()
.trim_start_matches(['v', 'V'])
.split(['.', '-'])
release_part(v)
.split('.')
.filter_map(|s| s.parse::<u32>().ok())
.collect()
}
/// a 是否比 b 新(按数字段逐位比较,缺失段视为 0)。
/// 预发布版本(含 `-` 后缀)不会比同数字段的正式版新(semver 语义)。
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());
@@ -102,11 +113,16 @@ pub fn pick_assets(rel: &serde_json::Value) -> Option<(String, String, u64, Opti
let want_ext = if cfg!(windows) { ".exe" } else { "" };
let triple = platform_triple();
for a in assets {
let name = a.get("name")?.as_str()?;
let Some(name) = a.get("name").and_then(|n| n.as_str()) else {
continue;
};
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 Some(url) = a.get("browser_download_url").and_then(|u| u.as_str()) else {
continue;
};
let url = url.to_string();
let size = a.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
// 侧车命名:<asset>.sha256
let sha = assets
@@ -205,16 +221,41 @@ pub fn download_and_stage(
) -> 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()));
// tmp 名加入毫秒时间戳:同 pid 并发下载不共用文件
let tmp = dir.join(format!(
"{}.{}.{}.tmp",
final_name,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
));
let dst = dir.join(&final_name);
if let Err(e) = download_to(info, &tmp, on_progress) {
// 失败路径统一清理临时文件
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
// 清掉其他版本的残留暂存文件,避免堆积与误装旧版
remove_other_staged(&dir, &dst);
std::fs::rename(&tmp, &dst).map_err(|e| format!("暂存失败: {e}"))?;
Ok(StagedUpdate {
version: info.version.clone(),
path: dst,
})
}
fn download_to(info: &UpdateInfo, tmp: &std::path::Path, on_progress: &dyn Fn(u64, u64)) -> Result<(), String> {
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 total = info.size;
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();
@@ -232,25 +273,45 @@ pub fn download_and_stage(
std::io::Write::flush(&mut file).ok();
drop(file);
// 传输完整性:API 提供了大小却没下满/超量,视为损坏(read 返回 0
// 无法区分干净结束与连接提前断开,必须与预期大小对账)
if total > 0 && downloaded != total {
return Err(format!(
"下载不完整:期望 {total} 字节,实际 {downloaded} 字节"
));
}
// sha256 校验
let expect = fetch_expected_sha256(info)?;
if let Some(expect) = expect {
if let Some(expect) = fetch_expected_sha256(info)? {
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}"
));
}
}
Ok(())
}
// 覆盖旧暂存(不同版本重名时以新版本为准)
let _ = std::fs::remove_file(&dst);
std::fs::rename(&tmp, &dst).map_err(|e| format!("暂存失败: {e}"))?;
Ok(StagedUpdate {
version: info.version.clone(),
path: dst,
})
/// 删除暂存目录中除 `keep` 外的其他版本暂存文件。
fn remove_other_staged(dir: &std::path::Path, keep: &std::path::Path) {
if let Ok(entries) = std::fs::read_dir(dir) {
for e in entries.flatten() {
let p = e.path();
if p == *keep {
continue;
}
let name = e.file_name();
let name = name.to_string_lossy();
if name.starts_with("mqttx-v")
&& name.contains(platform_triple())
&& !name.ends_with(".tmp")
&& !name.ends_with(".sha256")
{
let _ = std::fs::remove_file(&p);
}
}
}
}
/// 读取 `.sha256` 侧车内容,返回其中的哈希值。没有侧车时返回 None。
@@ -277,32 +338,44 @@ fn fetch_expected_sha256(info: &UpdateInfo) -> Result<Option<String>, String> {
}
}
/// 从暂存文件名解析版本号(需匹配当前平台的命名约定;`.tmp` 不算)。
/// 预发布后缀原样保留。
pub fn parse_staged_name(name: &str) -> Option<String> {
let ext = if cfg!(windows) { ".exe" } else { "" };
let suffix = format!("-{}-mqttx{ext}", platform_triple());
if name.contains(".tmp") {
return None;
}
name.strip_prefix("mqttx-v")?
.strip_suffix(&suffix)
.map(|s| s.to_string())
}
/// 扫描暂存区,返回待安装的更新(文件存在且比当前版本新才算)。
/// 多个版本并存时取最新版本(readdir 顺序不保证有序)。
pub fn load_staged() -> Option<StagedUpdate> {
let dir = stage_dir().ok()?;
let ext = if cfg!(windows) { ".exe" } else { "" };
let triple = platform_triple();
let mut best: Option<StagedUpdate> = None;
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") {
if !e.path().is_file() {
continue;
}
let version = rest
.trim_end_matches(ext)
.split('-')
.next()?
.to_string();
let name = e.file_name();
let name = name.to_string_lossy().to_string();
let Some(version) = parse_staged_name(&name) else {
continue;
};
if !is_newer(&version, current_version()) {
continue;
}
return Some(StagedUpdate { version, path: e.path() });
if best.as_ref().is_none_or(|b| is_newer(&version, &b.version)) {
best = Some(StagedUpdate {
version,
path: e.path(),
});
}
}
None
best
}
/// 安装暂存的更新:替换自身并拉起新进程(旧文件改名 `.old`,
@@ -317,15 +390,71 @@ pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> {
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}"));
let rollback = std::fs::rename(&old, &cur);
return Err(format!(
"替换失败: {e};{}",
rollback_result(rollback, &old)
));
}
std::process::Command::new(&cur)
.spawn()
.map_err(|e| format!("启动新版本失败: {e}"))?;
if let Err(e) = std::process::Command::new(&cur).spawn() {
// 新文件无法启动(常见:杀毒软件锁定刚落地的文件):恢复旧版本
let _ = std::fs::remove_file(&cur);
let rollback = std::fs::rename(&old, &cur);
return Err(format!(
"启动新版本失败: {e};{}",
rollback_result(rollback, &old)
));
}
clear_install_consent();
Ok(())
}
/// 描述回滚结果;回滚失败时提示 `.old` 备份仍保留在原处。
fn rollback_result(rollback: std::io::Result<()>, old: &std::path::Path) -> String {
match rollback {
Ok(()) => "已恢复原版本".to_string(),
Err(re) => format!(
"回滚失败({re}),原版本备份保留在 {}",
old.display()
),
}
}
/// 安装同意标记:仅当用户在更新对话框明确选择「下次启动安装」后才写入,
/// 启动时据此自动安装——用户未选择的更新不会被强制替换。
fn consent_path() -> Result<PathBuf, String> {
stage_dir().map(|d| d.join("install-consent.txt"))
}
/// 记录对指定版本的安装同意。
pub fn mark_install_consent(version: &str) {
if let Ok(p) = consent_path() {
let _ = std::fs::write(p, version);
}
}
/// 清除安装同意标记。
pub fn clear_install_consent() {
if let Ok(p) = consent_path() {
let _ = std::fs::remove_file(p);
}
}
/// 同意标记是否与暂存的版本匹配(版本不匹配视为未同意)。
pub fn install_consent_matches(staged_version: &str) -> bool {
consent_path()
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|v| v.trim() == staged_version)
.unwrap_or(false)
}
/// 用户拒绝本次更新:删除暂存包与同意标记。
pub fn discard_staged(staged: &StagedUpdate) {
let _ = std::fs::remove_file(&staged.path);
clear_install_consent();
}
/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。
pub fn cleanup_old() {
if let Ok(cur) = std::env::current_exe()
@@ -333,24 +462,35 @@ pub fn cleanup_old() {
&& let Ok(entries) = std::fs::read_dir(dir)
{
for e in entries.flatten() {
// Windows 文件系统不区分大小写,统一按小写匹配
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 lower = name.to_string_lossy().to_lowercase();
if (lower.ends_with(".old") && lower.starts_with("mqttx"))
|| lower.ends_with(".update.tmp")
{
let _ = std::fs::remove_file(e.path());
}
}
}
// 暂存目录里中断的 .tmp
// 暂存目录里中断的 .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());
if !name.to_string_lossy().ends_with(".tmp") {
continue;
}
let recent = std::fs::metadata(e.path())
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age < std::time::Duration::from_secs(10 * 60));
if recent {
continue;
}
let _ = std::fs::remove_file(e.path());
}
}
}
@@ -366,11 +506,34 @@ mod tests {
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)
// 预发布版本不比同数字段的正式版新(semver 语义)
assert!(!is_newer("1.0.0-beta", "1.0.0"));
assert!(!is_newer("1.0.0-beta.2", "1.0.0"));
assert!(!is_newer("1.0.0", "1.0.0"));
assert!(!is_newer("0.9", "1.0.0"));
// 更高版本号的预发布仍是更新(1.0.1-beta > 1.0.0)
assert!(is_newer("1.0.1-beta", "1.0.0"));
assert_eq!(parse_version("v1.2.3-beta"), vec![1, 2, 3]);
assert_eq!(parse_version("v1.2.3"), vec![1, 2, 3]);
}
#[cfg(windows)]
#[test]
fn staged_name_parsing_keeps_prerelease_suffix() {
let triple = platform_triple();
assert_eq!(
parse_staged_name(&format!("mqttx-v1.0.3-{triple}-mqttx.exe")),
Some("1.0.3".to_string())
);
assert_eq!(
parse_staged_name(&format!("mqttx-v1.0.3-beta.1-{triple}-mqttx.exe")),
Some("1.0.3-beta.1".to_string())
);
assert_eq!(
parse_staged_name(&format!("mqttx-v1.0.3-{triple}-mqttx.exe.1234.56.tmp")),
None
);
assert_eq!(parse_staged_name("其他文件.exe"), None);
}
#[test]