From bc49d1839c40f401012140563f0ba5ac4c865d23 Mon Sep 17 00:00:00 2001 From: JoeAllen <2494136784@qq.com> Date: Mon, 28 Sep 2026 23:59:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20OTA=20=E6=94=B9=E4=B8=BA=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E4=B8=8B=E8=BD=BD=20+=20=E5=AE=89=E8=A3=85=E9=9C=80?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 下载落到数据目录 updates/ 暂存区(退出不丢失),校验后等待安装 - 启动自动检查:静默检查+下载,完成后弹「更新就绪」对话框, 由用户选择 立即安装(重启) 或 下次启动安装 - main 在进入 UI 前检测暂存区并完成自替换(下次启动安装路径) - 设置对话框:下载完成后进入「待安装」状态,提供两个安装时机按钮 - install_and_restart 拆分为 download_and_stage / load_staged / install_staged --- .gitignore | 3 + gpui-app/src/main.rs | 11 +++ gpui-app/src/ui/app.rs | 53 ++++++++--- gpui-app/src/ui/settings_dialog.rs | 82 +++++++++++++---- gpui-app/src/update.rs | 137 ++++++++++++++++++++++------- 5 files changed, 226 insertions(+), 60 deletions(-) diff --git a/.gitignore b/.gitignore index cf672ce..a4377b8 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ src-tauri/target_d *.pdb /.v2c /.video_agent + +# 发布产物暂存目录 +/dist-publish diff --git a/gpui-app/src/main.rs b/gpui-app/src/main.rs index d9f6bef..aa054ac 100644 --- a/gpui-app/src/main.rs +++ b/gpui-app/src/main.rs @@ -2,8 +2,19 @@ use gpui_kit::component::{Root, TitleBar}; use gpui_kit::*; use mqttx_desktop::ui; +use mqttx_desktop::update; fn main() { + // OTA 收尾:清理遗留文件;若暂存区有待安装的新版本(用户上次同意安装), + // 在进入 UI 前完成自替换并重启。 + update::cleanup_old(); + if let Some(staged) = update::load_staged() + && update::install_staged(&staged).is_ok() + { + // 新进程已拉起,当前进程立即退出 + std::process::exit(0); + } + application() // AllAssets 嵌入全量 Lucide 图标;Assets 只含 default-icons.txt 的 101 个, // 其余 IconName(MailOpen/SlidersHorizontal 等)运行时会渲染空白 diff --git a/gpui-app/src/ui/app.rs b/gpui-app/src/ui/app.rs index 8995297..7757d29 100644 --- a/gpui-app/src/ui/app.rs +++ b/gpui-app/src/ui/app.rs @@ -128,21 +128,54 @@ impl MqttXApp { } }); - // OTA:清理上次自更新遗留文件;开启自动检查则后台查一次新版本 + // OTA:清理上次自更新遗留文件;开启自动检查则后台静默检查并下载, + // 下载完成后弹对话框征得同意再安装(不自动重启)。 update::cleanup_old(); if settings.auto_check_update { - let rx = engine.run_blocking(update::check_latest); + let rx = engine.run_blocking(update::check_and_download); let weak = cx.entity().downgrade(); cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| { - if let Ok(Ok(Some(info))) = rx.recv().await { + if let Ok(Ok(Some(staged))) = rx.recv().await { weak.update_in(cx, |_app, window, cx| { - window.push_notification( - Notification::info(format!( - "发现新版本 v{},可在 设置 → 关于 中更新", - info.version - )), - cx, - ); + window.open_dialog(cx, move |dialog, _, _cx| { + let staged = staged.clone(); + dialog + .w(px(440.)) + .title("更新就绪") + .child( + v_flex().gap_2().child( + div().text_sm().child(format!( + "新版本 v{} 已下载完成(已通过 sha256 校验)。", + staged.version + )), + ), + ) + .footer( + h_flex() + .gap_2() + .justify_end() + .w_full() + .child( + Button::new("upd-later") + .label("下次启动安装") + .outline() + .on_click(|_, window, cx| { + window.close_dialog(cx) + }), + ) + .child( + Button::new("upd-now") + .label("立即安装") + .primary() + .on_click(move |_, window, cx| { + window.close_dialog(cx); + if update::install_staged(&staged).is_ok() { + cx.quit(); + } + }), + ), + ) + }); }) .ok(); } diff --git a/gpui-app/src/ui/settings_dialog.rs b/gpui-app/src/ui/settings_dialog.rs index ab8439c..302a28a 100644 --- a/gpui-app/src/ui/settings_dialog.rs +++ b/gpui-app/src/ui/settings_dialog.rs @@ -41,6 +41,8 @@ enum UpdateUi { UpToDate, Available(Box), Downloading { downloaded: u64, total: u64 }, + /// 已下载到暂存区,等待用户选择安装时机 + ReadyToInstall(Box), Failed(String), } @@ -107,20 +109,12 @@ impl SettingsDialog { cx.notify(); let (prog_tx, prog_rx) = smol::channel::unbounded(); let info = info.clone(); - let rx: smol::channel::Receiver> = + let rx: smol::channel::Receiver> = self.engine.run_blocking(move || { - let result = update::download_and_verify(&info, &|d, t| { - let _ = prog_tx.try_send((d, t)); + update::download_and_stage(&info, &|d, t| { + let _ = prog_tx.try_send((d, t)); + }) }); - match result { - Ok(tmp) => match update::install_and_restart(&tmp) { - // 新进程已拉起并接管,旧进程直接退出(设置均为即时落盘) - Ok(()) => std::process::exit(0), - Err(e) => Err(e), - }, - Err(e) => Err(e), - } - }); cx.spawn_in(window, async move |this, cx| { while let Ok((downloaded, total)) = prog_rx.recv().await { this.update_in(cx, |s, _window, cx| { @@ -129,13 +123,14 @@ impl SettingsDialog { }) .ok(); } - // 进度通道关闭:要么安装成功(进程已退出),要么拿到最终结果 + // 进度通道关闭:拿到最终结果 let result = rx.recv().await.unwrap_or(Err("任务丢失".into())); this.update_in(cx, |s, _window, cx| { - if let Err(e) = result { - s.update = UpdateUi::Failed(e); - cx.notify(); - } + s.update = match result { + Ok(staged) => UpdateUi::ReadyToInstall(Box::new(staged)), + Err(e) => UpdateUi::Failed(e), + }; + cx.notify(); }) .ok(); }) @@ -215,6 +210,59 @@ impl SettingsDialog { true, ) } + UpdateUi::ReadyToInstall(staged) => { + let ver = staged.version.clone(); + let staged_now = staged.clone(); + v_flex() + .gap_1() + .child( + div() + .text_xs() + .text_color(cx.theme().accent) + .child(format!( + "新版本 v{} 已下载并通过校验,等待安装", + ver + )), + ) + .child( + h_flex() + .gap_1p5() + .child( + Button::new("upd-now") + .label("立即安装") + .primary() + .xsmall() + .on_click(move |_, window, cx| { + if update::install_staged(&staged_now).is_ok() { + cx.quit(); + } else { + window.push_notification( + Notification::error( + "安装失败,请重试", + ), + cx, + ); + } + }), + ) + .child( + Button::new("upd-later") + .label("下次启动安装") + .outline() + .xsmall() + .on_click(cx.listener(|this, _, window, cx| { + this.update = UpdateUi::Idle; + window.push_notification( + Notification::info( + "已暂存,下次启动时将自动安装", + ), + cx, + ); + cx.notify(); + })), + ), + ) + } UpdateUi::Downloading { downloaded, total } => { let pct = if *total > 0 { *downloaded as f32 / *total as f32 diff --git a/gpui-app/src/update.rs b/gpui-app/src/update.rs index 5c2c576..dbc2ecf 100644 --- a/gpui-app/src/update.rs +++ b/gpui-app/src/update.rs @@ -5,7 +5,7 @@ //! (改名自替换后拉起新进程)。全程 HTTPS + sha256 校验。 use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use sha2::Digest as _; @@ -164,19 +164,49 @@ pub fn check_latest() -> Result, String> { })) } -/// 下载并校验到 exe 同目录的临时文件,返回临时文件路径。 +/// 已下载并校验、等待安装的更新包(存放在数据目录的暂存区)。 +#[derive(Debug, Clone)] +pub struct StagedUpdate { + pub version: String, + pub path: PathBuf, +} + +/// 暂存目录:数据目录下的 `updates/`,重启不丢失。 +fn stage_dir() -> Result { + 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, 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_verify( +/// `on_progress(downloaded, total)` 在阻塞线程上回调,total 为 0 表示未知。 +pub fn download_and_stage( info: &UpdateInfo, on_progress: &dyn Fn(u64, u64), -) -> Result { - let exe_dir = std::env::current_exe() - .map_err(|e| format!("无法定位自身: {e}"))? - .parent() - .ok_or("无法定位安装目录")? - .to_path_buf(); - let tmp = exe_dir.join(format!("mqttx.update.{}.tmp", std::process::id())); +) -> Result { + 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) @@ -214,7 +244,13 @@ pub fn download_and_verify( } } - Ok(tmp) + // 覆盖旧暂存(不同版本重名时以新版本为准) + 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。 @@ -241,9 +277,37 @@ fn fetch_expected_sha256(info: &UpdateInfo) -> Result, String> { } } -/// 安装:把临时文件替换为自身,并拉起新进程(旧文件改名为 `.old`, -/// 下次启动由 [`cleanup_old`] 清理)。成功后调用方应立即退出进程。 -pub fn install_and_restart(new_exe: &Path) -> Result<(), String> { +/// 扫描暂存区,返回待安装的更新(文件存在且比当前版本新才算)。 +pub fn load_staged() -> Option { + 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--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"); @@ -251,7 +315,7 @@ pub fn install_and_restart(new_exe: &Path) -> Result<(), String> { // 旧残留先清掉,避免改名失败 let _ = std::fs::remove_file(&old); std::fs::rename(&cur, &old).map_err(|e| format!("替换失败(可能无写入权限): {e}"))?; - if let Err(e) = std::fs::rename(new_exe, &cur) { + if let Err(e) = std::fs::rename(&staged.path, &cur) { // 回滚,尽量保住当前可执行文件 let _ = std::fs::rename(&old, &cur); return Err(format!("替换失败: {e}")); @@ -262,24 +326,31 @@ pub fn install_and_restart(new_exe: &Path) -> Result<(), String> { Ok(()) } -/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、异常退出的 `.tmp`)。 +/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。 pub fn cleanup_old() { - let Ok(cur) = std::env::current_exe() else { - return; - }; - let Some(dir) = cur.parent() else { - return; - }; - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - 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()); + 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()); + } } } }