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
+298
View File
@@ -0,0 +1,298 @@
//! 阿里云 IoT 设备接入对话框:保存预设,并可一键生成连接并连接。
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::select::{Select, SelectState};
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, ActiveTheme as _, Sizable as _, StyledExt as _,
WindowExt as _,
};
use gpui_kit::prelude::FluentBuilder as _;
use crate::ui::IconName;
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, StatefulInteractiveElement as _,
IntoElement, ParentElement as _, Render, Styled as _, Window,
};
use crate::aliyun::{AliyunAuthMode, AliyunPreset};
use crate::model::ConnectionConfig;
use crate::ui::app::MqttXApp;
use crate::ui::widgets::{field, make_select, OptionDelegate};
const AUTH_MODES: [&str; 2] = ["Group 令牌 (Token)", "一机一密"];
struct AliyunDialog {
app: Entity<MqttXApp>,
presets: Vec<AliyunPreset>,
selected: Option<usize>,
name: Entity<InputState>,
group: Entity<InputState>,
instance_id: Entity<InputState>,
region: Entity<InputState>,
auth_mode: Entity<SelectState<OptionDelegate>>,
access_key_id: Entity<InputState>,
access_key_secret: Entity<InputState>,
group_id: Entity<InputState>,
device_id: Entity<InputState>,
device_secret: Entity<InputState>,
}
macro_rules! text_field {
($cx:expr, $window:expr, $value:expr, $ph:expr) => {
$cx.new(|cx| {
InputState::new($window, cx)
.default_value($value)
.placeholder($ph)
})
};
}
impl AliyunDialog {
fn new(app: Entity<MqttXApp>, presets: Vec<AliyunPreset>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let first = presets.first().cloned().unwrap_or_else(AliyunPreset::new);
Self {
app,
presets,
selected: None,
name: text_field!(cx, window, &first.name, "预设名称"),
group: text_field!(cx, window, &first.group, "分组(可选)"),
instance_id: text_field!(cx, window, &first.instance_id, "post-cn-xxxx"),
region: text_field!(cx, window, &first.region, "如 cn-shanghai(可留空)"),
auth_mode: make_select(
&AUTH_MODES,
match first.auth_mode {
AliyunAuthMode::Token => 0,
AliyunAuthMode::DeviceCredential => 1,
},
window,
cx,
),
access_key_id: text_field!(cx, window, &first.access_key_id, "AccessKey ID"),
access_key_secret: cx.new(|cx| {
InputState::new(window, cx)
.default_value(&first.access_key_secret)
.placeholder("AccessKey Secret")
.masked(true)
}),
group_id: text_field!(cx, window, &first.group_id, "GID-xxx"),
device_id: text_field!(cx, window, &first.device_id, "设备 ID"),
device_secret: cx.new(|cx| {
InputState::new(window, cx)
.default_value(&first.device_secret)
.placeholder("一机一密:DeviceSecret")
.masked(true)
}),
}
}
fn collect(&self, cx: &App) -> AliyunPreset {
let v = |e: &Entity<InputState>| e.read(cx).value().to_string();
let id = self
.selected
.and_then(|i| self.presets.get(i))
.map(|p| p.id.clone())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
AliyunPreset {
id,
name: v(&self.name),
group: v(&self.group),
instance_id: v(&self.instance_id),
region: v(&self.region),
auth_mode: match self.auth_mode.read(cx).selected_value() {
Some(1) => AliyunAuthMode::DeviceCredential,
_ => AliyunAuthMode::Token,
},
access_key_id: v(&self.access_key_id),
access_key_secret: v(&self.access_key_secret),
group_id: v(&self.group_id),
device_id: v(&self.device_id),
device_secret: v(&self.device_secret),
}
}
fn save_preset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let preset = self.collect(cx);
if preset.name.trim().is_empty() {
window.push_notification(Notification::warning("请填写预设名称"), cx);
return;
}
match self.selected {
Some(i) if i < self.presets.len() => self.presets[i] = preset,
_ => self.presets.push(preset),
}
let saved = self.presets.clone();
self.app.update(cx, |a, cx| {
a.save_aliyun(saved);
cx.notify();
});
window.push_notification(Notification::success("预设已保存"), cx);
}
fn connect(&self, window: &mut Window, cx: &mut Context<Self>) {
let preset = self.collect(cx);
match preset.to_connection() {
Ok(conn) => {
let mut conn: ConnectionConfig = conn;
conn.name = preset.name.clone();
self.app.update(cx, |a, cx| {
a.save_connection(conn.clone());
a.engine.connect(conn);
cx.notify();
});
window.close_dialog(cx);
}
Err(e) => {
window.push_notification(Notification::error(format!("无法生成连接: {e}")), cx);
}
}
}
}
impl Render for AliyunDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.gap_3()
.w_full()
// 左:预设列表
.child(
v_flex()
.w(px(160.))
.gap_1()
.child(div().text_xs().font_semibold().child("已保存预设"))
.children(
self.presets
.iter()
.enumerate()
.map(|(i, p)| {
let selected = self.selected == Some(i);
div()
.id(("aliyun-preset", i))
.px_2()
.py_1()
.rounded_md()
.text_sm()
.when(selected, |d| d.bg(cx.theme().secondary))
.cursor_pointer()
.child(p.name.clone())
.on_click(cx.listener(move |this, _, _, cx| {
// 选中仅做高亮;表单编辑的是“新建/另存”工作流
this.selected = Some(i);
cx.notify();
}))
}),
)
.child(
Button::new("aliyun-new")
.icon(IconName::Plus)
.label("新建")
.ghost()
.small()
.on_click(cx.listener(|this, _, _, cx| {
this.selected = None;
cx.notify();
})),
),
)
// 右:表单
.child(
v_flex()
.flex_1()
.min_w(px(0.))
.gap_2()
.child(field("预设名称", Input::new(&self.name)))
.child(
h_flex()
.gap_2()
.child(
div().flex_1().min_w(px(0.)).child(field(
"实例 ID",
Input::new(&self.instance_id),
)),
)
.child(
div().flex_1().min_w(px(0.)).child(field(
"地域",
Input::new(&self.region),
)),
),
)
.child(field("鉴权方式", Select::new(&self.auth_mode)))
.child(
h_flex()
.gap_2()
.child(
div().flex_1().min_w(px(0.)).child(field(
"AccessKey ID",
Input::new(&self.access_key_id),
)),
)
.child(
div().flex_1().min_w(px(0.)).child(field(
"AccessKey Secret",
Input::new(&self.access_key_secret).mask_toggle(),
)),
),
)
.child(
h_flex()
.gap_2()
.child(
div().flex_1().min_w(px(0.)).child(field(
"Group ID",
Input::new(&self.group_id),
)),
)
.child(
div().flex_1().min_w(px(0.)).child(field(
"设备 ID",
Input::new(&self.device_id),
)),
),
)
.child(field(
"DeviceSecret(一机一密)",
Input::new(&self.device_secret).mask_toggle(),
))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Token 模式使用 MQTT 3.1.1,clientId={GroupId}@@@{设备ID},密码为 HMAC-SHA1 签名"),
)
.child(
h_flex()
.gap_2()
.justify_end()
.child(
Button::new("aliyun-save")
.label("保存预设")
.outline()
.on_click(cx.listener(|this, _, window, cx| {
this.save_preset(window, cx)
})),
)
.child(
Button::new("aliyun-connect")
.label("生成并连接")
.primary()
.on_click(cx.listener(|this, _, window, cx| {
this.connect(window, cx)
})),
),
),
)
}
}
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
let presets = app.read(cx).aliyun_presets.clone();
let dialog_view = cx.new(|cx| AliyunDialog::new(app, presets, window, cx));
window.open_dialog(cx, move |dialog, _, _| {
dialog
.w(px(760.))
.title("阿里云 IoT 设备")
.child(dialog_view.clone())
.footer(gpui_kit::div())
});
}