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
|
||||
/.v2c
|
||||
/.video_agent
|
||||
|
||||
# 发布产物暂存目录
|
||||
/dist-publish
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 单元测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user