From 5dc1a8cc0b0050e768a1f808907892147d8b832d Mon Sep 17 00:00:00 2001 From: JoeAllen <2494136784@qq.com> Date: Tue, 29 Sep 2026 10:33:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20OTA=20=E6=9B=B4=E6=96=B0=E9=9C=80?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=90=8C=E6=84=8F=E3=80=81=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7=E4=B8=8E=E6=9A=82=E5=AD=98=E5=8C=BA?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 安装同意标记:仅「下次启动安装」写入,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 与更新检查同源版本号 --- gpui-app/src/main.rs | 29 +++- gpui-app/src/ui/settings_dialog.rs | 35 +++- gpui-app/src/update.rs | 257 +++++++++++++++++++++++------ 3 files changed, 259 insertions(+), 62 deletions(-) diff --git a/gpui-app/src/main.rs b/gpui-app/src/main.rs index 9791f6e..3e6339c 100644 --- a/gpui-app/src/main.rs +++ b/gpui-app/src/main.rs @@ -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(); diff --git a/gpui-app/src/ui/settings_dialog.rs b/gpui-app/src/ui/settings_dialog.rs index 302a28a..f3b46bd 100644 --- a/gpui-app/src/ui/settings_dialog.rs +++ b/gpui-app/src/ui/settings_dialog.rs @@ -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() diff --git a/gpui-app/src/update.rs b/gpui-app/src/update.rs index dbc2ecf..98031cb 100644 --- a/gpui-app/src/update.rs +++ b/gpui-app/src/update.rs @@ -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 { .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 { - v.trim() - .trim_start_matches(['v', 'V']) - .split(['.', '-']) + release_part(v) + .split('.') .filter_map(|s| s.parse::().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); // 侧车命名:.sha256 let sha = assets @@ -205,16 +221,41 @@ pub fn download_and_stage( ) -> Result { 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, String> { } } +/// 从暂存文件名解析版本号(需匹配当前平台的命名约定;`.tmp` 不算)。 +/// 预发布后缀原样保留。 +pub fn parse_staged_name(name: &str) -> Option { + 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 { let dir = stage_dir().ok()?; - let ext = if cfg!(windows) { ".exe" } else { "" }; - let triple = platform_triple(); + let mut best: Option = 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--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 { + 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]