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
+152
View File
@@ -0,0 +1,152 @@
//! 阿里云 IoT 平台一键接入预设(对标官方 MQTTX 的阿里云模板)。
//!
//! Token 模式鉴权参数:
//! - clientId: `{GroupId}@@@{DeviceId}`
//! - username: `Signature|{AccessKeyId}|{InstanceId}`
//! - password: base64(HMAC-SHA1(AccessKeySecret, clientId))
use base64::Engine as _;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use crate::model::{ConnectionConfig, ProtocolVersion, TransportKind};
type HmacSha1 = Hmac<Sha1>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AliyunAuthMode {
/// Group ID + 设备 ID,使用账号 AccessKey 签名
#[default]
Token,
/// 一机一密:设备独立 DeviceName + DeviceSecret(直接以设备密钥鉴权)
DeviceCredential,
}
impl AliyunAuthMode {
pub const ALL: [Self; 2] = [Self::Token, Self::DeviceCredential];
pub fn label(self) -> &'static str {
match self {
Self::Token => "Group 令牌 (Token)",
Self::DeviceCredential => "一机一密",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AliyunPreset {
pub id: String,
pub name: String,
/// 分组(仅 UI 分类用)
#[serde(default)]
pub group: String,
/// 实例 ID,如 post-cn-4591dq2ra1i
pub instance_id: String,
/// 地域 host 后缀前的 region,如 cn-shanghai;留空则使用公共接入点
#[serde(default)]
pub region: String,
#[serde(default)]
pub auth_mode: AliyunAuthMode,
// Token 模式
#[serde(default)]
pub access_key_id: String,
#[serde(default)]
pub access_key_secret: String,
#[serde(default)]
pub group_id: String,
// 共用
pub device_id: String,
// 一机一密模式
#[serde(default)]
pub device_secret: String,
}
impl AliyunPreset {
pub fn new() -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name: "阿里云设备".into(),
group: String::new(),
instance_id: String::new(),
region: String::new(),
auth_mode: AliyunAuthMode::Token,
access_key_id: String::new(),
access_key_secret: String::new(),
group_id: String::new(),
device_id: format!("device_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]),
device_secret: String::new(),
}
}
/// HMAC-SHA1(secret, client_id) 的 Base64 编码。
pub fn signature(secret: &str, client_id: &str) -> Result<String, String> {
let mut mac =
HmacSha1::new_from_slice(secret.as_bytes()).map_err(|e| format!("HMAC 初始化失败: {e}"))?;
mac.update(client_id.as_bytes());
Ok(base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
}
/// 接入点 host:`{instanceId}.mqtt.iothub.aliyuncs.com`
/// (实例级接入点在各区域通用;region 为空时同样可用)
pub fn host(&self) -> String {
if self.region.is_empty() {
format!("{}.mqtt.iothub.aliyuncs.com", self.instance_id)
} else {
format!(
"{}.mqtt.{}.aliyuncs.com",
self.instance_id, self.region
)
}
}
/// 由预设生成一份连接配置(不写入 id/name 之外的覆盖项)。
pub fn to_connection(&self) -> Result<ConnectionConfig, String> {
let mut conn = ConnectionConfig::new();
conn.name = self.name.clone();
conn.host = self.host();
conn.port = TransportKind::Tcp.default_port();
conn.transport = TransportKind::Tcp;
conn.protocol = ProtocolVersion::V311;
conn.clean_start = true;
match self.auth_mode {
AliyunAuthMode::Token => {
if self.group_id.is_empty()
|| self.access_key_id.is_empty()
|| self.access_key_secret.is_empty()
{
return Err("Group ID、AccessKey ID 与 AccessKey Secret 不能为空".into());
}
let client_id = format!("{}@@@{}", self.group_id, self.device_id);
let password = Self::signature(&self.access_key_secret, &client_id)?;
conn.client_id = client_id;
conn.username =
format!("Signature|{}|{}", self.access_key_id, self.instance_id);
conn.password = password;
}
AliyunAuthMode::DeviceCredential => {
if self.device_secret.is_empty() {
return Err("设备密钥 (DeviceSecret) 不能为空".into());
}
// 一机一密三元组:clientId=设备名,username=设备名&实例,password=HMAC-SHA1(DeviceSecret, clientId)
let client_id = self.device_id.clone();
let password = Self::signature(&self.device_secret, &client_id)?;
conn.client_id = client_id;
conn.username = format!("{}&{}", self.device_id, self.instance_id);
conn.password = password;
}
}
Ok(conn)
}
}
impl Default for AliyunPreset {
fn default() -> Self {
Self::new()
}
}
+7
View File
@@ -0,0 +1,7 @@
//! MQTTX:数据模型、持久化、MQTT 引擎与 GPUI 界面。
pub mod aliyun;
pub mod model;
pub mod mqtt;
pub mod store;
pub mod ui;
+27
View File
@@ -0,0 +1,27 @@
use gpui_kit::component::{Root, TitleBar};
use gpui_kit::*;
use mqttx_desktop::ui;
fn main() {
application()
.with_assets(gpui_kit::assets::Assets)
.run(|cx: &mut App| {
gpui_kit::init(cx);
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(1440.), px(900.)),
cx,
))),
..TitleBar::window_options()
};
cx.open_window(options, |window, cx| {
let view = cx.new(|cx| ui::app::MqttXApp::new(window, cx));
cx.new(|cx| Root::new(view.clone(), window, cx))
})
.expect("Failed to open window");
});
}
+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
);
}
}
+981
View File
@@ -0,0 +1,981 @@
//! MQTT 引擎:在独立 tokio runtime 上管理多个连接的生命周期,
//! 通过 smol 无界通道把状态/消息/日志事件送回 GPUI 线程。
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use rumqttc::mqttbytes::v5::{
ConnectReturnCode as V5ReturnCode, Packet as V5Packet, Publish as V5Publish,
PublishProperties as V5PublishProperties,
};
use rumqttc::{
AsyncClient as V5AsyncClient, Broker as V5Broker, ConnectionError as V5Error,
Event as V5Event, EventLoop as V5EventLoop, MqttOptions as V5Options, Outgoing as V5Outgoing,
PublishOptions as V5PublishOptions, QoS as V5QoS, Transport as V5Transport,
};
use rumqttc_v4 as rv4;
use tokio::sync::watch;
use crate::model::{
ConnectionConfig, ConnectionStatus, Direction, LogEntry, LogLevel, MqttRecord, PublishParams,
Subscription, TransportKind,
};// ─── 引擎事件 ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum EngineEvent {
/// 连接状态变化
Status {
connection_id: String,
status: ConnectionStatus,
error: Option<String>,
reason_code: Option<u8>,
session_present: Option<bool>,
},
/// 收到消息
Message(MqttRecord),
/// 一条本地发出的消息(已成功交给客户端)
Published {
connection_id: String,
topic: String,
payload: String,
qos: u8,
retain: bool,
content_type: Option<String>,
user_properties: Vec<(String, String)>,
},
/// 订阅请求结果(tracked,等待 SUBACK)
SubscribeResult {
connection_id: String,
topic: String,
qos: u8,
ok: bool,
error: Option<String>,
},
/// 日志
Log(LogEntry),
}
pub type EventSender = smol::channel::Sender<EngineEvent>;
// ─── 句柄 ────────────────────────────────────────────────────────────────────
enum ClientKind {
V5(V5AsyncClient),
V4(rv4::AsyncClient),
}
struct ConnHandle {
client: ClientKind,
cancel_tx: watch::Sender<bool>,
connected: Arc<AtomicBool>,
}
pub struct MqttEngine {
runtime: tokio::runtime::Runtime,
conns: Mutex<HashMap<String, ConnHandle>>,
tx: EventSender,
seq: Arc<AtomicU64>,
}
fn now_ms() -> i64 {
chrono::Local::now().timestamp_millis()
}
impl MqttEngine {
pub fn new(tx: EventSender) -> Arc<Self> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.thread_name("mqtt-engine")
.build()
.expect("创建 tokio runtime 失败");
Arc::new(Self {
runtime,
conns: Mutex::new(HashMap::new()),
tx,
seq: Arc::new(AtomicU64::new(1)),
})
}
fn next_seq(&self) -> u64 {
self.seq.fetch_add(1, Ordering::Relaxed)
}
fn emit(&self, event: EngineEvent) {
let _ = self.tx.try_send(event);
}
fn log(&self, conn_id: &str, level: LogLevel, event: &str, message: String, details: Option<String>) {
self.emit(EngineEvent::Log(LogEntry {
timestamp: now_ms(),
connection_id: conn_id.to_string(),
level,
event: event.to_string(),
message,
details,
}));
}
pub fn is_connected(&self, id: &str) -> bool {
let Ok(map) = self.conns.lock() else {
return false;
};
map.get(id)
.map(|h| h.connected.load(Ordering::SeqCst))
.unwrap_or(false)
}
// ── 连接 ──────────────────────────────────────────────────────────────
pub fn connect(self: &Arc<Self>, cfg: ConnectionConfig) {
// 关闭同名旧连接
self.close(&cfg.id, false);
let id = cfg.id.clone();
self.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Connecting,
error: None,
reason_code: None,
session_present: None,
});
self.log(
&id,
LogLevel::Info,
"connect",
format!(
"正在连接 {}:{} ({}, {})",
cfg.host,
cfg.port,
cfg.protocol.label(),
cfg.transport.label()
),
None,
);
let engine = Arc::clone(self);
self.runtime.spawn(async move {
let build = if cfg.protocol.is_v5() {
build_v5(&cfg)
.map(|(client, el)| (ClientKind::V5(client), EventLoopKind::V5(Box::new(el))))
} else {
build_v4(&cfg)
.map(|(client, el)| (ClientKind::V4(client), EventLoopKind::V4(Box::new(el))))
};
let (client, eventloop) = match build {
Ok(pair) => pair,
Err(e) => {
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Error,
error: Some(e.clone()),
reason_code: None,
session_present: None,
});
engine.log(&id, LogLevel::Error, "connect_error", format!("连接失败: {e}"), None);
return;
}
};
let connected = Arc::new(AtomicBool::new(false));
let (cancel_tx, cancel_rx) = watch::channel(false);
let handle = ConnHandle {
client,
cancel_tx,
connected: connected.clone(),
};
engine
.conns
.lock()
.unwrap()
.insert(id.clone(), handle);
match eventloop {
EventLoopKind::V5(el) => {
spawn_v5_loop(
engine.clone(),
id,
*el,
cancel_rx,
connected,
cfg.auto_reconnect,
);
}
EventLoopKind::V4(el) => {
spawn_v4_loop(
engine.clone(),
id,
*el,
cancel_rx,
connected,
cfg.auto_reconnect,
);
}
}
});
}
/// 主动断开。`notify` 为 true 时发出 Disconnected 状态(用户操作)。
pub fn close(&self, id: &str, user_initiated: bool) {
let handle = self.conns.lock().unwrap().remove(id);
if let Some(h) = handle {
let _ = h.cancel_tx.send(true);
let connected = h.connected.clone();
// 发送 MQTT DISCONNECT 报文后丢弃客户端
self.runtime.spawn(async move {
match h.client {
ClientKind::V5(c) => {
let _ = c.disconnect().await;
}
ClientKind::V4(c) => {
let _ = c.disconnect().await;
}
}
connected.store(false, Ordering::SeqCst);
});
if user_initiated {
self.log(id, LogLevel::Info, "disconnect", "用户请求断开连接".into(), None);
self.emit(EngineEvent::Status {
connection_id: id.to_string(),
status: ConnectionStatus::Disconnected,
error: None,
reason_code: None,
session_present: None,
});
}
}
}
// ── 发布 ──────────────────────────────────────────────────────────────
pub fn publish(self: &Arc<Self>, connection_id: String, params: PublishParams) {
let engine = Arc::clone(self);
self.runtime.spawn(async move {
let Some(client) = engine.client_of(&connection_id) else {
engine.log(
&connection_id,
LogLevel::Error,
"publish_error",
"发布失败: 连接不存在".into(),
None,
);
return;
};
let payload = params
.raw_bytes
.clone()
.unwrap_or_else(|| params.payload.as_bytes().to_vec());
// v5 与 v4 两个 crate 各自定义 ClientError,统一归一化为 String。
let result: Result<(), String> = match &client {
ClientKind::V5(client) => {
let props = V5PublishProperties {
user_properties: params.user_properties.clone(),
content_type: params.content_type.clone(),
message_expiry_interval: params.message_expiry_interval,
response_topic: params.response_topic.clone(),
correlation_data: params
.correlation_data
.clone()
.map(|s| s.into_bytes().into()),
..Default::default()
};
let options = V5PublishOptions::new(v5_qos(params.qos))
.retain(params.retain)
.properties(props);
client
.publish(params.topic.clone(), payload, options)
.await
.map_err(|e| e.to_string())
}
ClientKind::V4(client) => {
let options =
rv4::PublishOptions::new(v4_qos(params.qos)).retain(params.retain);
client
.publish(params.topic.clone(), payload, options)
.await
.map_err(|e| e.to_string())
}
};
match result {
Ok(_) => {
engine.log(
&connection_id,
LogLevel::Info,
"publish_sent",
format!(
"发布消息到 {} (QoS {}, retain={})",
params.topic, params.qos, params.retain
),
None,
);
engine.emit(EngineEvent::Published {
connection_id,
topic: params.topic,
payload: params.payload,
qos: params.qos,
retain: params.retain,
content_type: params.content_type,
user_properties: params.user_properties,
});
}
Err(e) => {
engine.log(
&connection_id,
LogLevel::Error,
"publish_error",
format!("发布失败: {e}"),
None,
);
}
}
});
}
// ── 订阅 / 取消订阅 ─────────────────────────────────────────────────────
pub fn subscribe(self: &Arc<Self>, connection_id: String, sub: Subscription) {
let engine = Arc::clone(self);
self.runtime.spawn(async move {
let Some(client) = engine.client_of(&connection_id) else {
engine.emit(EngineEvent::SubscribeResult {
connection_id,
topic: sub.topic,
qos: sub.qos,
ok: false,
error: Some("连接不存在".into()),
});
return;
};
engine.log(
&connection_id,
LogLevel::Info,
"subscribe",
format!("订阅主题: {} (QoS {})", sub.topic, sub.qos),
None,
);
let result = match &client {
ClientKind::V5(client) => {
match client
.subscribe_tracked(sub.topic.clone(), v5_qos(sub.qos))
.await
{
Ok(notice) => notice.wait_completion_async().await.map_err(|e| {
format!("订阅被拒绝: {e}")
}),
Err(e) => Err(format!("订阅请求失败: {e}")),
}
}
ClientKind::V4(client) => {
match client
.subscribe_tracked(sub.topic.clone(), v4_qos(sub.qos))
.await
{
Ok(notice) => notice.wait_completion_async().await.map_err(|e| {
format!("订阅被拒绝: {e}")
}),
Err(e) => Err(format!("订阅请求失败: {e}")),
}
}
};
let (ok, error) = match result {
Ok(()) => (true, None),
Err(e) => {
engine.log(
&connection_id,
LogLevel::Error,
"subscribe_error",
e.clone(),
None,
);
(false, Some(e))
}
};
engine.emit(EngineEvent::SubscribeResult {
connection_id,
topic: sub.topic,
qos: sub.qos,
ok,
error,
});
});
}
pub fn unsubscribe(self: &Arc<Self>, connection_id: String, topic: String) {
let engine = Arc::clone(self);
self.runtime.spawn(async move {
let Some(client) = engine.client_of(&connection_id) else {
return;
};
let result: Result<(), String> = match &client {
ClientKind::V5(client) => client
.unsubscribe(topic.clone())
.await
.map_err(|e| e.to_string()),
ClientKind::V4(client) => client
.unsubscribe(topic.clone())
.await
.map_err(|e| e.to_string()),
};
match result {
Ok(()) => {
engine.log(
&connection_id,
LogLevel::Info,
"unsubscribe",
format!("取消订阅: {topic}"),
None,
);
}
Err(e) => {
engine.log(
&connection_id,
LogLevel::Error,
"unsubscribe_error",
format!("取消订阅失败: {e}"),
None,
);
}
}
});
}
/// 测试连接:5 秒内等待 CONNACK,结果通过 channel 返回。
pub fn test_connection(
self: &Arc<Self>,
cfg: ConnectionConfig,
) -> smol::channel::Receiver<Result<(), String>> {
let (tx, rx) = smol::channel::bounded(1);
self.runtime.spawn(async move {
let result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
test_handshake(&cfg).await
})
.await;
let reply = match result {
Ok(inner) => inner,
Err(_) => Err("连接测试超时(5 秒),请检查主机地址与端口".into()),
};
let _ = tx.try_send(reply);
});
rx
}
// ── 内部工具 ───────────────────────────────────────────────────────────
/// 克隆一个连接的异步客户端用于发命令。
/// 不持有 std Mutex 守卫跨 await,避免与 tokio runtime 的 Send 要求冲突。
fn client_of(&self, id: &str) -> Option<ClientKind> {
let map = self.conns.lock().ok()?;
let h = map.get(id)?;
Some(match &h.client {
ClientKind::V5(c) => ClientKind::V5(c.clone()),
ClientKind::V4(c) => ClientKind::V4(c.clone()),
})
}
}
enum EventLoopKind {
// 两个事件循环都在 KB 级,装箱让枚举本身只有指针大小
V5(Box<V5EventLoop>),
V4(Box<rv4::EventLoop>),
}
// ─── 选项构建 ────────────────────────────────────────────────────────────────
fn build_v5(
cfg: &ConnectionConfig,
) -> Result<(V5AsyncClient, V5EventLoop), String> {
let mut opts = match cfg.transport {
TransportKind::Tcp | TransportKind::Tls => {
V5Options::new(cfg.client_id.clone(), V5Broker::tcp(&cfg.host, cfg.port))
}
TransportKind::Ws => {
let url = format!("ws://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
V5Options::new(cfg.client_id.clone(), V5Broker::websocket(url).map_err(|e| format!("{e:?}"))?)
}
TransportKind::Wss => {
let url = format!("wss://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
V5Options::try_websocket_with_default_tls(cfg.client_id.clone(), url)
.map_err(|e| format!("{e:?}"))?
}
};
if cfg.transport == TransportKind::Tls {
opts.set_transport(
V5Transport::try_tls_with_default_config().map_err(|e| format!("TLS 初始化失败: {e:?}"))?,
);
}
opts.set_keep_alive(cfg.keep_alive);
opts.set_clean_start(cfg.clean_start);
if cfg.session_expiry_interval > 0 {
opts.set_session_expiry_interval(Some(cfg.session_expiry_interval));
}
if !cfg.username.is_empty() {
opts.set_credentials(cfg.username.clone(), cfg.password.clone());
}
if let Some(v) = cfg.receive_maximum {
opts.set_receive_maximum(Some(v));
}
if let Some(v) = cfg.maximum_packet_size {
opts.set_max_packet_size(Some(v));
}
if let Some(v) = cfg.topic_alias_maximum {
opts.set_topic_alias_max(Some(v));
}
if let Some(will) = &cfg.last_will
&& !will.topic.is_empty() {
let lw = rumqttc::mqttbytes::v5::LastWill::new(
will.topic.clone(),
will.payload.as_bytes().to_vec(),
v5_qos(will.qos),
will.retain,
None,
);
opts.set_last_will(lw);
}
Ok(V5AsyncClient::builder(opts).build())
}
fn build_v4(
cfg: &ConnectionConfig,
) -> Result<(rv4::AsyncClient, rv4::EventLoop), String> {
use rv4::{Broker as V4Broker, MqttOptions as V4Options, Transport as V4Transport};
let mut opts = match cfg.transport {
TransportKind::Tcp | TransportKind::Tls => {
V4Options::new(cfg.client_id.clone(), V4Broker::tcp(&cfg.host, cfg.port))
}
TransportKind::Ws => {
let url = format!("ws://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
V4Options::new(cfg.client_id.clone(), V4Broker::websocket(url).map_err(|e| format!("{e:?}"))?)
}
TransportKind::Wss => {
let url = format!("wss://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
V4Options::try_websocket_with_default_tls(cfg.client_id.clone(), url)
.map_err(|e| format!("{e:?}"))?
}
};
if cfg.transport == TransportKind::Tls {
opts.set_transport(V4Transport::try_tls_with_default_config().map_err(
|e| format!("TLS 初始化失败: {e:?}"),
)?);
}
opts.set_keep_alive(cfg.keep_alive);
opts.set_clean_session(cfg.clean_start);
if !cfg.username.is_empty() {
opts.set_credentials(cfg.username.clone(), cfg.password.clone());
}
if let Some(will) = &cfg.last_will
&& !will.topic.is_empty() {
let lw = rv4::mqttbytes::v4::LastWill::new(
will.topic.clone(),
will.payload.as_bytes().to_vec(),
v4_qos(will.qos),
will.retain,
);
opts.set_last_will(lw);
}
Ok(rv4::AsyncClient::builder(opts).build())
}
fn normalize_path(path: &str) -> String {
if path.is_empty() {
"/mqtt".to_string()
} else if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
}
}
fn v5_qos(qos: u8) -> V5QoS {
match qos {
1 => V5QoS::AtLeastOnce,
2 => V5QoS::ExactlyOnce,
_ => V5QoS::AtMostOnce,
}
}
fn v4_qos(qos: u8) -> rv4::QoS {
match qos {
1 => rv4::QoS::AtLeastOnce,
2 => rv4::QoS::ExactlyOnce,
_ => rv4::QoS::AtMostOnce,
}
}
// ─── 测试连接握手 ────────────────────────────────────────────────────────────
async fn test_handshake(cfg_in: &ConnectionConfig) -> Result<(), String> {
// 握手测试必须使用独立 client_id,否则会把使用相同 client_id 的现有连接
// 从 broker 顶下线(MQTT 同 client_id 单会话规则)。
let mut cfg = cfg_in.clone();
cfg.client_id = format!("mqttx_test_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
let cfg = &cfg;
if cfg.protocol.is_v5() {
let (client, mut eventloop) = build_v5(cfg)?;
loop {
match eventloop.poll().await {
Ok(V5Event::Incoming(V5Packet::ConnAck(ack))) => {
if ack.code == V5ReturnCode::Success {
// 测试完成,主动断开避免在 broker 侧残留会话
let _ = client.disconnect().await;
return Ok(());
}
return Err(format!("连接被拒绝: {:?}", ack.code));
}
Ok(V5Event::Incoming(V5Packet::Disconnect(d))) => {
return Err(format!("服务端断开: {:?}", d.reason_code));
}
Err(e) => return Err(format!("连接失败: {e}")),
_ => {}
}
}
} else {
let (client, mut eventloop) = build_v4(cfg)?;
loop {
match eventloop.poll().await {
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
if ack.code == rv4::ConnectReturnCode::Success {
let _ = client.disconnect().await;
return Ok(());
}
return Err(format!("连接被拒绝: {:?}", ack.code));
}
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
return Err("服务端主动断开".into());
}
Err(e) => return Err(format!("连接失败: {e}")),
_ => {}
}
}
}
}
// ─── 事件循环 ────────────────────────────────────────────────────────────────
fn v5_record(seq: u64, conn_id: &str, p: &V5Publish) -> MqttRecord {
let props = p.properties.as_ref();
MqttRecord {
seq,
connection_id: conn_id.to_string(),
direction: Direction::Received,
topic: String::from_utf8_lossy(&p.topic).to_string(),
payload: String::from_utf8_lossy(&p.payload).to_string(),
qos: v5_qos_u8(p.qos),
retain: p.retain,
timestamp: now_ms(),
user_properties: props
.map(|p| p.user_properties.clone())
.unwrap_or_default(),
content_type: props.and_then(|p| p.content_type.clone()),
response_topic: props.and_then(|p| p.response_topic.clone()),
correlation_data: props.and_then(|p| {
p.correlation_data
.as_ref()
.map(|d| d.iter().map(|b| format!("{b:02x}")).collect())
}),
message_expiry_interval: props.and_then(|p| p.message_expiry_interval),
subscription_identifier: props
.and_then(|p| p.subscription_identifiers.first().map(|v| *v as u32)),
}
}
fn v5_qos_u8(qos: V5QoS) -> u8 {
match qos {
V5QoS::AtMostOnce => 0,
V5QoS::AtLeastOnce => 1,
V5QoS::ExactlyOnce => 2,
}
}
fn spawn_v5_loop(
engine: Arc<MqttEngine>,
id: String,
mut eventloop: V5EventLoop,
mut cancel_rx: watch::Receiver<bool>,
connected: Arc<AtomicBool>,
auto_reconnect: bool,
) {
let runtime = engine.runtime.handle().clone();
runtime.spawn(async move {
let mut first_connect = true;
loop {
tokio::select! {
event = eventloop.poll() => {
match event {
Ok(V5Event::Incoming(V5Packet::Publish(p))) => {
let topic = String::from_utf8_lossy(&p.topic).to_string();
let record = v5_record(engine.next_seq(), &id, &p);
engine.log(
&id,
LogLevel::Info,
"publish_received",
format!("收到消息: {topic} ({} 字节, QoS {})", p.payload.len(), v5_qos_u8(p.qos)),
None,
);
engine.emit(EngineEvent::Message(record));
}
Ok(V5Event::Incoming(V5Packet::ConnAck(ack))) => {
let code = ack.code as u8;
connected.store(ack.code == V5ReturnCode::Success, Ordering::SeqCst);
if ack.code == V5ReturnCode::Success {
first_connect = false;
engine.log(
&id,
LogLevel::Info,
"connack",
format!("连接成功 (session_present: {})", ack.session_present),
None,
);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Connected,
error: None,
reason_code: Some(code),
session_present: Some(ack.session_present),
});
} else {
engine.log(
&id,
LogLevel::Error,
"connack",
format!("连接被拒绝: {:?} (code {code})", ack.code),
None,
);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Error,
error: Some(format!("{:?}", ack.code)),
reason_code: Some(code),
session_present: Some(ack.session_present),
});
break;
}
}
Ok(V5Event::Incoming(V5Packet::Disconnect(d))) => {
connected.store(false, Ordering::SeqCst);
engine.log(
&id,
LogLevel::Warn,
"disconnect_received",
format!("收到断开连接: {:?} (code {})", d.reason_code, d.reason_code as u8),
None,
);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Disconnected,
error: Some(format!("{:?}", d.reason_code)),
reason_code: Some(d.reason_code as u8),
session_present: None,
});
break;
}
Ok(V5Event::Incoming(V5Packet::SubAck(s))) => {
let granted: Vec<String> = s
.return_codes
.iter()
.map(|c| format!("{c:?}"))
.collect();
engine.log(&id, LogLevel::Info, "suback",
format!("订阅确认 (packet {}): {}", s.pkid, granted.join(", ")), None);
}
Ok(V5Event::Incoming(V5Packet::UnsubAck(_))) => {
engine.log(&id, LogLevel::Info, "unsuback", "取消订阅确认".into(), None);
}
Ok(V5Event::Incoming(V5Packet::PubAck(a))) => {
engine.log(&id, LogLevel::Debug, "puback",
format!("发布确认 (packet {}, reason {:?})", a.pkid, a.reason), None);
}
Ok(V5Event::Incoming(V5Packet::PubRec(a))) => {
engine.log(&id, LogLevel::Debug, "pubrec",
format!("QoS2 发布收到 (packet {})", a.pkid), None);
}
Ok(V5Event::Incoming(V5Packet::PubComp(a))) => {
engine.log(&id, LogLevel::Debug, "pubcomp",
format!("QoS2 发布完成 (packet {})", a.pkid), None);
}
Ok(V5Event::Outgoing(V5Outgoing::Publish(pkid))) => {
engine.log(&id, LogLevel::Debug, "publish_outgoing",
format!("消息已发出 (packet {pkid})"), None);
}
Ok(_) => {}
Err(V5Error::RequestsDone) => {
connected.store(false, Ordering::SeqCst);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Disconnected,
error: None,
reason_code: None,
session_present: None,
});
break;
}
Err(e) => {
connected.store(false, Ordering::SeqCst);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Error,
error: Some(e.to_string()),
reason_code: None,
session_present: None,
});
if !auto_reconnect || first_connect {
engine.log(&id, LogLevel::Error, "connection_error",
format!("连接错误: {e}"), None);
break;
}
engine.log(&id, LogLevel::Warn, "connection_error",
format!("连接错误,2 秒后重连: {e}"), None);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
}
_ = cancel_rx.changed() => {
engine.log(&id, LogLevel::Info, "disconnect_cancelled", "连接已取消".into(), None);
break;
}
}
}
engine.conns.lock().ok().and_then(|mut m| m.remove(&id));
});
}
fn spawn_v4_loop(
engine: Arc<MqttEngine>,
id: String,
mut eventloop: rv4::EventLoop,
mut cancel_rx: watch::Receiver<bool>,
connected: Arc<AtomicBool>,
auto_reconnect: bool,
) {
let runtime = engine.runtime.handle().clone();
runtime.spawn(async move {
let mut first_connect = true;
loop {
tokio::select! {
event = eventloop.poll() => {
match event {
Ok(rv4::Event::Incoming(rv4::Packet::Publish(p))) => {
let record = MqttRecord {
seq: engine.next_seq(),
connection_id: id.clone(),
direction: Direction::Received,
topic: String::from_utf8_lossy(&p.topic).to_string(),
payload: String::from_utf8_lossy(&p.payload).to_string(),
qos: match p.qos {
rv4::QoS::AtMostOnce => 0,
rv4::QoS::AtLeastOnce => 1,
rv4::QoS::ExactlyOnce => 2,
},
retain: p.retain,
timestamp: now_ms(),
user_properties: Vec::new(),
content_type: None,
response_topic: None,
correlation_data: None,
message_expiry_interval: None,
subscription_identifier: None,
};
engine.log(
&id, LogLevel::Info, "publish_received",
format!("收到消息: {} ({} 字节)", record.topic, p.payload.len()), None);
engine.emit(EngineEvent::Message(record));
}
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
connected.store(ack.code == rv4::ConnectReturnCode::Success, Ordering::SeqCst);
if ack.code == rv4::ConnectReturnCode::Success {
first_connect = false;
engine.log(&id, LogLevel::Info, "connack",
format!("连接成功 (session_present: {})", ack.session_present), None);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Connected,
error: None,
reason_code: Some(0),
session_present: Some(ack.session_present),
});
} else {
engine.log(&id, LogLevel::Error, "connack",
format!("连接被拒绝: {:?}", ack.code), None);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Error,
error: Some(format!("{:?}", ack.code)),
reason_code: Some(1),
session_present: Some(ack.session_present),
});
break;
}
}
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
connected.store(false, Ordering::SeqCst);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Disconnected,
error: Some("服务端断开".into()),
reason_code: None,
session_present: None,
});
break;
}
Ok(rv4::Event::Incoming(rv4::Packet::SubAck(s))) => {
engine.log(&id, LogLevel::Info, "suback",
format!("订阅确认 (packet {})", s.pkid), None);
}
Ok(rv4::Event::Incoming(rv4::Packet::UnsubAck(a))) => {
engine.log(&id, LogLevel::Info, "unsuback",
format!("取消订阅确认 (packet {})", a.pkid), None);
}
Ok(rv4::Event::Incoming(rv4::Packet::PubAck(a))) => {
engine.log(&id, LogLevel::Debug, "puback",
format!("发布确认 (packet {})", a.pkid), None);
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::Publish(pkid))) => {
engine.log(&id, LogLevel::Debug, "publish_outgoing",
format!("消息已发出 (packet {pkid})"), None);
}
Ok(_) => {}
Err(rv4::ConnectionError::RequestsDone) => {
connected.store(false, Ordering::SeqCst);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Disconnected,
error: None, reason_code: None, session_present: None,
});
break;
}
Err(e) => {
connected.store(false, Ordering::SeqCst);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Error,
error: Some(e.to_string()),
reason_code: None, session_present: None,
});
if !auto_reconnect || first_connect {
engine.log(&id, LogLevel::Error, "connection_error",
format!("连接错误: {e}"), None);
break;
}
engine.log(&id, LogLevel::Warn, "connection_error",
format!("连接错误,2 秒后重连: {e}"), None);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
}
_ = cancel_rx.changed() => {
engine.log(&id, LogLevel::Info, "disconnect_cancelled", "连接已取消".into(), None);
break;
}
}
}
engine.conns.lock().ok().and_then(|mut m| m.remove(&id));
});
}
+166
View File
@@ -0,0 +1,166 @@
//! JSON 文件持久化。数据存放在系统应用数据目录的 `MQTTX-GPUI/` 下:
//! Windows: %APPDATA%\MQTTX-GPUI,macOS: ~/Library/Application Support/MQTTX-GPUI,
//! Linux: ~/.local/share/MQTTX-GPUI。
//!
//! 刻意与官方 Electron MQTTX 的 `MQTTX/` 目录区分,避免互相覆盖数据。
/// 存储层诊断输出到 stderr(避免仅为文件持久化引入日志框架)。
macro_rules! elog {
($($arg:tt)*) => {
eprintln!("[store][{}] {}",
chrono::Local::now().format("%H:%M:%S"),
format!($($arg)*))
};
}
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::{
aliyun::AliyunPreset,
model::{AppSettings, ConnectionConfig, GlobalVariable, PublishPreset, Subscription},
};
#[derive(Debug, Clone)]
pub struct Storage {
dir: PathBuf,
}
impl Storage {
pub fn new() -> Self {
let dir = dirs::data_dir()
.unwrap_or_else(std::env::temp_dir)
.join("MQTTX-GPUI");
if let Err(e) = std::fs::create_dir_all(&dir) {
elog!("创建数据目录 {} 失败: {e}", dir.display());
}
Self { dir }
}
fn path(&self, name: &str) -> PathBuf {
self.dir.join(name)
}
fn read_json<T: for<'de> serde::Deserialize<'de>>(&self, name: &str) -> Result<Option<T>> {
let p = self.path(name);
if !p.exists() {
return Ok(None);
}
let mut text = std::fs::read_to_string(&p)
.with_context(|| format!("读取 {} 失败", p.display()))?;
// 容忍 Windows 编辑器/PowerShell 写入的 UTF-8 BOM
if text.starts_with('\u{feff}') {
text.drain(..3);
}
if text.trim().is_empty() {
return Ok(None);
}
let value = serde_json::from_str(&text)
.with_context(|| format!("解析 {} 失败", p.display()))?;
Ok(Some(value))
}
fn write_json<T: serde::Serialize + ?Sized>(&self, name: &str, value: &T) -> Result<()> {
let target = self.path(name);
let tmp = target.with_extension("json.tmp");
let text = serde_json::to_string_pretty(value)?;
std::fs::write(&tmp, text)
.with_context(|| format!("写入 {} 失败", tmp.display()))?;
std::fs::rename(&tmp, &target)
.with_context(|| format!("替换 {} 失败", target.display()))?;
Ok(())
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn log_dir(&self) -> PathBuf {
self.dir.join("logs")
}
// ── connections ──
pub fn load_connections(&self) -> Vec<ConnectionConfig> {
self.read_json("connections.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_connections(&self, conns: &[ConnectionConfig]) {
if let Err(e) = self.write_json("connections.json", conns) {
elog!("保存连接失败: {e}");
}
}
// ── subscriptions ──
pub fn load_subscriptions(&self) -> Vec<Subscription> {
self.read_json("subscriptions.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_subscriptions(&self, subs: &[Subscription]) {
if let Err(e) = self.write_json("subscriptions.json", subs) {
elog!("保存订阅失败: {e}");
}
}
// ── presets ──
pub fn load_presets(&self) -> Vec<PublishPreset> {
self.read_json("presets.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_presets(&self, presets: &[PublishPreset]) {
if let Err(e) = self.write_json("presets.json", presets) {
elog!("保存预设失败: {e}");
}
}
// ── variables ──
pub fn load_variables(&self) -> Vec<GlobalVariable> {
self.read_json("variables.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_variables(&self, vars: &[GlobalVariable]) {
if let Err(e) = self.write_json("variables.json", vars) {
elog!("保存全局变量失败: {e}");
}
}
// ── aliyun presets ──
pub fn load_aliyun(&self) -> Vec<AliyunPreset> {
self.read_json("aliyun.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_aliyun(&self, presets: &[AliyunPreset]) {
if let Err(e) = self.write_json("aliyun.json", presets) {
elog!("保存阿里云预设失败: {e}");
}
}
// ── settings ──
pub fn load_settings(&self) -> AppSettings {
self.read_json("settings.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_settings(&self, settings: &AppSettings) {
if let Err(e) = self.write_json("settings.json", settings) {
elog!("保存设置失败: {e}");
}
}
}
impl Default for Storage {
fn default() -> Self {
Self::new()
}
}
+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())
});
}
+878
View File
@@ -0,0 +1,878 @@
//! 应用外壳:标题栏 + 连接侧边栏 + 多标签连接视图 + 引擎事件泵。
use std::collections::{HashMap, VecDeque};
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem};
use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, ActiveTheme as _, Selectable as _, Sizable as _,
StyledExt as _, Theme, ThemeMode, TitleBar, WindowExt as _,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, StatefulInteractiveElement as _,
IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window,
};
use crate::aliyun::AliyunPreset;
use crate::model::{
AppSettings, ConnectionConfig, ConnectionStatus, Direction, GlobalVariable, LogEntry,
LogLevel, MqttRecord, PublishParams, PublishPreset, Subscription, ThemeModePref,
};
use crate::mqtt::{EngineEvent, MqttEngine};
use crate::store::Storage;
use crate::ui::IconName;
use crate::ui::{
connection_form::ConnectionForm,
connection_view::ConnectionView,
widgets::status_color,
};
const MAX_LOGS: usize = 3000;
pub struct MqttXApp {
storage: Storage,
pub engine: std::sync::Arc<MqttEngine>,
_pump: gpui_kit::Task<()>,
// ── 持久化数据 ──
pub connections: Vec<ConnectionConfig>,
pub subscriptions: Vec<Subscription>,
pub presets: Vec<PublishPreset>,
pub variables: Vec<GlobalVariable>,
pub aliyun_presets: Vec<AliyunPreset>,
pub settings: AppSettings,
// ── 运行时状态 ──
pub statuses: HashMap<String, ConnectionStatus>,
pub errors: HashMap<String, String>,
pub messages: HashMap<String, Vec<MqttRecord>>,
pub logs: VecDeque<LogEntry>,
pub seq: u64,
// ── 标签与视图 ──
open_tabs: Vec<String>,
active_tab: Option<String>,
views: HashMap<String, Entity<ConnectionView>>,
search: Entity<InputState>,
}
impl MqttXApp {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let storage = Storage::new();
let connections = storage.load_connections();
let subscriptions = storage.load_subscriptions();
let presets = storage.load_presets();
let variables = storage.load_variables();
let aliyun_presets = storage.load_aliyun();
let settings = storage.load_settings();
apply_theme(settings.theme, cx);
let (tx, rx) = smol::channel::unbounded();
let engine = MqttEngine::new(tx);
let weak = cx.entity().downgrade();
let pump = cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
while let Ok(event) = rx.recv().await {
weak.update_in(cx, |app, window, cx| {
app.on_engine_event(event, window, cx);
})
.ok();
}
});
let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…"));
Self {
storage,
engine,
_pump: pump,
connections,
subscriptions,
presets,
variables,
aliyun_presets,
settings,
statuses: HashMap::new(),
errors: HashMap::new(),
messages: HashMap::new(),
logs: VecDeque::with_capacity(MAX_LOGS),
seq: 0,
open_tabs: Vec::new(),
active_tab: None,
views: HashMap::new(),
search,
}
}
// ── 引擎事件 ──────────────────────────────────────────────────────────
fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window, cx: &mut Context<Self>) {
match event {
EngineEvent::Status {
connection_id,
status,
error,
..
} => {
self.statuses.insert(connection_id.clone(), status);
match error {
Some(e) => {
self.errors.insert(connection_id.clone(), e);
}
None => {
self.errors.remove(&connection_id);
}
}
if status == ConnectionStatus::Connected {
self.on_connected(&connection_id);
}
if let Some(view) = self.views.get(&connection_id) {
view.update(cx, |_, cx| cx.notify());
}
cx.notify();
}
EngineEvent::Message(mut record) => {
let id = record.connection_id.clone();
record.seq = self.next_seq();
self.push_message(id.clone(), record);
if let Some(view) = self.views.get(&id) {
view.update(cx, |_, cx| cx.notify());
}
}
EngineEvent::Published {
connection_id,
topic,
payload,
qos,
retain,
content_type,
user_properties,
} => {
let record = MqttRecord {
seq: self.next_seq(),
connection_id: connection_id.clone(),
direction: Direction::Published,
topic,
payload,
qos,
retain,
timestamp: chrono::Local::now().timestamp_millis(),
user_properties,
content_type,
response_topic: None,
correlation_data: None,
message_expiry_interval: None,
subscription_identifier: None,
};
self.push_message(connection_id.clone(), record);
if let Some(view) = self.views.get(&connection_id) {
view.update(cx, |_, cx| cx.notify());
}
}
EngineEvent::SubscribeResult {
connection_id,
topic,
qos,
ok,
error,
} => {
if ok {
self.add_subscription(Subscription::new(connection_id.clone(), topic, qos));
} else if let Some(e) = error {
window.push_notification(
Notification::error(format!("订阅失败: {e}")),
cx,
);
}
if let Some(view) = self.views.get(&connection_id) {
view.update(cx, |_, cx| cx.notify());
}
}
EngineEvent::Log(entry) => {
self.push_log(entry);
if let Some(id) = self.active_tab.clone()
&& let Some(view) = self.views.get(&id) {
view.update(cx, |_, cx| cx.notify());
}
}
}
}
fn on_connected(&mut self, connection_id: &str) {
// 连接成功后自动恢复已保存订阅(auto resubscribe)。
let Some(config) = self.connections.iter().find(|c| c.id == connection_id).cloned() else {
return;
};
if !config.auto_resubscribe {
return;
}
let subs: Vec<Subscription> = self
.subscriptions
.iter()
.filter(|s| s.connection_id == connection_id)
.cloned()
.collect();
for sub in subs {
self.engine.subscribe(connection_id.to_string(), sub);
}
}
// ── 数据工具 ──────────────────────────────────────────────────────────
fn next_seq(&mut self) -> u64 {
self.seq += 1;
self.seq
}
fn push_message(&mut self, connection_id: String, record: MqttRecord) {
let cap = self.settings.max_messages.max(100);
let list = self.messages.entry(connection_id).or_default();
if list.len() >= cap {
let drop_n = list.len() - cap + 1;
list.drain(0..drop_n);
}
list.push(record);
}
fn push_log(&mut self, entry: LogEntry) {
if self.logs.len() >= MAX_LOGS {
self.logs.pop_front();
}
self.logs.push_back(entry);
}
pub fn log(&mut self, conn: &str, level: LogLevel, event: &str, message: String) {
self.push_log(LogEntry {
timestamp: chrono::Local::now().timestamp_millis(),
connection_id: conn.to_string(),
level,
event: event.to_string(),
message,
details: None,
});
}
// ── 连接 CRUD / 生命周期 ──────────────────────────────────────────────
pub fn save_connection(&mut self, cfg: ConnectionConfig) {
if let Some(slot) = self.connections.iter_mut().find(|c| c.id == cfg.id) {
*slot = cfg;
} else {
self.connections.push(cfg);
}
self.storage.save_connections(&self.connections);
}
pub fn duplicate_connection(&mut self, id: &str) {
let Some(src) = self.connections.iter().find(|c| c.id == id).cloned() else {
return;
};
let mut copy = src;
copy.id = uuid::Uuid::new_v4().to_string();
copy.name = format!("{} 副本", copy.name);
copy.client_id =
format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
self.connections.push(copy);
self.storage.save_connections(&self.connections);
}
pub fn delete_connection(&mut self, id: &str, cx: &mut Context<Self>) {
self.engine.close(id, false);
self.connections.retain(|c| c.id != id);
self.subscriptions.retain(|s| s.connection_id != id);
self.messages.remove(id);
self.statuses.remove(id);
self.views.remove(id);
self.open_tabs.retain(|t| t != id);
if self.active_tab.as_deref() == Some(id) {
self.active_tab = self.open_tabs.last().cloned();
}
self.storage.save_connections(&self.connections);
self.storage.save_subscriptions(&self.subscriptions);
cx.notify();
}
pub fn toggle_connection(&self, cfg: &ConnectionConfig) {
if self.engine.is_connected(&cfg.id) {
self.engine.close(&cfg.id, true);
} else {
self.engine.connect(cfg.clone());
}
}
pub fn open_tab(&mut self, conn_id: &str, window: &mut Window, cx: &mut Context<Self>) {
if !self.open_tabs.contains(&conn_id.to_string()) {
self.open_tabs.push(conn_id.to_string());
let weak = cx.entity().downgrade();
let engine = self.engine.clone();
let view = cx.new(|cx| ConnectionView::new(conn_id.to_string(), weak, engine, window, cx));
self.views.insert(conn_id.to_string(), view);
}
self.active_tab = Some(conn_id.to_string());
cx.notify();
}
fn close_tab(&mut self, id: &str, cx: &mut Context<Self>) {
self.open_tabs.retain(|t| t != id);
self.views.remove(id);
if self.active_tab.as_deref() == Some(id) {
self.active_tab = self.open_tabs.last().cloned();
}
cx.notify();
}
// ── 订阅 ──────────────────────────────────────────────────────────────
pub fn add_subscription(&mut self, sub: Subscription) {
if !self
.subscriptions
.iter()
.any(|s| s.connection_id == sub.connection_id && s.topic == sub.topic)
{
self.subscriptions.push(sub);
self.storage.save_subscriptions(&self.subscriptions);
}
}
pub fn remove_subscription(&mut self, connection_id: &str, topic: &str) {
self.engine
.unsubscribe(connection_id.to_string(), topic.to_string());
self.subscriptions
.retain(|s| !(s.connection_id == connection_id && s.topic == topic));
self.storage.save_subscriptions(&self.subscriptions);
}
// ── 变量 / 预设 / 阿里云 / 设置 ──────────────────────────────────────────
pub fn save_variables(&mut self, vars: Vec<GlobalVariable>) {
self.variables = vars;
self.storage.save_variables(&self.variables);
}
pub fn save_presets(&mut self, presets: Vec<PublishPreset>) {
self.presets = presets;
self.storage.save_presets(&self.presets);
}
/// 保存一个发布预设(按 name 去重更新)。
pub fn upsert_publish_preset(&mut self, name: String, params: PublishParams) {
match self.presets.iter_mut().find(|p| p.name == name) {
Some(p) => p.params = params,
None => self.presets.push(PublishPreset::new(name, params)),
}
self.storage.save_presets(&self.presets);
}
pub fn delete_publish_preset(&mut self, id: &str) {
self.presets.retain(|p| p.id != id);
self.storage.save_presets(&self.presets);
}
pub fn save_aliyun(&mut self, presets: Vec<AliyunPreset>) {
self.aliyun_presets = presets;
self.storage.save_aliyun(&self.aliyun_presets);
}
pub fn save_settings(&mut self, settings: AppSettings, cx: &mut App) {
apply_theme(settings.theme, cx);
self.settings = settings;
self.storage.save_settings(&self.settings);
}
// ── 对话框 ────────────────────────────────────────────────────────────
pub fn open_connection_form(
&mut self,
edit: Option<ConnectionConfig>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let app = cx.entity().downgrade();
let engine = self.engine.clone();
let form = cx.new(|cx| ConnectionForm::new(engine, edit, window, cx));
window.open_dialog(cx, move |dialog, _, cx| {
let form_ok = form.clone();
let form_test = form.clone();
let app_ok = app.clone();
let testing = form_test.read(cx).is_testing();
dialog
.w(px(680.))
.title("连接配置")
.child(form.clone())
.footer(
h_flex()
.gap_2()
.w_full()
// 测试连接常驻左下角,保存前即可验证配置
.child(
Button::new("form-test")
.icon(if testing {
IconName::LoaderCircle
} else {
IconName::PlugZap
})
.label(if testing { "测试中…" } else { "测试连接" })
.outline()
.loading(testing)
.on_click(move |_, window, cx| {
form_test.update(cx, |f, cx| f.run_test(window, cx));
}),
)
.child(div().flex_1())
.child(
Button::new("form-cancel")
.label("取消")
.outline()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("form-ok")
.label("保存")
.primary()
.on_click(move |_, window, cx| {
let built = form_ok.read(cx).build(cx);
match built {
Ok(cfg) => {
app_ok
.update(cx, |app, cx| {
app.save_connection(cfg);
cx.notify();
})
.ok();
window.close_dialog(cx);
}
Err(e) => {
window.push_notification(
Notification::error(e),
cx,
);
}
}
}),
),
)
});
}
}
// ─── 主题 ────────────────────────────────────────────────────────────────────
pub fn apply_theme(pref: ThemeModePref, cx: &mut App) {
let mode = match pref {
ThemeModePref::Light => ThemeMode::Light,
ThemeModePref::Dark => ThemeMode::Dark,
// 未做系统外观检测前,“跟随系统”暂按浅色处理
ThemeModePref::System => ThemeMode::Light,
};
Theme::change(mode, None, cx);
}
// ─── 渲染 ────────────────────────────────────────────────────────────────────
impl Render for MqttXApp {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// 0.6.x 的 Root 不自动渲染 dialog/sheet/notification 层,需由内容视图挂载。
v_flex()
.size_full()
.bg(cx.theme().background)
.text_color(cx.theme().foreground)
.child(self.render_titlebar(window, cx))
.child(
h_flex()
.flex_1()
.min_h(px(0.))
.child(self.render_sidebar(window, cx))
.child(self.render_main(window, cx)),
)
.children(gpui_kit::component::Root::render_sheet_layer(window, cx))
.children(gpui_kit::component::Root::render_dialog_layer(window, cx))
.children(gpui_kit::component::Root::render_notification_layer(window, cx))
}
}
impl MqttXApp {
fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let app_weak = cx.entity().downgrade();
TitleBar::new().child(
h_flex()
.w_full()
.h_full()
.items_center()
.justify_between()
.pl_2()
.child(
h_flex()
.gap_2()
.items_center()
.child(crate::ui::logo::logo(
px(26.),
cx.theme().primary,
cx.theme().primary_foreground,
))
.child(div().text_sm().font_semibold().child("MQTTX"))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("MQTT 调试客户端"),
),
)
.child(
h_flex()
.gap_1()
.pr_2()
.items_center()
.child(
Button::new("new-connection")
.icon(IconName::Plus)
.label("新建连接")
.primary()
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.open_connection_form(None, window, cx);
})),
)
.child(self.render_theme_toggle(cx))
.child(
Button::new("more-menu")
.icon(IconName::Ellipsis)
.ghost()
.small()
.dropdown_menu(move |menu, _, _| {
let w1 = app_weak.clone();
let w2 = app_weak.clone();
let w3 = app_weak.clone();
menu.item(
PopupMenuItem::new("阿里云设备")
.on_click(move |_, window, cx| {
if let Some(entity) = w1.upgrade() {
crate::ui::aliyun_dialog::open(entity, window, cx);
}
}),
)
.item(PopupMenuItem::new("全局变量").on_click(
move |_, window, cx| {
if let Some(entity) = w2.upgrade() {
crate::ui::variables_dialog::open(entity, window, cx);
}
},
))
.separator()
.item(PopupMenuItem::new("设置").on_click(
move |_, window, cx| {
if let Some(entity) = w3.upgrade() {
crate::ui::settings_dialog::open(entity, window, cx);
}
},
))
}),
),
),
)
}
fn render_theme_toggle(&self, cx: &mut Context<Self>) -> Button {
let dark = cx.theme().mode == ThemeMode::Dark;
Button::new("toggle-theme")
.icon(if dark { IconName::Sun } else { IconName::Moon })
.ghost()
.small()
.tooltip(if dark { "切换为浅色" } else { "切换为深色" })
.on_click(cx.listener(|this, _, _, cx| {
let next = if cx.theme().mode == ThemeMode::Dark {
ThemeModePref::Light
} else {
ThemeModePref::Dark
};
let mut s = this.settings.clone();
s.theme = next;
this.save_settings(s, cx);
cx.notify();
}))
}
fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let query = self.search.read(cx).value().to_lowercase();
let search = self.search.clone();
let total = self.connections.len();
let hover_bg = cx.theme().muted;
let mut rows = v_flex().gap_0p5().flex_1().overflow_y_scrollbar();
let mut shown = 0usize;
for conn in self.connections.clone() {
if !query.is_empty() && !conn.name.to_lowercase().contains(&query) {
continue;
}
shown += 1;
let status = self
.statuses
.get(&conn.id)
.copied()
.unwrap_or(ConnectionStatus::Disconnected);
let active = self.active_tab.as_deref() == Some(conn.id.as_str());
let cfg = conn.clone();
let name = conn.name.clone();
let address = conn.display_address();
let row_id = conn.id.clone();
rows = rows.child(
h_flex()
.id(SharedString::from(format!("conn-row-{}", conn.id)))
.w_full()
.items_center()
.gap_2()
.mx_2()
.my_0p5()
.px_2()
.py_2()
.rounded_md()
.when(active, |this| this.bg(cx.theme().secondary))
.when(!active, |this| {
this.hover(move |this| this.bg(hover_bg))
})
.cursor_pointer()
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tab(&row_id, window, cx);
}))
.child(
div()
.size(px(7.))
.rounded_full()
.bg(status_color(status, cx)),
)
.child(
v_flex()
.flex_1()
.min_w(px(0.))
.gap_0p5()
.child(
div()
.text_sm()
.when(active, |t| t.font_semibold())
.overflow_hidden()
.text_ellipsis()
.child(name),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.overflow_hidden()
.text_ellipsis()
.child(address),
),
)
.child(self.render_conn_power(&cfg, status, cx))
.child(self.render_conn_menu(&conn.id, cx)),
);
}
let body = if self.connections.is_empty() {
v_flex()
.flex_1()
.items_center()
.justify_center()
.gap_2()
.p_4()
.child(
gpui_kit::component::Icon::new(IconName::PlugZap)
.text_color(cx.theme().muted_foreground),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("还没有连接,点击上方 + 新建"),
)
} else if query.is_empty() {
v_flex().flex_1().child(rows)
} else if shown == 0 {
v_flex()
.flex_1()
.items_center()
.justify_center()
.p_4()
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("没有匹配的连接"),
)
} else {
v_flex().flex_1().child(rows)
};
v_flex()
.w(px(264.))
.h_full()
.flex_shrink_0()
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.child(
h_flex()
.h_11()
.px_3()
.items_center()
.justify_between()
.child(
h_flex()
.gap_1p5()
.items_center()
.child(div().text_sm().font_semibold().child("连接"))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(total.to_string()),
),
)
.child(
Button::new("sidebar-add")
.icon(IconName::Plus)
.tooltip("新建连接")
.ghost()
.xsmall()
.on_click(cx.listener(|this, _, window, cx| {
this.open_connection_form(None, window, cx);
})),
),
)
.child(v_flex().px_2().pb_2().child(Input::new(&search).small().prefix(
gpui_kit::component::Icon::new(IconName::Search).small(),
)))
.child(body)
}
fn render_conn_power(
&self,
cfg: &ConnectionConfig,
status: ConnectionStatus,
cx: &mut Context<Self>,
) -> Button {
let cfg = cfg.clone();
let connected = matches!(status, ConnectionStatus::Connected);
Button::new(SharedString::from(format!("conn-power-{}", cfg.id)))
.icon(if connected { IconName::Square } else { IconName::PlugZap })
.ghost()
.xsmall()
.tooltip(if connected { "断开" } else { "连接" })
.on_click(cx.listener(move |this, _, _, _| {
this.toggle_connection(&cfg);
}))
}
fn render_conn_menu(&self, conn_id: &str, cx: &mut Context<Self>) -> impl IntoElement {
let weak = cx.entity().downgrade();
let eid = conn_id.to_string();
Button::new(SharedString::from(format!("conn-menu-{}", conn_id)))
.icon(IconName::Ellipsis)
.ghost()
.xsmall()
.dropdown_menu(move |menu, _, _| {
let (e1, e2, e3) = (eid.clone(), eid.clone(), eid.clone());
let (w1, w2, w3) = (weak.clone(), weak.clone(), weak.clone());
menu.item(PopupMenuItem::new("编辑").on_click(move |_, window, cx| {
w1.update(cx, |app, cx| {
let cfg = app.connections.iter().find(|c| c.id == e1).cloned();
if let Some(cfg) = cfg {
app.open_connection_form(Some(cfg), window, cx);
}
})
.ok();
}))
.item(PopupMenuItem::new("复制连接").on_click(move |_, _, cx| {
w2.update(cx, |app, cx| {
app.duplicate_connection(&e2);
cx.notify();
})
.ok();
}))
.separator()
.item(PopupMenuItem::new("删除").on_click(move |_, _, cx| {
w3.update(cx, |app, cx| app.delete_connection(&e3, cx)).ok();
}))
})
}
fn render_main(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.open_tabs.is_empty() {
return v_flex()
.flex_1()
.items_center()
.justify_center()
.gap_3()
.child(crate::ui::logo::logo(
px(72.),
cx.theme().muted,
cx.theme().muted_foreground,
))
.child(div().text_base().font_medium().child("开始使用 MQTTX"))
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.text_center()
.child("从左侧选择一个连接,或新建连接开始调试"),
)
.child(
Button::new("empty-new")
.icon(IconName::Plus)
.label("新建连接")
.outline()
.small()
.mt_1()
.on_click(cx.listener(|this, _, window, cx| {
this.open_connection_form(None, window, cx);
})),
)
.into_any_element();
}
let active = self.active_tab.clone().unwrap_or_default();
let mut bar = TabBar::new("conn-tabs");
for id in self.open_tabs.clone() {
let name = self
.connections
.iter()
.find(|c| c.id == id)
.map(|c| c.name.clone())
.unwrap_or_else(|| id.clone());
let tab_id = id.clone();
let close_id = id.clone();
let is_active = id == active;
bar = bar.child(
Tab::new()
.label(SharedString::from(name))
.selected(is_active)
.suffix(
Button::new(SharedString::from(format!("tab-close-{}", id)))
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, _, cx| {
this.close_tab(&close_id, cx);
})),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tab(&tab_id, window, cx);
})),
);
}
let view = self.views.get(&active).cloned();
v_flex()
.flex_1()
.min_w(px(0.))
.child(div().flex_shrink_0().px_2().pt_1().child(bar))
.when_some(view, |this, v| this.child(v))
.into_any_element()
}
}
+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),
)
}
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
//! MQTTX 品牌 Logo:纯 GPUI 原语绘制的 pub/sub 拓扑徽标。
//!
//! - 圆角方块作为品牌底(主色);
//! - 中心圆点代表 broker,连线连向发布端与订阅端,
//! 表达 MQTT “发布 / 代理 / 订阅” 的星型拓扑;
//! - 末端节点用环形描边,与中心实心 broker 区分。
use gpui_kit::{
canvas, div, px, App, Bounds, Corners, Edges, Hsla, IntoElement, ParentElement,
PathBuilder, Pixels, Point, Size, Styled as _, Window,
};
/// 绘制一个指定边长的 Logo(容器被设置为该尺寸)。
pub fn logo(side: Pixels, bg: Hsla, fg: Hsla) -> impl IntoElement {
let inner = canvas(
move |_bounds, _window, _cx| (bg, fg, side),
move |bounds, (bg, fg, side), window, _cx| {
// 品牌圆角方块
let radius = side * 0.26;
window.paint_quad(gpui_kit::quad(
bounds,
Corners::all(radius),
bg,
Edges::all(px(0.)),
bg,
gpui_kit::BorderStyle::default(),
));
let origin = bounds.origin;
// 归一化比例坐标 → 像素
let at = move |x: f32, y: f32| Point::new(origin.x + side * x, origin.y + side * y);
// 中心 broker 与三个客户端节点(右上任一发布、右下与左侧订阅)
let center = (0.5_f32, 0.5_f32);
let nodes = [(0.78_f32, 0.27_f32), (0.78, 0.73), (0.23, 0.5)];
// 连线,两端补圆点形成圆头效果(不依赖 lyon LineCap)
for &(nx, ny) in &nodes {
let a = at(center.0, center.1);
let b = at(nx, ny);
if let Some(p) = line_path(a, b, side * 0.04) {
window.paint_path(p, fg.alpha(0.9));
}
paint_dot(window, a, side * 0.05, fg.alpha(0.9));
paint_dot(window, b, side * 0.05, fg.alpha(0.9));
}
// 实心中心 broker(覆盖端点)
paint_dot(window, at(center.0, center.1), side * 0.17, fg);
// 环形末端节点
for &(nx, ny) in &nodes {
paint_ring(window, at(nx, ny), side * 0.15, side * 0.03, bg, fg);
}
},
)
.absolute()
.size_full();
div()
.relative()
.w(side)
.h(side)
.child(inner)
}
fn paint_dot(window: &mut Window, center: Point<Pixels>, d: Pixels, color: Hsla) {
let r = d * 0.5;
let bounds = Bounds {
origin: Point::new(center.x - r, center.y - r),
size: Size {
width: d,
height: d,
},
};
window.paint_quad(gpui_kit::quad(
bounds,
Corners::all(r),
color,
Edges::all(px(0.)),
color,
gpui_kit::BorderStyle::default(),
));
}
fn paint_ring(
window: &mut Window,
center: Point<Pixels>,
d: Pixels,
thickness: Pixels,
fill: Hsla,
border: Hsla,
) {
let r = d * 0.5;
let bounds = Bounds {
origin: Point::new(center.x - r, center.y - r),
size: Size {
width: d,
height: d,
},
};
window.paint_quad(gpui_kit::quad(
bounds,
Corners::all(r),
fill,
Edges::all(thickness),
border,
gpui_kit::BorderStyle::Solid,
));
}
fn line_path(
a: Point<Pixels>,
b: Point<Pixels>,
width: Pixels,
) -> Option<gpui_kit::Path<Pixels>> {
let mut pb = PathBuilder::stroke(width);
pb.move_to(a);
pb.line_to(b);
pb.build().ok()
}
#[allow(unused_imports)]
use App as _App;
+13
View File
@@ -0,0 +1,13 @@
//! GPUI 界面层。
pub mod aliyun_dialog;
pub mod app;
pub mod connection_form;
pub mod connection_view;
pub mod logo;
pub mod settings_dialog;
pub mod variables_dialog;
pub mod widgets;
/// 统一使用全量 Lucide 图标(gpui-kit-assets 已随应用加载)。
pub use gpui_kit::assets::IconName;
+138
View File
@@ -0,0 +1,138 @@
//! 应用设置对话框:主题、消息缓存条数、时间戳显示。
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::switch::Switch;
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, WindowExt as _,
};
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
Styled as _, Window,
};
use crate::model::{AppSettings, ThemeModePref};
use crate::ui::app::MqttXApp;
use crate::ui::widgets::{field, make_select, OptionDelegate};
const THEMES: [&str; 3] = ["跟随系统", "浅色", "深色"];
struct SettingsDialog {
theme: Entity<SelectState<OptionDelegate>>,
max_messages: Entity<InputState>,
show_millis: bool,
}
impl SettingsDialog {
fn new(initial: AppSettings, window: &mut Window, cx: &mut Context<Self>) -> Self {
let theme_idx = match initial.theme {
ThemeModePref::System => 0,
ThemeModePref::Light => 1,
ThemeModePref::Dark => 2,
};
Self {
theme: make_select(&THEMES, theme_idx, window, cx),
max_messages: cx.new(|cx| {
InputState::new(window, cx).default_value(initial.max_messages.to_string())
}),
show_millis: initial.show_millis,
}
}
fn collect(&self, cx: &App) -> Result<AppSettings, String> {
let theme = match self.theme.read(cx).selected_value() {
Some(1) => ThemeModePref::Light,
Some(2) => ThemeModePref::Dark,
_ => ThemeModePref::System,
};
let max_messages = self
.max_messages
.read(cx)
.value()
.trim()
.parse::<usize>()
.map_err(|_| "消息缓存条数必须是正整数".to_string())?
.max(100);
Ok(AppSettings {
theme,
max_messages,
show_millis: self.show_millis,
..Default::default()
})
}
}
impl Render for SettingsDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_4()
.w(px(420.))
.child(field("主题", Select::new(&self.theme)))
.child(field(
"每条连接内存中保留的消息条数",
Input::new(&self.max_messages),
))
.child(
h_flex()
.justify_between()
.child(div().text_sm().child("时间戳显示毫秒"))
.child(
Switch::new("show-millis")
.checked(self.show_millis)
.on_change(cx.listener(|this, v, _, cx| {
this.show_millis = *v;
cx.notify();
})),
),
)
}
}
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
let initial = app.read(cx).settings.clone();
let dialog_view: Entity<SettingsDialog> =
cx.new(|cx| SettingsDialog::new(initial, window, cx));
let app_save = app.clone();
window.open_dialog(cx, move |dialog, _window, _cx| {
let body = dialog_view.clone();
let for_collect = dialog_view.clone();
let app_for_save = app_save.clone();
dialog
.w(px(480.))
.title("设置")
.child(body)
.footer(
h_flex()
.gap_2()
.justify_end()
.w_full()
.child(
Button::new("settings-cancel")
.label("取消")
.outline()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("settings-ok")
.label("保存")
.primary()
.on_click(move |_, window, cx| {
let result = for_collect.read(cx).collect(cx);
match result {
Ok(settings) => {
app_for_save.update(cx, |a, cx| {
a.save_settings(settings, cx);
cx.notify();
});
window.close_dialog(cx);
}
Err(e) => {
window.push_notification(Notification::error(e), cx);
}
}
}),
),
)
});
}
+74
View File
@@ -0,0 +1,74 @@
//! 全局变量管理对话框。变量可在发布主题/负载中以 `{{key}}` 引用。
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::{h_flex, v_flex, WindowExt as _};
use gpui_kit::{
px, App, AppContext as _, Entity, ParentElement as _, Styled as _, Window,
};
use crate::model::GlobalVariable;
use crate::ui::app::MqttXApp;
use crate::ui::widgets::KvEditor;
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
let pairs: Vec<(String, String)> = app
.read(cx)
.variables
.iter()
.map(|v| (v.key.clone(), v.value.clone()))
.collect();
let editor: Entity<KvEditor> =
cx.new(|cx| KvEditor::new("global-var", &pairs, window, cx));
let app = app.clone();
window.open_dialog(cx, move |dialog, _, _| {
let editor_body = editor.clone();
let for_ok = editor.clone();
let app_ok = app.clone();
dialog
.w(px(560.))
.title("全局变量")
.child(
v_flex()
.gap_2()
.child(
v_flex()
.text_sm()
.gap_1()
.child("在发布主题、消息负载中使用 {{变量名}} 引用。")
.child("内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}"),
)
.child(editor_body),
)
.footer(
h_flex()
.gap_2()
.justify_end()
.w_full()
.child(
Button::new("vars-cancel")
.label("取消")
.outline()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("vars-ok")
.label("保存")
.primary()
.on_click(move |_, window, cx| {
let vars = for_ok
.read(cx)
.pairs(cx)
.into_iter()
.map(|(key, value)| GlobalVariable { key, value })
.collect();
app_ok.update(cx, |a, cx| {
a.save_variables(vars);
cx.notify();
});
window.close_dialog(cx);
}),
),
)
});
}
+240
View File
@@ -0,0 +1,240 @@
//! 跨界面复用的小组件:枚举下拉、键值对编辑器、状态指示、字段行等。
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::searchable_list::{SearchableListDelegate, SearchableListItem};
use gpui_kit::component::select::{Select, SelectState};
use gpui_kit::component::{h_flex, v_flex, ActiveTheme as _, Disableable as _, IndexPath, Sizable as _};
use crate::ui::IconName;
use gpui_kit::{App, AppContext as _, Context, Entity, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div, px};
use crate::model::ConnectionStatus;
// ─── 枚举下拉 ────────────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct OptionItem {
pub value: usize,
pub label: SharedString,
}
impl SearchableListItem for OptionItem {
type Value = usize;
fn title(&self) -> SharedString {
self.label.clone()
}
fn value(&self) -> &usize {
&self.value
}
}
#[derive(Clone)]
pub struct OptionDelegate {
pub items: Vec<OptionItem>,
}
impl OptionDelegate {
pub fn new(labels: &[&str]) -> Self {
Self {
items: labels
.iter()
.enumerate()
.map(|(i, l)| OptionItem {
value: i,
label: (*l).into(),
})
.collect(),
}
}
}
impl SearchableListDelegate for OptionDelegate {
type Item = OptionItem;
fn items_count(&self, _section: usize) -> usize {
self.items.len()
}
fn item(&self, ix: IndexPath) -> Option<&Self::Item> {
self.items.get(ix.row)
}
fn position<V>(&self, _value: &V) -> Option<IndexPath>
where
Self::Item: SearchableListItem<Value = V>,
V: PartialEq,
{
// 选中项统一通过 IndexPath 构造,无需按值反查
None
}
}
/// 创建字符串枚举下拉状态(在任意实体上下文里调用)。
pub fn make_select<C: gpui_kit::AppContext>(
labels: &[&str],
selected: usize,
window: &mut Window,
cx: &mut C,
) -> Entity<SelectState<OptionDelegate>> {
let selected = selected.min(labels.len().saturating_sub(1));
cx.new(|cx| {
SelectState::new(
OptionDelegate::new(labels),
Some(IndexPath::new(selected)),
window,
cx,
)
})
}
// ─── 字段行 ──────────────────────────────────────────────────────────────────
/// 小号标签 + 控件的垂直字段容器。
pub fn field(label: impl Into<SharedString>, control: impl IntoElement) -> impl IntoElement {
v_flex()
.gap_1()
.min_w(px(0.))
.child(div().text_xs().child(label.into()))
.child(control)
}
// ─── 状态颜色 ────────────────────────────────────────────────────────────────
pub fn status_color(status: ConnectionStatus, cx: &App) -> Hsla {
let t = cx.theme();
match status {
ConnectionStatus::Connected => t.success,
ConnectionStatus::Connecting => t.warning,
ConnectionStatus::Error => t.danger,
ConnectionStatus::Disconnected => t.muted_foreground,
}
}
pub fn status_dot(status: ConnectionStatus, cx: &App) -> impl IntoElement {
div()
.size(px(8.))
.rounded_full()
.bg(status_color(status, cx))
}
// ─── 键值对编辑器 ────────────────────────────────────────────────────────────
/// MQTT 5 用户属性等 key/value 列表编辑器。
pub struct KvEditor {
id_prefix: SharedString,
pub rows: Vec<(Entity<InputState>, Entity<InputState>)>,
}
impl KvEditor {
pub fn new(
id_prefix: impl Into<SharedString>,
pairs: &[(String, String)],
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut editor = Self {
id_prefix: id_prefix.into(),
rows: Vec::new(),
};
for (k, v) in pairs {
editor.push(Some((k.as_str(), v.as_str())), window, cx);
}
if editor.rows.is_empty() {
editor.push(None, window, cx);
}
editor
}
fn push(&mut self, initial: Option<(&str, &str)>, window: &mut Window, cx: &mut Context<Self>) {
let key = cx.new(|cx| {
let mut s = InputState::new(window, cx).placeholder("key");
if let Some((k, _)) = initial {
s.set_value(k, window, cx);
}
s
});
let value = cx.new(|cx| {
let mut s = InputState::new(window, cx).placeholder("value");
if let Some((_, v)) = initial {
s.set_value(v, window, cx);
}
s
});
self.rows.push((key, value));
}
pub fn pairs(&self, cx: &App) -> Vec<(String, String)> {
self.rows
.iter()
.map(|(k, v)| (k.read(cx).value().to_string(), v.read(cx).value().to_string()))
.filter(|(k, _)| !k.trim().is_empty())
.collect()
}
}
impl Render for KvEditor {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let prefix = self.id_prefix.clone();
let count = self.rows.len();
let mut rows = v_flex().gap_1();
for (i, (key, value)) in self.rows.iter().cloned().enumerate() {
rows = rows.child(
h_flex()
.id(SharedString::from(format!("kv-row-{prefix}-{i}")))
.gap_1()
.child(div().flex_1().min_w(px(0.)).child(Input::new(&key)))
.child(div().flex_1().min_w(px(0.)).child(Input::new(&value)))
.child(
Button::new(SharedString::from(format!("kv-del-{prefix}-{i}")))
.icon(IconName::Close)
.ghost()
.xsmall()
.disabled(count <= 1)
.on_click(cx.listener(move |this, _, _, cx| {
if this.rows.len() > 1 {
this.rows.remove(i);
cx.notify();
}
})),
),
);
}
v_flex()
.gap_2()
.child(rows)
.child(
Button::new(SharedString::from(format!("kv-add-{prefix}")))
.icon(IconName::Plus)
.label("添加属性")
.ghost()
.xsmall()
.on_click(cx.listener(|this, _, window, cx| {
this.push(None, window, cx);
cx.notify();
})),
)
}
}
// ─── 时间格式化 ──────────────────────────────────────────────────────────────
pub fn format_time(ts_ms: i64, show_millis: bool) -> String {
use chrono::TimeZone as _;
let Some(dt) = chrono::Local.timestamp_millis_opt(ts_ms).single() else {
return String::new();
};
if show_millis {
dt.format("%H:%M:%S%.3f").to_string()
} else {
dt.format("%H:%M:%S").to_string()
}
}
/// Select::new 的包装,统一外观。
pub fn select_element<D: SearchableListDelegate>(
state: &Entity<SelectState<D>>,
) -> Select<D>
where
<D::Item as SearchableListItem>::Value: PartialEq + Clone,
{
Select::new(state)
}