feat: OTA 自更新(Gitea Release 为更新源)
- 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:
Generated
+40
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -5,3 +5,4 @@ pub mod model;
|
||||
pub mod mqtt;
|
||||
pub mod store;
|
||||
pub mod ui;
|
||||
pub mod update;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 时重应用主题。
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user