feat: OTA 自更新(Gitea Release 为更新源)
Release / Meta (push) Canceled after 0s
Release / Test (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Publish package (push) Canceled after 0s

- update.rs:检查最新 Release(latest 端点+列表回退)、版本比较、
  平台产物挑选(含 .sha256 侧车)、下载进度、sha256 校验、
  自替换(改名 .old → 换入新 exe → 拉起新进程)、启动清理遗留
- 设置 → 关于:检查更新按钮、发现新版本提示、下载进度条(Progress)、
  失败重试;自动检查更新开关真正生效(启动后台检查并弹通知)
- 版本 1.0.0 → 1.0.1(首个带 OTA 的发布版本)
- 顺手修 clippy 遗留警告(is_none_or、合并 if)
This commit is contained in:
2026-09-28 23:44:56 +08:00
parent 262bc265d8
commit bf6d165854
8 changed files with 654 additions and 27 deletions
Generated
+40 -1
View File
@@ -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"
+5 -1
View File
@@ -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"
+1
View File
@@ -5,3 +5,4 @@ pub mod model;
pub mod mqtt;
pub mod store;
pub mod ui;
pub mod update;
+15
View File
@@ -475,6 +475,21 @@ impl MqttEngine {
/// 测试连接:使用配置的连接超时(上限 30 秒,防止误填过大值长时间挂起),
/// 结果通过 channel 返回。
/// 在引擎的 tokio runtime 上调度阻塞任务(OTA 下载等),
/// 结果经 smol 通道送回 GPUI 执行器;任务 panic 时接收端以空错误结束。
pub fn run_blocking<T: Send + 'static>(
&self,
f: impl FnOnce() -> T + Send + 'static,
) -> smol::channel::Receiver<T> {
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<Self>,
cfg: ConnectionConfig,
+23
View File
@@ -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 时重应用主题。
+2 -4
View File
@@ -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,8 +2513,7 @@ fn extract_placeholder_keys(topic: &str, payload: &str) -> Vec<String> {
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("}}") {
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());
@@ -2522,7 +2521,6 @@ fn extract_placeholder_keys(topic: &str, payload: &str) -> Vec<String> {
i = i + 2 + rel_end + 2;
continue;
}
}
// 按字符推进,避免把多字节 UTF-8 拆开
let ch = text[i..].chars().next().unwrap();
i += ch.len_utf8();
+210 -16
View File
@@ -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<UpdateInfo>),
Downloading { downloaded: u64, total: u64 },
Failed(String),
}
struct SettingsDialog {
engine: Arc<MqttEngine>,
theme: Entity<SelectState<OptionDelegate>>,
max_messages: Entity<InputState>,
show_millis: bool,
auto_check_update: bool,
data_dir: PathBuf,
log_dir: PathBuf,
update: UpdateUi,
}
impl SettingsDialog {
fn new(
engine: Arc<MqttEngine>,
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>) {
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>) {
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<Result<(), String>> =
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<Self>,
) -> 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<AppSettings, String> {
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<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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<MqttXApp>, 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<SettingsDialog> = 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| {
+353
View File
@@ -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<String>,
/// 产物字节数(API 提供,用于下载进度)
pub size: u64,
}
/// 当前版本。测试可用环境变量 `MQTTX_VERSION_OVERRIDE` 伪装旧版本,
/// 验证"发现新版本"路径。
pub fn current_version() -> &'static str {
static VERSION: std::sync::OnceLock<String> = 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-<ver>-<triple>-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<serde_json::Value, String> {
let resp = agent()
.get(url)
.call()
.map_err(|e| format!("请求失败: {e}"))?;
resp.into_json::<serde_json::Value>()
.map_err(|e| format!("解析响应失败: {e}"))
}
/// 从版本字符串提取数字段(容忍 v 前缀与 -beta 之类后缀)。
pub fn parse_version(v: &str) -> Vec<u32> {
v.trim()
.trim_start_matches(['v', 'V'])
.split(['.', '-'])
.filter_map(|s| s.parse::<u32>().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<String>)> {
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);
// 侧车命名:<asset>.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<Option<UpdateInfo>, 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<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()));
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<Option<String>, 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> <filename> 或纯 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());
}
}
}