feat: 重写为 GPUI 原生桌面应用并接入 Gitea 软件包发布

- gpui-kit 0.6 原生 UI:连接管理、消息流/日志、发布预设、全局变量、
  设置、阿里云连接预设、原生 Logo
- MQTT 5.0/3.1.1 引擎(rumqttc,TCP/TLS/WS/WSS),独立 tokio runtime
  + 事件泵,真实 broker 端到端测试
- 连接表单:保存前可一键测试连接(宽松校验,自动生成临时 Client ID),
  高级属性(认证/MQTT 5 属性/遗嘱)按需折叠显示,WS 路径仅 WS/WSS 显示
- bug 修复:Hex 奇数长度显式报错、测试连接唯一 Client ID、Base64/Hex
  发布发送真实字节、v5 发布记录携带 Content-Type/用户属性、预设应用
  清除旧 Content-Type、模板时间基准统一;补 8 个单元测试
- Gitea 发布:Generic Package 上传脚本(bash/PowerShell)+ Actions
  release 工作流(tag 触发,双平台构建),README 使用说明
This commit is contained in:
2026-09-28 11:39:47 +08:00
parent 0152a10ee6
commit bf6937b776
25 changed files with 15287 additions and 120 deletions
+583
View File
@@ -0,0 +1,583 @@
//! 新建/编辑连接的表单对话框内容。
use std::sync::Arc;
use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::select::{Select, SelectEvent, SelectState};
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, ActiveTheme as _, Icon, Sizable as _,
StyledExt as _, WindowExt as _,
};
use gpui_kit::prelude::FluentBuilder as _;
use crate::ui::IconName;
use gpui_kit::{
div, percentage, px, App, AppContext as _, Context, Entity, InteractiveElement as _,
IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _,
Styled as _, Subscription, Window,
};
use crate::model::{ConnectionConfig, LastWill, ProtocolVersion, TransportKind};
use crate::mqtt::MqttEngine;
use crate::ui::widgets::{field, make_select, OptionDelegate};
const PROTOCOLS: [&str; 2] = ["MQTT 5.0", "MQTT 3.1.1"];
const TRANSPORTS: [&str; 4] = ["TCP", "TLS", "WebSocket", "WSS"];
const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"];
pub struct ConnectionForm {
engine: Arc<MqttEngine>,
editing_id: Option<String>,
created_at: i64,
name: Entity<InputState>,
host: Entity<InputState>,
port: Entity<InputState>,
path: Entity<InputState>,
client_id: Entity<InputState>,
username: Entity<InputState>,
password: Entity<InputState>,
keep_alive: Entity<InputState>,
session_expiry: Entity<InputState>,
receive_max: Entity<InputState>,
max_packet: Entity<InputState>,
topic_alias: Entity<InputState>,
will_topic: Entity<InputState>,
will_payload: Entity<InputState>,
protocol: Entity<SelectState<OptionDelegate>>,
transport: Entity<SelectState<OptionDelegate>>,
will_qos: Entity<SelectState<OptionDelegate>>,
clean_start: bool,
auto_resubscribe: bool,
auto_reconnect: bool,
will_enabled: bool,
will_retain: bool,
// 按需显示:高级分组默认折叠
show_auth: bool,
show_mqtt5: bool,
show_will: bool,
testing: bool,
_subs: Vec<Subscription>,
}
impl ConnectionForm {
/// 对话框 footer 的测试按钮需要读取测试进行中的状态。
pub fn is_testing(&self) -> bool {
self.testing
}
}
fn input(
value: &str,
placeholder: &str,
window: &mut Window,
cx: &mut Context<ConnectionForm>,
) -> Entity<InputState> {
cx.new(|cx| {
InputState::new(window, cx)
.placeholder(placeholder)
.default_value(value)
})
}
fn optional_input(
value: Option<u32>,
placeholder: &str,
window: &mut Window,
cx: &mut Context<ConnectionForm>,
) -> Entity<InputState> {
cx.new(|cx| {
let s = InputState::new(window, cx).placeholder(placeholder);
match value {
Some(v) => s.default_value(v.to_string()),
None => s,
}
})
}
impl ConnectionForm {
pub fn new(
engine: Arc<MqttEngine>,
edit: Option<ConnectionConfig>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let c = edit.clone().unwrap_or_default();
let editing_id = edit.as_ref().map(|e| e.id.clone());
let created_at = edit.as_ref().map(|e| e.created_at).unwrap_or_else(|| {
chrono::Local::now().timestamp()
});
let name = input(&c.name, "连接名称", window, cx);
let host = input(&c.host, "broker.example.com", window, cx);
let port = input(&c.port.to_string(), "端口", window, cx);
let path = input(&c.path, "/mqtt", window, cx);
let client_id = input(&c.client_id, "Client ID", window, cx);
let username = input(&c.username, "用户名(可选)", window, cx);
let password = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("密码(可选)")
.default_value(&c.password)
.masked(true)
});
let keep_alive = input(&c.keep_alive.to_string(), "60", window, cx);
let session_expiry = input(
&if c.session_expiry_interval > 0 {
c.session_expiry_interval.to_string()
} else {
String::new()
},
"秒(0=会话随连接结束)",
window,
cx,
);
let receive_max = optional_input(c.receive_maximum.map(u32::from), "服务端接收上限", window, cx);
let max_packet = optional_input(c.maximum_packet_size, "最大报文长度", window, cx);
let topic_alias = optional_input(c.topic_alias_maximum.map(u32::from), "主题别名上限", window, cx);
let (will_topic, will_payload, will_enabled, will_retain, will_qos_idx) =
match &c.last_will {
Some(w) => (
input(&w.topic, "遗嘱主题", window, cx),
input(&w.payload, "遗嘱消息", window, cx),
true,
w.retain,
w.qos as usize,
),
None => (
input("", "遗嘱主题", window, cx),
input("", "遗嘱消息", window, cx),
false,
false,
0,
),
};
let protocol = make_select(
&PROTOCOLS,
if c.protocol.is_v5() { 0 } else { 1 },
window,
cx,
);
let transport_idx = TransportKind::ALL
.iter()
.position(|t| *t == c.transport)
.unwrap_or(0);
let transport = make_select(&TRANSPORTS, transport_idx, window, cx);
let will_qos = make_select(&QOS, will_qos_idx, window, cx);
// 切换传输方式时,把端口同步成该方式的默认端口,并刷新 WS 路径的按需显示。
let port_for_sub = port.clone();
let sub_transport = cx.subscribe_in(
&transport,
window,
move |_this, state, event, window, cx| {
if matches!(event, SelectEvent::Confirm(Some(_)))
&& let Some(idx) = state.read(cx).selected_value() {
let kind = TransportKind::ALL[*idx];
port_for_sub.update(cx, |p, cx| {
p.set_value(kind.default_port().to_string(), window, cx);
});
cx.notify();
}
},
);
// 切换协议时刷新 MQTT 5 属性分组的按需显示。
let sub_protocol = cx.subscribe_in(
&protocol,
window,
move |_this, _state, event, _window, cx| {
if matches!(event, SelectEvent::Confirm(Some(_))) {
cx.notify();
}
},
);
Self {
engine,
editing_id,
created_at,
name,
host,
port,
path,
client_id,
username,
password,
keep_alive,
session_expiry,
receive_max,
max_packet,
topic_alias,
will_topic,
will_payload,
protocol,
transport,
will_qos,
clean_start: c.clean_start,
auto_resubscribe: c.auto_resubscribe,
auto_reconnect: c.auto_reconnect,
will_enabled,
will_retain,
show_auth: true,
show_mqtt5: false,
show_will: false,
testing: false,
_subs: vec![sub_transport, sub_protocol],
}
}
fn val(&self, e: &Entity<InputState>, cx: &App) -> String {
e.read(cx).value().to_string()
}
fn parse_u16(s: &str, label: &str) -> Result<u16, String> {
s.trim()
.parse::<u16>()
.map_err(|_| format!("{label} 需要是 0~65535 的整数"))
}
fn parse_opt_u32(s: &str, label: &str) -> Result<Option<u32>, String> {
let s = s.trim();
if s.is_empty() {
return Ok(None);
}
s.parse::<u32>()
.map(Some)
.map_err(|_| format!("{label} 需要是非负整数"))
}
pub fn build(&self, cx: &App) -> Result<ConnectionConfig, String> {
self.build_inner(cx, false)
}
/// 测试连接专用的宽松构建:名称与 Client ID 不阻塞测试
///(握手时会生成临时唯一 Client ID)。
fn build_for_test(&self, cx: &App) -> Result<ConnectionConfig, String> {
self.build_inner(cx, true)
}
fn build_inner(&self, cx: &App, for_test: bool) -> Result<ConnectionConfig, String> {
let mut name = self.val(&self.name, cx);
if name.trim().is_empty() {
if !for_test {
return Err("连接名称不能为空".into());
}
name = "测试连接".into();
}
let host = self.val(&self.host, cx);
if host.trim().is_empty() {
return Err("主机地址不能为空".into());
}
let port = Self::parse_u16(&self.val(&self.port, cx), "端口")?;
let mut client_id = self.val(&self.client_id, cx);
if client_id.trim().is_empty() {
if !for_test {
return Err("Client ID 不能为空".into());
}
client_id = format!("mqttx-test-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
}
let keep_alive = Self::parse_u16(&self.val(&self.keep_alive, cx), "Keep Alive")?;
let session_expiry = self
.val(&self.session_expiry, cx)
.trim()
.parse::<u32>()
.unwrap_or(0);
let receive_maximum = Self::parse_opt_u32(&self.val(&self.receive_max, cx), "接收上限")?
.map(|v| u16::try_from(v).map_err(|_| "接收上限不能超过 65535".to_string()))
.transpose()?;
let maximum_packet_size =
Self::parse_opt_u32(&self.val(&self.max_packet, cx), "最大报文长度")?;
let topic_alias_maximum = Self::parse_opt_u32(&self.val(&self.topic_alias, cx), "主题别名")?
.map(|v| u16::try_from(v).map_err(|_| "主题别名上限不能超过 65535".to_string()))
.transpose()?;
let protocol = match self.protocol.read(cx).selected_value() {
Some(1) => ProtocolVersion::V311,
_ => ProtocolVersion::V5,
};
let transport = self
.transport
.read(cx)
.selected_value()
.and_then(|i| TransportKind::ALL.get(*i).copied())
.unwrap_or_default();
let last_will = if self.will_enabled {
let topic = self.val(&self.will_topic, cx);
if topic.trim().is_empty() {
return Err("遗嘱主题不能为空".into());
}
Some(LastWill {
topic,
payload: self.val(&self.will_payload, cx),
qos: self.will_qos.read(cx).selected_value().copied().unwrap_or(0) as u8,
retain: self.will_retain,
content_type: None,
response_topic: None,
})
} else {
None
};
let id = self.editing_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
Ok(ConnectionConfig {
id,
name,
host,
port,
path: self.val(&self.path, cx),
protocol,
transport,
client_id,
username: self.val(&self.username, cx),
password: self.val(&self.password, cx),
clean_start: self.clean_start,
keep_alive,
session_expiry_interval: session_expiry,
receive_maximum,
maximum_packet_size,
topic_alias_maximum,
auto_resubscribe: self.auto_resubscribe,
auto_reconnect: self.auto_reconnect,
last_will,
created_at: self.created_at,
})
}
pub fn run_test(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let mut cfg = match self.build_for_test(cx) {
Ok(c) => c,
Err(e) => {
window.push_notification(Notification::error(e), cx);
return;
}
};
// 测试连接用临时 id,不影响已有连接
cfg.id = uuid::Uuid::new_v4().to_string();
let rx = self.engine.test_connection(cfg);
self.testing = true;
cx.notify();
cx.spawn_in(window, async move |this, cx| {
let result = rx.recv().await.unwrap_or(Err("测试任务丢失".into()));
this.update_in(cx, |form, window, cx| {
form.testing = false;
match result {
Ok(()) => window.push_notification(
Notification::success("连接测试成功"),
cx,
),
Err(e) => window.push_notification(Notification::error(format!(
"连接测试失败: {e}"
)), cx),
}
cx.notify();
})
.ok();
})
.detach();
}
fn switch_row(
&self,
id: &'static str,
label: &'static str,
checked: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
h_flex()
.justify_between()
.child(div().text_sm().child(label))
.child(
Switch::new(id)
.checked(checked)
.on_change(cx.listener(move |this, v, _, cx| {
let v = *v;
match id {
"clean_start" => this.clean_start = v,
"auto_resubscribe" => this.auto_resubscribe = v,
"auto_reconnect" => this.auto_reconnect = v,
"will_enabled" => this.will_enabled = v,
"will_retain" => this.will_retain = v,
_ => {}
}
cx.notify();
})),
)
}
/// 可折叠分组:点击标题行切换展开/收起,收起时不渲染内容。
#[allow(clippy::too_many_arguments)]
fn collapsible_section(
&self,
id: &'static str,
title: &'static str,
hint: Option<SharedString>,
hint_accent: bool,
open: bool,
toggle: fn(&mut Self),
border: gpui_kit::Hsla,
bg: gpui_kit::Hsla,
body: impl IntoElement,
cx: &mut Context<Self>,
) -> impl IntoElement {
let title: SharedString = title.into();
v_flex().gap_2().child(
v_flex()
.child(
h_flex()
.id(id)
.justify_between()
.rounded_md()
.px_1()
.py_1()
.cursor_pointer()
.hover(|s| s.bg(cx.theme().secondary_hover))
.on_click(cx.listener(move |this, _, _, cx| {
toggle(this);
cx.notify();
}))
.child(
h_flex()
.gap_1p5()
.items_center()
.child(div().text_sm().font_semibold().child(title))
.children(hint.map(|h| {
let color = if hint_accent {
cx.theme().accent
} else {
cx.theme().muted_foreground
};
div().text_xs().text_color(color).child(h)
})),
)
.child(
Icon::new(IconName::ChevronDown)
.xsmall()
.text_color(cx.theme().muted_foreground)
.rotate(percentage(if open { 0.5 } else { 0. })),
),
)
.when(open, |w| {
w.child(
div()
.rounded_lg()
.border_1()
.border_color(border)
.bg(bg)
.p_3()
.child(body),
)
}),
)
}
}
impl Render for ConnectionForm {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let border = cx.theme().border;
let card_bg = cx.theme().secondary;
// 协议/传输决定高级分组的按需显示
let is_v5 = self.protocol.read(cx).selected_value() != Some(&1);
let is_ws = matches!(
self.transport.read(cx).selected_value(),
Some(2) | Some(3)
);
v_flex()
.gap_3()
.max_h(px(560.))
.overflow_y_scrollbar()
.pr_1()
// ── 基本信息(始终展开) ──
.child(section("基本信息", border, card_bg,
v_flex().gap_2().child(
h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("名称", Input::new(&self.name))))
.child(div().w(px(120.)).child(field("端口", Input::new(&self.port))))
)
.child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("主机", Input::new(&self.host))))
.child(div().flex_1().min_w(px(0.)).child(field("协议", Select::new(&self.protocol))))
.child(div().flex_1().min_w(px(0.)).child(field("传输", Select::new(&self.transport))))
)
.child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("Client ID", Input::new(&self.client_id))))
// WS 路径仅对 WebSocket/WSS 有意义,按需显示
.when(is_ws, |w| w.child(
div().flex_1().min_w(px(0.)).child(field("WS 路径", Input::new(&self.path)))
))
)
))
// ── 认证与心跳 ──
.child(self.collapsible_section(
"sec-auth", "认证与心跳", None, false, self.show_auth,
|f| f.show_auth = !f.show_auth, border, card_bg,
v_flex().gap_2()
.child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("用户名", Input::new(&self.username))))
.child(div().flex_1().min_w(px(0.)).child(field("密码", Input::new(&self.password).mask_toggle())))
.child(div().w(px(110.)).child(field("Keep Alive (秒)", Input::new(&self.keep_alive))))
)
.child(self.switch_row("clean_start", "Clean Start / Clean Session", self.clean_start, cx))
.child(self.switch_row("auto_reconnect", "断线自动重连", self.auto_reconnect, cx))
.child(self.switch_row("auto_resubscribe", "连接后自动恢复订阅", self.auto_resubscribe, cx)),
cx,
))
// ── MQTT 5 高级属性(仅 v5 协议显示) ──
.when(is_v5, |w| w.child(self.collapsible_section(
"sec-mqtt5", "MQTT 5 属性", None, false, self.show_mqtt5,
|f| f.show_mqtt5 = !f.show_mqtt5, border, card_bg,
h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("会话过期间隔(秒)", Input::new(&self.session_expiry))))
.child(div().flex_1().min_w(px(0.)).child(field("接收最大值", Input::new(&self.receive_max))))
.child(div().flex_1().min_w(px(0.)).child(field("最大报文长度", Input::new(&self.max_packet))))
.child(div().flex_1().min_w(px(0.)).child(field("主题别名上限", Input::new(&self.topic_alias)))),
cx,
)))
// ── 遗嘱 ──
.child(self.collapsible_section(
"sec-will", "遗嘱消息 (Last Will)",
self.will_enabled.then(|| "已启用".into()), true, self.show_will,
|f| f.show_will = !f.show_will, border, card_bg,
v_flex().gap_2()
.child(self.switch_row("will_enabled", "启用遗嘱消息", self.will_enabled, cx))
.when(self.will_enabled, |w| w
.child(h_flex().gap_2()
.child(div().flex_1().min_w(px(0.)).child(field("遗嘱主题", Input::new(&self.will_topic))))
.child(div().w(px(130.)).child(field("QoS", Select::new(&self.will_qos))))
)
.child(field("遗嘱内容", Input::new(&self.will_payload)))
.child(self.switch_row("will_retain", "Retain", self.will_retain, cx))
),
cx,
))
}
}
fn section(
title: impl Into<gpui_kit::SharedString>,
border: gpui_kit::Hsla,
bg: gpui_kit::Hsla,
body: impl IntoElement,
) -> impl IntoElement {
let title: gpui_kit::SharedString = title.into();
v_flex()
.gap_2()
.child(div().text_sm().font_semibold().child(title))
.child(
div()
.rounded_lg()
.border_1()
.border_color(border)
.bg(bg)
.p_3()
.child(body),
)
}