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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! MQTTX:数据模型、持久化、MQTT 引擎与 GPUI 界面。
|
||||
|
||||
pub mod aliyun;
|
||||
pub mod model;
|
||||
pub mod mqtt;
|
||||
pub mod store;
|
||||
pub mod ui;
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user