feat: OTA 改为静默下载 + 安装需用户确认

- 下载落到数据目录 updates/ 暂存区(退出不丢失),校验后等待安装
- 启动自动检查:静默检查+下载,完成后弹「更新就绪」对话框,
  由用户选择 立即安装(重启) 或 下次启动安装
- main 在进入 UI 前检测暂存区并完成自替换(下次启动安装路径)
- 设置对话框:下载完成后进入「待安装」状态,提供两个安装时机按钮
- install_and_restart 拆分为 download_and_stage / load_staged / install_staged
This commit is contained in:
2026-09-29 00:22:35 +08:00
parent bf6d165854
commit bc49d1839c
5 changed files with 226 additions and 60 deletions
+3
View File
@@ -38,3 +38,6 @@ src-tauri/target_d
*.pdb
/.v2c
/.video_agent
# 发布产物暂存目录
/dist-publish
+11
View File
@@ -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 等)运行时会渲染空白
+42 -9
View File
@@ -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
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
)),
cx,
);
),
)
.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();
}
+63 -15
View File
@@ -41,6 +41,8 @@ enum UpdateUi {
UpToDate,
Available(Box<UpdateInfo>),
Downloading { downloaded: u64, total: u64 },
/// 已下载到暂存区,等待用户选择安装时机
ReadyToInstall(Box<update::StagedUpdate>),
Failed(String),
}
@@ -107,19 +109,11 @@ impl SettingsDialog {
cx.notify();
let (prog_tx, prog_rx) = smol::channel::unbounded();
let info = info.clone();
let rx: smol::channel::Receiver<Result<(), String>> =
let rx: smol::channel::Receiver<Result<update::StagedUpdate, String>> =
self.engine.run_blocking(move || {
let result = update::download_and_verify(&info, &|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 {
@@ -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);
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
+97 -26
View File
@@ -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<Option<UpdateInfo>, String> {
}))
}
/// 下载并校验到 exe 同目录的临时文件,返回临时文件路径。
/// 已下载并校验、等待安装的更新包(存放在数据目录的暂存区)。
#[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_verify(
/// `on_progress(downloaded, total)` 在阻塞线程上回调,total 为 0 表示未知。
pub fn download_and_stage(
info: &UpdateInfo,
on_progress: &dyn Fn(u64, u64),
) -> Result<PathBuf, String> {
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<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)
@@ -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<Option<String>, String> {
}
}
/// 安装:把临时文件替换为自身,并拉起新进程(旧文件改名为 `.old`,
/// 下次启动由 [`cleanup_old`] 清理)。成功后调用方应立即退出进程。
pub fn install_and_restart(new_exe: &Path) -> Result<(), String> {
/// 扫描暂存区,返回待安装的更新(文件存在且比当前版本新才算)。
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");
@@ -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,17 +326,12 @@ 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;
};
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();
@@ -282,6 +341,18 @@ pub fn cleanup_old() {
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());
}
}
}
}
// ─── 单元测试 ────────────────────────────────────────────────────────────────