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
+658
View File
@@ -0,0 +1,658 @@
//! 应用数据模型:连接配置、订阅、消息、日志、预设、全局变量、设置。
//!
//! 本层不依赖 GPUI,可被持久化层与 MQTT 引擎共用。
use serde::{Deserialize, Serialize};
// ─── 连接配置 ────────────────────────────────────────────────────────────────
/// MQTT 协议版本。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum ProtocolVersion {
/// MQTT 3.1.1
V311,
/// MQTT 5.0
#[default]
V5,
}
impl ProtocolVersion {
pub const ALL: [Self; 2] = [Self::V5, Self::V311];
pub fn label(self) -> &'static str {
match self {
Self::V311 => "MQTT 3.1.1",
Self::V5 => "MQTT 5.0",
}
}
pub fn is_v5(self) -> bool {
matches!(self, Self::V5)
}
}
/// 传输方式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum TransportKind {
/// 明文 TCP,默认 1883
#[default]
Tcp,
/// TLS over TCP,默认 8883
Tls,
/// WebSocket (ws://),默认 8083
Ws,
/// WebSocket over TLS (wss://),默认 8084
Wss,
}
impl TransportKind {
pub const ALL: [Self; 4] = [Self::Tcp, Self::Tls, Self::Ws, Self::Wss];
pub fn label(self) -> &'static str {
match self {
Self::Tcp => "TCP",
Self::Tls => "TLS",
Self::Ws => "WebSocket",
Self::Wss => "WSS",
}
}
pub fn default_port(self) -> u16 {
match self {
Self::Tcp => 1883,
Self::Tls => 8883,
Self::Ws => 8083,
Self::Wss => 8084,
}
}
pub fn is_tls(self) -> bool {
matches!(self, Self::Tls | Self::Wss)
}
pub fn is_websocket(self) -> bool {
matches!(self, Self::Ws | Self::Wss)
}
}
/// MQTT 5 遗嘱消息(3.1.1 下忽略 v5 专属属性)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LastWill {
pub topic: String,
pub payload: String,
pub qos: u8,
pub retain: bool,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub response_topic: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionConfig {
pub id: String,
pub name: String,
/// 主机(不含端口、协议前缀)
pub host: String,
pub port: u16,
/// WebSocket 路径,如 /mqtt
#[serde(default)]
pub path: String,
#[serde(default)]
pub protocol: ProtocolVersion,
#[serde(default)]
pub transport: TransportKind,
pub client_id: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
#[serde(default = "default_true")]
pub clean_start: bool,
#[serde(default = "default_keep_alive")]
pub keep_alive: u16,
// ── MQTT 5 专属 ──
#[serde(default)]
pub session_expiry_interval: u32,
#[serde(default)]
pub receive_maximum: Option<u16>,
#[serde(default)]
pub maximum_packet_size: Option<u32>,
#[serde(default)]
pub topic_alias_maximum: Option<u16>,
/// 连接成功后自动恢复已保存的订阅
#[serde(default = "default_true")]
pub auto_resubscribe: bool,
/// 断线自动重连
#[serde(default = "default_true")]
pub auto_reconnect: bool,
#[serde(default)]
pub last_will: Option<LastWill>,
/// 创建时间(unix 秒),用于排序
#[serde(default)]
pub created_at: i64,
}
fn default_true() -> bool {
true
}
fn default_keep_alive() -> u16 {
60
}
impl ConnectionConfig {
pub fn new() -> Self {
let transport = TransportKind::default();
Self {
id: uuid::Uuid::new_v4().to_string(),
name: "新建连接".into(),
host: "broker.emqx.io".into(),
port: transport.default_port(),
path: "/mqtt".into(),
protocol: ProtocolVersion::V5,
transport,
client_id: format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]),
username: String::new(),
password: String::new(),
clean_start: true,
keep_alive: 60,
session_expiry_interval: 0,
receive_maximum: None,
maximum_packet_size: None,
topic_alias_maximum: None,
auto_resubscribe: true,
auto_reconnect: true,
last_will: None,
created_at: chrono::Local::now().timestamp(),
}
}
/// broker.emqx.io 等公共服务器的快速预设。
pub fn with_public(name: &str, host: &str, port: u16, transport: TransportKind) -> Self {
let mut c = Self::new();
c.name = name.into();
c.host = host.into();
c.port = port;
c.transport = transport;
c
}
pub fn display_address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
impl Default for ConnectionConfig {
fn default() -> Self {
Self::new()
}
}
// ─── 订阅 ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subscription {
pub id: String,
pub connection_id: String,
pub topic: String,
pub qos: u8,
/// 本地备注颜色(与 MQTTX 一致,按订阅给消息着色),0..=359 色相
#[serde(default)]
pub color: Option<f32>,
}
impl Subscription {
pub fn new(connection_id: impl Into<String>, topic: impl Into<String>, qos: u8) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
connection_id: connection_id.into(),
topic: topic.into(),
qos,
color: None,
}
}
}
// ─── 消息 ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
Received,
Published,
}
impl Direction {
pub fn label(self) -> &'static str {
match self {
Self::Received => "接收",
Self::Published => "发布",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum PayloadFormat {
#[default]
Plaintext,
Json,
Base64,
Hex,
}
impl PayloadFormat {
pub const ALL: [Self; 4] = [Self::Plaintext, Self::Json, Self::Base64, Self::Hex];
pub fn label(self) -> &'static str {
match self {
Self::Plaintext => "Plaintext",
Self::Json => "JSON",
Self::Base64 => "Base64",
Self::Hex => "Hex",
}
}
pub fn encode(self, text: &str) -> Result<Vec<u8>, String> {
match self {
Self::Plaintext | Self::Json => Ok(text.as_bytes().to_vec()),
Self::Base64 => {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD
.decode(text.trim())
.map_err(|e| format!("Base64 解码失败: {e}"))
}
Self::Hex => {
let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect();
if !cleaned.len().is_multiple_of(2) {
return Err("Hex 长度必须为偶数(每两个字符一个字节)".into());
}
(0..cleaned.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&cleaned[i..i + 2], 16)
.map_err(|e| format!("Hex 解码失败: {e}"))
})
.collect()
}
}
}
}
/// 一条收到/发出的消息记录。payload 以 UTF-8 文本保存(非文本按 lossy 显示)。
#[derive(Debug, Clone)]
pub struct MqttRecord {
pub seq: u64,
pub connection_id: String,
pub direction: Direction,
pub topic: String,
pub payload: String,
pub qos: u8,
pub retain: bool,
/// unix 毫秒
pub timestamp: i64,
pub user_properties: Vec<(String, String)>,
pub content_type: Option<String>,
pub response_topic: Option<String>,
pub correlation_data: Option<String>,
pub message_expiry_interval: Option<u32>,
pub subscription_identifier: Option<u32>,
}
// ─── 发布参数 / 发布预设 ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PublishParams {
pub topic: String,
/// 用于界面展示与记录的文本负载。
pub payload: String,
#[serde(default)]
pub payload_format: PayloadFormat,
pub qos: u8,
pub retain: bool,
/// MQTT 5 用户属性
#[serde(default)]
pub user_properties: Vec<(String, String)>,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub message_expiry_interval: Option<u32>,
#[serde(default)]
pub response_topic: Option<String>,
#[serde(default)]
pub correlation_data: Option<String>,
/// 实际发送的字节。Base64/Hex 时为解码结果;不持久化、不参与预设。
/// None 时引擎回退为 `payload.as_bytes()`。
#[serde(skip)]
pub raw_bytes: Option<Vec<u8>>,
}
/// 发布预设:保存常用的发布参数,一键复用(对标 MQTTX Publish Scripts/Preset)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishPreset {
pub id: String,
pub name: String,
#[serde(default)]
pub connection_id: Option<String>,
#[serde(flatten)]
pub params: PublishParams,
}
impl PublishPreset {
pub fn new(name: impl Into<String>, params: PublishParams) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name: name.into(),
connection_id: None,
params,
}
}
}
// ─── 全局变量 ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalVariable {
pub key: String,
pub value: String,
}
/// 解析 `{{key}}` 模板:
/// - 用户定义的全局变量优先;
/// - 内置变量:`{{$ts}}`(unix 秒)、`{{$ts_ms}}`(毫秒)、`{{$uuid}}`;
/// - 找不到的占位符保持原样。
pub fn render_template(input: &str, vars: &[GlobalVariable]) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
// 同一次渲染内时间基准保持一致,避免一条消息里多个时间变量取值不同。
let now_ms = chrono::Local::now().timestamp_millis();
let now_s = now_ms / 1000;
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'{' && bytes[i + 1] == b'{'
&& let Some(rel_end) = input[i + 2..].find("}}") {
let key = &input[i + 2..i + 2 + rel_end];
let resolved = match key.trim() {
"$ts" => Some(now_s.to_string()),
"$ts_ms" => Some(now_ms.to_string()),
"$uuid" => Some(uuid::Uuid::new_v4().to_string()),
k => vars
.iter()
.find(|v| v.key == k)
.map(|v| v.value.clone()),
};
match resolved {
Some(v) => out.push_str(&v),
None => out.push_str(&input[i..i + 2 + rel_end + 2]),
}
i = i + 2 + rel_end + 2;
continue;
}
// 按字符推进,避免把多字节 UTF-8 拆开
let ch = input[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}
// ─── 日志 ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}
impl LogLevel {
pub fn label(self) -> &'static str {
match self {
Self::Debug => "DEBUG",
Self::Info => "INFO",
Self::Warn => "WARN",
Self::Error => "ERROR",
}
}
pub fn parse(s: &str) -> Self {
match s {
"debug" => Self::Debug,
"warn" => Self::Warn,
"error" => Self::Error,
_ => Self::Info,
}
}
}
#[derive(Debug, Clone)]
pub struct LogEntry {
pub timestamp: i64,
pub connection_id: String,
pub level: LogLevel,
pub event: String,
pub message: String,
pub details: Option<String>,
}
// ─── 连接状态 ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConnectionStatus {
#[default]
Disconnected,
Connecting,
Connected,
Error,
}
impl ConnectionStatus {
pub fn label(self) -> &'static str {
match self {
Self::Disconnected => "未连接",
Self::Connecting => "连接中",
Self::Connected => "已连接",
Self::Error => "错误",
}
}
}
// ─── 应用设置 ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThemeModePref {
Light,
Dark,
#[default]
System,
}
impl ThemeModePref {
pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
pub fn label(self) -> &'static str {
match self {
Self::System => "跟随系统",
Self::Light => "浅色",
Self::Dark => "深色",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default)]
pub theme: ThemeModePref,
/// 每条连接在内存中保留的最大消息条数
#[serde(default = "default_max_messages")]
pub max_messages: usize,
/// 时间戳是否显示毫秒
#[serde(default = "default_true")]
pub show_millis: bool,
/// 是否自动检查更新(占位,暂不实现更新)
#[serde(default = "default_true")]
pub auto_check_update: bool,
}
fn default_max_messages() -> usize {
2000
}
impl Default for AppSettings {
fn default() -> Self {
Self {
theme: ThemeModePref::default(),
max_messages: default_max_messages(),
show_millis: true,
auto_check_update: true,
}
}
}
// ─── 单元测试 ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// 修复回归:Hex 奇数长度必须报错,不能静默截断
#[test]
fn hex_odd_length_is_rejected() {
let err = PayloadFormat::Hex.encode("abc").unwrap_err();
assert!(err.contains("偶数"), "应提示偶数长度,实际: {err}");
assert!(PayloadFormat::Hex.encode("").is_ok());
assert_eq!(
PayloadFormat::Hex.encode("68 69").unwrap(),
vec![0x68, 0x69],
"空白应被忽略"
);
assert!(PayloadFormat::Hex.encode("zz").is_err());
}
#[test]
fn base64_encode_roundtrip() {
use base64::Engine as _;
let text = "hello 世界";
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
assert_eq!(PayloadFormat::Base64.encode(&encoded).unwrap(), text.as_bytes());
assert!(PayloadFormat::Base64.encode("not base64!!!").is_err());
}
#[test]
fn plaintext_and_json_encode_as_utf8() {
assert_eq!(
PayloadFormat::Plaintext.encode("中文").unwrap(),
"中文".as_bytes()
);
assert_eq!(
PayloadFormat::Json.encode(r#"{"a":1}"#).unwrap(),
r#"{"a":1}"#.as_bytes()
);
}
// 修复回归:raw_bytes 是运行时字段,不得写入持久化 JSON
#[test]
fn raw_bytes_not_serialized() {
let p = PublishParams {
topic: "t".into(),
payload: "hi".into(),
raw_bytes: Some(vec![0x00, 0xff]),
..Default::default()
};
let json = serde_json::to_string(&p).unwrap();
assert!(!json.contains("raw_bytes"), "raw_bytes 不应被序列化: {json}");
let back: PublishParams = serde_json::from_str(&json).unwrap();
assert!(back.raw_bytes.is_none());
// 反序列化时输入中的 raw_bytes 字段应被忽略(skip 双向生效)
let mut with_junk = serde_json::to_string(&PublishParams::default()).unwrap();
with_junk.pop(); // 去掉结尾 '}'
with_junk.push_str(r#", "raw_bytes":[1,2]}"#);
let back: PublishParams = serde_json::from_str(&with_junk).unwrap();
assert!(back.raw_bytes.is_none(), "#[serde(skip)] 应忽略输入字段");
}
// 修复回归:同一条消息内 $ts 与 $ts_ms 基于同一时刻
#[test]
fn template_timestamps_share_one_timebase() {
let out = render_template("s={{$ts}} ms={{$ts_ms}}", &[]);
let (s_part, ms_part) = out.split_once(" ms=").expect("格式 s=.. ms=..");
let s: i64 = s_part
.strip_prefix("s=")
.expect("秒字段")
.parse()
.unwrap();
let ms: i64 = ms_part.parse().expect("毫秒字段");
assert_eq!(s, ms / 1000, "秒与毫秒应来自同一时间基准: {out}");
}
#[test]
fn template_unknown_placeholder_kept_and_custom_var_resolved() {
assert_eq!(render_template("{{$unknown}}", &[]), "{{$unknown}}");
let vars = vec![GlobalVariable {
key: "device".into(),
value: "sensor-1".into(),
}];
assert!(render_template("d={{device}}/{{$uuid}}", &vars).starts_with("d=sensor-1/"));
// 多字节字符不被拆坏
assert_eq!(render_template("中文{{$no}}", &[]), "中文{{$no}}");
}
// 预设持久化往返:raw_bytes 不落盘,参数完整保留
#[test]
fn publish_preset_roundtrip_skips_raw_bytes() {
let preset = PublishPreset {
id: "p1".into(),
name: "报警".into(),
connection_id: Some("c1".into()),
params: PublishParams {
topic: "alarm/{{device}}".into(),
payload: "6869".into(),
payload_format: PayloadFormat::Hex,
qos: 1,
retain: true,
content_type: Some("application/json".into()),
user_properties: vec![("k".into(), "v".into())],
raw_bytes: Some(vec![0x68, 0x69]),
..Default::default()
},
};
let json = serde_json::to_string(&preset).unwrap();
let back: PublishPreset = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "报警");
assert_eq!(back.params.topic, "alarm/{{device}}");
assert_eq!(back.params.payload_format, PayloadFormat::Hex);
assert_eq!(back.params.qos, 1);
assert!(back.params.retain);
assert_eq!(back.params.content_type.as_deref(), Some("application/json"));
assert_eq!(back.params.user_properties.len(), 1);
assert!(back.params.raw_bytes.is_none());
}
#[test]
fn payload_format_equality_and_default() {
assert_eq!(PayloadFormat::default(), PayloadFormat::Plaintext);
assert_ne!(PayloadFormat::Hex, PayloadFormat::Base64);
let json = serde_json::to_string(&PayloadFormat::Hex).unwrap();
assert_eq!(
serde_json::from_str::<PayloadFormat>(&json).unwrap(),
PayloadFormat::Hex
);
}
}