From b5089f58987b110eb8303ac7c38544884b9f179c Mon Sep 17 00:00:00 2001 From: JoeAllen <2494136784@qq.com> Date: Tue, 29 Sep 2026 22:20:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor(core):=20=E6=8B=86=E5=88=86=20model/mq?= =?UTF-8?q?tt/update=20=E4=B8=BA=E5=8D=95=E4=B8=80=E8=81=8C=E8=B4=A3?= =?UTF-8?q?=E5=AD=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model/ 按领域拆为 connection/subscription/message/publish/variable/log/settings - mqtt/ 拆为门面(引擎与事件)、tls、v5、v4 四块 - update/ 拆为门面(版本工具与命名约定)、check(检查)、install(下载/安装/清理) - sysmon 只保留进程资源采样,窗口置顶迁至新 platform.rs - 对外路径经 mod.rs 再导出保持不变,测试随门面保留(33 通过) --- gpui-app/src/lib.rs | 1 + gpui-app/src/model.rs | 1130 ---------------------- gpui-app/src/model/connection.rs | 277 ++++++ gpui-app/src/model/log.rs | 45 + gpui-app/src/model/message.rs | 199 ++++ gpui-app/src/model/mod.rs | 379 ++++++++ gpui-app/src/model/publish.rs | 57 ++ gpui-app/src/model/settings.rs | 82 ++ gpui-app/src/model/subscription.rs | 80 ++ gpui-app/src/model/variable.rs | 71 ++ gpui-app/src/mqtt.rs | 1418 ---------------------------- gpui-app/src/mqtt/mod.rs | 628 ++++++++++++ gpui-app/src/mqtt/tls.rs | 178 ++++ gpui-app/src/mqtt/v4.rs | 317 +++++++ gpui-app/src/mqtt/v5.rs | 354 +++++++ gpui-app/src/platform.rs | 44 + gpui-app/src/sysmon.rs | 49 +- gpui-app/src/update.rs | 658 ------------- gpui-app/src/update/check.rs | 121 +++ gpui-app/src/update/install.rs | 319 +++++++ gpui-app/src/update/mod.rs | 238 +++++ 21 files changed, 3393 insertions(+), 3252 deletions(-) delete mode 100644 gpui-app/src/model.rs create mode 100644 gpui-app/src/model/connection.rs create mode 100644 gpui-app/src/model/log.rs create mode 100644 gpui-app/src/model/message.rs create mode 100644 gpui-app/src/model/mod.rs create mode 100644 gpui-app/src/model/publish.rs create mode 100644 gpui-app/src/model/settings.rs create mode 100644 gpui-app/src/model/subscription.rs create mode 100644 gpui-app/src/model/variable.rs delete mode 100644 gpui-app/src/mqtt.rs create mode 100644 gpui-app/src/mqtt/mod.rs create mode 100644 gpui-app/src/mqtt/tls.rs create mode 100644 gpui-app/src/mqtt/v4.rs create mode 100644 gpui-app/src/mqtt/v5.rs create mode 100644 gpui-app/src/platform.rs delete mode 100644 gpui-app/src/update.rs create mode 100644 gpui-app/src/update/check.rs create mode 100644 gpui-app/src/update/install.rs create mode 100644 gpui-app/src/update/mod.rs diff --git a/gpui-app/src/lib.rs b/gpui-app/src/lib.rs index 72d8a45..9c5f03f 100644 --- a/gpui-app/src/lib.rs +++ b/gpui-app/src/lib.rs @@ -3,6 +3,7 @@ pub mod aliyun; pub mod model; pub mod mqtt; +pub mod platform; pub mod store; pub mod sysmon; pub mod ui; diff --git a/gpui-app/src/model.rs b/gpui-app/src/model.rs deleted file mode 100644 index 20b8109..0000000 --- a/gpui-app/src/model.rs +++ /dev/null @@ -1,1130 +0,0 @@ -//! 应用数据模型:连接配置、订阅、消息、日志、预设、全局变量、设置。 -//! -//! 本层不依赖 GPUI,可被持久化层与 MQTT 引擎共用。 - -use std::sync::Arc; - -use serde::{Deserialize, Serialize}; - -use std::collections::VecDeque; - -// ─── 连接配置 ──────────────────────────────────────────────────────────────── - -/// 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, PartialEq, Serialize, Deserialize)] -pub struct LastWill { - pub topic: String, - pub payload: String, - pub qos: u8, - pub retain: bool, - #[serde(default)] - pub content_type: Option, - #[serde(default)] - pub response_topic: Option, -} - -/// SSL/TLS 自定义配置(CA、双向认证、跳过校验)。 -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct SslConfig { - /// 自定义 CA 文件路径(PEM),作为系统根证书的补充 - #[serde(default)] - pub ca_file: String, - /// 客户端证书(PEM),双向认证用 - #[serde(default)] - pub client_cert_file: String, - /// 客户端私钥(PEM),与客户端证书配套 - #[serde(default)] - pub client_key_file: String, - /// 跳过服务器证书校验(insecure),仅调试环境使用 - #[serde(default)] - pub ignore_ca: bool, -} - -impl SslConfig { - /// 是否完全未配置(走 rumqttc 默认 TLS 即可)。 - pub fn is_default(&self) -> bool { - !self.ignore_ca - && self.ca_file.is_empty() - && self.client_cert_file.is_empty() - && self.client_key_file.is_empty() - } -} - -#[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, - #[serde(default)] - pub maximum_packet_size: Option, - #[serde(default)] - pub topic_alias_maximum: Option, - - /// 连接成功后自动恢复已保存的订阅 - #[serde(default = "default_true")] - pub auto_resubscribe: bool, - /// 断线自动重连 - #[serde(default = "default_true")] - pub auto_reconnect: bool, - /// 应用启动时自动连接该连接 - #[serde(default)] - pub auto_connect: bool, - - #[serde(default)] - pub last_will: Option, - - /// 创建时间(unix 秒),用于排序 - #[serde(default)] - pub created_at: i64, - - /// 连接分组(对标 MQTTX folder) - #[serde(default)] - pub group: Option, - /// 连接超时(秒),覆盖建连与 CONNACK 等待 - #[serde(default = "default_connection_timeout")] - pub connection_timeout_secs: u16, - /// 最大重连次数,0 = 无限重连 - #[serde(default)] - pub max_reconnect_times: u32, - /// SSL/TLS 自定义配置 - #[serde(default)] - pub ssl: SslConfig, -} - -fn default_true() -> bool { - true -} -fn default_keep_alive() -> u16 { - 60 -} -fn default_connection_timeout() -> u16 { - 10 -} - -/// 生成随机 Client ID(新建连接与表单「重新生成」共用同一逻辑)。 -pub fn generate_client_id() -> String { - format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]) -} - -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: generate_client_id(), - 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, - auto_connect: false, - last_will: None, - created_at: chrono::Local::now().timestamp(), - group: None, - connection_timeout_secs: default_connection_timeout(), - max_reconnect_times: 0, - ssl: SslConfig::default(), - } - } - - /// 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) - } - - /// 与另一份配置相比,影响 broker 会话建立的关键参数是否变化。 - /// 这些字段改动只有在重新建立连接后才会生效,用于提示用户重连。 - /// WS 路径、遗嘱、TLS 配置、keep_alive 同样随 CONNECT 报文一次性生效, - /// 必须纳入比较。 - pub fn session_params_changed(&self, other: &ConnectionConfig) -> bool { - self.host != other.host - || self.port != other.port - || self.path != other.path - || self.transport != other.transport - || self.protocol != other.protocol - || self.client_id != other.client_id - || self.username != other.username - || self.password != other.password - || self.clean_start != other.clean_start - || self.keep_alive != other.keep_alive - || self.last_will != other.last_will - || self.ssl != other.ssl - } -} - -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, - /// 订阅别名(对标 MQTTX topic alias),仅用于界面展示 - #[serde(default)] - pub alias: Option, - /// 是否启用该订阅;禁用后界面置灰、恢复订阅时跳过 - #[serde(default = "default_true")] - pub enabled: bool, - /// MQTT 5 订阅标识符 - #[serde(default)] - pub sub_identifier: Option, - /// MQTT 5 No Local (NL) - #[serde(default)] - pub no_local: bool, - /// MQTT 5 Retain As Published (RAP) - #[serde(default)] - pub retain_as_published: bool, - /// MQTT 5 Retain Handling (0/1/2) - #[serde(default)] - pub retain_handling: u8, -} - -impl Subscription { - pub fn new(connection_id: impl Into, topic: impl Into, qos: u8) -> Self { - Self { - id: uuid::Uuid::new_v4().to_string(), - connection_id: connection_id.into(), - topic: topic.into(), - qos, - color: None, - alias: None, - enabled: true, - sub_identifier: None, - no_local: false, - retain_as_published: false, - retain_handling: 0, - } - } -} - -/// MQTT 5 订阅选项(NL/RAP/Retain Handling/订阅标识符);v4 连接会忽略这些选项。 -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct SubscribeOptions { - /// 订阅标识符,0 视为未设置 - #[serde(default)] - pub sub_identifier: Option, - #[serde(default)] - pub no_local: bool, - #[serde(default)] - pub retain_as_published: bool, - /// 0=每次订阅都发送保留消息,1=仅新订阅发送,2=不发送 - #[serde(default)] - pub retain_handling: u8, -} - -impl From<&Subscription> for SubscribeOptions { - fn from(sub: &Subscription) -> Self { - Self { - sub_identifier: sub.sub_identifier, - no_local: sub.no_local, - retain_as_published: sub.retain_as_published, - retain_handling: sub.retain_handling, - } - } -} - -// ─── 消息 ──────────────────────────────────────────────────────────────────── - -#[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, 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()); - } - // 必须先做全字节校验:长度检查按字节计数,多字节字符(如中文) - // 可凑成偶数字节,直接按字节索引切片会落在字符边界内而 panic。 - if !cleaned.bytes().all(|b| b.is_ascii_hexdigit()) { - 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 显示)。 -/// -/// 内存优化:`connection_id`/`topic` 用 `Arc`——同一连接内主题高度重复, -/// 驻留后数千条记录只保留一份字符串。 -#[derive(Debug, Clone)] -pub struct MqttRecord { - pub seq: u64, - pub connection_id: Arc, - pub direction: Direction, - pub topic: Arc, - /// Arc 共享:消息列表渲染每帧克隆最多数百条记录,零拷贝 - pub payload: Arc, - pub qos: u8, - pub retain: bool, - /// unix 毫秒 - pub timestamp: i64, - pub user_properties: Vec<(String, String)>, - pub content_type: Option, - pub response_topic: Option, - pub correlation_data: Option, - pub message_expiry_interval: Option, - pub subscription_identifier: Option, - /// payload 超过 [`MAX_PAYLOAD_RETAIN`] 被截断 - pub payload_truncated: bool, - /// 非文本负载的原始字节(合法 UTF-8 时为 None)。文本化会失真 - /// (非法字节被替换为 U+FFFD),详情面板的 Hex/Base64 需要真实报文。 - pub raw_bytes: Option>, -} - -impl MqttRecord { - /// 该记录保留的主要字节数(文本 + 原始字节),用于内存预算驱逐。 - /// 其余字段(主题/属性等)相对小一个数量级,不计入。 - pub fn retained_bytes(&self) -> usize { - self.payload.len() + self.raw_bytes.as_ref().map_or(0, |b| b.len()) - } -} - -/// 单连接的消息环形缓冲:条数上限 + [`MAX_CONNECTION_MESSAGE_BYTES`] -/// 字节上限双重驱逐(从最旧开始,均摊 O(1))。 -pub struct MessageRing { - queue: VecDeque, - bytes: usize, -} - -impl MessageRing { - pub fn new(max_count: usize) -> Self { - Self { - // 预分配按较小值钳制:设置上限十万条时不一次性分配过大 - queue: VecDeque::with_capacity(max_count.clamp(16, 512)), - bytes: 0, - } - } - - /// `max_count` 每次传入以便运行时调整设置后即时生效。 - /// 设置层已校验 ≥100,这里只防 0 值导致缓冲被清空。 - pub fn push(&mut self, record: MqttRecord, max_count: usize) { - let max_count = max_count.max(1); - self.bytes += record.retained_bytes(); - while self.queue.len() >= max_count - || (self.bytes > MAX_CONNECTION_MESSAGE_BYTES && self.queue.len() > 1) - { - let Some(old) = self.queue.pop_front() else { break }; - self.bytes -= old.retained_bytes(); - } - self.queue.push_back(record); - } - - pub fn len(&self) -> usize { - self.queue.len() - } - - pub fn is_empty(&self) -> bool { - self.queue.is_empty() - } - - /// 当前保留的字节数(payload + 原始字节)。 - pub fn retained_bytes(&self) -> usize { - self.bytes - } - - pub fn iter(&self) -> std::collections::vec_deque::Iter<'_, MqttRecord> { - self.queue.iter() - } -} - -/// 消息历史中单条 payload 的保留上限。超限截断,避免个别大报文 -/// 长期占据历史缓冲(MQTTX 亦有类似限制)。 -pub const MAX_PAYLOAD_RETAIN: usize = 128 * 1024; - -/// 每连接消息历史的字节预算(payload + 原始字节)。条数上限 -/// (max_messages)在大报文流下形同虚设(2000 条 × 128KB ≈ 256MB), -/// 超出预算后从最旧开始驱逐,保证单连接消息内存有上界。 -pub const MAX_CONNECTION_MESSAGE_BYTES: usize = 32 * 1024 * 1024; - -/// 把原始 payload 字节转成用于历史记录的共享文本,超限截断。 -/// 返回 (文本, 非文本时的原始字节, 是否截断)。截断时回退到 UTF-8 字符边界。 -/// -/// 仅当文本化会失真(非合法 UTF-8,字节被替换为 U+FFFD)时才保留原始 -/// 字节(同样受 [`MAX_PAYLOAD_RETAIN`] 限制);纯文本负载零额外开销。 -pub fn retained_payload(raw: &[u8]) -> (Arc, Option>, bool) { - let truncated = raw.len() > MAX_PAYLOAD_RETAIN; - let text_part = if truncated { - let mut end = MAX_PAYLOAD_RETAIN; - while end > 0 && (raw[end] & 0xC0) == 0x80 { - end -= 1; - } - &raw[..end] - } else { - raw - }; - let raw_bytes = match std::str::from_utf8(text_part) { - Ok(_) => None, - Err(_) => Some(Arc::from(text_part.to_vec())), - }; - let text: Arc = Arc::from(String::from_utf8_lossy(text_part).as_ref()); - (text, raw_bytes, truncated) -} - -// ─── 发布参数 / 发布预设 ────────────────────────────────────────────────────── - -#[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, - #[serde(default)] - pub message_expiry_interval: Option, - #[serde(default)] - pub response_topic: Option, - #[serde(default)] - pub correlation_data: Option, - /// 实际发送的字节。Base64/Hex 时为解码结果;不持久化、不参与预设。 - /// None 时引擎回退为 `payload.as_bytes()`。 - #[serde(skip)] - pub raw_bytes: Option>, -} - -/// 发布预设:保存常用的发布参数,一键复用(对标 MQTTX Publish Scripts/Preset)。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PublishPreset { - pub id: String, - pub name: String, - #[serde(default)] - pub connection_id: Option, - #[serde(flatten)] - pub params: PublishParams, -} - -impl PublishPreset { - pub fn new(name: impl Into, 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 -} - -/// 连接前渲染遗嘱消息中的 `{{变量}}`。 -/// 只作用于本次连接的配置副本,落盘的连接配置保持模板原文, -/// 这样改全局变量后下次重连即生效,`$ts` 等也按连接时刻求值。 -pub fn render_will_templates(cfg: &mut ConnectionConfig, vars: &[GlobalVariable]) { - let Some(will) = cfg.last_will.as_mut() else { - return; - }; - will.topic = render_template(&will.topic, vars); - will.payload = render_template(&will.payload, vars); - if let Some(v) = will.content_type.take() { - will.content_type = Some(render_template(&v, vars)); - } - if let Some(v) = will.response_topic.take() { - will.response_topic = Some(render_template(&v, vars)); - } -} - -// ─── 日志 ──────────────────────────────────────────────────────────────────── - -#[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, -} - -// ─── 连接状态 ──────────────────────────────────────────────────────────────── - -#[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}}"); - } - - // 连接前遗嘱模板注入:只改副本、空遗嘱不 panic、v5 属性一并渲染 - #[test] - fn render_will_templates_injects_and_keeps_no_will_safe() { - let vars = vec![GlobalVariable { - key: "device".into(), - value: "sensor-9".into(), - }]; - // 无遗嘱:直接返回 - let mut plain = ConnectionConfig::new(); - render_will_templates(&mut plain, &vars); - assert!(plain.last_will.is_none()); - - let mut cfg = ConnectionConfig::new(); - cfg.last_will = Some(LastWill { - topic: "alarm/{{device}}".into(), - payload: "d={{device}}".into(), - qos: 1, - retain: false, - content_type: Some("text/{{device}}".into()), - response_topic: Some("ack/{{device}}".into()), - }); - render_will_templates(&mut cfg, &vars); - let w = cfg.last_will.as_ref().unwrap(); - assert_eq!(w.topic, "alarm/sensor-9"); - assert_eq!(w.payload, "d=sensor-9"); - assert_eq!(w.content_type.as_deref(), Some("text/sensor-9")); - assert_eq!(w.response_topic.as_deref(), Some("ack/sensor-9")); - // 原始模板串不因注入而需要回写:连接配置持久化的是模板原文, - // 这里只验证副本注入结果;未知占位符保留 - let mut raw = ConnectionConfig::new(); - raw.last_will = Some(LastWill { - topic: "t/{{unknown}}".into(), - ..Default::default() - }); - render_will_templates(&mut raw, &vars); - assert_eq!(raw.last_will.as_ref().unwrap().topic, "t/{{unknown}}"); - } - - // 预设持久化往返: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::(&json).unwrap(), - PayloadFormat::Hex - ); - } - - // 向后兼容:旧版 connections.json 不含 Phase 1 新字段,必须能反序列化且取默认值 - #[test] - fn legacy_connection_json_fills_new_defaults() { - let legacy = r#"{ - "id": "c1", - "name": "旧连接", - "host": "broker.emqx.io", - "port": 1883, - "client_id": "legacy_cid", - "keep_alive": 30 - }"#; - let c: ConnectionConfig = serde_json::from_str(legacy).expect("旧版 JSON 应能反序列化"); - assert_eq!(c.name, "旧连接"); - assert_eq!(c.keep_alive, 30); - assert!(c.group.is_none(), "group 默认应为 None"); - assert_eq!(c.connection_timeout_secs, 10, "连接超时默认 10 秒"); - assert_eq!(c.max_reconnect_times, 0, "最大重连次数默认 0(无限)"); - assert!(c.ssl.ca_file.is_empty()); - assert!(c.ssl.client_cert_file.is_empty()); - assert!(c.ssl.client_key_file.is_empty()); - assert!(!c.ssl.ignore_ca, "ignore_ca 默认关闭"); - } - - // 向后兼容:旧版订阅 JSON 必须默认 enabled=true,其余 v5 选项为关闭/None - #[test] - fn legacy_subscription_json_defaults_enabled() { - let legacy = r#"{"id":"s1","connection_id":"c1","topic":"a/b","qos":1}"#; - let s: Subscription = serde_json::from_str(legacy).expect("旧版订阅应能反序列化"); - assert!(s.enabled, "旧订阅默认启用"); - assert!(s.alias.is_none()); - assert!(s.sub_identifier.is_none()); - assert!(!s.no_local); - assert!(!s.retain_as_published); - assert_eq!(s.retain_handling, 0); - // Subscription::new 同样产出默认启用的订阅 - assert!(Subscription::new("c1", "t", 0).enabled); - } - - // payload 截断:上限内不截断;超出时截断且不切坏 UTF-8 字符 - #[test] - fn retained_payload_caps_and_preserves_utf8() { - let small = b"hello"; - let (p, raw, trunc) = retained_payload(small); - assert_eq!(&*p, "hello"); - assert!(raw.is_none(), "合法 UTF-8 不保留原始字节"); - assert!(!trunc); - - // 中文每字符 3 字节,让上限落在字符中间(256K 不是 3 的倍数) - let big: Vec = "中".repeat(MAX_PAYLOAD_RETAIN).into_bytes(); - let (p, _, trunc) = retained_payload(&big); - assert!(trunc, "超过上限必须截断"); - assert!(p.len() <= MAX_PAYLOAD_RETAIN); - assert_eq!(p.chars().last(), Some('中'), "不能切坏 UTF-8 字符"); - assert!( - big.starts_with(p.as_bytes()), - "截断内容必须是原数据前缀" - ); - - // 恰好上限不截断 - let exact = vec![b'a'; MAX_PAYLOAD_RETAIN]; - let (_, raw, trunc) = retained_payload(&exact); - assert!(!trunc); - assert!(raw.is_none()); - } - - // 非文本负载:保留原始字节供 Hex/Base64 详情,文本侧 lossy 展示 - #[test] - fn retained_payload_keeps_raw_bytes_for_binary() { - let binary = vec![0x00, 0xff, 0xfe, 0x01]; - let (p, raw, trunc) = retained_payload(&binary); - assert!(!trunc); - assert_eq!(&*p, String::from_utf8_lossy(&binary)); - let raw = raw.expect("非法 UTF-8 必须保留原始字节"); - assert_eq!(&*raw, &binary); - } - - // Hex 编码:多字节字符不得 panic(曾按字节切片落入字符边界内而崩溃) - #[test] - fn hex_encode_rejects_non_hex_without_panic() { - assert!(PayloadFormat::Hex.encode("deadBEEF").is_ok()); - assert!(PayloadFormat::Hex.encode("de ad be ef").is_ok()); - assert!(PayloadFormat::Hex.encode("abc").is_err(), "奇数长度报错"); - assert!(PayloadFormat::Hex.encode("zz").is_err(), "非法字符报错"); - // 偶数字节长度的多字节字符:修复前此处在字符边界内切片 panic - assert!(PayloadFormat::Hex.encode("中文").is_err()); - assert!(PayloadFormat::Hex.encode("😀").is_err()); - } - - // 会话参数比较必须覆盖所有随 CONNECT 报文一次性生效的字段 - #[test] - fn session_params_changed_covers_connect_time_fields() { - let a = ConnectionConfig::default(); - let mut b = a.clone(); - assert!(!a.session_params_changed(&b)); - - b.keep_alive = a.keep_alive + 1; - assert!(a.session_params_changed(&b), "keep_alive 改动应触发重连提示"); - b = a.clone(); - - b.last_will = Some(LastWill { - topic: "w/t".into(), - ..Default::default() - }); - assert!(a.session_params_changed(&b), "遗嘱改动应触发重连提示"); - b = a.clone(); - - b.ssl.ca_file = "/tmp/ca.pem".into(); - assert!(a.session_params_changed(&b), "TLS 配置改动应触发重连提示"); - } - - fn ring_record(seq: u64, payload_len: usize, raw_len: usize) -> MqttRecord { - MqttRecord { - seq, - connection_id: "c".into(), - direction: Direction::Received, - topic: "t".into(), - payload: Arc::from("a".repeat(payload_len)), - qos: 0, - retain: false, - timestamp: 0, - user_properties: Vec::new(), - content_type: None, - response_topic: None, - correlation_data: None, - message_expiry_interval: None, - subscription_identifier: None, - payload_truncated: false, - raw_bytes: if raw_len > 0 { - Some(Arc::from(vec![0u8; raw_len])) - } else { - None - }, - } - } - - // 消息环形缓冲:条数上限正常驱逐 - #[test] - fn message_ring_enforces_count_limit() { - let mut ring = MessageRing::new(4); - for i in 0..10 { - ring.push(ring_record(i, 64, 0), 4); - } - assert_eq!(ring.len(), 4); - assert_eq!( - ring.iter().next().map(|r| r.seq), - Some(6), - "应从最旧开始驱逐" - ); - assert_eq!(ring.retained_bytes(), 4 * 64); - } - - // 消息环形缓冲:大报文流下字节预算优先于条数上限,内存有上界 - #[test] - fn message_ring_enforces_byte_budget() { - let mut ring = MessageRing::new(2000); - // 每条 256KB(payload 128KB + raw 128KB):2000 条本会到 512MB - let per = MAX_PAYLOAD_RETAIN; - for i in 0..2000 { - ring.push(ring_record(i, per, per), 2000); - } - assert!( - ring.retained_bytes() <= MAX_CONNECTION_MESSAGE_BYTES + 2 * per, - "字节数 {} 超出预算 {}", - ring.retained_bytes(), - MAX_CONNECTION_MESSAGE_BYTES - ); - assert!( - ring.len() < 2000, - "大报文流不应保留满额条数(实际 {} 条)", - ring.len() - ); - assert_eq!(ring.iter().next().map(|r| r.seq), Some((2000 - ring.len()) as u64)); - } - - // 单条超预算的记录也至少保留最新一条(列表不能被清空) - #[test] - fn message_ring_never_empties_on_oversized_record() { - let mut ring = MessageRing::new(10); - ring.push(ring_record(0, MAX_PAYLOAD_RETAIN, MAX_PAYLOAD_RETAIN), 10); - assert_eq!(ring.len(), 1); - ring.push(ring_record(1, 64, 0), 10); - assert_eq!(ring.len(), 2); - assert_eq!(ring.iter().next().map(|r| r.seq), Some(0)); - } -} diff --git a/gpui-app/src/model/connection.rs b/gpui-app/src/model/connection.rs new file mode 100644 index 0000000..2b265a9 --- /dev/null +++ b/gpui-app/src/model/connection.rs @@ -0,0 +1,277 @@ +//! 连接配置:协议版本、传输方式、遗嘱、TLS 与连接参数。 + + +use serde::{Deserialize, Serialize}; + + + +#[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, PartialEq, Serialize, Deserialize)] +pub struct LastWill { + pub topic: String, + pub payload: String, + pub qos: u8, + pub retain: bool, + #[serde(default)] + pub content_type: Option, + #[serde(default)] + pub response_topic: Option, +} + +/// SSL/TLS 自定义配置(CA、双向认证、跳过校验)。 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct SslConfig { + /// 自定义 CA 文件路径(PEM),作为系统根证书的补充 + #[serde(default)] + pub ca_file: String, + /// 客户端证书(PEM),双向认证用 + #[serde(default)] + pub client_cert_file: String, + /// 客户端私钥(PEM),与客户端证书配套 + #[serde(default)] + pub client_key_file: String, + /// 跳过服务器证书校验(insecure),仅调试环境使用 + #[serde(default)] + pub ignore_ca: bool, +} + +impl SslConfig { + /// 是否完全未配置(走 rumqttc 默认 TLS 即可)。 + pub fn is_default(&self) -> bool { + !self.ignore_ca + && self.ca_file.is_empty() + && self.client_cert_file.is_empty() + && self.client_key_file.is_empty() + } +} + +#[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, + #[serde(default)] + pub maximum_packet_size: Option, + #[serde(default)] + pub topic_alias_maximum: Option, + + /// 连接成功后自动恢复已保存的订阅 + #[serde(default = "default_true")] + pub auto_resubscribe: bool, + /// 断线自动重连 + #[serde(default = "default_true")] + pub auto_reconnect: bool, + /// 应用启动时自动连接该连接 + #[serde(default)] + pub auto_connect: bool, + + #[serde(default)] + pub last_will: Option, + + /// 创建时间(unix 秒),用于排序 + #[serde(default)] + pub created_at: i64, + + /// 连接分组(对标 MQTTX folder) + #[serde(default)] + pub group: Option, + /// 连接超时(秒),覆盖建连与 CONNACK 等待 + #[serde(default = "default_connection_timeout")] + pub connection_timeout_secs: u16, + /// 最大重连次数,0 = 无限重连 + #[serde(default)] + pub max_reconnect_times: u32, + /// SSL/TLS 自定义配置 + #[serde(default)] + pub ssl: SslConfig, +} + +pub(super) fn default_true() -> bool { + true +} +pub(super) fn default_keep_alive() -> u16 { + 60 +} +pub(super) fn default_connection_timeout() -> u16 { + 10 +} + +/// 生成随机 Client ID(新建连接与表单「重新生成」共用同一逻辑)。 +pub fn generate_client_id() -> String { + format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]) +} + +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: generate_client_id(), + 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, + auto_connect: false, + last_will: None, + created_at: chrono::Local::now().timestamp(), + group: None, + connection_timeout_secs: default_connection_timeout(), + max_reconnect_times: 0, + ssl: SslConfig::default(), + } + } + + /// 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) + } + + /// 与另一份配置相比,影响 broker 会话建立的关键参数是否变化。 + /// 这些字段改动只有在重新建立连接后才会生效,用于提示用户重连。 + /// WS 路径、遗嘱、TLS 配置、keep_alive 同样随 CONNECT 报文一次性生效, + /// 必须纳入比较。 + pub fn session_params_changed(&self, other: &ConnectionConfig) -> bool { + self.host != other.host + || self.port != other.port + || self.path != other.path + || self.transport != other.transport + || self.protocol != other.protocol + || self.client_id != other.client_id + || self.username != other.username + || self.password != other.password + || self.clean_start != other.clean_start + || self.keep_alive != other.keep_alive + || self.last_will != other.last_will + || self.ssl != other.ssl + } +} + +impl Default for ConnectionConfig { + fn default() -> Self { + Self::new() + } +} + +// ─── 订阅 ──────────────────────────────────────────────────────────────────── diff --git a/gpui-app/src/model/log.rs b/gpui-app/src/model/log.rs new file mode 100644 index 0000000..90bc1de --- /dev/null +++ b/gpui-app/src/model/log.rs @@ -0,0 +1,45 @@ +//! 运行日志:日志级别与日志条目。 + + + + + +#[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, +} + +// ─── 连接状态 ──────────────────────────────────────────────────────────────── diff --git a/gpui-app/src/model/message.rs b/gpui-app/src/model/message.rs new file mode 100644 index 0000000..7c64317 --- /dev/null +++ b/gpui-app/src/model/message.rs @@ -0,0 +1,199 @@ +//! 消息:方向、载荷格式、消息记录与条数/字节双预算的环形缓冲。 + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use std::collections::VecDeque; + + +#[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, 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()); + } + // 必须先做全字节校验:长度检查按字节计数,多字节字符(如中文) + // 可凑成偶数字节,直接按字节索引切片会落在字符边界内而 panic。 + if !cleaned.bytes().all(|b| b.is_ascii_hexdigit()) { + 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 显示)。 +/// +/// 内存优化:`connection_id`/`topic` 用 `Arc`——同一连接内主题高度重复, +/// 驻留后数千条记录只保留一份字符串。 +#[derive(Debug, Clone)] +pub struct MqttRecord { + pub seq: u64, + pub connection_id: Arc, + pub direction: Direction, + pub topic: Arc, + /// Arc 共享:消息列表渲染每帧克隆最多数百条记录,零拷贝 + pub payload: Arc, + pub qos: u8, + pub retain: bool, + /// unix 毫秒 + pub timestamp: i64, + pub user_properties: Vec<(String, String)>, + pub content_type: Option, + pub response_topic: Option, + pub correlation_data: Option, + pub message_expiry_interval: Option, + pub subscription_identifier: Option, + /// payload 超过 [`MAX_PAYLOAD_RETAIN`] 被截断 + pub payload_truncated: bool, + /// 非文本负载的原始字节(合法 UTF-8 时为 None)。文本化会失真 + /// (非法字节被替换为 U+FFFD),详情面板的 Hex/Base64 需要真实报文。 + pub raw_bytes: Option>, +} + +impl MqttRecord { + /// 该记录保留的主要字节数(文本 + 原始字节),用于内存预算驱逐。 + /// 其余字段(主题/属性等)相对小一个数量级,不计入。 + pub fn retained_bytes(&self) -> usize { + self.payload.len() + self.raw_bytes.as_ref().map_or(0, |b| b.len()) + } +} + +/// 单连接的消息环形缓冲:条数上限 + [`MAX_CONNECTION_MESSAGE_BYTES`] +/// 字节上限双重驱逐(从最旧开始,均摊 O(1))。 +pub struct MessageRing { + queue: VecDeque, + bytes: usize, +} + +impl MessageRing { + pub fn new(max_count: usize) -> Self { + Self { + // 预分配按较小值钳制:设置上限十万条时不一次性分配过大 + queue: VecDeque::with_capacity(max_count.clamp(16, 512)), + bytes: 0, + } + } + + /// `max_count` 每次传入以便运行时调整设置后即时生效。 + /// 设置层已校验 ≥100,这里只防 0 值导致缓冲被清空。 + pub fn push(&mut self, record: MqttRecord, max_count: usize) { + let max_count = max_count.max(1); + self.bytes += record.retained_bytes(); + while self.queue.len() >= max_count + || (self.bytes > MAX_CONNECTION_MESSAGE_BYTES && self.queue.len() > 1) + { + let Some(old) = self.queue.pop_front() else { break }; + self.bytes -= old.retained_bytes(); + } + self.queue.push_back(record); + } + + pub fn len(&self) -> usize { + self.queue.len() + } + + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } + + /// 当前保留的字节数(payload + 原始字节)。 + pub fn retained_bytes(&self) -> usize { + self.bytes + } + + pub fn iter(&self) -> std::collections::vec_deque::Iter<'_, MqttRecord> { + self.queue.iter() + } +} + +/// 消息历史中单条 payload 的保留上限。超限截断,避免个别大报文 +/// 长期占据历史缓冲(MQTTX 亦有类似限制)。 +pub const MAX_PAYLOAD_RETAIN: usize = 128 * 1024; + +/// 每连接消息历史的字节预算(payload + 原始字节)。条数上限 +/// (max_messages)在大报文流下形同虚设(2000 条 × 128KB ≈ 256MB), +/// 超出预算后从最旧开始驱逐,保证单连接消息内存有上界。 +pub const MAX_CONNECTION_MESSAGE_BYTES: usize = 32 * 1024 * 1024; + +/// 把原始 payload 字节转成用于历史记录的共享文本,超限截断。 +/// 返回 (文本, 非文本时的原始字节, 是否截断)。截断时回退到 UTF-8 字符边界。 +/// +/// 仅当文本化会失真(非合法 UTF-8,字节被替换为 U+FFFD)时才保留原始 +/// 字节(同样受 [`MAX_PAYLOAD_RETAIN`] 限制);纯文本负载零额外开销。 +pub fn retained_payload(raw: &[u8]) -> (Arc, Option>, bool) { + let truncated = raw.len() > MAX_PAYLOAD_RETAIN; + let text_part = if truncated { + let mut end = MAX_PAYLOAD_RETAIN; + while end > 0 && (raw[end] & 0xC0) == 0x80 { + end -= 1; + } + &raw[..end] + } else { + raw + }; + let raw_bytes = match std::str::from_utf8(text_part) { + Ok(_) => None, + Err(_) => Some(Arc::from(text_part.to_vec())), + }; + let text: Arc = Arc::from(String::from_utf8_lossy(text_part).as_ref()); + (text, raw_bytes, truncated) +} + +// ─── 发布参数 / 发布预设 ────────────────────────────────────────────────────── diff --git a/gpui-app/src/model/mod.rs b/gpui-app/src/model/mod.rs new file mode 100644 index 0000000..2381d16 --- /dev/null +++ b/gpui-app/src/model/mod.rs @@ -0,0 +1,379 @@ +//! 应用数据模型:按领域拆分的纯数据层(不依赖 GPUI)。 +//! +//! - [`connection`] 连接配置与协议/传输枚举 +//! - [`subscription`] 订阅与订阅选项 +//! - [`message`] 消息记录与环形缓冲 +//! - [`publish`] 发布参数与发布预设 +//! - [`variable`] 全局变量与模板渲染 +//! - [`log`] 运行日志 +//! - [`settings`] 应用设置与主题偏好 + +mod connection; +mod log; +mod message; +mod publish; +mod settings; +mod subscription; +mod variable; + +pub use connection::*; +pub use log::*; +pub use message::*; +pub use publish::*; +pub use settings::*; +pub use subscription::*; +pub use variable::*; + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::Arc; + + // 修复回归: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}}"); + } + + // 连接前遗嘱模板注入:只改副本、空遗嘱不 panic、v5 属性一并渲染 + #[test] + fn render_will_templates_injects_and_keeps_no_will_safe() { + let vars = vec![GlobalVariable { + key: "device".into(), + value: "sensor-9".into(), + }]; + // 无遗嘱:直接返回 + let mut plain = ConnectionConfig::new(); + render_will_templates(&mut plain, &vars); + assert!(plain.last_will.is_none()); + + let mut cfg = ConnectionConfig::new(); + cfg.last_will = Some(LastWill { + topic: "alarm/{{device}}".into(), + payload: "d={{device}}".into(), + qos: 1, + retain: false, + content_type: Some("text/{{device}}".into()), + response_topic: Some("ack/{{device}}".into()), + }); + render_will_templates(&mut cfg, &vars); + let w = cfg.last_will.as_ref().unwrap(); + assert_eq!(w.topic, "alarm/sensor-9"); + assert_eq!(w.payload, "d=sensor-9"); + assert_eq!(w.content_type.as_deref(), Some("text/sensor-9")); + assert_eq!(w.response_topic.as_deref(), Some("ack/sensor-9")); + // 原始模板串不因注入而需要回写:连接配置持久化的是模板原文, + // 这里只验证副本注入结果;未知占位符保留 + let mut raw = ConnectionConfig::new(); + raw.last_will = Some(LastWill { + topic: "t/{{unknown}}".into(), + ..Default::default() + }); + render_will_templates(&mut raw, &vars); + assert_eq!(raw.last_will.as_ref().unwrap().topic, "t/{{unknown}}"); + } + + // 预设持久化往返: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::(&json).unwrap(), + PayloadFormat::Hex + ); + } + + // 向后兼容:旧版 connections.json 不含 Phase 1 新字段,必须能反序列化且取默认值 + #[test] + fn legacy_connection_json_fills_new_defaults() { + let legacy = r#"{ + "id": "c1", + "name": "旧连接", + "host": "broker.emqx.io", + "port": 1883, + "client_id": "legacy_cid", + "keep_alive": 30 + }"#; + let c: ConnectionConfig = serde_json::from_str(legacy).expect("旧版 JSON 应能反序列化"); + assert_eq!(c.name, "旧连接"); + assert_eq!(c.keep_alive, 30); + assert!(c.group.is_none(), "group 默认应为 None"); + assert_eq!(c.connection_timeout_secs, 10, "连接超时默认 10 秒"); + assert_eq!(c.max_reconnect_times, 0, "最大重连次数默认 0(无限)"); + assert!(c.ssl.ca_file.is_empty()); + assert!(c.ssl.client_cert_file.is_empty()); + assert!(c.ssl.client_key_file.is_empty()); + assert!(!c.ssl.ignore_ca, "ignore_ca 默认关闭"); + } + + // 向后兼容:旧版订阅 JSON 必须默认 enabled=true,其余 v5 选项为关闭/None + #[test] + fn legacy_subscription_json_defaults_enabled() { + let legacy = r#"{"id":"s1","connection_id":"c1","topic":"a/b","qos":1}"#; + let s: Subscription = serde_json::from_str(legacy).expect("旧版订阅应能反序列化"); + assert!(s.enabled, "旧订阅默认启用"); + assert!(s.alias.is_none()); + assert!(s.sub_identifier.is_none()); + assert!(!s.no_local); + assert!(!s.retain_as_published); + assert_eq!(s.retain_handling, 0); + // Subscription::new 同样产出默认启用的订阅 + assert!(Subscription::new("c1", "t", 0).enabled); + } + + // payload 截断:上限内不截断;超出时截断且不切坏 UTF-8 字符 + #[test] + fn retained_payload_caps_and_preserves_utf8() { + let small = b"hello"; + let (p, raw, trunc) = retained_payload(small); + assert_eq!(&*p, "hello"); + assert!(raw.is_none(), "合法 UTF-8 不保留原始字节"); + assert!(!trunc); + + // 中文每字符 3 字节,让上限落在字符中间(256K 不是 3 的倍数) + let big: Vec = "中".repeat(MAX_PAYLOAD_RETAIN).into_bytes(); + let (p, _, trunc) = retained_payload(&big); + assert!(trunc, "超过上限必须截断"); + assert!(p.len() <= MAX_PAYLOAD_RETAIN); + assert_eq!(p.chars().last(), Some('中'), "不能切坏 UTF-8 字符"); + assert!( + big.starts_with(p.as_bytes()), + "截断内容必须是原数据前缀" + ); + + // 恰好上限不截断 + let exact = vec![b'a'; MAX_PAYLOAD_RETAIN]; + let (_, raw, trunc) = retained_payload(&exact); + assert!(!trunc); + assert!(raw.is_none()); + } + + // 非文本负载:保留原始字节供 Hex/Base64 详情,文本侧 lossy 展示 + #[test] + fn retained_payload_keeps_raw_bytes_for_binary() { + let binary = vec![0x00, 0xff, 0xfe, 0x01]; + let (p, raw, trunc) = retained_payload(&binary); + assert!(!trunc); + assert_eq!(&*p, String::from_utf8_lossy(&binary)); + let raw = raw.expect("非法 UTF-8 必须保留原始字节"); + assert_eq!(&*raw, &binary); + } + + // Hex 编码:多字节字符不得 panic(曾按字节切片落入字符边界内而崩溃) + #[test] + fn hex_encode_rejects_non_hex_without_panic() { + assert!(PayloadFormat::Hex.encode("deadBEEF").is_ok()); + assert!(PayloadFormat::Hex.encode("de ad be ef").is_ok()); + assert!(PayloadFormat::Hex.encode("abc").is_err(), "奇数长度报错"); + assert!(PayloadFormat::Hex.encode("zz").is_err(), "非法字符报错"); + // 偶数字节长度的多字节字符:修复前此处在字符边界内切片 panic + assert!(PayloadFormat::Hex.encode("中文").is_err()); + assert!(PayloadFormat::Hex.encode("😀").is_err()); + } + + // 会话参数比较必须覆盖所有随 CONNECT 报文一次性生效的字段 + #[test] + fn session_params_changed_covers_connect_time_fields() { + let a = ConnectionConfig::default(); + let mut b = a.clone(); + assert!(!a.session_params_changed(&b)); + + b.keep_alive = a.keep_alive + 1; + assert!(a.session_params_changed(&b), "keep_alive 改动应触发重连提示"); + b = a.clone(); + + b.last_will = Some(LastWill { + topic: "w/t".into(), + ..Default::default() + }); + assert!(a.session_params_changed(&b), "遗嘱改动应触发重连提示"); + b = a.clone(); + + b.ssl.ca_file = "/tmp/ca.pem".into(); + assert!(a.session_params_changed(&b), "TLS 配置改动应触发重连提示"); + } + + fn ring_record(seq: u64, payload_len: usize, raw_len: usize) -> MqttRecord { + MqttRecord { + seq, + connection_id: "c".into(), + direction: Direction::Received, + topic: "t".into(), + payload: Arc::from("a".repeat(payload_len)), + qos: 0, + retain: false, + timestamp: 0, + user_properties: Vec::new(), + content_type: None, + response_topic: None, + correlation_data: None, + message_expiry_interval: None, + subscription_identifier: None, + payload_truncated: false, + raw_bytes: if raw_len > 0 { + Some(Arc::from(vec![0u8; raw_len])) + } else { + None + }, + } + } + + // 消息环形缓冲:条数上限正常驱逐 + #[test] + fn message_ring_enforces_count_limit() { + let mut ring = MessageRing::new(4); + for i in 0..10 { + ring.push(ring_record(i, 64, 0), 4); + } + assert_eq!(ring.len(), 4); + assert_eq!( + ring.iter().next().map(|r| r.seq), + Some(6), + "应从最旧开始驱逐" + ); + assert_eq!(ring.retained_bytes(), 4 * 64); + } + + // 消息环形缓冲:大报文流下字节预算优先于条数上限,内存有上界 + #[test] + fn message_ring_enforces_byte_budget() { + let mut ring = MessageRing::new(2000); + // 每条 256KB(payload 128KB + raw 128KB):2000 条本会到 512MB + let per = MAX_PAYLOAD_RETAIN; + for i in 0..2000 { + ring.push(ring_record(i, per, per), 2000); + } + assert!( + ring.retained_bytes() <= MAX_CONNECTION_MESSAGE_BYTES + 2 * per, + "字节数 {} 超出预算 {}", + ring.retained_bytes(), + MAX_CONNECTION_MESSAGE_BYTES + ); + assert!( + ring.len() < 2000, + "大报文流不应保留满额条数(实际 {} 条)", + ring.len() + ); + assert_eq!(ring.iter().next().map(|r| r.seq), Some((2000 - ring.len()) as u64)); + } + + // 单条超预算的记录也至少保留最新一条(列表不能被清空) + #[test] + fn message_ring_never_empties_on_oversized_record() { + let mut ring = MessageRing::new(10); + ring.push(ring_record(0, MAX_PAYLOAD_RETAIN, MAX_PAYLOAD_RETAIN), 10); + assert_eq!(ring.len(), 1); + ring.push(ring_record(1, 64, 0), 10); + assert_eq!(ring.len(), 2); + assert_eq!(ring.iter().next().map(|r| r.seq), Some(0)); + } +} diff --git a/gpui-app/src/model/publish.rs b/gpui-app/src/model/publish.rs new file mode 100644 index 0000000..5d7e37e --- /dev/null +++ b/gpui-app/src/model/publish.rs @@ -0,0 +1,57 @@ +//! 发布:发布参数与可保存的发布预设。 + + +use serde::{Deserialize, Serialize}; + + +use super::*; + +#[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, + #[serde(default)] + pub message_expiry_interval: Option, + #[serde(default)] + pub response_topic: Option, + #[serde(default)] + pub correlation_data: Option, + /// 实际发送的字节。Base64/Hex 时为解码结果;不持久化、不参与预设。 + /// None 时引擎回退为 `payload.as_bytes()`。 + #[serde(skip)] + pub raw_bytes: Option>, +} + +/// 发布预设:保存常用的发布参数,一键复用(对标 MQTTX Publish Scripts/Preset)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublishPreset { + pub id: String, + pub name: String, + #[serde(default)] + pub connection_id: Option, + #[serde(flatten)] + pub params: PublishParams, +} + +impl PublishPreset { + pub fn new(name: impl Into, params: PublishParams) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + name: name.into(), + connection_id: None, + params, + } + } +} + +// ─── 全局变量 ──────────────────────────────────────────────────────────────── diff --git a/gpui-app/src/model/settings.rs b/gpui-app/src/model/settings.rs new file mode 100644 index 0000000..d52daf1 --- /dev/null +++ b/gpui-app/src/model/settings.rs @@ -0,0 +1,82 @@ +//! 应用设置:连接状态枚举、主题偏好与全局设置项。 + + +use serde::{Deserialize, Serialize}; + + +use super::*; + +#[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, + } + } +} + +// ─── 单元测试 ──────────────────────────────────────────────────────────────── diff --git a/gpui-app/src/model/subscription.rs b/gpui-app/src/model/subscription.rs new file mode 100644 index 0000000..bb6ea91 --- /dev/null +++ b/gpui-app/src/model/subscription.rs @@ -0,0 +1,80 @@ +//! 订阅:订阅条目与下发引擎的订阅选项。 + + +use serde::{Deserialize, Serialize}; + + +use super::*; + +#[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, + /// 订阅别名(对标 MQTTX topic alias),仅用于界面展示 + #[serde(default)] + pub alias: Option, + /// 是否启用该订阅;禁用后界面置灰、恢复订阅时跳过 + #[serde(default = "default_true")] + pub enabled: bool, + /// MQTT 5 订阅标识符 + #[serde(default)] + pub sub_identifier: Option, + /// MQTT 5 No Local (NL) + #[serde(default)] + pub no_local: bool, + /// MQTT 5 Retain As Published (RAP) + #[serde(default)] + pub retain_as_published: bool, + /// MQTT 5 Retain Handling (0/1/2) + #[serde(default)] + pub retain_handling: u8, +} + +impl Subscription { + pub fn new(connection_id: impl Into, topic: impl Into, qos: u8) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + connection_id: connection_id.into(), + topic: topic.into(), + qos, + color: None, + alias: None, + enabled: true, + sub_identifier: None, + no_local: false, + retain_as_published: false, + retain_handling: 0, + } + } +} + +/// MQTT 5 订阅选项(NL/RAP/Retain Handling/订阅标识符);v4 连接会忽略这些选项。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubscribeOptions { + /// 订阅标识符,0 视为未设置 + #[serde(default)] + pub sub_identifier: Option, + #[serde(default)] + pub no_local: bool, + #[serde(default)] + pub retain_as_published: bool, + /// 0=每次订阅都发送保留消息,1=仅新订阅发送,2=不发送 + #[serde(default)] + pub retain_handling: u8, +} + +impl From<&Subscription> for SubscribeOptions { + fn from(sub: &Subscription) -> Self { + Self { + sub_identifier: sub.sub_identifier, + no_local: sub.no_local, + retain_as_published: sub.retain_as_published, + retain_handling: sub.retain_handling, + } + } +} diff --git a/gpui-app/src/model/variable.rs b/gpui-app/src/model/variable.rs new file mode 100644 index 0000000..b4c791a --- /dev/null +++ b/gpui-app/src/model/variable.rs @@ -0,0 +1,71 @@ +//! 全局变量:模板占位符渲染(含 {{timestamp}} 时间基一致性)。 + + +use serde::{Deserialize, Serialize}; + + +use super::*; + +#[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 +} + +/// 连接前渲染遗嘱消息中的 `{{变量}}`。 +/// 只作用于本次连接的配置副本,落盘的连接配置保持模板原文, +/// 这样改全局变量后下次重连即生效,`$ts` 等也按连接时刻求值。 +pub fn render_will_templates(cfg: &mut ConnectionConfig, vars: &[GlobalVariable]) { + let Some(will) = cfg.last_will.as_mut() else { + return; + }; + will.topic = render_template(&will.topic, vars); + will.payload = render_template(&will.payload, vars); + if let Some(v) = will.content_type.take() { + will.content_type = Some(render_template(&v, vars)); + } + if let Some(v) = will.response_topic.take() { + will.response_topic = Some(render_template(&v, vars)); + } +} + +// ─── 日志 ──────────────────────────────────────────────────────────────────── diff --git a/gpui-app/src/mqtt.rs b/gpui-app/src/mqtt.rs deleted file mode 100644 index 75fbb62..0000000 --- a/gpui-app/src/mqtt.rs +++ /dev/null @@ -1,1418 +0,0 @@ -//! MQTT 引擎:在独立 tokio runtime 上管理多个连接的生命周期, -//! 通过 smol 无界通道把状态/消息/日志事件送回 GPUI 线程。 - -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -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, RetainForwardRule, SubscribeFilterInput, - SubscribeProperties as V5SubscribeProperties, TlsConfiguration, Transport as V5Transport, -}; -use rumqttc_v4 as rv4; -use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer}; -use tokio::sync::watch; - -use crate::model::{ - ConnectionConfig, ConnectionStatus, Direction, LogEntry, LogLevel, MqttRecord, PublishParams, - SslConfig, SubscribeOptions, Subscription, TransportKind, -};// ─── 引擎事件 ──────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone)] -pub enum EngineEvent { - /// 连接状态变化 - Status { - connection_id: String, - status: ConnectionStatus, - error: Option, - reason_code: Option, - session_present: Option, - }, - /// 收到消息 - Message(MqttRecord), - /// 一条本地发出的消息(已成功交给客户端)。payload 为实际发送的字节。 - /// 属性与接收记录对齐,本地发送记录可完整回显。 - Published { - connection_id: String, - topic: String, - payload: Vec, - qos: u8, - retain: bool, - content_type: Option, - user_properties: Vec<(String, String)>, - response_topic: Option, - correlation_data: Option, - message_expiry_interval: Option, - }, - /// 订阅请求结果(tracked,等待 SUBACK) - SubscribeResult { - connection_id: String, - topic: String, - qos: u8, - ok: bool, - error: Option, - }, - /// 日志 - Log(LogEntry), -} - -pub type EventSender = smol::channel::Sender; - -// ─── 句柄 ──────────────────────────────────────────────────────────────────── - -enum ClientKind { - V5(V5AsyncClient), - V4(rv4::AsyncClient), -} - -struct ConnHandle { - /// `None` 表示仍在建立中(占位)。建连完成后由 connect 的任务升级为 Some。 - client: Option, - cancel_tx: watch::Sender, - connected: Arc, - /// 连接代数:同一 id 的第几次 connect。事件循环退出清理时只在 - /// map 中仍是自己这一代时才移除,避免误删重连后的新句柄。 - generation: u64, -} - -pub struct MqttEngine { - runtime: tokio::runtime::Runtime, - conns: Mutex>, - /// 下一次 connect 使用的代数 - next_generation: AtomicU64, - /// 主题/连接 ID 驻留缓存:同一连接内主题高度重复,驻留让消息记录共享字符串 - topics: Mutex>>, - tx: EventSender, - seq: Arc, -} - -fn now_ms() -> i64 { - chrono::Local::now().timestamp_millis() -} - -impl MqttEngine { - pub fn new(tx: EventSender) -> Arc { - 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()), - next_generation: AtomicU64::new(1), - topics: 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) { - 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, cfg: ConnectionConfig) { - // 关闭同名旧连接 - self.close(&cfg.id, false); - - let id = cfg.id.clone(); - let generation = self.next_generation.fetch_add(1, Ordering::Relaxed) + 1; - let (cancel_tx, cancel_rx) = watch::channel(false); - let connected = Arc::new(AtomicBool::new(false)); - // 同步占位(client=None):建连期间 close() 也能取消本次连接, - // 且 is_connected() 在建连完成前保持 false。 - self.conns.lock().unwrap().insert( - id.clone(), - ConnHandle { - client: None, - cancel_tx, - connected: connected.clone(), - generation, - }, - ); - - 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 auto_reconnect = cfg.auto_reconnect; - let max_reconnect_times = cfg.max_reconnect_times; - // build 含证书/CA 文件读取等阻塞 IO,放 spawn_blocking, - // 避免慢盘上卡住 2 线程 runtime 里其他连接的调度 - let build = tokio::task::spawn_blocking(move || { - 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))) - }) - } - }) - .await; - - let build_error = |engine: &Arc, id: &str, generation: u64, e: String| { - engine.remove_handle_if(id, generation); - engine.emit(EngineEvent::Status { - connection_id: id.to_string(), - status: ConnectionStatus::Error, - error: Some(e.clone()), - reason_code: None, - session_present: None, - }); - engine.log(id, LogLevel::Error, "connect_error", format!("连接失败: {e}"), None); - }; - let (client, eventloop) = match build { - Ok(Ok(pair)) => pair, - Ok(Err(e)) => { - build_error(&engine, &id, generation, e); - return; - } - Err(j) => { - build_error(&engine, &id, generation, format!("内部任务失败: {j}")); - return; - } - }; - - // 建连期间该连接可能已被 close 或更新的 connect 顶掉: - // 仅当占位句柄仍是本代时才升级为可用客户端,否则丢弃本次建连。 - { - let mut map = match engine.conns.lock() { - Ok(m) => m, - Err(_) => return, - }; - match map.get_mut(&id) { - Some(h) if h.generation == generation => h.client = Some(client), - _ => return, - } - } - - match eventloop { - EventLoopKind::V5(el) => { - spawn_v5_loop( - engine.clone(), - id, - *el, - cancel_rx, - connected, - generation, - auto_reconnect, - max_reconnect_times, - ); - } - EventLoopKind::V4(el) => { - spawn_v4_loop( - engine.clone(), - id, - *el, - cancel_rx, - connected, - generation, - auto_reconnect, - max_reconnect_times, - ); - } - } - }); - } - - /// 主动断开。`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 { - Some(ClientKind::V5(c)) => { - let _ = c.disconnect().await; - } - Some(ClientKind::V4(c)) => { - let _ = c.disconnect().await; - } - None => {} - } - 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, 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()); - // 实际发送的字节留给 Published 事件(记录需要真实内容) - let sent_payload = payload.clone(); - // 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: sent_payload, - qos: params.qos, - retain: params.retain, - content_type: params.content_type, - user_properties: params.user_properties, - response_topic: params.response_topic, - correlation_data: params.correlation_data, - message_expiry_interval: params.message_expiry_interval, - }); - } - Err(e) => { - engine.log( - &connection_id, - LogLevel::Error, - "publish_error", - format!("发布失败: {e}"), - None, - ); - } - } - }); - } - - // ── 订阅 / 取消订阅 ───────────────────────────────────────────────────── - - /// 旧签名薄封装:不携带 v5 订阅选项(连接视图等现有调用点继续可用)。 - pub fn subscribe(self: &Arc, connection_id: String, sub: Subscription) { - self.subscribe_with_options(connection_id, sub, SubscribeOptions::default()); - } - - /// 带 MQTT 5 订阅选项的订阅(sub_identifier / NL / RAP / Retain Handling); - /// v4 连接会忽略这些选项。 - pub fn subscribe_with_options( - self: &Arc, - connection_id: String, - sub: Subscription, - opts: SubscribeOptions, - ) { - 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) => { - // NL/RAP/Retain Handling 是「按主题过滤器」的订阅选项, - // 订阅标识符则在订阅报文属性里,分别写入两处。 - let filter = SubscribeFilterInput::new(sub.topic.clone(), v5_qos(sub.qos)) - .no_local(opts.no_local) - .preserve_retain(opts.retain_as_published) - .retain_forward_rule(match opts.retain_handling { - 1 => RetainForwardRule::OnNewSubscribe, - 2 => RetainForwardRule::Never, - _ => RetainForwardRule::OnEverySubscribe, - }); - let properties = V5SubscribeProperties { - // 0 不是合法的订阅标识符,按未设置处理 - id: opts.sub_identifier.filter(|v| *v > 0).map(|v| v as usize), - user_properties: Vec::new(), - }; - match client - .subscribe_many_with_properties_tracked(std::iter::once(filter), properties) - .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, 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, - ); - } - } - }); - } - - /// 测试连接:使用配置的连接超时(上限 30 秒,防止误填过大值长时间挂起), - /// 结果通过 channel 返回。 - /// 在引擎的 tokio runtime 上调度阻塞任务(OTA 下载等), - /// 结果经 smol 通道送回 GPUI 执行器;任务 panic 时接收端以空错误结束。 - pub fn run_blocking( - &self, - f: impl FnOnce() -> T + Send + 'static, - ) -> smol::channel::Receiver { - let (tx, rx) = smol::channel::bounded(1); - self.runtime.spawn(async move { - if let Ok(value) = tokio::task::spawn_blocking(f).await { - let _ = tx.send(value).await; - } - }); - rx - } - - /// 驻留字符串(主题/连接 ID):同一内容全进程只保留一份 `Arc`。 - /// 缓存有上限,异常流量下整体清空重建,避免无限增长。 - pub(crate) fn intern(&self, s: &str) -> Arc { - const INTERN_CAP: usize = 10_000; - let mut cache = self.topics.lock().unwrap(); - if let Some(arc) = cache.get(s) { - return arc.clone(); - } - if cache.len() >= INTERN_CAP { - cache.clear(); - } - let arc: Arc = Arc::from(s); - cache.insert(s.to_string(), arc.clone()); - arc - } - - pub fn test_connection( - self: &Arc, - cfg: ConnectionConfig, - ) -> smol::channel::Receiver> { - let (tx, rx) = smol::channel::bounded(1); - let secs = u64::from(cfg.connection_timeout_secs.clamp(1, 30)); - self.runtime.spawn(async move { - let result = tokio::time::timeout(Duration::from_secs(secs), async { - test_handshake(&cfg).await - }) - .await; - let reply = match result { - Ok(inner) => inner, - Err(_) => Err(format!( - "连接测试超时({secs} 秒),请检查主机地址与端口" - )), - }; - let _ = tx.try_send(reply); - }); - rx - } - - // ── 内部工具 ─────────────────────────────────────────────────────────── - - /// 克隆一个连接的异步客户端用于发命令。 - /// 不持有 std Mutex 守卫跨 await,避免与 tokio runtime 的 Send 要求冲突。 - /// 占位句柄(建连未完成)视为不存在。 - fn client_of(&self, id: &str) -> Option { - let map = self.conns.lock().ok()?; - let client = map.get(id)?.client.as_ref()?; - Some(match client { - ClientKind::V5(c) => ClientKind::V5(c.clone()), - ClientKind::V4(c) => ClientKind::V4(c.clone()), - }) - } - - /// 仅当 map 中该 id 的句柄仍是 `generation` 这一代时才移除。 - /// 事件循环退出清理必须走这里:无条件按 id 删除会把重连后的 - /// 新句柄误删,导致连接无法再从 UI 断开。 - fn remove_handle_if(&self, id: &str, generation: u64) { - if let Ok(mut map) = self.conns.lock() - && map.get(id).map(|h| h.generation) == Some(generation) - { - map.remove(id); - } - } -} - -enum EventLoopKind { - // 两个事件循环都在 KB 级,装箱让枚举本身只有指针大小 - V5(Box), - V4(Box), -} - -// ─── 选项构建 ──────────────────────────────────────────────────────────────── - -/// 连接超时秒数:至少 1 秒,避免误填 0 导致立刻超时。 -fn connection_timeout_secs(cfg: &ConnectionConfig) -> u64 { - u64::from(cfg.connection_timeout_secs.max(1)) -} - -/// SSL 配置非默认时构建自定义 rustls 配置;返回 None 表示沿用 rumqttc 默认 TLS。 -/// 文件读取/解析失败在此返回 Err,由 connect 的错误通道上报为连接失败事件。 -fn resolve_tls_config(cfg: &ConnectionConfig) -> Result, String> { - if !cfg.transport.is_tls() || cfg.ssl.is_default() { - return Ok(None); - } - Ok(Some(TlsConfiguration::Rustls(build_rustls_config(&cfg.ssl)?))) -} - -/// 按 SslConfig 构造 rustls ClientConfig: -/// - `ignore_ca`:跳过服务器证书链/主机名校验; -/// - `ca_file`:在系统根证书之外追加自定义 CA; -/// - `client_cert_file` + `client_key_file`:双向认证。 -fn build_rustls_config(ssl: &SslConfig) -> Result, String> { - // 只填证书或只填私钥必然握手失败,提前给出明确错误 - if ssl.client_cert_file.is_empty() != ssl.client_key_file.is_empty() { - return Err("客户端证书与客户端密钥必须同时配置".into()); - } - let provider = ensure_crypto_provider(); - let builder = rustls::ClientConfig::builder(); - let builder = if ssl.ignore_ca { - builder - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoVerifier { - provider: provider.clone(), - })) - } else { - let mut roots = rustls::RootCertStore::empty(); - let native = rustls_native_certs::load_native_certs(); - if native.certs.is_empty() && ssl.ca_file.is_empty() { - let detail = native - .errors - .iter() - .map(|e| e.to_string()) - .collect::>() - .join("; "); - return Err(format!("加载系统根证书失败: {detail}")); - } - // 个别系统证书加载失败不阻断连接:自定义 CA 与其余根证书仍参与校验 - for cert in native.certs { - let _ = roots.add(cert); - } - if !ssl.ca_file.is_empty() { - let pem = std::fs::read(&ssl.ca_file) - .map_err(|e| format!("读取 CA 文件 {} 失败: {e}", ssl.ca_file))?; - let mut added = 0usize; - for cert in CertificateDer::pem_slice_iter(&pem) { - let cert = cert.map_err(|e| format!("解析 CA 文件 {} 失败: {e}", ssl.ca_file))?; - roots - .add(cert) - .map_err(|e| format!("CA 文件 {} 中存在无效证书: {e}", ssl.ca_file))?; - added += 1; - } - if added == 0 { - return Err(format!("CA 文件 {} 中没有找到任何证书", ssl.ca_file)); - } - } - builder.with_root_certificates(roots) - }; - - let config = if ssl.client_cert_file.is_empty() { - builder.with_no_client_auth() - } else { - let chain_pem = std::fs::read(&ssl.client_cert_file).map_err(|e| { - format!("读取客户端证书文件 {} 失败: {e}", ssl.client_cert_file) - })?; - let key_pem = std::fs::read(&ssl.client_key_file) - .map_err(|e| format!("读取客户端私钥文件 {} 失败: {e}", ssl.client_key_file))?; - let certs = CertificateDer::pem_slice_iter(&chain_pem) - .collect::, _>>() - .map_err(|e| format!("解析客户端证书文件 {} 失败: {e}", ssl.client_cert_file))?; - if certs.is_empty() { - return Err(format!( - "客户端证书文件 {} 中没有找到证书", - ssl.client_cert_file - )); - } - let key = PrivateKeyDer::from_pem_slice(&key_pem) - .map_err(|e| format!("解析客户端私钥文件 {} 失败: {e}", ssl.client_key_file))?; - builder - .with_client_auth_cert(certs, key) - .map_err(|e| format!("加载客户端证书/私钥失败: {e}"))? - }; - Ok(Arc::new(config)) -} - -/// 取得(必要时安装)进程级 CryptoProvider。 -/// 工作区同时启用 ring 与 aws-lc-rs 特性时 rustls 无法自动推断默认 provider, -/// 不显式安装会让 `ClientConfig::builder()` panic。 -fn ensure_crypto_provider() -> Arc { - if let Some(provider) = rustls::crypto::CryptoProvider::get_default() { - return Arc::clone(provider); - } - let provider = rustls::crypto::aws_lc_rs::default_provider(); - // 输掉安装竞争时直接采用已安装的那个 - match provider.clone().install_default() { - Ok(()) => Arc::new(provider), - Err(installed) => installed, - } -} - -/// 「忽略 CA 校验」用:跳过服务器证书链与主机名校验, -/// 握手签名仍用 provider 校验(与 rustls 官方示例做法一致)。 -struct NoVerifier { - provider: Arc, -} - -impl std::fmt::Debug for NoVerifier { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("NoVerifier").finish_non_exhaustive() - } -} - -impl rustls::client::danger::ServerCertVerifier for NoVerifier { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp_response: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> Result { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result { - rustls::crypto::verify_tls12_signature( - message, - cert, - dss, - &self.provider.signature_verification_algorithms, - ) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result { - rustls::crypto::verify_tls13_signature( - message, - cert, - dss, - &self.provider.signature_verification_algorithms, - ) - } - - fn supported_verify_schemes(&self) -> Vec { - self.provider - .signature_verification_algorithms - .supported_schemes() - } -} - -/// 去掉空白字符串,避免表单存了空串也写进报文属性。 -fn non_blank(s: Option) -> Option { - s.filter(|v| !v.trim().is_empty()) -} - -fn build_v5( - cfg: &ConnectionConfig, -) -> Result<(V5AsyncClient, V5EventLoop), String> { - let custom_tls = resolve_tls_config(cfg)?; - 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)); - match &custom_tls { - Some(tls) => V5Options::websocket_with_tls_config( - cfg.client_id.clone(), - url, - tls.clone(), - ) - .map_err(|e| format!("{e:?}"))?, - None => V5Options::try_websocket_with_default_tls(cfg.client_id.clone(), url) - .map_err(|e| format!("{e:?}"))?, - } - } - }; - - if cfg.transport == TransportKind::Tls { - match &custom_tls { - Some(tls) => { - opts.set_transport(V5Transport::tls_with_config(tls.clone())); - } - None => { - opts.set_transport( - V5Transport::try_tls_with_default_config() - .map_err(|e| format!("TLS 初始化失败: {e:?}"))?, - ); - } - }; - } - - // 连接超时同时覆盖底层建连与 CONNACK 等待 - let timeout = Duration::from_secs(connection_timeout_secs(cfg)); - opts.set_connect_timeout(timeout); - let mut network_options = rumqttc::NetworkOptions::new(); - network_options.set_connection_timeout(connection_timeout_secs(cfg)); - opts.set_network_options(network_options); - - 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() { - // v5 遗嘱属性:content_type / response_topic(3.1.1 下无此扩展) - let properties = if will.content_type.is_some() || will.response_topic.is_some() { - Some(rumqttc::mqttbytes::v5::LastWillProperties { - delay_interval: None, - payload_format_indicator: None, - message_expiry_interval: None, - content_type: non_blank(will.content_type.clone()), - response_topic: non_blank(will.response_topic.clone()), - correlation_data: None, - user_properties: Vec::new(), - }) - } else { - None - }; - let lw = rumqttc::mqttbytes::v5::LastWill::new( - will.topic.clone(), - will.payload.as_bytes().to_vec(), - v5_qos(will.qos), - will.retain, - properties, - ); - 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 custom_tls = resolve_tls_config(cfg)?; - 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)); - match &custom_tls { - Some(tls) => V4Options::websocket_with_tls_config( - cfg.client_id.clone(), - url, - tls.clone(), - ) - .map_err(|e| format!("{e:?}"))?, - None => V4Options::try_websocket_with_default_tls(cfg.client_id.clone(), url) - .map_err(|e| format!("{e:?}"))?, - } - } - }; - - if cfg.transport == TransportKind::Tls { - match &custom_tls { - Some(tls) => { - opts.set_transport(V4Transport::tls_with_config(tls.clone())); - } - None => { - 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() { - // 3.1.1 的遗嘱没有 content_type / response_topic 属性,忽略 - 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); - } - - let (client, mut eventloop) = rv4::AsyncClient::builder(opts).build(); - // v4 的连接超时(含 CONNACK 等待)由 EventLoop 的 NetworkOptions 控制 - let mut network_options = rv4::NetworkOptions::new(); - network_options.set_connection_timeout(connection_timeout_secs(cfg)); - eventloop.set_network_options(network_options); - Ok((client, eventloop)) -} - -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 侧残留会话。 - // disconnect() 只是把请求入队,需再 poll 才会把 - // DISCONNECT 真正写上网络。 - let _ = client.disconnect().await; - for _ in 0..3 { - if eventloop.poll().await.is_err() { - break; - } - } - 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 { - // 同 v5:disconnect() 只入队,需 poll 把报文冲出 - let _ = client.disconnect().await; - for _ in 0..3 { - if eventloop.poll().await.is_err() { - break; - } - } - 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(engine: &MqttEngine, seq: u64, conn_id: &str, p: &V5Publish) -> MqttRecord { - let props = p.properties.as_ref(); - let (payload, raw_bytes, payload_truncated) = crate::model::retained_payload(&p.payload); - MqttRecord { - seq, - connection_id: engine.intern(conn_id), - direction: Direction::Received, - topic: engine.intern(&String::from_utf8_lossy(&p.topic)), - payload, - 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| correlation_display(d)) - }), - 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)), - payload_truncated, - raw_bytes, - } -} - -/// correlation data 的可读表示:优先按 UTF-8 文本展示,非文本回退 hex。 -/// 发送端把字符串按字节原样发出,两侧展示保持对称(此前收端一律 hex、 -/// 发端保留原文,同一条数据两侧对不上)。 -fn correlation_display(data: &[u8]) -> String { - match std::str::from_utf8(data) { - Ok(s) => s.to_string(), - Err(_) => data.iter().map(|b| format!("{b:02x}")).collect(), - } -} - -fn v5_qos_u8(qos: V5QoS) -> u8 { - match qos { - V5QoS::AtMostOnce => 0, - V5QoS::AtLeastOnce => 1, - V5QoS::ExactlyOnce => 2, - } -} - -fn spawn_v5_loop( - engine: Arc, - id: String, - mut eventloop: V5EventLoop, - mut cancel_rx: watch::Receiver, - connected: Arc, - generation: u64, - auto_reconnect: bool, - max_reconnect_times: u32, -) { - #![allow(clippy::too_many_arguments)] - let runtime = engine.runtime.handle().clone(); - runtime.spawn(async move { - let mut first_connect = true; - // 已执行的重连次数,用于 max_reconnect_times(0=无限)限流 - let mut reconnect_attempts: u32 = 0; - 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, 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; - // 重连成功后清零计数:max_reconnect_times 限流的是 - // 「连续失败次数」,不能把历史错误累计进来 - reconnect_attempts = 0; - 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 = 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; - } - // max_reconnect_times>0 时按次数停止重试;0 表示无限重连 - if max_reconnect_times > 0 && reconnect_attempts >= max_reconnect_times { - engine.log(&id, LogLevel::Error, "reconnect_give_up", - format!("已重连 {reconnect_attempts} 次仍失败,达到最大重连次数 {max_reconnect_times},停止重连: {e}"), None); - break; - } - reconnect_attempts += 1; - engine.log(&id, LogLevel::Warn, "connection_error", - format!("连接错误,2 秒后重连(第 {reconnect_attempts} 次): {e}"), None); - // 退避等待也必须可取消:否则 close() 的取消信号最长 - // 2 秒不可见,期间会对用户已关闭的连接继续重连。 - tokio::select! { - _ = cancel_rx.changed() => break, - _ = tokio::time::sleep(Duration::from_secs(2)) => {} - } - } - } - } - _ = cancel_rx.changed() => { - engine.log(&id, LogLevel::Info, "disconnect_cancelled", "连接已取消".into(), None); - break; - } - } - } - // 仅当 map 中仍是本代句柄时才移除,避免误删重连后的新句柄 - engine.remove_handle_if(&id, generation); - }); -} - -fn spawn_v4_loop( - engine: Arc, - id: String, - mut eventloop: rv4::EventLoop, - mut cancel_rx: watch::Receiver, - connected: Arc, - generation: u64, - auto_reconnect: bool, - max_reconnect_times: u32, -) { - #![allow(clippy::too_many_arguments)] - let runtime = engine.runtime.handle().clone(); - runtime.spawn(async move { - let mut first_connect = true; - // 已执行的重连次数,用于 max_reconnect_times(0=无限)限流 - let mut reconnect_attempts: u32 = 0; - loop { - tokio::select! { - event = eventloop.poll() => { - match event { - Ok(rv4::Event::Incoming(rv4::Packet::Publish(p))) => { - let (payload, raw_bytes, payload_truncated) = - crate::model::retained_payload(&p.payload); - let record = MqttRecord { - seq: engine.next_seq(), - connection_id: engine.intern(&id), - direction: Direction::Received, - topic: engine.intern(&String::from_utf8_lossy(&p.topic)), - payload, - 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, - payload_truncated, - raw_bytes, - }; - 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; - // 重连成功后清零计数:max_reconnect_times 限流的是 - // 「连续失败次数」,不能把历史错误累计进来 - reconnect_attempts = 0; - 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)), - // 上报真实的 CONNACK 拒绝码,不再硬编码为 1 - reason_code: Some(ack.code as u8), - 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; - } - // max_reconnect_times>0 时按次数停止重试;0 表示无限重连 - if max_reconnect_times > 0 && reconnect_attempts >= max_reconnect_times { - engine.log(&id, LogLevel::Error, "reconnect_give_up", - format!("已重连 {reconnect_attempts} 次仍失败,达到最大重连次数 {max_reconnect_times},停止重连: {e}"), None); - break; - } - reconnect_attempts += 1; - engine.log(&id, LogLevel::Warn, "connection_error", - format!("连接错误,2 秒后重连(第 {reconnect_attempts} 次): {e}"), None); - // 退避等待也必须可取消:否则 close() 的取消信号最长 - // 2 秒不可见,期间会对用户已关闭的连接继续重连。 - tokio::select! { - _ = cancel_rx.changed() => break, - _ = tokio::time::sleep(Duration::from_secs(2)) => {} - } - } - } - } - _ = cancel_rx.changed() => { - engine.log(&id, LogLevel::Info, "disconnect_cancelled", "连接已取消".into(), None); - break; - } - } - } - // 仅当 map 中仍是本代句柄时才移除,避免误删重连后的新句柄 - engine.remove_handle_if(&id, generation); - }); -} diff --git a/gpui-app/src/mqtt/mod.rs b/gpui-app/src/mqtt/mod.rs new file mode 100644 index 0000000..bda9b13 --- /dev/null +++ b/gpui-app/src/mqtt/mod.rs @@ -0,0 +1,628 @@ +//! MQTT 引擎:在独立 tokio runtime 上管理多个连接的生命周期, +//! 通过 smol 无界通道把状态/消息/日志事件送回 GPUI 线程。 +//! +//! - [`tls`] TLS/rustls 配置与跳过校验 +//! - [`v5`] MQTT 5.0 客户端构建与事件循环 +//! - [`v4`] MQTT 3.1.1 客户端构建与事件循环 +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use rumqttc::mqttbytes::v5::PublishProperties as V5PublishProperties; +use rumqttc::{ + AsyncClient as V5AsyncClient, EventLoop as V5EventLoop, + PublishOptions as V5PublishOptions, RetainForwardRule, SubscribeFilterInput, + SubscribeProperties as V5SubscribeProperties, +}; +use rumqttc_v4 as rv4; +use tokio::sync::watch; + +use crate::model::{ + ConnectionConfig, ConnectionStatus, LogEntry, LogLevel, MqttRecord, PublishParams, SubscribeOptions, Subscription, +};// ─── 引擎事件 ──────────────────────────────────────────────────────────────── + + +mod tls; +mod v4; +mod v5; + +use tls::*; +use v4::*; +use v5::*; + +pub enum EngineEvent { + /// 连接状态变化 + Status { + connection_id: String, + status: ConnectionStatus, + error: Option, + reason_code: Option, + session_present: Option, + }, + /// 收到消息 + Message(MqttRecord), + /// 一条本地发出的消息(已成功交给客户端)。payload 为实际发送的字节。 + /// 属性与接收记录对齐,本地发送记录可完整回显。 + Published { + connection_id: String, + topic: String, + payload: Vec, + qos: u8, + retain: bool, + content_type: Option, + user_properties: Vec<(String, String)>, + response_topic: Option, + correlation_data: Option, + message_expiry_interval: Option, + }, + /// 订阅请求结果(tracked,等待 SUBACK) + SubscribeResult { + connection_id: String, + topic: String, + qos: u8, + ok: bool, + error: Option, + }, + /// 日志 + Log(LogEntry), +} + +pub type EventSender = smol::channel::Sender; + +// ─── 句柄 ──────────────────────────────────────────────────────────────────── + +enum ClientKind { + V5(V5AsyncClient), + V4(rv4::AsyncClient), +} + +struct ConnHandle { + /// `None` 表示仍在建立中(占位)。建连完成后由 connect 的任务升级为 Some。 + client: Option, + cancel_tx: watch::Sender, + connected: Arc, + /// 连接代数:同一 id 的第几次 connect。事件循环退出清理时只在 + /// map 中仍是自己这一代时才移除,避免误删重连后的新句柄。 + generation: u64, +} + +pub struct MqttEngine { + runtime: tokio::runtime::Runtime, + conns: Mutex>, + /// 下一次 connect 使用的代数 + next_generation: AtomicU64, + /// 主题/连接 ID 驻留缓存:同一连接内主题高度重复,驻留让消息记录共享字符串 + topics: Mutex>>, + tx: EventSender, + seq: Arc, +} + +fn now_ms() -> i64 { + chrono::Local::now().timestamp_millis() +} + +impl MqttEngine { + pub fn new(tx: EventSender) -> Arc { + 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()), + next_generation: AtomicU64::new(1), + topics: 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) { + 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, cfg: ConnectionConfig) { + // 关闭同名旧连接 + self.close(&cfg.id, false); + + let id = cfg.id.clone(); + let generation = self.next_generation.fetch_add(1, Ordering::Relaxed) + 1; + let (cancel_tx, cancel_rx) = watch::channel(false); + let connected = Arc::new(AtomicBool::new(false)); + // 同步占位(client=None):建连期间 close() 也能取消本次连接, + // 且 is_connected() 在建连完成前保持 false。 + self.conns.lock().unwrap().insert( + id.clone(), + ConnHandle { + client: None, + cancel_tx, + connected: connected.clone(), + generation, + }, + ); + + 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 auto_reconnect = cfg.auto_reconnect; + let max_reconnect_times = cfg.max_reconnect_times; + // build 含证书/CA 文件读取等阻塞 IO,放 spawn_blocking, + // 避免慢盘上卡住 2 线程 runtime 里其他连接的调度 + let build = tokio::task::spawn_blocking(move || { + 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))) + }) + } + }) + .await; + + let build_error = |engine: &Arc, id: &str, generation: u64, e: String| { + engine.remove_handle_if(id, generation); + engine.emit(EngineEvent::Status { + connection_id: id.to_string(), + status: ConnectionStatus::Error, + error: Some(e.clone()), + reason_code: None, + session_present: None, + }); + engine.log(id, LogLevel::Error, "connect_error", format!("连接失败: {e}"), None); + }; + let (client, eventloop) = match build { + Ok(Ok(pair)) => pair, + Ok(Err(e)) => { + build_error(&engine, &id, generation, e); + return; + } + Err(j) => { + build_error(&engine, &id, generation, format!("内部任务失败: {j}")); + return; + } + }; + + // 建连期间该连接可能已被 close 或更新的 connect 顶掉: + // 仅当占位句柄仍是本代时才升级为可用客户端,否则丢弃本次建连。 + { + let mut map = match engine.conns.lock() { + Ok(m) => m, + Err(_) => return, + }; + match map.get_mut(&id) { + Some(h) if h.generation == generation => h.client = Some(client), + _ => return, + } + } + + match eventloop { + EventLoopKind::V5(el) => { + spawn_v5_loop( + engine.clone(), + id, + *el, + cancel_rx, + connected, + generation, + auto_reconnect, + max_reconnect_times, + ); + } + EventLoopKind::V4(el) => { + spawn_v4_loop( + engine.clone(), + id, + *el, + cancel_rx, + connected, + generation, + auto_reconnect, + max_reconnect_times, + ); + } + } + }); + } + + /// 主动断开。`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 { + Some(ClientKind::V5(c)) => { + let _ = c.disconnect().await; + } + Some(ClientKind::V4(c)) => { + let _ = c.disconnect().await; + } + None => {} + } + 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, 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()); + // 实际发送的字节留给 Published 事件(记录需要真实内容) + let sent_payload = payload.clone(); + // 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: sent_payload, + qos: params.qos, + retain: params.retain, + content_type: params.content_type, + user_properties: params.user_properties, + response_topic: params.response_topic, + correlation_data: params.correlation_data, + message_expiry_interval: params.message_expiry_interval, + }); + } + Err(e) => { + engine.log( + &connection_id, + LogLevel::Error, + "publish_error", + format!("发布失败: {e}"), + None, + ); + } + } + }); + } + + // ── 订阅 / 取消订阅 ───────────────────────────────────────────────────── + + /// 旧签名薄封装:不携带 v5 订阅选项(连接视图等现有调用点继续可用)。 + pub fn subscribe(self: &Arc, connection_id: String, sub: Subscription) { + self.subscribe_with_options(connection_id, sub, SubscribeOptions::default()); + } + + /// 带 MQTT 5 订阅选项的订阅(sub_identifier / NL / RAP / Retain Handling); + /// v4 连接会忽略这些选项。 + pub fn subscribe_with_options( + self: &Arc, + connection_id: String, + sub: Subscription, + opts: SubscribeOptions, + ) { + 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) => { + // NL/RAP/Retain Handling 是「按主题过滤器」的订阅选项, + // 订阅标识符则在订阅报文属性里,分别写入两处。 + let filter = SubscribeFilterInput::new(sub.topic.clone(), v5_qos(sub.qos)) + .no_local(opts.no_local) + .preserve_retain(opts.retain_as_published) + .retain_forward_rule(match opts.retain_handling { + 1 => RetainForwardRule::OnNewSubscribe, + 2 => RetainForwardRule::Never, + _ => RetainForwardRule::OnEverySubscribe, + }); + let properties = V5SubscribeProperties { + // 0 不是合法的订阅标识符,按未设置处理 + id: opts.sub_identifier.filter(|v| *v > 0).map(|v| v as usize), + user_properties: Vec::new(), + }; + match client + .subscribe_many_with_properties_tracked(std::iter::once(filter), properties) + .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, 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, + ); + } + } + }); + } + + /// 测试连接:使用配置的连接超时(上限 30 秒,防止误填过大值长时间挂起), + /// 结果通过 channel 返回。 + /// 在引擎的 tokio runtime 上调度阻塞任务(OTA 下载等), + /// 结果经 smol 通道送回 GPUI 执行器;任务 panic 时接收端以空错误结束。 + pub fn run_blocking( + &self, + f: impl FnOnce() -> T + Send + 'static, + ) -> smol::channel::Receiver { + let (tx, rx) = smol::channel::bounded(1); + self.runtime.spawn(async move { + if let Ok(value) = tokio::task::spawn_blocking(f).await { + let _ = tx.send(value).await; + } + }); + rx + } + + /// 驻留字符串(主题/连接 ID):同一内容全进程只保留一份 `Arc`。 + /// 缓存有上限,异常流量下整体清空重建,避免无限增长。 + pub(crate) fn intern(&self, s: &str) -> Arc { + const INTERN_CAP: usize = 10_000; + let mut cache = self.topics.lock().unwrap(); + if let Some(arc) = cache.get(s) { + return arc.clone(); + } + if cache.len() >= INTERN_CAP { + cache.clear(); + } + let arc: Arc = Arc::from(s); + cache.insert(s.to_string(), arc.clone()); + arc + } + + pub fn test_connection( + self: &Arc, + cfg: ConnectionConfig, + ) -> smol::channel::Receiver> { + let (tx, rx) = smol::channel::bounded(1); + let secs = u64::from(cfg.connection_timeout_secs.clamp(1, 30)); + self.runtime.spawn(async move { + let result = tokio::time::timeout(Duration::from_secs(secs), async { + test_handshake(&cfg).await + }) + .await; + let reply = match result { + Ok(inner) => inner, + Err(_) => Err(format!( + "连接测试超时({secs} 秒),请检查主机地址与端口" + )), + }; + let _ = tx.try_send(reply); + }); + rx + } + + // ── 内部工具 ─────────────────────────────────────────────────────────── + + /// 克隆一个连接的异步客户端用于发命令。 + /// 不持有 std Mutex 守卫跨 await,避免与 tokio runtime 的 Send 要求冲突。 + /// 占位句柄(建连未完成)视为不存在。 + fn client_of(&self, id: &str) -> Option { + let map = self.conns.lock().ok()?; + let client = map.get(id)?.client.as_ref()?; + Some(match client { + ClientKind::V5(c) => ClientKind::V5(c.clone()), + ClientKind::V4(c) => ClientKind::V4(c.clone()), + }) + } + + /// 仅当 map 中该 id 的句柄仍是 `generation` 这一代时才移除。 + /// 事件循环退出清理必须走这里:无条件按 id 删除会把重连后的 + /// 新句柄误删,导致连接无法再从 UI 断开。 + fn remove_handle_if(&self, id: &str, generation: u64) { + if let Ok(mut map) = self.conns.lock() + && map.get(id).map(|h| h.generation) == Some(generation) + { + map.remove(id); + } + } +} + +enum EventLoopKind { + // 两个事件循环都在 KB 级,装箱让枚举本身只有指针大小 + V5(Box), + V4(Box), +} + +// ─── 选项构建 ──────────────────────────────────────────────────────────────── + +/// 连接超时秒数:至少 1 秒,避免误填 0 导致立刻超时。 +fn connection_timeout_secs(cfg: &ConnectionConfig) -> u64 { + u64::from(cfg.connection_timeout_secs.max(1)) +} + diff --git a/gpui-app/src/mqtt/tls.rs b/gpui-app/src/mqtt/tls.rs new file mode 100644 index 0000000..a4faa33 --- /dev/null +++ b/gpui-app/src/mqtt/tls.rs @@ -0,0 +1,178 @@ +//! TLS:由 SslConfig 解析 rustls 配置,含跳过证书校验的 NoVerifier。 + +use std::sync::Arc; + +use rumqttc::TlsConfiguration; +use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer}; + +use crate::model::{ + ConnectionConfig, + SslConfig, +};// ─── 引擎事件 ──────────────────────────────────────────────────────────────── + + + +/// SSL 配置非默认时构建自定义 rustls 配置;返回 None 表示沿用 rumqttc 默认 TLS。 +/// 文件读取/解析失败在此返回 Err,由 connect 的错误通道上报为连接失败事件。 +pub(super) fn resolve_tls_config(cfg: &ConnectionConfig) -> Result, String> { + if !cfg.transport.is_tls() || cfg.ssl.is_default() { + return Ok(None); + } + Ok(Some(TlsConfiguration::Rustls(build_rustls_config(&cfg.ssl)?))) +} + +/// 按 SslConfig 构造 rustls ClientConfig: +/// - `ignore_ca`:跳过服务器证书链/主机名校验; +/// - `ca_file`:在系统根证书之外追加自定义 CA; +/// - `client_cert_file` + `client_key_file`:双向认证。 +pub(super) fn build_rustls_config(ssl: &SslConfig) -> Result, String> { + // 只填证书或只填私钥必然握手失败,提前给出明确错误 + if ssl.client_cert_file.is_empty() != ssl.client_key_file.is_empty() { + return Err("客户端证书与客户端密钥必须同时配置".into()); + } + let provider = ensure_crypto_provider(); + let builder = rustls::ClientConfig::builder(); + let builder = if ssl.ignore_ca { + builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerifier { + provider: provider.clone(), + })) + } else { + let mut roots = rustls::RootCertStore::empty(); + let native = rustls_native_certs::load_native_certs(); + if native.certs.is_empty() && ssl.ca_file.is_empty() { + let detail = native + .errors + .iter() + .map(|e| e.to_string()) + .collect::>() + .join("; "); + return Err(format!("加载系统根证书失败: {detail}")); + } + // 个别系统证书加载失败不阻断连接:自定义 CA 与其余根证书仍参与校验 + for cert in native.certs { + let _ = roots.add(cert); + } + if !ssl.ca_file.is_empty() { + let pem = std::fs::read(&ssl.ca_file) + .map_err(|e| format!("读取 CA 文件 {} 失败: {e}", ssl.ca_file))?; + let mut added = 0usize; + for cert in CertificateDer::pem_slice_iter(&pem) { + let cert = cert.map_err(|e| format!("解析 CA 文件 {} 失败: {e}", ssl.ca_file))?; + roots + .add(cert) + .map_err(|e| format!("CA 文件 {} 中存在无效证书: {e}", ssl.ca_file))?; + added += 1; + } + if added == 0 { + return Err(format!("CA 文件 {} 中没有找到任何证书", ssl.ca_file)); + } + } + builder.with_root_certificates(roots) + }; + + let config = if ssl.client_cert_file.is_empty() { + builder.with_no_client_auth() + } else { + let chain_pem = std::fs::read(&ssl.client_cert_file).map_err(|e| { + format!("读取客户端证书文件 {} 失败: {e}", ssl.client_cert_file) + })?; + let key_pem = std::fs::read(&ssl.client_key_file) + .map_err(|e| format!("读取客户端私钥文件 {} 失败: {e}", ssl.client_key_file))?; + let certs = CertificateDer::pem_slice_iter(&chain_pem) + .collect::, _>>() + .map_err(|e| format!("解析客户端证书文件 {} 失败: {e}", ssl.client_cert_file))?; + if certs.is_empty() { + return Err(format!( + "客户端证书文件 {} 中没有找到证书", + ssl.client_cert_file + )); + } + let key = PrivateKeyDer::from_pem_slice(&key_pem) + .map_err(|e| format!("解析客户端私钥文件 {} 失败: {e}", ssl.client_key_file))?; + builder + .with_client_auth_cert(certs, key) + .map_err(|e| format!("加载客户端证书/私钥失败: {e}"))? + }; + Ok(Arc::new(config)) +} + +/// 取得(必要时安装)进程级 CryptoProvider。 +/// 工作区同时启用 ring 与 aws-lc-rs 特性时 rustls 无法自动推断默认 provider, +/// 不显式安装会让 `ClientConfig::builder()` panic。 +pub(super) fn ensure_crypto_provider() -> Arc { + if let Some(provider) = rustls::crypto::CryptoProvider::get_default() { + return Arc::clone(provider); + } + let provider = rustls::crypto::aws_lc_rs::default_provider(); + // 输掉安装竞争时直接采用已安装的那个 + match provider.clone().install_default() { + Ok(()) => Arc::new(provider), + Err(installed) => installed, + } +} + +/// 「忽略 CA 校验」用:跳过服务器证书链与主机名校验, +/// 握手签名仍用 provider 校验(与 rustls 官方示例做法一致)。 +pub(super) struct NoVerifier { + provider: Arc, +} + +impl std::fmt::Debug for NoVerifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NoVerifier").finish_non_exhaustive() + } +} + +impl rustls::client::danger::ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.provider + .signature_verification_algorithms + .supported_schemes() + } +} + +/// 去掉空白字符串,避免表单存了空串也写进报文属性。 +pub(super) fn non_blank(s: Option) -> Option { + s.filter(|v| !v.trim().is_empty()) +} diff --git a/gpui-app/src/mqtt/v4.rs b/gpui-app/src/mqtt/v4.rs new file mode 100644 index 0000000..e835b82 --- /dev/null +++ b/gpui-app/src/mqtt/v4.rs @@ -0,0 +1,317 @@ +//! MQTT 3.1.1:MqttOptions/last-will 组装与事件循环泵。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use rumqttc::mqttbytes::v5::{ + ConnectReturnCode as V5ReturnCode, Packet as V5Packet, +}; +use rumqttc::Event as V5Event; +use rumqttc_v4 as rv4; +use tokio::sync::watch; + +use crate::model::{ + ConnectionConfig, ConnectionStatus, Direction, LogLevel, MqttRecord, TransportKind, +};// ─── 引擎事件 ──────────────────────────────────────────────────────────────── + + +use super::*; + +pub(super) fn build_v4( + cfg: &ConnectionConfig, +) -> Result<(rv4::AsyncClient, rv4::EventLoop), String> { + use rv4::{Broker as V4Broker, MqttOptions as V4Options, Transport as V4Transport}; + + let custom_tls = resolve_tls_config(cfg)?; + 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)); + match &custom_tls { + Some(tls) => V4Options::websocket_with_tls_config( + cfg.client_id.clone(), + url, + tls.clone(), + ) + .map_err(|e| format!("{e:?}"))?, + None => V4Options::try_websocket_with_default_tls(cfg.client_id.clone(), url) + .map_err(|e| format!("{e:?}"))?, + } + } + }; + + if cfg.transport == TransportKind::Tls { + match &custom_tls { + Some(tls) => { + opts.set_transport(V4Transport::tls_with_config(tls.clone())); + } + None => { + 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() { + // 3.1.1 的遗嘱没有 content_type / response_topic 属性,忽略 + 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); + } + + let (client, mut eventloop) = rv4::AsyncClient::builder(opts).build(); + // v4 的连接超时(含 CONNACK 等待)由 EventLoop 的 NetworkOptions 控制 + let mut network_options = rv4::NetworkOptions::new(); + network_options.set_connection_timeout(connection_timeout_secs(cfg)); + eventloop.set_network_options(network_options); + Ok((client, eventloop)) +} + +pub(super) fn v4_qos(qos: u8) -> rv4::QoS { + match qos { + 1 => rv4::QoS::AtLeastOnce, + 2 => rv4::QoS::ExactlyOnce, + _ => rv4::QoS::AtMostOnce, + } +} + +// ─── 测试连接握手 ──────────────────────────────────────────────────────────── + +pub(super) 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 侧残留会话。 + // disconnect() 只是把请求入队,需再 poll 才会把 + // DISCONNECT 真正写上网络。 + let _ = client.disconnect().await; + for _ in 0..3 { + if eventloop.poll().await.is_err() { + break; + } + } + 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 { + // 同 v5:disconnect() 只入队,需 poll 把报文冲出 + let _ = client.disconnect().await; + for _ in 0..3 { + if eventloop.poll().await.is_err() { + break; + } + } + return Ok(()); + } + return Err(format!("连接被拒绝: {:?}", ack.code)); + } + Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => { + return Err("服务端主动断开".into()); + } + Err(e) => return Err(format!("连接失败: {e}")), + _ => {} + } + } + } +} + +// ─── 事件循环 ──────────────────────────────────────────────────────────────── + +pub(super) fn spawn_v4_loop( + engine: Arc, + id: String, + mut eventloop: rv4::EventLoop, + mut cancel_rx: watch::Receiver, + connected: Arc, + generation: u64, + auto_reconnect: bool, + max_reconnect_times: u32, +) { + #![allow(clippy::too_many_arguments)] + let runtime = engine.runtime.handle().clone(); + runtime.spawn(async move { + let mut first_connect = true; + // 已执行的重连次数,用于 max_reconnect_times(0=无限)限流 + let mut reconnect_attempts: u32 = 0; + loop { + tokio::select! { + event = eventloop.poll() => { + match event { + Ok(rv4::Event::Incoming(rv4::Packet::Publish(p))) => { + let (payload, raw_bytes, payload_truncated) = + crate::model::retained_payload(&p.payload); + let record = MqttRecord { + seq: engine.next_seq(), + connection_id: engine.intern(&id), + direction: Direction::Received, + topic: engine.intern(&String::from_utf8_lossy(&p.topic)), + payload, + 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, + payload_truncated, + raw_bytes, + }; + 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; + // 重连成功后清零计数:max_reconnect_times 限流的是 + // 「连续失败次数」,不能把历史错误累计进来 + reconnect_attempts = 0; + 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)), + // 上报真实的 CONNACK 拒绝码,不再硬编码为 1 + reason_code: Some(ack.code as u8), + 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; + } + // max_reconnect_times>0 时按次数停止重试;0 表示无限重连 + if max_reconnect_times > 0 && reconnect_attempts >= max_reconnect_times { + engine.log(&id, LogLevel::Error, "reconnect_give_up", + format!("已重连 {reconnect_attempts} 次仍失败,达到最大重连次数 {max_reconnect_times},停止重连: {e}"), None); + break; + } + reconnect_attempts += 1; + engine.log(&id, LogLevel::Warn, "connection_error", + format!("连接错误,2 秒后重连(第 {reconnect_attempts} 次): {e}"), None); + // 退避等待也必须可取消:否则 close() 的取消信号最长 + // 2 秒不可见,期间会对用户已关闭的连接继续重连。 + tokio::select! { + _ = cancel_rx.changed() => break, + _ = tokio::time::sleep(Duration::from_secs(2)) => {} + } + } + } + } + _ = cancel_rx.changed() => { + engine.log(&id, LogLevel::Info, "disconnect_cancelled", "连接已取消".into(), None); + break; + } + } + } + // 仅当 map 中仍是本代句柄时才移除,避免误删重连后的新句柄 + engine.remove_handle_if(&id, generation); + }); +} diff --git a/gpui-app/src/mqtt/v5.rs b/gpui-app/src/mqtt/v5.rs new file mode 100644 index 0000000..bf0515f --- /dev/null +++ b/gpui-app/src/mqtt/v5.rs @@ -0,0 +1,354 @@ +//! MQTT 5.0:MqttOptions/last-will/属性组装与事件循环泵。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use rumqttc::mqttbytes::v5::{ + ConnectReturnCode as V5ReturnCode, Packet as V5Packet, Publish as V5Publish, +}; +use rumqttc::{ + AsyncClient as V5AsyncClient, Broker as V5Broker, ConnectionError as V5Error, + Event as V5Event, EventLoop as V5EventLoop, MqttOptions as V5Options, Outgoing as V5Outgoing, QoS as V5QoS, Transport as V5Transport, +}; +use tokio::sync::watch; + +use crate::model::{ + ConnectionConfig, ConnectionStatus, Direction, LogLevel, MqttRecord, TransportKind, +};// ─── 引擎事件 ──────────────────────────────────────────────────────────────── + + +use super::*; + +pub(super) fn build_v5( + cfg: &ConnectionConfig, +) -> Result<(V5AsyncClient, V5EventLoop), String> { + let custom_tls = resolve_tls_config(cfg)?; + 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)); + match &custom_tls { + Some(tls) => V5Options::websocket_with_tls_config( + cfg.client_id.clone(), + url, + tls.clone(), + ) + .map_err(|e| format!("{e:?}"))?, + None => V5Options::try_websocket_with_default_tls(cfg.client_id.clone(), url) + .map_err(|e| format!("{e:?}"))?, + } + } + }; + + if cfg.transport == TransportKind::Tls { + match &custom_tls { + Some(tls) => { + opts.set_transport(V5Transport::tls_with_config(tls.clone())); + } + None => { + opts.set_transport( + V5Transport::try_tls_with_default_config() + .map_err(|e| format!("TLS 初始化失败: {e:?}"))?, + ); + } + }; + } + + // 连接超时同时覆盖底层建连与 CONNACK 等待 + let timeout = Duration::from_secs(connection_timeout_secs(cfg)); + opts.set_connect_timeout(timeout); + let mut network_options = rumqttc::NetworkOptions::new(); + network_options.set_connection_timeout(connection_timeout_secs(cfg)); + opts.set_network_options(network_options); + + 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() { + // v5 遗嘱属性:content_type / response_topic(3.1.1 下无此扩展) + let properties = if will.content_type.is_some() || will.response_topic.is_some() { + Some(rumqttc::mqttbytes::v5::LastWillProperties { + delay_interval: None, + payload_format_indicator: None, + message_expiry_interval: None, + content_type: non_blank(will.content_type.clone()), + response_topic: non_blank(will.response_topic.clone()), + correlation_data: None, + user_properties: Vec::new(), + }) + } else { + None + }; + let lw = rumqttc::mqttbytes::v5::LastWill::new( + will.topic.clone(), + will.payload.as_bytes().to_vec(), + v5_qos(will.qos), + will.retain, + properties, + ); + opts.set_last_will(lw); + } + + Ok(V5AsyncClient::builder(opts).build()) +} + +pub(super) fn normalize_path(path: &str) -> String { + if path.is_empty() { + "/mqtt".to_string() + } else if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + } +} + +pub(super) fn v5_qos(qos: u8) -> V5QoS { + match qos { + 1 => V5QoS::AtLeastOnce, + 2 => V5QoS::ExactlyOnce, + _ => V5QoS::AtMostOnce, + } +} + +fn v5_record(engine: &MqttEngine, seq: u64, conn_id: &str, p: &V5Publish) -> MqttRecord { + let props = p.properties.as_ref(); + let (payload, raw_bytes, payload_truncated) = crate::model::retained_payload(&p.payload); + MqttRecord { + seq, + connection_id: engine.intern(conn_id), + direction: Direction::Received, + topic: engine.intern(&String::from_utf8_lossy(&p.topic)), + payload, + 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| correlation_display(d)) + }), + 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)), + payload_truncated, + raw_bytes, + } +} + +/// correlation data 的可读表示:优先按 UTF-8 文本展示,非文本回退 hex。 +/// 发送端把字符串按字节原样发出,两侧展示保持对称(此前收端一律 hex、 +/// 发端保留原文,同一条数据两侧对不上)。 +fn correlation_display(data: &[u8]) -> String { + match std::str::from_utf8(data) { + Ok(s) => s.to_string(), + Err(_) => data.iter().map(|b| format!("{b:02x}")).collect(), + } +} + +fn v5_qos_u8(qos: V5QoS) -> u8 { + match qos { + V5QoS::AtMostOnce => 0, + V5QoS::AtLeastOnce => 1, + V5QoS::ExactlyOnce => 2, + } +} + +pub(super) fn spawn_v5_loop( + engine: Arc, + id: String, + mut eventloop: V5EventLoop, + mut cancel_rx: watch::Receiver, + connected: Arc, + generation: u64, + auto_reconnect: bool, + max_reconnect_times: u32, +) { + #![allow(clippy::too_many_arguments)] + let runtime = engine.runtime.handle().clone(); + runtime.spawn(async move { + let mut first_connect = true; + // 已执行的重连次数,用于 max_reconnect_times(0=无限)限流 + let mut reconnect_attempts: u32 = 0; + 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, 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; + // 重连成功后清零计数:max_reconnect_times 限流的是 + // 「连续失败次数」,不能把历史错误累计进来 + reconnect_attempts = 0; + 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 = 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; + } + // max_reconnect_times>0 时按次数停止重试;0 表示无限重连 + if max_reconnect_times > 0 && reconnect_attempts >= max_reconnect_times { + engine.log(&id, LogLevel::Error, "reconnect_give_up", + format!("已重连 {reconnect_attempts} 次仍失败,达到最大重连次数 {max_reconnect_times},停止重连: {e}"), None); + break; + } + reconnect_attempts += 1; + engine.log(&id, LogLevel::Warn, "connection_error", + format!("连接错误,2 秒后重连(第 {reconnect_attempts} 次): {e}"), None); + // 退避等待也必须可取消:否则 close() 的取消信号最长 + // 2 秒不可见,期间会对用户已关闭的连接继续重连。 + tokio::select! { + _ = cancel_rx.changed() => break, + _ = tokio::time::sleep(Duration::from_secs(2)) => {} + } + } + } + } + _ = cancel_rx.changed() => { + engine.log(&id, LogLevel::Info, "disconnect_cancelled", "连接已取消".into(), None); + break; + } + } + } + // 仅当 map 中仍是本代句柄时才移除,避免误删重连后的新句柄 + engine.remove_handle_if(&id, generation); + }); +} diff --git a/gpui-app/src/platform.rs b/gpui-app/src/platform.rs new file mode 100644 index 0000000..b422519 --- /dev/null +++ b/gpui-app/src/platform.rs @@ -0,0 +1,44 @@ +//! 平台专属窗口操作。当前仅 Windows:置顶 / 取消置顶。 + +/// 窗口置顶 / 取消置顶。返回是否成功(非 Windows 或句柄不可得为 false)。 +pub fn set_topmost(window: &gpui_kit::Window, topmost: bool) -> bool { + if !cfg!(target_os = "windows") { + return false; + } + set_topmost_windows(window, topmost) +} + +#[cfg(target_os = "windows")] +fn set_topmost_windows(window: &gpui_kit::Window, topmost: bool) -> bool { + use raw_window_handle::RawWindowHandle; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + SetWindowPos, HWND_NOTOPMOST, HWND_TOPMOST, SET_WINDOW_POS_FLAGS, + }; + + // gpui 的 Window 自带同名方法,HasWindowHandle 必须全路径调用 + let handle = match raw_window_handle::HasWindowHandle::window_handle(window) { + Ok(h) => h, + Err(_) => return false, + }; + // WindowHandle::raw 是私有字段;HasWindowHandle 的实现返回自身拷贝, + // trait 方法在此类型上拿不到 RawWindowHandle,用 deref 后的 as_raw 途径 + let raw = handle.as_raw(); + #[allow(clippy::infallible_destructuring_match)] + let RawWindowHandle::Win32(w) = raw else { + return false; + }; + let hwnd = HWND(w.hwnd.get() as *mut core::ffi::c_void); + let insert = if topmost { HWND_TOPMOST } else { HWND_NOTOPMOST }; + let flags = SET_WINDOW_POS_FLAGS(SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + unsafe { SetWindowPos(hwnd, insert, 0, 0, 0, 0, flags).is_ok() } +} + +#[cfg(not(target_os = "windows"))] +fn set_topmost_windows(_window: &gpui_kit::Window, _topmost: bool) -> bool { + false +} + +const SWP_NOSIZE: u32 = 0x0001; +const SWP_NOMOVE: u32 = 0x0002; +const SWP_NOACTIVATE: u32 = 0x0010; diff --git a/gpui-app/src/sysmon.rs b/gpui-app/src/sysmon.rs index 1124b32..f42c7e0 100644 --- a/gpui-app/src/sysmon.rs +++ b/gpui-app/src/sysmon.rs @@ -1,7 +1,7 @@ -//! 进程自身资源采样与窗口置顶辅助。 +//! 进程自身资源采样(CPU/内存/线程/句柄)。 //! -//! 仅 Windows 实现数据采集(`windows` crate);其他平台采样返回 -//! 全零数据、置顶返回 false,调用方按缺失能力展示。 +//! 仅 Windows 实现数据采集(`windows` crate);其他平台采样返回全零数据, +//! 调用方按缺失能力展示。窗口置顶等平台操作见 [`crate::platform`]。 use std::time::{Duration, Instant}; @@ -180,49 +180,6 @@ pub fn uptime() -> Duration { START.get().map(|t| t.elapsed()).unwrap_or_default() } -/// 窗口置顶 / 取消置顶。返回是否成功(非 Windows 或句柄不可得为 false)。 -pub fn set_topmost(window: &gpui_kit::Window, topmost: bool) -> bool { - if !cfg!(target_os = "windows") { - return false; - } - set_topmost_windows(window, topmost) -} - -#[cfg(target_os = "windows")] -fn set_topmost_windows(window: &gpui_kit::Window, topmost: bool) -> bool { - use raw_window_handle::RawWindowHandle; - use windows::Win32::Foundation::HWND; - use windows::Win32::UI::WindowsAndMessaging::{ - SetWindowPos, HWND_NOTOPMOST, HWND_TOPMOST, SET_WINDOW_POS_FLAGS, - }; - - // gpui 的 Window 自带同名方法,HasWindowHandle 必须全路径调用 - let handle = match raw_window_handle::HasWindowHandle::window_handle(window) { - Ok(h) => h, - Err(_) => return false, - }; - // WindowHandle::raw 是私有字段;HasWindowHandle 的实现返回自身拷贝, - // trait 方法在此类型上拿不到 RawWindowHandle,用 deref 后的 as_raw 途径 - let raw = handle.as_raw(); - #[allow(clippy::infallible_destructuring_match)] - let RawWindowHandle::Win32(w) = raw else { - return false; - }; - let hwnd = HWND(w.hwnd.get() as *mut core::ffi::c_void); - let insert = if topmost { HWND_TOPMOST } else { HWND_NOTOPMOST }; - let flags = SET_WINDOW_POS_FLAGS(SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); - unsafe { SetWindowPos(hwnd, insert, 0, 0, 0, 0, flags).is_ok() } -} - -#[cfg(not(target_os = "windows"))] -fn set_topmost_windows(_window: &gpui_kit::Window, _topmost: bool) -> bool { - false -} - -const SWP_NOSIZE: u32 = 0x0001; -const SWP_NOMOVE: u32 = 0x0002; -const SWP_NOACTIVATE: u32 = 0x0010; - #[cfg(test)] mod tests { use super::*; diff --git a/gpui-app/src/update.rs b/gpui-app/src/update.rs deleted file mode 100644 index 859f27b..0000000 --- a/gpui-app/src/update.rs +++ /dev/null @@ -1,658 +0,0 @@ -//! OTA 自更新:以 Gitea Release 为更新源。 -//! -//! 流程:`check_latest`(比较最新 Release 与当前版本)→ `download_and_verify` -//! (下载到 exe 同目录临时文件并校验 sha256)→ `install_and_restart` -//! (改名自替换后拉起新进程)。全程 HTTPS + sha256 校验。 - -use std::io::Read; -use std::path::PathBuf; - -use sha2::Digest as _; - -/// 更新源(自建 Gitea,仓库公开、附件可直接下载,无需认证)。 -pub const GITEA_URL: &str = "https://gitea.heavenlybook.cn"; -pub const PKG_OWNER: &str = "JoeAllen"; -pub const PKG_REPO: &str = "mqttx"; - -const HTTP_TIMEOUT_SECS: u64 = 30; - -/// 发现的可用更新。 -#[derive(Debug, Clone)] -pub struct UpdateInfo { - /// 新版本号(不含 v 前缀),如 "1.0.1" - pub version: String, - /// Release 说明(Markdown 原文) - pub notes: String, - /// 附件名(判断 7z 压缩包 / 裸二进制) - pub asset_name: String, - /// 平台对应产物下载地址 - pub asset_url: String, - /// sha256 校验文件地址(若 Release 提供了 `.sha256` 侧车) - pub sha256_url: Option, - /// 产物字节数(API 提供,用于下载进度) - pub size: u64, -} - -/// 当前版本。测试可用环境变量 `MQTTX_VERSION_OVERRIDE` 伪装旧版本, -/// 验证"发现新版本"路径。 -pub fn current_version() -> &'static str { - static VERSION: std::sync::OnceLock = std::sync::OnceLock::new(); - VERSION.get_or_init(|| { - std::env::var("MQTTX_VERSION_OVERRIDE") - .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string()) - }) -} - -/// 构建目标的平台三元组,需与 release.yml / publish 脚本的产物命名一致。 -pub fn platform_triple() -> &'static str { - if cfg!(windows) { - "x86_64-pc-windows-msvc" - } else if cfg!(target_os = "macos") { - "x86_64-apple-darwin" - } else { - "x86_64-unknown-linux-gnu" - } -} - -/// 某版本解压后二进制的暂存名(与 CI 的命名约定 `mqttx-v--mqttx[.exe]` 一致)。 -pub fn binary_name_for(version: &str) -> String { - let ext = if cfg!(windows) { ".exe" } else { "" }; - format!("mqttx-v{}-{}-mqttx{}", version, platform_triple(), ext) -} - -/// 某版本的 7z 压缩附件名(打包端与此约定一致;内部为 [`binary_name_for`])。 -pub fn asset_name_for(version: &str) -> String { - format!("{}.7z", binary_name_for(version)) -} - -fn agent() -> ureq::Agent { - ureq::AgentBuilder::new() - // 整体上限:防止慢速滴流的下载无限占用阻塞线程 - .timeout(std::time::Duration::from_secs(10 * 60)) - .timeout_connect(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) - .timeout_read(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) - .build() -} - -fn http_get_json(url: &str) -> Result { - let resp = agent() - .get(url) - .call() - .map_err(|e| format!("请求失败: {e}"))?; - resp.into_json::() - .map_err(|e| format!("解析响应失败: {e}")) -} - -/// 去掉 v 前缀与预发布后缀(`-` 及之后),返回数字主体。 -fn release_part(v: &str) -> &str { - let v = v.trim().trim_start_matches(['v', 'V']); - match v.find('-') { - Some(i) => &v[..i], - None => v, - } -} - -/// 从版本字符串提取数字段(容忍 v 前缀;预发布后缀(`-` 之后)不参与比较)。 -pub fn parse_version(v: &str) -> Vec { - release_part(v) - .split('.') - .filter_map(|s| s.parse::().ok()) - .collect() -} - -/// a 是否比 b 新(按数字段逐位比较,缺失段视为 0)。 -/// 预发布版本(含 `-` 后缀)不会比同数字段的正式版新(semver 语义)。 -pub fn is_newer(a: &str, b: &str) -> bool { - let (a, b) = (parse_version(a), parse_version(b)); - let len = a.len().max(b.len()); - for i in 0..len { - let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0)); - if x != y { - return x > y; - } - } - false -} - -/// 从 Release JSON 里挑选当前平台的产物与 sha256 侧车。 -/// 优先 7z 压缩包(体积小、下载快);Release 未提供 7z 时回退裸二进制 -/// (兼容旧发布方式)。 -pub fn pick_assets(rel: &serde_json::Value) -> Option<(String, String, u64, Option)> { - let assets = rel.get("assets")?.as_array()?; - let want_ext = if cfg!(windows) { ".exe" } else { "" }; - let triple = platform_triple(); - - // (后缀过滤器, 说明):第一轮 7z,第二轮裸二进制 - for suffix in [".7z", want_ext] { - for a in assets { - let Some(name) = a.get("name").and_then(|n| n.as_str()) else { - continue; - }; - if !name.contains(triple) - || !name.ends_with(suffix) - || name.ends_with(".sha256") - { - continue; - } - // 7z 轮要排除裸二进制(windows 裸 exe 不以 .7z 结尾,天然互斥) - if suffix == ".7z" && !name.ends_with(&format!("{want_ext}.7z")) && !want_ext.is_empty() - { - continue; - } - let Some(url) = a.get("browser_download_url").and_then(|u| u.as_str()) else { - continue; - }; - let url = url.to_string(); - let size = a.get("size").and_then(|s| s.as_u64()).unwrap_or(0); - // 侧车命名:.sha256 - let sha = assets - .iter() - .find(|b| { - b.get("name").and_then(|n| n.as_str()) == Some(&format!("{name}.sha256")) - }) - .and_then(|b| b.get("browser_download_url")) - .and_then(|u| u.as_str()) - .map(|s| s.to_string()); - return Some((name.to_string(), url, size, sha)); - } - } - None -} - -/// 检查更新。返回 Ok(None) 表示已是最新。 -pub fn check_latest() -> Result, String> { - let base = format!("{GITEA_URL}/api/v1/repos/{PKG_OWNER}/{PKG_REPO}/releases"); - // 优先 latest 端点;旧版 Gitea 没有时回退到列表取第一个非草稿/预发布 - let rel = match http_get_json(&format!("{base}/latest")) { - Ok(v) => v, - Err(_) => { - let list = http_get_json(&format!("{base}?limit=5"))?; - let first = list - .as_array() - .and_then(|l| l.iter().find(|r| { - r.get("draft").and_then(|d| d.as_bool()) == Some(false) - && r.get("prerelease").and_then(|d| d.as_bool()) == Some(false) - })) - .ok_or_else(|| "没有可用 Release".to_string())?; - first.clone() - } - }; - - let tag = rel - .get("tag_name") - .and_then(|t| t.as_str()) - .ok_or_else(|| "Release 缺少 tag_name".to_string())?; - if !is_newer(tag, current_version()) { - return Ok(None); - } - let Some((name, url, size, sha)) = pick_assets(&rel) else { - return Err(format!("Release {tag} 没有当前平台({})的产物", platform_triple())); - }; - Ok(Some(UpdateInfo { - version: tag.trim_start_matches('v').to_string(), - notes: rel - .get("body") - .and_then(|b| b.as_str()) - .unwrap_or_default() - .to_string(), - asset_name: name, - asset_url: url, - sha256_url: sha, - size, - })) -} - -/// 已下载并校验、等待安装的更新包(存放在数据目录的暂存区)。 -#[derive(Debug, Clone)] -pub struct StagedUpdate { - pub version: String, - pub path: PathBuf, -} - -/// 暂存目录:数据目录下的 `updates/`,重启不丢失。 -fn stage_dir() -> Result { - let dir = dirs::data_dir() - .unwrap_or_else(std::env::temp_dir) - .join("MQTTX-GPUI") - .join("updates"); - std::fs::create_dir_all(&dir).map_err(|e| format!("创建暂存目录失败: {e}"))?; - Ok(dir) -} - -/// 检查更新并静默下载到暂存区(供启动自动检查使用)。 -/// 返回 Ok(None) 表示已是最新;错误仅记录、不打扰用户。 -pub fn check_and_download() -> Result, String> { - let Some(info) = check_latest()? else { - return Ok(None); - }; - // 已暂存同版本则不重复下载 - if let Some(staged) = load_staged() - && staged.version == info.version - { - return Ok(Some(staged)); - } - download_and_stage(&info, &|_, _| {}).map(Some) -} - -/// 下载并校验到暂存区,返回待安装的更新。 -/// -/// 附件为 7z 压缩包:下载 → 校验 → 解压出二进制暂存(压缩包随即删除)。 -/// 回退路径(裸二进制附件)直接校验暂存。 -/// `on_progress(downloaded, total)` 在阻塞线程上回调,total 为 0 表示未知。 -pub fn download_and_stage( - info: &UpdateInfo, - on_progress: &dyn Fn(u64, u64), -) -> Result { - let dir = stage_dir()?; - let binary_name = binary_name_for(&info.version); - let is_7z = info.asset_name.ends_with(".7z"); - let final_name = if is_7z { - asset_name_for(&info.version) - } else { - binary_name.clone() - }; - // tmp 名加入毫秒时间戳:同 pid 并发下载不共用文件 - let tmp = dir.join(format!( - "{}.{}.{}.tmp", - final_name, - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) - )); - let dst = dir.join(&final_name); - - if let Err(e) = download_to(info, &tmp, on_progress) { - // 失败路径统一清理临时文件 - let _ = std::fs::remove_file(&tmp); - return Err(e); - } - - // 清掉其他版本的残留暂存文件,避免堆积与误装旧版 - let staged_path = if is_7z { - // 解压到暂存目录,取出二进制后删掉压缩包 - if let Err(e) = sevenz_rust::decompress_file(&tmp, &dir) { - let _ = std::fs::remove_file(&tmp); - return Err(format!("解压更新包失败: {e}")); - } - let _ = std::fs::remove_file(&tmp); - let extracted = dir.join(&binary_name); - if !extracted.exists() { - return Err(format!( - "更新包内缺少 {},压缩包内容异常", - binary_name - )); - } - // 确认解压产物后再清理其他版本,最后返回二进制路径 - remove_other_staged(&dir, &extracted); - extracted - } else { - remove_other_staged(&dir, &dst); - std::fs::rename(&tmp, &dst).map_err(|e| format!("暂存失败: {e}"))?; - dst - }; - Ok(StagedUpdate { - version: info.version.clone(), - path: staged_path, - }) -} - -fn download_to(info: &UpdateInfo, tmp: &std::path::Path, on_progress: &dyn Fn(u64, u64)) -> Result<(), String> { - let resp = agent() - .get(&info.asset_url) - .call() - .map_err(|e| format!("下载失败: {e}"))?; - let mut reader = resp.into_reader(); - let total = info.size; - let mut file = std::fs::File::create(tmp).map_err(|e| format!("创建临时文件失败: {e}"))?; - let mut buf = [0u8; 64 * 1024]; - let mut downloaded: u64 = 0; - let mut hasher = sha2::Sha256::new(); - loop { - let n = reader.read(&mut buf).map_err(|e| format!("下载中断: {e}"))?; - if n == 0 { - break; - } - std::io::Write::write_all(&mut file, &buf[..n]) - .map_err(|e| format!("写入临时文件失败: {e}"))?; - hasher.update(&buf[..n]); - downloaded += n as u64; - on_progress(downloaded, total); - } - std::io::Write::flush(&mut file).ok(); - drop(file); - - // 传输完整性:API 提供了大小却没下满/超量,视为损坏(read 返回 0 - // 无法区分干净结束与连接提前断开,必须与预期大小对账) - if total > 0 && downloaded != total { - return Err(format!( - "下载不完整:期望 {total} 字节,实际 {downloaded} 字节" - )); - } - - // sha256 校验 - if let Some(expect) = fetch_expected_sha256(info)? { - let actual = format!("{:x}", hasher.finalize()); - if !actual.eq_ignore_ascii_case(&expect) { - return Err(format!( - "校验失败:期望 sha256 {expect},实际 {actual}" - )); - } - } - Ok(()) -} - -/// 删除暂存目录中除 `keep` 外的其他版本暂存文件。 -fn remove_other_staged(dir: &std::path::Path, keep: &std::path::Path) { - if let Ok(entries) = std::fs::read_dir(dir) { - for e in entries.flatten() { - let p = e.path(); - if p == *keep { - continue; - } - let name = e.file_name(); - let name = name.to_string_lossy(); - if name.starts_with("mqttx-v") - && name.contains(platform_triple()) - && !name.ends_with(".tmp") - && !name.ends_with(".sha256") - && !name.ends_with(".7z") - { - let _ = std::fs::remove_file(&p); - } - } - } -} - -/// 读取 `.sha256` 侧车内容,返回其中的哈希值。没有侧车时返回 None。 -fn fetch_expected_sha256(info: &UpdateInfo) -> Result, String> { - let Some(url) = &info.sha256_url else { - return Ok(None); - }; - let text = agent() - .get(url) - .call() - .map_err(|e| format!("获取校验文件失败: {e}"))? - .into_string() - .map_err(|e| format!("读取校验文件失败: {e}"))?; - // 常见格式: 或纯 hex - let hex = text - .split_whitespace() - .next() - .unwrap_or_default() - .to_lowercase(); - if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) { - Ok(Some(hex)) - } else { - Err("校验文件格式异常".into()) - } -} - -/// 从暂存文件名解析版本号(需匹配当前平台的命名约定;`.tmp` 不算)。 -/// 预发布后缀原样保留。 -pub fn parse_staged_name(name: &str) -> Option { - let ext = if cfg!(windows) { ".exe" } else { "" }; - let suffix = format!("-{}-mqttx{ext}", platform_triple()); - if name.contains(".tmp") { - return None; - } - name.strip_prefix("mqttx-v")? - .strip_suffix(&suffix) - .map(|s| s.to_string()) -} - -/// 扫描暂存区,返回待安装的更新(文件存在且比当前版本新才算)。 -/// 多个版本并存时取最新版本(readdir 顺序不保证有序)。 -pub fn load_staged() -> Option { - let dir = stage_dir().ok()?; - let mut best: Option = None; - for e in std::fs::read_dir(&dir).ok()?.flatten() { - if !e.path().is_file() { - continue; - } - let name = e.file_name(); - let name = name.to_string_lossy().to_string(); - let Some(version) = parse_staged_name(&name) else { - continue; - }; - if !is_newer(&version, current_version()) { - continue; - } - if best.as_ref().is_none_or(|b| is_newer(&version, &b.version)) { - best = Some(StagedUpdate { - version, - path: e.path(), - }); - } - } - best -} - -/// 安装暂存的更新:替换自身并拉起新进程(旧文件改名 `.old`, -/// 新进程启动时由 [`cleanup_old`] 清理)。成功后调用方应退出当前进程。 -pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> { - let cur = std::env::current_exe().map_err(|e| format!("无法定位自身: {e}"))?; - let mut old_name = cur.clone().into_os_string(); - old_name.push(".old"); - let old = PathBuf::from(old_name); - // 旧残留先清掉,避免改名失败 - let _ = std::fs::remove_file(&old); - std::fs::rename(&cur, &old).map_err(|e| format!("替换失败(可能无写入权限): {e}"))?; - if let Err(e) = std::fs::rename(&staged.path, &cur) { - // 回滚,尽量保住当前可执行文件 - let rollback = std::fs::rename(&old, &cur); - return Err(format!( - "替换失败: {e};{}", - rollback_result(rollback, &old) - )); - } - if let Err(e) = std::process::Command::new(&cur).spawn() { - // 新文件无法启动(常见:杀毒软件锁定刚落地的文件):恢复旧版本 - let _ = std::fs::remove_file(&cur); - let rollback = std::fs::rename(&old, &cur); - return Err(format!( - "启动新版本失败: {e};{}", - rollback_result(rollback, &old) - )); - } - clear_install_consent(); - Ok(()) -} - -/// 描述回滚结果;回滚失败时提示 `.old` 备份仍保留在原处。 -fn rollback_result(rollback: std::io::Result<()>, old: &std::path::Path) -> String { - match rollback { - Ok(()) => "已恢复原版本".to_string(), - Err(re) => format!( - "回滚失败({re}),原版本备份保留在 {}", - old.display() - ), - } -} - -/// 安装同意标记:仅当用户在更新对话框明确选择「下次启动安装」后才写入, -/// 启动时据此自动安装——用户未选择的更新不会被强制替换。 -fn consent_path() -> Result { - stage_dir().map(|d| d.join("install-consent.txt")) -} - -/// 记录对指定版本的安装同意。 -pub fn mark_install_consent(version: &str) { - if let Ok(p) = consent_path() { - let _ = std::fs::write(p, version); - } -} - -/// 清除安装同意标记。 -pub fn clear_install_consent() { - if let Ok(p) = consent_path() { - let _ = std::fs::remove_file(p); - } -} - -/// 同意标记是否与暂存的版本匹配(版本不匹配视为未同意)。 -pub fn install_consent_matches(staged_version: &str) -> bool { - consent_path() - .ok() - .and_then(|p| std::fs::read_to_string(p).ok()) - .map(|v| v.trim() == staged_version) - .unwrap_or(false) -} - -/// 用户拒绝本次更新:删除暂存包与同意标记。 -pub fn discard_staged(staged: &StagedUpdate) { - let _ = std::fs::remove_file(&staged.path); - clear_install_consent(); -} - -/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。 -pub fn cleanup_old() { - if let Ok(cur) = std::env::current_exe() - && let Some(dir) = cur.parent() - && let Ok(entries) = std::fs::read_dir(dir) - { - for e in entries.flatten() { - // Windows 文件系统不区分大小写,统一按小写匹配 - let name = e.file_name(); - let lower = name.to_string_lossy().to_lowercase(); - if (lower.ends_with(".old") && lower.starts_with("mqttx")) - || lower.ends_with(".update.tmp") - { - let _ = std::fs::remove_file(e.path()); - } - } - } - // 暂存目录里中断的 .tmp。跳过最近修改的文件,避免误删 - // 并行实例正在写入的临时文件。 - if let Ok(dir) = stage_dir() - && let Ok(entries) = std::fs::read_dir(&dir) - { - for e in entries.flatten() { - let name = e.file_name(); - if !name.to_string_lossy().ends_with(".tmp") { - continue; - } - let recent = std::fs::metadata(e.path()) - .and_then(|m| m.modified()) - .ok() - .and_then(|t| t.elapsed().ok()) - .is_some_and(|age| age < std::time::Duration::from_secs(10 * 60)); - if recent { - continue; - } - let _ = std::fs::remove_file(e.path()); - } - } -} - -// ─── 单元测试 ──────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn version_compare() { - assert!(is_newer("1.0.1", "1.0.0")); - assert!(is_newer("v1.1.0", "1.0.9")); - assert!(is_newer("2.0", "1.9.9")); - // 预发布版本不比同数字段的正式版新(semver 语义) - assert!(!is_newer("1.0.0-beta", "1.0.0")); - assert!(!is_newer("1.0.0-beta.2", "1.0.0")); - assert!(!is_newer("1.0.0", "1.0.0")); - assert!(!is_newer("0.9", "1.0.0")); - // 更高版本号的预发布仍是更新(1.0.1-beta > 1.0.0) - assert!(is_newer("1.0.1-beta", "1.0.0")); - assert_eq!(parse_version("v1.2.3-beta"), vec![1, 2, 3]); - assert_eq!(parse_version("v1.2.3"), vec![1, 2, 3]); - } - - #[cfg(windows)] - #[test] - fn staged_name_parsing_keeps_prerelease_suffix() { - let triple = platform_triple(); - assert_eq!( - parse_staged_name(&format!("mqttx-v1.0.3-{triple}-mqttx.exe")), - Some("1.0.3".to_string()) - ); - assert_eq!( - parse_staged_name(&format!("mqttx-v1.0.3-beta.1-{triple}-mqttx.exe")), - Some("1.0.3-beta.1".to_string()) - ); - assert_eq!( - parse_staged_name(&format!("mqttx-v1.0.3-{triple}-mqttx.exe.1234.56.tmp")), - None - ); - assert_eq!(parse_staged_name("其他文件.exe"), None); - } - - #[test] - fn asset_naming_matches_ci_convention() { - let ext = if cfg!(windows) { ".exe" } else { "" }; - // 附件为 7z 压缩包;解压出的二进制保留原命名 - assert_eq!( - asset_name_for("1.0.1"), - format!("mqttx-v1.0.1-{triple}-mqttx{ext}.7z", triple = platform_triple()) - ); - assert_eq!( - binary_name_for("1.0.1"), - format!("mqttx-v1.0.1-{triple}-mqttx{ext}", triple = platform_triple()) - ); - } - - #[test] - fn pick_assets_prefers_7z_over_raw_binary() { - let rel: serde_json::Value = serde_json::json!({ - "tag_name": "v1.0.1", - "assets": [ - {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe", "size": 21000000, - "browser_download_url": "https://x/win.exe"}, - {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65, - "browser_download_url": "https://x/win.exe.sha256"}, - {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.7z", "size": 9000000, - "browser_download_url": "https://x/win.7z"}, - {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.7z.sha256", "size": 65, - "browser_download_url": "https://x/win.7z.sha256"} - ] - }); - let (name, url, size, sha) = pick_assets(&rel).expect("应选中 7z"); - assert!(name.ends_with(".7z"), "应优先 7z 压缩包,实际 {name}"); - assert_eq!(url, "https://x/win.7z"); - assert_eq!(size, 9000000); - assert_eq!(sha.as_deref(), Some("https://x/win.7z.sha256")); - } - - #[test] - fn pick_assets_falls_back_to_raw_binary() { - // 旧发布方式只有裸二进制:仍可被选中 - let rel: serde_json::Value = serde_json::json!({ - "tag_name": "v1.0.1", - "assets": [ - {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe", "size": 19132928, - "browser_download_url": "https://x/win"}, - {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65, - "browser_download_url": "https://x/win.sha256"} - ] - }); - let (name, url, _size, sha) = pick_assets(&rel).expect("应回退裸二进制"); - assert!(name.ends_with(".exe")); - assert_eq!(url, "https://x/win"); - assert_eq!(sha.as_deref(), Some("https://x/win.sha256")); - } - - #[test] - fn pick_assets_skips_sidecars_and_other_platforms() { - let rel: serde_json::Value = serde_json::json!({ - "tag_name": "v1.0.1", - "assets": [ - {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65, - "browser_download_url": "https://x/side"}, - ] - }); - if cfg!(windows) { - // 只有侧车、没有主体 → 不应选中侧车 - assert!(pick_assets(&rel).is_none()); - } - } -} diff --git a/gpui-app/src/update/check.rs b/gpui-app/src/update/check.rs new file mode 100644 index 0000000..5b627ce --- /dev/null +++ b/gpui-app/src/update/check.rs @@ -0,0 +1,121 @@ +//! 更新检查:访问 Gitea Release API,比较版本并挑选本平台资产。 + +use super::*; + +pub(super) fn agent() -> ureq::Agent { + ureq::AgentBuilder::new() + // 整体上限:防止慢速滴流的下载无限占用阻塞线程 + .timeout(std::time::Duration::from_secs(10 * 60)) + .timeout_connect(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) + .timeout_read(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) + .build() +} + +pub(super) fn http_get_json(url: &str) -> Result { + let resp = agent() + .get(url) + .call() + .map_err(|e| format!("请求失败: {e}"))?; + resp.into_json::() + .map_err(|e| format!("解析响应失败: {e}")) +} + + +pub fn pick_assets(rel: &serde_json::Value) -> Option<(String, String, u64, Option)> { + let assets = rel.get("assets")?.as_array()?; + let want_ext = if cfg!(windows) { ".exe" } else { "" }; + let triple = platform_triple(); + + // (后缀过滤器, 说明):第一轮 7z,第二轮裸二进制 + for suffix in [".7z", want_ext] { + for a in assets { + let Some(name) = a.get("name").and_then(|n| n.as_str()) else { + continue; + }; + if !name.contains(triple) + || !name.ends_with(suffix) + || name.ends_with(".sha256") + { + continue; + } + // 7z 轮要排除裸二进制(windows 裸 exe 不以 .7z 结尾,天然互斥) + if suffix == ".7z" && !name.ends_with(&format!("{want_ext}.7z")) && !want_ext.is_empty() + { + continue; + } + let Some(url) = a.get("browser_download_url").and_then(|u| u.as_str()) else { + continue; + }; + let url = url.to_string(); + let size = a.get("size").and_then(|s| s.as_u64()).unwrap_or(0); + // 侧车命名:.sha256 + let sha = assets + .iter() + .find(|b| { + b.get("name").and_then(|n| n.as_str()) == Some(&format!("{name}.sha256")) + }) + .and_then(|b| b.get("browser_download_url")) + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + return Some((name.to_string(), url, size, sha)); + } + } + None +} + +/// 检查更新。返回 Ok(None) 表示已是最新。 +pub fn check_latest() -> Result, String> { + let base = format!("{GITEA_URL}/api/v1/repos/{PKG_OWNER}/{PKG_REPO}/releases"); + // 优先 latest 端点;旧版 Gitea 没有时回退到列表取第一个非草稿/预发布 + let rel = match http_get_json(&format!("{base}/latest")) { + Ok(v) => v, + Err(_) => { + let list = http_get_json(&format!("{base}?limit=5"))?; + let first = list + .as_array() + .and_then(|l| l.iter().find(|r| { + r.get("draft").and_then(|d| d.as_bool()) == Some(false) + && r.get("prerelease").and_then(|d| d.as_bool()) == Some(false) + })) + .ok_or_else(|| "没有可用 Release".to_string())?; + first.clone() + } + }; + + let tag = rel + .get("tag_name") + .and_then(|t| t.as_str()) + .ok_or_else(|| "Release 缺少 tag_name".to_string())?; + if !is_newer(tag, current_version()) { + return Ok(None); + } + let Some((name, url, size, sha)) = pick_assets(&rel) else { + return Err(format!("Release {tag} 没有当前平台({})的产物", platform_triple())); + }; + Ok(Some(UpdateInfo { + version: tag.trim_start_matches('v').to_string(), + notes: rel + .get("body") + .and_then(|b| b.as_str()) + .unwrap_or_default() + .to_string(), + asset_name: name, + asset_url: url, + sha256_url: sha, + size, + })) +} + +/// 返回 Ok(None) 表示已是最新;错误仅记录、不打扰用户。 +pub fn check_and_download() -> Result, String> { + let Some(info) = check_latest()? else { + return Ok(None); + }; + // 已暂存同版本则不重复下载 + if let Some(staged) = load_staged() + && staged.version == info.version + { + return Ok(Some(staged)); + } + download_and_stage(&info, &|_, _| {}).map(Some) +} diff --git a/gpui-app/src/update/install.rs b/gpui-app/src/update/install.rs new file mode 100644 index 0000000..4066d16 --- /dev/null +++ b/gpui-app/src/update/install.rs @@ -0,0 +1,319 @@ +//! 更新执行:下载暂存(7z 优先)→ sha256 校验 → 重启自替换安装,以及同意标记与旧档清理。 + +use std::io::Read; +use std::path::PathBuf; + + +use super::check::agent; +use super::*; + +/// 暂存目录:数据目录下的 `updates/`,重启不丢失。 +fn stage_dir() -> Result { + let dir = dirs::data_dir() + .unwrap_or_else(std::env::temp_dir) + .join("MQTTX-GPUI") + .join("updates"); + std::fs::create_dir_all(&dir).map_err(|e| format!("创建暂存目录失败: {e}"))?; + Ok(dir) +} + + +/// 下载并校验到暂存区,返回待安装的更新。 +/// +/// 附件为 7z 压缩包:下载 → 校验 → 解压出二进制暂存(压缩包随即删除)。 +/// 回退路径(裸二进制附件)直接校验暂存。 +/// `on_progress(downloaded, total)` 在阻塞线程上回调,total 为 0 表示未知。 +pub fn download_and_stage( + info: &UpdateInfo, + on_progress: &dyn Fn(u64, u64), +) -> Result { + let dir = stage_dir()?; + let binary_name = binary_name_for(&info.version); + let is_7z = info.asset_name.ends_with(".7z"); + let final_name = if is_7z { + asset_name_for(&info.version) + } else { + binary_name.clone() + }; + // tmp 名加入毫秒时间戳:同 pid 并发下载不共用文件 + let tmp = dir.join(format!( + "{}.{}.{}.tmp", + final_name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + )); + let dst = dir.join(&final_name); + + if let Err(e) = download_to(info, &tmp, on_progress) { + // 失败路径统一清理临时文件 + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + + // 清掉其他版本的残留暂存文件,避免堆积与误装旧版 + let staged_path = if is_7z { + // 解压到暂存目录,取出二进制后删掉压缩包 + if let Err(e) = sevenz_rust::decompress_file(&tmp, &dir) { + let _ = std::fs::remove_file(&tmp); + return Err(format!("解压更新包失败: {e}")); + } + let _ = std::fs::remove_file(&tmp); + let extracted = dir.join(&binary_name); + if !extracted.exists() { + return Err(format!( + "更新包内缺少 {},压缩包内容异常", + binary_name + )); + } + // 确认解压产物后再清理其他版本,最后返回二进制路径 + remove_other_staged(&dir, &extracted); + extracted + } else { + remove_other_staged(&dir, &dst); + std::fs::rename(&tmp, &dst).map_err(|e| format!("暂存失败: {e}"))?; + dst + }; + Ok(StagedUpdate { + version: info.version.clone(), + path: staged_path, + }) +} + +fn download_to(info: &UpdateInfo, tmp: &std::path::Path, on_progress: &dyn Fn(u64, u64)) -> Result<(), String> { + let resp = agent() + .get(&info.asset_url) + .call() + .map_err(|e| format!("下载失败: {e}"))?; + let mut reader = resp.into_reader(); + let total = info.size; + let mut file = std::fs::File::create(tmp).map_err(|e| format!("创建临时文件失败: {e}"))?; + let mut buf = [0u8; 64 * 1024]; + let mut downloaded: u64 = 0; + let mut hasher = sha2::Sha256::new(); + loop { + let n = reader.read(&mut buf).map_err(|e| format!("下载中断: {e}"))?; + if n == 0 { + break; + } + std::io::Write::write_all(&mut file, &buf[..n]) + .map_err(|e| format!("写入临时文件失败: {e}"))?; + hasher.update(&buf[..n]); + downloaded += n as u64; + on_progress(downloaded, total); + } + std::io::Write::flush(&mut file).ok(); + drop(file); + + // 传输完整性:API 提供了大小却没下满/超量,视为损坏(read 返回 0 + // 无法区分干净结束与连接提前断开,必须与预期大小对账) + if total > 0 && downloaded != total { + return Err(format!( + "下载不完整:期望 {total} 字节,实际 {downloaded} 字节" + )); + } + + // sha256 校验 + if let Some(expect) = fetch_expected_sha256(info)? { + let actual = format!("{:x}", hasher.finalize()); + if !actual.eq_ignore_ascii_case(&expect) { + return Err(format!( + "校验失败:期望 sha256 {expect},实际 {actual}" + )); + } + } + Ok(()) +} + +/// 删除暂存目录中除 `keep` 外的其他版本暂存文件。 +fn remove_other_staged(dir: &std::path::Path, keep: &std::path::Path) { + if let Ok(entries) = std::fs::read_dir(dir) { + for e in entries.flatten() { + let p = e.path(); + if p == *keep { + continue; + } + let name = e.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("mqttx-v") + && name.contains(platform_triple()) + && !name.ends_with(".tmp") + && !name.ends_with(".sha256") + && !name.ends_with(".7z") + { + let _ = std::fs::remove_file(&p); + } + } + } +} + +/// 读取 `.sha256` 侧车内容,返回其中的哈希值。没有侧车时返回 None。 +fn fetch_expected_sha256(info: &UpdateInfo) -> Result, String> { + let Some(url) = &info.sha256_url else { + return Ok(None); + }; + let text = agent() + .get(url) + .call() + .map_err(|e| format!("获取校验文件失败: {e}"))? + .into_string() + .map_err(|e| format!("读取校验文件失败: {e}"))?; + // 常见格式: 或纯 hex + let hex = text + .split_whitespace() + .next() + .unwrap_or_default() + .to_lowercase(); + if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) { + Ok(Some(hex)) + } else { + Err("校验文件格式异常".into()) + } +} + +/// 多个版本并存时取最新版本(readdir 顺序不保证有序)。 +pub fn load_staged() -> Option { + let dir = stage_dir().ok()?; + let mut best: Option = None; + for e in std::fs::read_dir(&dir).ok()?.flatten() { + if !e.path().is_file() { + continue; + } + let name = e.file_name(); + let name = name.to_string_lossy().to_string(); + let Some(version) = parse_staged_name(&name) else { + continue; + }; + if !is_newer(&version, current_version()) { + continue; + } + if best.as_ref().is_none_or(|b| is_newer(&version, &b.version)) { + best = Some(StagedUpdate { + version, + path: e.path(), + }); + } + } + best +} + +/// 安装暂存的更新:替换自身并拉起新进程(旧文件改名 `.old`, +/// 新进程启动时由 [`cleanup_old`] 清理)。成功后调用方应退出当前进程。 +pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> { + let cur = std::env::current_exe().map_err(|e| format!("无法定位自身: {e}"))?; + let mut old_name = cur.clone().into_os_string(); + old_name.push(".old"); + let old = PathBuf::from(old_name); + // 旧残留先清掉,避免改名失败 + let _ = std::fs::remove_file(&old); + std::fs::rename(&cur, &old).map_err(|e| format!("替换失败(可能无写入权限): {e}"))?; + if let Err(e) = std::fs::rename(&staged.path, &cur) { + // 回滚,尽量保住当前可执行文件 + let rollback = std::fs::rename(&old, &cur); + return Err(format!( + "替换失败: {e};{}", + rollback_result(rollback, &old) + )); + } + if let Err(e) = std::process::Command::new(&cur).spawn() { + // 新文件无法启动(常见:杀毒软件锁定刚落地的文件):恢复旧版本 + let _ = std::fs::remove_file(&cur); + let rollback = std::fs::rename(&old, &cur); + return Err(format!( + "启动新版本失败: {e};{}", + rollback_result(rollback, &old) + )); + } + clear_install_consent(); + Ok(()) +} + +/// 描述回滚结果;回滚失败时提示 `.old` 备份仍保留在原处。 +fn rollback_result(rollback: std::io::Result<()>, old: &std::path::Path) -> String { + match rollback { + Ok(()) => "已恢复原版本".to_string(), + Err(re) => format!( + "回滚失败({re}),原版本备份保留在 {}", + old.display() + ), + } +} + +/// 安装同意标记:仅当用户在更新对话框明确选择「下次启动安装」后才写入, +/// 启动时据此自动安装——用户未选择的更新不会被强制替换。 +fn consent_path() -> Result { + stage_dir().map(|d| d.join("install-consent.txt")) +} + +/// 记录对指定版本的安装同意。 +pub fn mark_install_consent(version: &str) { + if let Ok(p) = consent_path() { + let _ = std::fs::write(p, version); + } +} + +/// 清除安装同意标记。 +pub fn clear_install_consent() { + if let Ok(p) = consent_path() { + let _ = std::fs::remove_file(p); + } +} + +/// 同意标记是否与暂存的版本匹配(版本不匹配视为未同意)。 +pub fn install_consent_matches(staged_version: &str) -> bool { + consent_path() + .ok() + .and_then(|p| std::fs::read_to_string(p).ok()) + .map(|v| v.trim() == staged_version) + .unwrap_or(false) +} + +/// 用户拒绝本次更新:删除暂存包与同意标记。 +pub fn discard_staged(staged: &StagedUpdate) { + let _ = std::fs::remove_file(&staged.path); + clear_install_consent(); +} + +/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。 +pub fn cleanup_old() { + if let Ok(cur) = std::env::current_exe() + && let Some(dir) = cur.parent() + && let Ok(entries) = std::fs::read_dir(dir) + { + for e in entries.flatten() { + // Windows 文件系统不区分大小写,统一按小写匹配 + let name = e.file_name(); + let lower = name.to_string_lossy().to_lowercase(); + if (lower.ends_with(".old") && lower.starts_with("mqttx")) + || lower.ends_with(".update.tmp") + { + let _ = std::fs::remove_file(e.path()); + } + } + } + // 暂存目录里中断的 .tmp。跳过最近修改的文件,避免误删 + // 并行实例正在写入的临时文件。 + if let Ok(dir) = stage_dir() + && let Ok(entries) = std::fs::read_dir(&dir) + { + for e in entries.flatten() { + let name = e.file_name(); + if !name.to_string_lossy().ends_with(".tmp") { + continue; + } + let recent = std::fs::metadata(e.path()) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .is_some_and(|age| age < std::time::Duration::from_secs(10 * 60)); + if recent { + continue; + } + let _ = std::fs::remove_file(e.path()); + } + } +} + +// ─── 单元测试 ──────────────────────────────────────────────────────────────── diff --git a/gpui-app/src/update/mod.rs b/gpui-app/src/update/mod.rs new file mode 100644 index 0000000..45a27c9 --- /dev/null +++ b/gpui-app/src/update/mod.rs @@ -0,0 +1,238 @@ +//! OTA 自更新:以 Gitea Release 为更新源。 +//! +//! - [`check`] 检查最新版本与资产挑选 +//! - [`install`] 下载暂存、校验安装、同意标记与清理 +//! +//! 本模块只暴露版本比较/资产命名等纯工具与二级模块的公开 API。 + +use std::path::PathBuf; + +use sha2::Digest as _; + +mod check; +mod install; + +pub use check::{check_and_download, check_latest, pick_assets}; +pub use install::{ + cleanup_old, clear_install_consent, discard_staged, download_and_stage, + install_consent_matches, install_staged, load_staged, mark_install_consent, +}; + +/// 更新源(自建 Gitea,仓库公开、附件可直接下载,无需认证)。 +pub const GITEA_URL: &str = "https://gitea.heavenlybook.cn"; +pub const PKG_OWNER: &str = "JoeAllen"; +pub const PKG_REPO: &str = "mqttx"; + +const HTTP_TIMEOUT_SECS: u64 = 30; + +/// 发现的可用更新。 +#[derive(Debug, Clone)] +pub struct UpdateInfo { + /// 新版本号(不含 v 前缀),如 "1.0.1" + pub version: String, + /// Release 说明(Markdown 原文) + pub notes: String, + /// 附件名(判断 7z 压缩包 / 裸二进制) + pub asset_name: String, + /// 平台对应产物下载地址 + pub asset_url: String, + /// sha256 校验文件地址(若 Release 提供了 `.sha256` 侧车) + pub sha256_url: Option, + /// 产物字节数(API 提供,用于下载进度) + pub size: u64, +} + +/// 当前版本。测试可用环境变量 `MQTTX_VERSION_OVERRIDE` 伪装旧版本, +/// 验证"发现新版本"路径。 +pub fn current_version() -> &'static str { + static VERSION: std::sync::OnceLock = std::sync::OnceLock::new(); + VERSION.get_or_init(|| { + std::env::var("MQTTX_VERSION_OVERRIDE") + .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string()) + }) +} + +/// 构建目标的平台三元组,需与 release.yml / publish 脚本的产物命名一致。 +pub fn platform_triple() -> &'static str { + if cfg!(windows) { + "x86_64-pc-windows-msvc" + } else if cfg!(target_os = "macos") { + "x86_64-apple-darwin" + } else { + "x86_64-unknown-linux-gnu" + } +} + +/// 某版本解压后二进制的暂存名(与 CI 的命名约定 `mqttx-v--mqttx[.exe]` 一致)。 +pub fn binary_name_for(version: &str) -> String { + let ext = if cfg!(windows) { ".exe" } else { "" }; + format!("mqttx-v{}-{}-mqttx{}", version, platform_triple(), ext) +} + +/// 某版本的 7z 压缩附件名(打包端与此约定一致;内部为 [`binary_name_for`])。 +pub fn asset_name_for(version: &str) -> String { + format!("{}.7z", binary_name_for(version)) +} + +/// 去掉 v 前缀与预发布后缀(`-` 及之后),返回数字主体。 +fn release_part(v: &str) -> &str { + let v = v.trim().trim_start_matches(['v', 'V']); + match v.find('-') { + Some(i) => &v[..i], + None => v, + } +} + +/// 从版本字符串提取数字段(容忍 v 前缀;预发布后缀(`-` 之后)不参与比较)。 +pub fn parse_version(v: &str) -> Vec { + release_part(v) + .split('.') + .filter_map(|s| s.parse::().ok()) + .collect() +} + +/// a 是否比 b 新(按数字段逐位比较,缺失段视为 0)。 +/// 预发布版本(含 `-` 后缀)不会比同数字段的正式版新(semver 语义)。 +pub fn is_newer(a: &str, b: &str) -> bool { + let (a, b) = (parse_version(a), parse_version(b)); + let len = a.len().max(b.len()); + for i in 0..len { + let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0)); + if x != y { + return x > y; + } + } + false +} + +/// 从 Release JSON 里挑选当前平台的产物与 sha256 侧车。 +/// 优先 7z 压缩包(体积小、下载快);Release 未提供 7z 时回退裸二进制 +/// (兼容旧发布方式)。 + +#[derive(Debug, Clone)] +pub struct StagedUpdate { + pub version: String, + pub path: PathBuf, +} + + +/// 预发布后缀原样保留。 +pub fn parse_staged_name(name: &str) -> Option { + let ext = if cfg!(windows) { ".exe" } else { "" }; + let suffix = format!("-{}-mqttx{ext}", platform_triple()); + if name.contains(".tmp") { + return None; + } + name.strip_prefix("mqttx-v")? + .strip_suffix(&suffix) + .map(|s| s.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_compare() { + assert!(is_newer("1.0.1", "1.0.0")); + assert!(is_newer("v1.1.0", "1.0.9")); + assert!(is_newer("2.0", "1.9.9")); + // 预发布版本不比同数字段的正式版新(semver 语义) + assert!(!is_newer("1.0.0-beta", "1.0.0")); + assert!(!is_newer("1.0.0-beta.2", "1.0.0")); + assert!(!is_newer("1.0.0", "1.0.0")); + assert!(!is_newer("0.9", "1.0.0")); + // 更高版本号的预发布仍是更新(1.0.1-beta > 1.0.0) + assert!(is_newer("1.0.1-beta", "1.0.0")); + assert_eq!(parse_version("v1.2.3-beta"), vec![1, 2, 3]); + assert_eq!(parse_version("v1.2.3"), vec![1, 2, 3]); + } + + #[cfg(windows)] + #[test] + fn staged_name_parsing_keeps_prerelease_suffix() { + let triple = platform_triple(); + assert_eq!( + parse_staged_name(&format!("mqttx-v1.0.3-{triple}-mqttx.exe")), + Some("1.0.3".to_string()) + ); + assert_eq!( + parse_staged_name(&format!("mqttx-v1.0.3-beta.1-{triple}-mqttx.exe")), + Some("1.0.3-beta.1".to_string()) + ); + assert_eq!( + parse_staged_name(&format!("mqttx-v1.0.3-{triple}-mqttx.exe.1234.56.tmp")), + None + ); + assert_eq!(parse_staged_name("其他文件.exe"), None); + } + + #[test] + fn asset_naming_matches_ci_convention() { + let ext = if cfg!(windows) { ".exe" } else { "" }; + // 附件为 7z 压缩包;解压出的二进制保留原命名 + assert_eq!( + asset_name_for("1.0.1"), + format!("mqttx-v1.0.1-{triple}-mqttx{ext}.7z", triple = platform_triple()) + ); + assert_eq!( + binary_name_for("1.0.1"), + format!("mqttx-v1.0.1-{triple}-mqttx{ext}", triple = platform_triple()) + ); + } + + #[test] + fn pick_assets_prefers_7z_over_raw_binary() { + let rel: serde_json::Value = serde_json::json!({ + "tag_name": "v1.0.1", + "assets": [ + {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe", "size": 21000000, + "browser_download_url": "https://x/win.exe"}, + {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65, + "browser_download_url": "https://x/win.exe.sha256"}, + {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.7z", "size": 9000000, + "browser_download_url": "https://x/win.7z"}, + {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.7z.sha256", "size": 65, + "browser_download_url": "https://x/win.7z.sha256"} + ] + }); + let (name, url, size, sha) = pick_assets(&rel).expect("应选中 7z"); + assert!(name.ends_with(".7z"), "应优先 7z 压缩包,实际 {name}"); + assert_eq!(url, "https://x/win.7z"); + assert_eq!(size, 9000000); + assert_eq!(sha.as_deref(), Some("https://x/win.7z.sha256")); + } + + #[test] + fn pick_assets_falls_back_to_raw_binary() { + // 旧发布方式只有裸二进制:仍可被选中 + let rel: serde_json::Value = serde_json::json!({ + "tag_name": "v1.0.1", + "assets": [ + {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe", "size": 19132928, + "browser_download_url": "https://x/win"}, + {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65, + "browser_download_url": "https://x/win.sha256"} + ] + }); + let (name, url, _size, sha) = pick_assets(&rel).expect("应回退裸二进制"); + assert!(name.ends_with(".exe")); + assert_eq!(url, "https://x/win"); + assert_eq!(sha.as_deref(), Some("https://x/win.sha256")); + } + + #[test] + fn pick_assets_skips_sidecars_and_other_platforms() { + let rel: serde_json::Value = serde_json::json!({ + "tag_name": "v1.0.1", + "assets": [ + {"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65, + "browser_download_url": "https://x/side"}, + ] + }); + if cfg!(windows) { + // 只有侧车、没有主体 → 不应选中侧车 + assert!(pick_assets(&rel).is_none()); + } + } +}