diff --git a/Cargo.lock b/Cargo.lock index 229d22e..72e9df0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4065,7 +4065,7 @@ dependencies = [ [[package]] name = "mqttx-desktop" -version = "1.0.0" +version = "1.0.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -4081,8 +4081,10 @@ dependencies = [ "serde", "serde_json", "sha1", + "sha2 0.10.9", "smol", "tokio", + "ureq", "uuid", ] @@ -5950,6 +5952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -7379,6 +7382,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -7789,6 +7810,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/gpui-app/Cargo.toml b/gpui-app/Cargo.toml index 7c8f2ce..fa54b13 100644 --- a/gpui-app/Cargo.toml +++ b/gpui-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mqttx-desktop" -version = "1.0.0" +version = "1.0.1" edition.workspace = true description = "MQTT X —— 基于 GPUI 的跨平台 MQTT 调试客户端" default-run = "mqttx" @@ -39,3 +39,7 @@ rustls-native-certs = "0.8" # 原生文件对话框(连接导入/导出);Windows 走内建 COM 后端,Linux 用默认 xdg-portal rfd = "0.17" + +# OTA 自更新:Gitea Release 检查/下载(ureq 用 rustls,与 rumqttc 依赖同源) +ureq = { version = "2", features = ["json"] } +sha2 = "0.10" diff --git a/gpui-app/src/lib.rs b/gpui-app/src/lib.rs index db5cb32..8230e48 100644 --- a/gpui-app/src/lib.rs +++ b/gpui-app/src/lib.rs @@ -5,3 +5,4 @@ pub mod model; pub mod mqtt; pub mod store; pub mod ui; +pub mod update; diff --git a/gpui-app/src/mqtt.rs b/gpui-app/src/mqtt.rs index 766c1bd..be426d0 100644 --- a/gpui-app/src/mqtt.rs +++ b/gpui-app/src/mqtt.rs @@ -475,6 +475,21 @@ impl MqttEngine { /// 测试连接:使用配置的连接超时(上限 30 秒,防止误填过大值长时间挂起), /// 结果通过 channel 返回。 + /// 在引擎的 tokio runtime 上调度阻塞任务(OTA 下载等), + /// 结果经 smol 通道送回 GPUI 执行器;任务 panic 时接收端以空错误结束。 + pub fn run_blocking( + &self, + f: impl FnOnce() -> T + Send + 'static, + ) -> smol::channel::Receiver { + let (tx, rx) = smol::channel::bounded(1); + self.runtime.spawn(async move { + if let Ok(value) = tokio::task::spawn_blocking(f).await { + let _ = tx.send(value).await; + } + }); + rx + } + pub fn test_connection( self: &Arc, cfg: ConnectionConfig, diff --git a/gpui-app/src/ui/app.rs b/gpui-app/src/ui/app.rs index 496fbcd..8995297 100644 --- a/gpui-app/src/ui/app.rs +++ b/gpui-app/src/ui/app.rs @@ -28,6 +28,7 @@ use crate::model::{ }; use crate::mqtt::{EngineEvent, MqttEngine}; use crate::store::Storage; +use crate::update; use crate::ui::IconName; use crate::ui::{ connection_form::ConnectionForm, @@ -127,6 +128,28 @@ impl MqttXApp { } }); + // OTA:清理上次自更新遗留文件;开启自动检查则后台查一次新版本 + update::cleanup_old(); + if settings.auto_check_update { + let rx = engine.run_blocking(update::check_latest); + 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 { + weak.update_in(cx, |_app, window, cx| { + window.push_notification( + Notification::info(format!( + "发现新版本 v{},可在 设置 → 关于 中更新", + info.version + )), + cx, + ); + }) + .ok(); + } + }) + .detach(); + } + let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…")); // 「跟随系统」:监听系统外观(深浅色)变化,仅在设置为 System 时重应用主题。 diff --git a/gpui-app/src/ui/connection_view.rs b/gpui-app/src/ui/connection_view.rs index 316fbe9..884849a 100644 --- a/gpui-app/src/ui/connection_view.rs +++ b/gpui-app/src/ui/connection_view.rs @@ -1546,7 +1546,7 @@ impl ConnectionView { || contains_ignore_case(&r.payload, &query); let sub_ok = sub_filter .as_deref() - .map_or(true, |f| topic_matches(f, &r.topic)); + .is_none_or(|f| topic_matches(f, &r.topic)); if dir_ok && query_ok && sub_ok { matched += 1; if records.len() < MAX_RENDERED_MESSAGES { @@ -2513,15 +2513,13 @@ fn extract_placeholder_keys(topic: &str, payload: &str) -> Vec { let bytes = text.as_bytes(); let mut i = 0; while i + 1 < bytes.len() { - if bytes[i] == b'{' && bytes[i + 1] == b'{' { - if let Some(rel_end) = text[i + 2..].find("}}") { - let key = text[i + 2..i + 2 + rel_end].trim(); - if !key.is_empty() && !key.starts_with('$') && !out.iter().any(|k| k == key) { - out.push(key.to_string()); - } - i = i + 2 + rel_end + 2; - continue; + if bytes[i] == b'{' && bytes[i + 1] == b'{' && let Some(rel_end) = text[i + 2..].find("}}") { + let key = text[i + 2..i + 2 + rel_end].trim(); + if !key.is_empty() && !key.starts_with('$') && !out.iter().any(|k| k == key) { + out.push(key.to_string()); } + i = i + 2 + rel_end + 2; + continue; } // 按字符推进,避免把多字节 UTF-8 拆开 let ch = text[i..].chars().next().unwrap(); diff --git a/gpui-app/src/ui/settings_dialog.rs b/gpui-app/src/ui/settings_dialog.rs index 2be43f3..ab8439c 100644 --- a/gpui-app/src/ui/settings_dialog.rs +++ b/gpui-app/src/ui/settings_dialog.rs @@ -1,6 +1,7 @@ //! 应用设置对话框:主题、消息缓存、时间戳、自动检查更新、数据/日志目录、关于。 use std::path::{Path, PathBuf}; +use std::sync::Arc; use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::clipboard::Clipboard; @@ -17,9 +18,11 @@ use gpui_kit::{ }; use crate::model::{AppSettings, ThemeModePref}; +use crate::mqtt::MqttEngine; use crate::ui::app::MqttXApp; use crate::ui::widgets::{field, make_select, OptionDelegate}; use crate::ui::IconName; +use crate::update::{self, UpdateInfo}; const THEMES: [&str; 3] = ["跟随系统(暂按浅色)", "浅色", "深色"]; @@ -30,17 +33,31 @@ const MAX_MESSAGES_MAX: usize = 100_000; const SITE_URL: &str = "https://mqttx.app"; const REPO_URL: &str = "https://gitea.heavenlybook.cn/JoeAllen/mqttx"; +/// 更新检查/安装在设置对话框内的展示状态。 +#[derive(Debug, Clone)] +enum UpdateUi { + Idle, + Checking, + UpToDate, + Available(Box), + Downloading { downloaded: u64, total: u64 }, + Failed(String), +} + struct SettingsDialog { + engine: Arc, theme: Entity>, max_messages: Entity, show_millis: bool, auto_check_update: bool, data_dir: PathBuf, log_dir: PathBuf, + update: UpdateUi, } impl SettingsDialog { fn new( + engine: Arc, initial: AppSettings, data_dir: PathBuf, log_dir: PathBuf, @@ -53,6 +70,7 @@ impl SettingsDialog { ThemeModePref::Dark => 2, }; Self { + engine, theme: make_select(&THEMES, theme_idx, window, cx), max_messages: cx.new(|cx| { InputState::new(window, cx).default_value(initial.max_messages.to_string()) @@ -61,9 +79,185 @@ impl SettingsDialog { auto_check_update: initial.auto_check_update, data_dir, log_dir, + update: UpdateUi::Idle, } } + fn run_check(&mut self, window: &mut Window, cx: &mut Context) { + self.update = UpdateUi::Checking; + cx.notify(); + let rx = self.engine.run_blocking(update::check_latest); + cx.spawn_in(window, async move |this, cx| { + let result = rx.recv().await.unwrap_or(Err("任务丢失".into())); + this.update_in(cx, |s, _window, cx| { + s.update = match result { + Ok(Some(info)) => UpdateUi::Available(Box::new(info)), + Ok(None) => UpdateUi::UpToDate, + Err(e) => UpdateUi::Failed(e), + }; + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + fn run_update(&mut self, info: &UpdateInfo, window: &mut Window, cx: &mut Context) { + self.update = UpdateUi::Downloading { downloaded: 0, total: info.size }; + cx.notify(); + let (prog_tx, prog_rx) = smol::channel::unbounded(); + let info = info.clone(); + 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)); + }); + 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| { + s.update = UpdateUi::Downloading { downloaded, total }; + cx.notify(); + }) + .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(); + } + }) + .ok(); + }) + .detach(); + } + + fn fmt_mb(bytes: u64) -> String { + format!("{:.1} MB", bytes as f64 / (1024. * 1024.)) + } + + /// 关于区的更新行:按钮 + 状态 + 下载进度。 + fn render_update_row( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let row = |button: gpui_kit::AnyElement, status: gpui_kit::AnyElement, accent: bool| { + h_flex() + .gap_2() + .items_center() + .child(button) + .child( + div() + .text_xs() + .text_color(if accent { + cx.theme().accent + } else { + cx.theme().muted_foreground + }) + .child(status), + ) + }; + match &self.update { + UpdateUi::Idle => row( + Button::new("check-update") + .label("检查更新") + .outline() + .xsmall() + .on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx))) + .into_any_element(), + "检查 Gitea Release 上的新版本".into_any_element(), + false, + ), + UpdateUi::Checking => row( + Button::new("check-update") + .label("检查中…") + .outline() + .xsmall() + .loading(true) + .into_any_element(), + "正在检查更新…".into_any_element(), + false, + ), + UpdateUi::UpToDate => row( + Button::new("check-update") + .label("重新检查") + .outline() + .xsmall() + .on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx))) + .into_any_element(), + "已是最新版本".into_any_element(), + false, + ), + UpdateUi::Available(info) => { + let ver = info.version.clone(); + let info_for_click = info.clone(); + row( + Button::new("apply-update") + .label(format!("立即更新到 v{}", ver)) + .primary() + .xsmall() + .on_click(cx.listener(move |this, _, window, cx| { + this.run_update(&info_for_click, window, cx) + })) + .into_any_element(), + format!("发现新版本 v{},下载后自动替换重启", ver).into_any_element(), + true, + ) + } + UpdateUi::Downloading { downloaded, total } => { + let pct = if *total > 0 { + *downloaded as f32 / *total as f32 + } else { + 0. + }; + let status = if *total > 0 { + format!( + "下载中 {:.0}%({} / {})", + pct * 100., + Self::fmt_mb(*downloaded), + Self::fmt_mb(*total) + ) + } else { + format!("下载中 {}", Self::fmt_mb(*downloaded)) + }; + v_flex() + .gap_1() + .child( + gpui_kit::component::progress::Progress::new("update-progress") + .value(pct), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(status), + ) + } + UpdateUi::Failed(e) => row( + Button::new("check-update") + .label("重试") + .outline() + .xsmall() + .on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx))) + .into_any_element(), + format!("更新失败:{e}").into_any_element(), + false, + ), + } + } + + fn collect(&self, cx: &App) -> Result { let theme = match self.theme.read(cx).selected_value() { Some(1) => ThemeModePref::Light, @@ -111,7 +305,7 @@ fn open_in_file_manager(path: &Path, window: &mut Window, cx: &mut App) { } impl Render for SettingsDialog { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let data_dir = self.data_dir.to_string_lossy().into_owned(); let log_dir = self.log_dir.to_string_lossy().into_owned(); let data_dir_for_open = self.data_dir.clone(); @@ -141,17 +335,7 @@ impl Render for SettingsDialog { .child( h_flex() .justify_between() - .child( - h_flex().gap_1p5().items_center() - .child(div().text_sm().child("自动检查更新")) - // 更新通道尚未接入,先保留开关并明示状态 - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("即将支持"), - ), - ) + .child(div().text_sm().child("自动检查更新(启动时)")) .child( Switch::new("auto-check-update") .checked(self.auto_check_update) @@ -236,9 +420,14 @@ impl Render for SettingsDialog { .gap_2() .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!("版本 {}", env!("CARGO_PKG_VERSION"))) + .child(format!( + "版本 {}(更新源 {})", + update::current_version(), + update::PKG_REPO + )) .child("Apache-2.0 许可证"), ) + .child(self.render_update_row(window, cx)) .child( div() .text_xs() @@ -268,12 +457,17 @@ impl Render for SettingsDialog { } pub fn open(app: Entity, window: &mut Window, cx: &mut App) { - let (initial, data_dir, log_dir) = { + let (engine, initial, data_dir, log_dir) = { let state = app.read(cx); - (state.settings.clone(), state.data_dir(), state.log_dir()) + ( + state.engine.clone(), + state.settings.clone(), + state.data_dir(), + state.log_dir(), + ) }; let dialog_view: Entity = cx.new(|cx| { - SettingsDialog::new(initial, data_dir, log_dir, window, cx) + SettingsDialog::new(engine, initial, data_dir, log_dir, window, cx) }); let app_save = app.clone(); window.open_dialog(cx, move |dialog, _window, _cx| { diff --git a/gpui-app/src/update.rs b/gpui-app/src/update.rs new file mode 100644 index 0000000..5c2c576 --- /dev/null +++ b/gpui-app/src/update.rs @@ -0,0 +1,353 @@ +//! OTA 自更新:以 Gitea Release 为更新源。 +//! +//! 流程:`check_latest`(比较最新 Release 与当前版本)→ `download_and_verify` +//! (下载到 exe 同目录临时文件并校验 sha256)→ `install_and_restart` +//! (改名自替换后拉起新进程)。全程 HTTPS + sha256 校验。 + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::Digest as _; + +/// 更新源(自建 Gitea,仓库公开、附件可直接下载,无需认证)。 +pub const GITEA_URL: &str = "https://gitea.heavenlybook.cn"; +pub const PKG_OWNER: &str = "JoeAllen"; +pub const PKG_REPO: &str = "mqttx"; + +const HTTP_TIMEOUT_SECS: u64 = 30; + +/// 发现的可用更新。 +#[derive(Debug, Clone)] +pub struct UpdateInfo { + /// 新版本号(不含 v 前缀),如 "1.0.1" + pub version: String, + /// Release 说明(Markdown 原文) + pub notes: String, + /// 平台对应产物下载地址 + pub asset_url: String, + /// sha256 校验文件地址(若 Release 提供了 `.sha256` 侧车) + pub sha256_url: Option, + /// 产物字节数(API 提供,用于下载进度) + pub size: u64, +} + +/// 当前版本。测试可用环境变量 `MQTTX_VERSION_OVERRIDE` 伪装旧版本, +/// 验证"发现新版本"路径。 +pub fn current_version() -> &'static str { + static VERSION: std::sync::OnceLock = std::sync::OnceLock::new(); + VERSION.get_or_init(|| { + std::env::var("MQTTX_VERSION_OVERRIDE") + .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string()) + }) +} + +/// 构建目标的平台三元组,需与 release.yml / publish 脚本的产物命名一致。 +pub fn platform_triple() -> &'static str { + if cfg!(windows) { + "x86_64-pc-windows-msvc" + } else if cfg!(target_os = "macos") { + "x86_64-apple-darwin" + } else { + "x86_64-unknown-linux-gnu" + } +} + +/// 某版本的产物文件名(与 CI 的命名约定 `mqttx---mqttx[.exe]` 一致)。 +pub fn asset_name_for(version: &str) -> String { + let ext = if cfg!(windows) { ".exe" } else { "" }; + format!("mqttx-v{}-{}-mqttx{}", version, platform_triple(), ext) +} + +fn agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) + .timeout_read(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) + .build() +} + +fn http_get_json(url: &str) -> Result { + let resp = agent() + .get(url) + .call() + .map_err(|e| format!("请求失败: {e}"))?; + resp.into_json::() + .map_err(|e| format!("解析响应失败: {e}")) +} + +/// 从版本字符串提取数字段(容忍 v 前缀与 -beta 之类后缀)。 +pub fn parse_version(v: &str) -> Vec { + v.trim() + .trim_start_matches(['v', 'V']) + .split(['.', '-']) + .filter_map(|s| s.parse::().ok()) + .collect() +} + +/// a 是否比 b 新(按数字段逐位比较,缺失段视为 0)。 +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()); + for i in 0..len { + let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0)); + if x != y { + return x > y; + } + } + false +} + +/// 从 Release JSON 里挑选当前平台的产物与 sha256 侧车。 +pub fn pick_assets(rel: &serde_json::Value) -> Option<(String, String, u64, Option)> { + let assets = rel.get("assets")?.as_array()?; + let want_ext = if cfg!(windows) { ".exe" } else { "" }; + let triple = platform_triple(); + for a in assets { + let name = a.get("name")?.as_str()?; + 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 size = a.get("size").and_then(|s| s.as_u64()).unwrap_or(0); + // 侧车命名:.sha256 + let sha = assets + .iter() + .find(|b| { + b.get("name").and_then(|n| n.as_str()) == Some(&format!("{name}.sha256")) + }) + .and_then(|b| b.get("browser_download_url")) + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + return Some((name.to_string(), url, size, sha)); + } + None +} + +/// 检查更新。返回 Ok(None) 表示已是最新。 +pub fn check_latest() -> Result, String> { + let base = format!("{GITEA_URL}/api/v1/repos/{PKG_OWNER}/{PKG_REPO}/releases"); + // 优先 latest 端点;旧版 Gitea 没有时回退到列表取第一个非草稿/预发布 + let rel = match http_get_json(&format!("{base}/latest")) { + Ok(v) => v, + Err(_) => { + let list = http_get_json(&format!("{base}?limit=5"))?; + let first = list + .as_array() + .and_then(|l| l.iter().find(|r| { + r.get("draft").and_then(|d| d.as_bool()) == Some(false) + && r.get("prerelease").and_then(|d| d.as_bool()) == Some(false) + })) + .ok_or_else(|| "没有可用 Release".to_string())?; + first.clone() + } + }; + + let tag = rel + .get("tag_name") + .and_then(|t| t.as_str()) + .ok_or_else(|| "Release 缺少 tag_name".to_string())?; + if !is_newer(tag, current_version()) { + return Ok(None); + } + let Some((_, url, size, sha)) = pick_assets(&rel) else { + return Err(format!("Release {tag} 没有当前平台({})的产物", platform_triple())); + }; + Ok(Some(UpdateInfo { + version: tag.trim_start_matches('v').to_string(), + notes: rel + .get("body") + .and_then(|b| b.as_str()) + .unwrap_or_default() + .to_string(), + asset_url: url, + sha256_url: sha, + size, + })) +} + +/// 下载并校验到 exe 同目录的临时文件,返回临时文件路径。 +/// +/// `on_progress(downloaded, total)` 在阻塞线程上回调,total 为 0 时表示未知。 +pub fn download_and_verify( + 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())); + + 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 mut buf = [0u8; 64 * 1024]; + let mut downloaded: u64 = 0; + let mut hasher = sha2::Sha256::new(); + loop { + let n = reader.read(&mut buf).map_err(|e| format!("下载中断: {e}"))?; + if n == 0 { + break; + } + std::io::Write::write_all(&mut file, &buf[..n]) + .map_err(|e| format!("写入临时文件失败: {e}"))?; + hasher.update(&buf[..n]); + downloaded += n as u64; + on_progress(downloaded, total); + } + std::io::Write::flush(&mut file).ok(); + drop(file); + + // sha256 校验 + let expect = fetch_expected_sha256(info)?; + if let Some(expect) = expect { + 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(tmp) +} + +/// 读取 `.sha256` 侧车内容,返回其中的哈希值。没有侧车时返回 None。 +fn fetch_expected_sha256(info: &UpdateInfo) -> Result, String> { + let Some(url) = &info.sha256_url else { + return Ok(None); + }; + let text = agent() + .get(url) + .call() + .map_err(|e| format!("获取校验文件失败: {e}"))? + .into_string() + .map_err(|e| format!("读取校验文件失败: {e}"))?; + // 常见格式: 或纯 hex + let hex = text + .split_whitespace() + .next() + .unwrap_or_default() + .to_lowercase(); + if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) { + Ok(Some(hex)) + } else { + Err("校验文件格式异常".into()) + } +} + +/// 安装:把临时文件替换为自身,并拉起新进程(旧文件改名为 `.old`, +/// 下次启动由 [`cleanup_old`] 清理)。成功后调用方应立即退出进程。 +pub fn install_and_restart(new_exe: &Path) -> 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"); + let old = PathBuf::from(old_name); + // 旧残留先清掉,避免改名失败 + 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) { + // 回滚,尽量保住当前可执行文件 + let _ = std::fs::rename(&old, &cur); + return Err(format!("替换失败: {e}")); + } + std::process::Command::new(&cur) + .spawn() + .map_err(|e| format!("启动新版本失败: {e}"))?; + Ok(()) +} + +/// 启动时清理上次更新遗留的旧文件(改名失败的 `.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()); + } + } +} + +// ─── 单元测试 ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_compare() { + 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) + assert!(!is_newer("1.0.0-beta", "1.0.0")); + assert!(!is_newer("1.0.0", "1.0.0")); + assert!(!is_newer("0.9", "1.0.0")); + assert_eq!(parse_version("v1.2.3-beta"), vec![1, 2, 3]); + } + + #[test] + fn asset_naming_matches_ci_convention() { + assert_eq!( + asset_name_for("1.0.1"), + format!("mqttx-v1.0.1-{}-mqttx{}", platform_triple(), if cfg!(windows) { ".exe" } else { "" }) + ); + } + + #[test] + fn pick_assets_selects_platform_and_sidecar() { + let rel: serde_json::Value = serde_json::json!({ + "tag_name": "v1.0.1", + "assets": [ + {"name": "mqttx-v1.0.1-x86_64-unknown-linux-gnu-mqttx", "size": 1, + "browser_download_url": "https://x/linux"}, + {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe", "size": 19132928, + "browser_download_url": "https://x/win"}, + {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65, + "browser_download_url": "https://x/win.sha256"} + ] + }); + let (name, url, size, sha) = pick_assets(&rel).expect("应选中平台产物"); + if cfg!(windows) { + assert_eq!(name, "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe"); + assert_eq!(url, "https://x/win"); + assert_eq!(size, 19132928); + assert_eq!(sha.as_deref(), Some("https://x/win.sha256")); + } else { + assert_eq!(name, "mqttx-v1.0.1-x86_64-unknown-linux-gnu-mqttx"); + assert_eq!(url, "https://x/linux"); + assert_eq!(sha, None); + } + } + + #[test] + fn pick_assets_skips_sidecars_and_other_platforms() { + let rel: serde_json::Value = serde_json::json!({ + "tag_name": "v1.0.1", + "assets": [ + {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65, + "browser_download_url": "https://x/side"}, + ] + }); + if cfg!(windows) { + // 只有侧车、没有主体 → 不应选中侧车 + assert!(pick_assets(&rel).is_none()); + } + } +}