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
+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| {