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:
+21
-8
@@ -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();
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user