feat: OTA 改为静默下载 + 安装需用户确认
- 下载落到数据目录 updates/ 暂存区(退出不丢失),校验后等待安装 - 启动自动检查:静默检查+下载,完成后弹「更新就绪」对话框, 由用户选择 立即安装(重启) 或 下次启动安装 - main 在进入 UI 前检测暂存区并完成自替换(下次启动安装路径) - 设置对话框:下载完成后进入「待安装」状态,提供两个安装时机按钮 - install_and_restart 拆分为 download_and_stage / load_staged / install_staged
This commit is contained in:
@@ -38,3 +38,6 @@ src-tauri/target_d
|
|||||||
*.pdb
|
*.pdb
|
||||||
/.v2c
|
/.v2c
|
||||||
/.video_agent
|
/.video_agent
|
||||||
|
|
||||||
|
# 发布产物暂存目录
|
||||||
|
/dist-publish
|
||||||
|
|||||||
@@ -2,8 +2,19 @@ use gpui_kit::component::{Root, TitleBar};
|
|||||||
use gpui_kit::*;
|
use gpui_kit::*;
|
||||||
|
|
||||||
use mqttx_desktop::ui;
|
use mqttx_desktop::ui;
|
||||||
|
use mqttx_desktop::update;
|
||||||
|
|
||||||
fn main() {
|
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()
|
application()
|
||||||
// AllAssets 嵌入全量 Lucide 图标;Assets 只含 default-icons.txt 的 101 个,
|
// AllAssets 嵌入全量 Lucide 图标;Assets 只含 default-icons.txt 的 101 个,
|
||||||
// 其余 IconName(MailOpen/SlidersHorizontal 等)运行时会渲染空白
|
// 其余 IconName(MailOpen/SlidersHorizontal 等)运行时会渲染空白
|
||||||
|
|||||||
+42
-9
@@ -128,21 +128,54 @@ impl MqttXApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// OTA:清理上次自更新遗留文件;开启自动检查则后台查一次新版本
|
// OTA:清理上次自更新遗留文件;开启自动检查则后台静默检查并下载,
|
||||||
|
// 下载完成后弹对话框征得同意再安装(不自动重启)。
|
||||||
update::cleanup_old();
|
update::cleanup_old();
|
||||||
if settings.auto_check_update {
|
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();
|
let weak = cx.entity().downgrade();
|
||||||
cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
|
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| {
|
weak.update_in(cx, |_app, window, cx| {
|
||||||
window.push_notification(
|
window.open_dialog(cx, move |dialog, _, _cx| {
|
||||||
Notification::info(format!(
|
let staged = staged.clone();
|
||||||
"发现新版本 v{},可在 设置 → 关于 中更新",
|
dialog
|
||||||
info.version
|
.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();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ enum UpdateUi {
|
|||||||
UpToDate,
|
UpToDate,
|
||||||
Available(Box<UpdateInfo>),
|
Available(Box<UpdateInfo>),
|
||||||
Downloading { downloaded: u64, total: u64 },
|
Downloading { downloaded: u64, total: u64 },
|
||||||
|
/// 已下载到暂存区,等待用户选择安装时机
|
||||||
|
ReadyToInstall(Box<update::StagedUpdate>),
|
||||||
Failed(String),
|
Failed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,19 +109,11 @@ impl SettingsDialog {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
let (prog_tx, prog_rx) = smol::channel::unbounded();
|
let (prog_tx, prog_rx) = smol::channel::unbounded();
|
||||||
let info = info.clone();
|
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 || {
|
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));
|
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| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
while let Ok((downloaded, total)) = prog_rx.recv().await {
|
while let Ok((downloaded, total)) = prog_rx.recv().await {
|
||||||
@@ -129,13 +123,14 @@ impl SettingsDialog {
|
|||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
// 进度通道关闭:要么安装成功(进程已退出),要么拿到最终结果
|
// 进度通道关闭:拿到最终结果
|
||||||
let result = rx.recv().await.unwrap_or(Err("任务丢失".into()));
|
let result = rx.recv().await.unwrap_or(Err("任务丢失".into()));
|
||||||
this.update_in(cx, |s, _window, cx| {
|
this.update_in(cx, |s, _window, cx| {
|
||||||
if let Err(e) = result {
|
s.update = match result {
|
||||||
s.update = UpdateUi::Failed(e);
|
Ok(staged) => UpdateUi::ReadyToInstall(Box::new(staged)),
|
||||||
|
Err(e) => UpdateUi::Failed(e),
|
||||||
|
};
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
})
|
})
|
||||||
@@ -215,6 +210,59 @@ impl SettingsDialog {
|
|||||||
true,
|
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 } => {
|
UpdateUi::Downloading { downloaded, total } => {
|
||||||
let pct = if *total > 0 {
|
let pct = if *total > 0 {
|
||||||
*downloaded as f32 / *total as f32
|
*downloaded as f32 / *total as f32
|
||||||
|
|||||||
+97
-26
@@ -5,7 +5,7 @@
|
|||||||
//! (改名自替换后拉起新进程)。全程 HTTPS + sha256 校验。
|
//! (改名自替换后拉起新进程)。全程 HTTPS + sha256 校验。
|
||||||
|
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use sha2::Digest as _;
|
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 时表示未知。
|
/// `on_progress(downloaded, total)` 在阻塞线程上回调,total 为 0 表示未知。
|
||||||
pub fn download_and_verify(
|
pub fn download_and_stage(
|
||||||
info: &UpdateInfo,
|
info: &UpdateInfo,
|
||||||
on_progress: &dyn Fn(u64, u64),
|
on_progress: &dyn Fn(u64, u64),
|
||||||
) -> Result<PathBuf, String> {
|
) -> Result<StagedUpdate, String> {
|
||||||
let exe_dir = std::env::current_exe()
|
let dir = stage_dir()?;
|
||||||
.map_err(|e| format!("无法定位自身: {e}"))?
|
let final_name = asset_name_for(&info.version);
|
||||||
.parent()
|
let tmp = dir.join(format!("{}.{}.tmp", final_name, std::process::id()));
|
||||||
.ok_or("无法定位安装目录")?
|
let dst = dir.join(&final_name);
|
||||||
.to_path_buf();
|
|
||||||
let tmp = exe_dir.join(format!("mqttx.update.{}.tmp", std::process::id()));
|
|
||||||
|
|
||||||
let resp = agent()
|
let resp = agent()
|
||||||
.get(&info.asset_url)
|
.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。
|
/// 读取 `.sha256` 侧车内容,返回其中的哈希值。没有侧车时返回 None。
|
||||||
@@ -241,9 +277,37 @@ fn fetch_expected_sha256(info: &UpdateInfo) -> Result<Option<String>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 安装:把临时文件替换为自身,并拉起新进程(旧文件改名为 `.old`,
|
/// 扫描暂存区,返回待安装的更新(文件存在且比当前版本新才算)。
|
||||||
/// 下次启动由 [`cleanup_old`] 清理)。成功后调用方应立即退出进程。
|
pub fn load_staged() -> Option<StagedUpdate> {
|
||||||
pub fn install_and_restart(new_exe: &Path) -> Result<(), String> {
|
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 cur = std::env::current_exe().map_err(|e| format!("无法定位自身: {e}"))?;
|
||||||
let mut old_name = cur.clone().into_os_string();
|
let mut old_name = cur.clone().into_os_string();
|
||||||
old_name.push(".old");
|
old_name.push(".old");
|
||||||
@@ -251,7 +315,7 @@ pub fn install_and_restart(new_exe: &Path) -> Result<(), String> {
|
|||||||
// 旧残留先清掉,避免改名失败
|
// 旧残留先清掉,避免改名失败
|
||||||
let _ = std::fs::remove_file(&old);
|
let _ = std::fs::remove_file(&old);
|
||||||
std::fs::rename(&cur, &old).map_err(|e| format!("替换失败(可能无写入权限): {e}"))?;
|
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);
|
let _ = std::fs::rename(&old, &cur);
|
||||||
return Err(format!("替换失败: {e}"));
|
return Err(format!("替换失败: {e}"));
|
||||||
@@ -262,17 +326,12 @@ pub fn install_and_restart(new_exe: &Path) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、异常退出的 `.tmp`)。
|
/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。
|
||||||
pub fn cleanup_old() {
|
pub fn cleanup_old() {
|
||||||
let Ok(cur) = std::env::current_exe() else {
|
if let Ok(cur) = std::env::current_exe()
|
||||||
return;
|
&& let Some(dir) = cur.parent()
|
||||||
};
|
&& let Ok(entries) = std::fs::read_dir(dir)
|
||||||
let Some(dir) = cur.parent() else {
|
{
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for e in entries.flatten() {
|
for e in entries.flatten() {
|
||||||
let name = e.file_name();
|
let name = e.file_name();
|
||||||
let name = name.to_string_lossy();
|
let name = name.to_string_lossy();
|
||||||
@@ -282,6 +341,18 @@ pub fn cleanup_old() {
|
|||||||
let _ = std::fs::remove_file(e.path());
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 单元测试 ────────────────────────────────────────────────────────────────
|
// ─── 单元测试 ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user