feat: 重写为 GPUI 原生桌面应用并接入 Gitea 软件包发布

- gpui-kit 0.6 原生 UI:连接管理、消息流/日志、发布预设、全局变量、
  设置、阿里云连接预设、原生 Logo
- MQTT 5.0/3.1.1 引擎(rumqttc,TCP/TLS/WS/WSS),独立 tokio runtime
  + 事件泵,真实 broker 端到端测试
- 连接表单:保存前可一键测试连接(宽松校验,自动生成临时 Client ID),
  高级属性(认证/MQTT 5 属性/遗嘱)按需折叠显示,WS 路径仅 WS/WSS 显示
- bug 修复:Hex 奇数长度显式报错、测试连接唯一 Client ID、Base64/Hex
  发布发送真实字节、v5 发布记录携带 Content-Type/用户属性、预设应用
  清除旧 Content-Type、模板时间基准统一;补 8 个单元测试
- Gitea 发布:Generic Package 上传脚本(bash/PowerShell)+ Actions
  release 工作流(tag 触发,双平台构建),README 使用说明
This commit is contained in:
2026-09-28 11:39:47 +08:00
parent 0152a10ee6
commit bf6937b776
25 changed files with 15287 additions and 120 deletions
+10
View File
@@ -0,0 +1,10 @@
# 本工程专属 cargo 配置:index.crates.io 在当前网络不可达,使用 rsproxy 镜像。
# 仅影响本仓库,不修改全局 ~/.cargo 配置。
[source.crates-io]
replace-with = 'rsproxy-sparse'
[source.rsproxy-sparse]
registry = "sparse+https://rsproxy.cn/index/"
[net]
retry = 5
+130
View File
@@ -0,0 +1,130 @@
# Gitea Actions:打 tag 时构建 release 产物并发布到 Gitea Package Registry。
#
# 依赖的仓库 Secrets/Variables(Settings → Actions → Secrets):
# GITEA_TOKEN — 包写权限令牌(package scope),用于上传 Generic 包
# 可选 GITEA_OWNER — 软件包所属用户/组织,缺省用仓库 owner
#
# 说明:Gitea Actions 语法与 GitHub Actions 兼容;若 runner 使用自定义
# label(如 `ubuntu`),请把 runs-on 改成你的 runner labels。
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: read
jobs:
# ─── 版本与元信息 ─────────────────────────────────────────────
meta:
name: Meta
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
owner: ${{ steps.ver.outputs.owner }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: ver
run: |
version="${GITHUB_REF_NAME}"
echo "version=${version}" >> "$GITHUB_OUTPUT"
owner="${GITEA_OWNER_OVERRIDE:-${GITHUB_REPOSITORY_OWNER}}"
echo "owner=${owner}" >> "$GITHUB_OUTPUT"
# ─── 测试(不依赖系统图形库) ─────────────────────────────────
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Unit tests
run: cargo test -p mqttx-desktop --lib
# ─── 构建矩阵 ─────────────────────────────────────────────────
build:
name: Build (${{ matrix.os }})
needs: [meta, test]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
asset: mqttx.exe
target: x86_64-pc-windows-msvc
- os: ubuntu-latest
asset: mqttx
target: x86_64-unknown-linux-gnu
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: ${{ matrix.target }}
- name: Install Linux system deps (GPUI)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev \
libwayland-dev libasound2-dev libfontconfig-dev \
libx11-dev libxcursor-dev libxrandr-dev libxi-dev
- name: Build
run: cargo build --release -p mqttx-desktop --target ${{ matrix.target }}
- name: Stage artifact
shell: bash
run: |
out="dist-${{ matrix.target }}"
mkdir -p "$out"
src="target/${{ matrix.target }}/release/${{ matrix.asset }}"
ver="${{ needs.meta.outputs.version }}"
dst="$out/mqttx-${ver}-${{ matrix.target }}-${{ matrix.asset }}"
cp "$src" "$dst"
if command -v sha256sum >/dev/null; then
sha256sum "$dst" > "$dst.sha256"
else
shasum -a 256 "$dst" > "$dst.sha256"
fi
echo "ASSET_DIR=$out" >> "$GITHUB_ENV"
- uses: actions/upload-artifact@v4
with:
name: mqttx-${{ matrix.target }}
path: ${{ env.ASSET_DIR }}/*
# ─── 发布到 Gitea Package Registry ────────────────────────────
publish:
name: Publish package
needs: [meta, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Upload to Generic Package Registry
env:
GITEA_URL: ${{ github.server_url }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_OWNER: ${{ needs.meta.outputs.owner }}
GITEA_PKG: mqttx
run: |
chmod +x scripts/publish-gitea.sh
files=(artifacts/*)
# 过滤掉 .sha256 侧车文件,脚本会自行生成
upload=()
for f in "${files[@]}"; do
case "$f" in *.sha256) ;; *) upload+=("$f") ;; esac
done
./scripts/publish-gitea.sh "${upload[@]}" "${{ needs.meta.outputs.version }}"
+6
View File
@@ -32,3 +32,9 @@ src-tauri/target_d
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Workspace-level Rust build artifacts (GPUI app)
/target
*.pdb
/.v2c
/.video_agent
Generated
+9147
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
[workspace]
resolver = "2"
members = ["gpui-app"]
exclude = ["src-tauri"]
[workspace.package]
edition = "2024"
[profile.release]
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true
+108 -119
View File
@@ -1,153 +1,142 @@
# MQTTX — MQTT v5 桌面调试工具 # MQTTX — 基于 GPUI 的跨平台 MQTT 桌面客户端
基于 **Tauri v2** + **Vue 3** + **TypeScript** 构建的跨平台 MQTT v5 调试客户端,支持完整的 MQTT v5 特性(用户属性、内容类型、消息过期、响应主题、关联数据等),提供直观的 GUI 操作体验。 使用 **Rust + [gpui-kit](https://github.com/longbridge/gpui-kit)**(基于 [GPUI](https://github.com/zed-industries/zed))重写的 MQTT 调试客户端,对标官方 [MQTTX](https://mqttx.app)。
纯 Rust 实现:MQTT 引擎、数据持久化与原生 UI 同属一个轻量二进制,无 Electron/WebView 运行时。
> 旧的 Tauri + Vue 版本仍保留在 `src/` 与 `src-tauri/`,新实现位于 `gpui-app/`。
## 功能特性 ## 功能特性
- **MQTT v5 完整支持** — 连接、发布、订阅、取消订阅,全面支持 MQTT v5 属性 - **双协议支持** — MQTT 5.0 与 MQTT 3.1.1([`rumqttc-next`](https://crates.io/crates/rumqttc-v5-next))
- **多连接管理** — 同时管理多个 MQTT 连接,连接状态实时可见 - **多传输方式** — 明文 TCP、TLS、WebSocket(ws)、WebSocket over TLS(wss),默认 rustls(aws-lc)
- **消息流监控** — 实时查看收发消息,支持消息过滤和搜索 - **多连接管理** — 同时管理多个连接,侧边栏实时显示连接状态(未连接/连接中/已连接/错误),多标签页切换
- **主题树** — 以树形结构组织订阅的主题,方便浏览 - **完整 MQTT 5 属性** — 用户属性(可视化键值对编辑器)、Content-Type、消息过期间隔、Response Topic、Correlation Data
- **预设模板** — 保存常用的连接/发布/订阅配置为预设,快速复用 - **发布 / 订阅** — QoS 0/1/2、Retain、遗嘱消息(Last Will)、订阅自动恢复(auto resubscribe)、断线自动重连
- **全局变量** — 支持在消息负载中使用 `{{变量名}}` 模板语法 - **消息流** — 收发方向区分、时间戳、QoS/Retain 标记、点击展开查看格式化 JSON 与 v5 属性、主题/内容过滤
- **用户属性编辑器** — 可视化编辑 MQTT v5 用户属性键值对 - **全局变量** — 发布主题与负载中使用 `{{变量名}}`,内置 `{{$ts}}`、`{{$ts_ms}}`、`{{$uuid}}`
- **日志系统** — 实时日志面板 + 文件日志持久化,方便排查问题 - **负载格式** — Plaintext / JSON(发送前校验)/ Base64 / Hex
- **暗色模式** — 支持亮色/暗色主题切换,自动跟随系统 - **阿里云 IoT** — Token(GroupId@@@DeviceId + HMAC-SHA1 签名)与一机一密两种鉴权方式一键生成连接
- **自动更新** — 内置 Tauri 更新器,支持应用自动升级 - **连接测试** — 表单内 5 秒握手测试,保存前验证连通性
- **日志面板** — 连接级事件日志(CONNACK/SUBACK/PUBACK/错误/重连等)
- **主题** — 浅色 / 深色切换
- **本地持久化** — 连接、订阅、预设、变量、设置以 JSON 存于系统数据目录
## 技术栈 ## 技术栈
| 层 | 技术 | | 层 | 技术 |
| --- | --- | | --- | --- |
| 桌面框架 | [Tauri v2](https://v2.tauri.app/) | | UI | GPUI 0.3 + gpui-kit(gpui-component)0.6 |
| 前端框架 | [Vue 3](https://vuejs.org/) (Composition API + `<script setup>`) | | MQTT | rumqttc-v5-next 0.34 / rumqttc-v4-next 0.34(TCP/TLS/WS) |
| 语言 | TypeScript / Rust | | 异步 | 引擎跑在独立 tokio runtime,事件经 smol channel 泵回 UI 线程 |
| UI 组件库 | [Element Plus](https://element-plus.org/) | | 持久化 | serde + serde_json,原子写(临时文件 + rename) |
| MQTT 客户端 | [rumqttc v5-next](https://crates.io/crates/rumqttc-v5-next) | | 其他 | chrono、dirs、uuid、hmac/sha1/base64(阿里云签名) |
| 构建工具 | [Vite](https://vitejs.dev/) |
| 包管理 | pnpm |
## 快速开始 ## 目录结构
### 前置要求 ```
gpui-app/
├── Cargo.toml
├── src/
│ ├── main.rs # 入口:创建窗口、挂载 gpui-kit Root
│ ├── lib.rs # 库导出(模型/引擎/UI 可被集成测试复用)
│ ├── model.rs # 数据模型(连接/订阅/消息/预设/变量/设置/模板渲染)
│ ├── mqtt.rs # MQTT 引擎:多连接生命周期、TLS/WS、重连、事件通道
│ ├── store.rs # JSON 持久化(%APPDATA%/MQTTX-GPUI)
│ ├── aliyun.rs # 阿里云 IoT 签名与连接生成
│ └── ui/
│ ├── app.rs # 应用外壳:标题栏/侧边栏/标签页/事件泵
│ ├── connection_form.rs # 新建/编辑连接对话框
│ ├── connection_view.rs # 连接工作区:订阅、消息流、发布面板、日志
│ ├── aliyun_dialog.rs # 阿里云设备对话框
│ ├── variables_dialog.rs # 全局变量对话框
│ ├── settings_dialog.rs # 设置对话框
│ └── widgets.rs # 复用组件(枚举下拉、键值对编辑器等)
└── tests/
└── engine_live.rs # 真实 broker 端到端测试(默认 ignored)
```
- [Node.js](https://nodejs.org/) >= 18 ## 构建与运行
- [pnpm](https://pnpm.io/)
- [Rust](https://www.rust-lang.org/) (安装参考 [Tauri 环境准备](https://v2.tauri.app/start/prerequisites/))
- Windows: [Microsoft Visual Studio C++ 生成工具](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
### 安装依赖 前置:Rust nightly(GPUI 0.3 依赖,使用 `rustup default nightly`)。
```bash ```bash
pnpm install # 开发运行
cargo run -p mqttx-desktop
# 优化构建
cargo build --release -p mqttx-desktop
# 产物:target/release/mqttx(.exe)
``` ```
### 开发模式运行 > 中国大陆网络下若 `index.crates.io` 不可达,仓库已提供仅作用于本工程的
> `.cargo/config.toml`(rsproxy 镜像)。
### 端到端连通性测试
测试默认 `#[ignore]`,需要外网:
```bash ```bash
pnpm tauri dev MQTTX_LIVE=1 cargo test -p mqttx-desktop --test engine_live -- --ignored
``` ```
### 生产构建 会连接公共服务器 `broker.emqx.io:1883`,验证:
- MQTT 5.0 连接 → 订阅 → 发布 → 回环接收
- MQTT 3.1.1 连接握手
## 发布到 Gitea Package Registry
构建产物可通过 Gitea 的 Generic Package API 发布,两种方式:
### 方式一:手动发布(本地脚本)
前置:构建 release 产物 + 一个 Gitea 访问令牌(需 `package` 写权限)。
```powershell
# Windows(PowerShell)
$env:GITEA_URL = "https://gitea.example.com"
$env:GITEA_TOKEN = "gta_xxx"
.\scripts\publish-gitea.ps1 # 默认产物 + 自动推断版本/owner
.\scripts\publish-gitea.ps1 -Version v1.0.1 # 指定版本
```
```bash ```bash
pnpm tauri build # macOS / Linux(bash)
export GITEA_URL=https://gitea.example.com
export GITEA_TOKEN=gta_xxx
./scripts/publish-gitea.sh # 默认产物
./scripts/publish-gitea.sh target/release/mqttx v1.0.1
``` ```
构建产物将输出到 `src-tauri/target/release/bundle/` 目录。 - **owner** 自动从 `git remote origin` 推断(可用 `GITEA_OWNER` / `-Owner` 覆盖)
- **版本** 依次取参数 → `git describe --tags` → `Cargo.toml`
- 每个文件附带 `.sha256` 校验侧车;同名版本重复上传按 409 跳过(Gitea 不允许覆盖)
- 软件包页面:`{GITEA_URL}/{owner}?tab=packages`
## 自动更新配置 ### 方式二:CI 自动发布(Gitea Actions)
MQTTX 内置了 Tauri 更新器插件。要启用自动更新,需要完成以下步骤: 推送 `v*` tag 即触发 [.gitea/workflows/release.yml](.gitea/workflows/release.yml):
### 1. 生成签名密钥 1. 单元测试
2. Windows / Linux 双平台 release 构建(产物重命名为 `mqttx-<ver>-<target>-<ext>`)
3. 调用 `scripts/publish-gitea.sh` 上传到包注册表
```bash 需要在仓库 **Settings → Actions → Secrets** 配置 `GITEA_TOKEN`;
pnpm tauri signer generate -w ~/.tauri/mqttx.key 若 runner 使用自定义 label,请相应修改工作流的 `runs-on`。
```
### 2. 配置公钥 ## 数据目录
将生成的公钥粘贴到 `src-tauri/tauri.conf.json` 中的 `plugins.updater.pubkey` 字段。 | 平台 | 路径 |
### 3. 部署更新服务器
更新端点默认配置为 `https://releases.mqttx.app/update/{{target}}-{{arch}}/{{current_version}}`,需要替换为你自己的更新服务器地址。更新服务器需返回如下格式的 JSON:
```json
{
"version": "1.0.0",
"notes": "更新说明",
"pub_date": "2024-01-01T00:00:00Z",
"platforms": {
"windows-x86_64": {
"signature": "...",
"url": "https://releases.example.com/mqttx_1.0.0_x64.msi.zip"
},
"darwin-x86_64": {
"signature": "...",
"url": "https://releases.example.com/mqttx_1.0.0_x64.dmg"
},
"darwin-aarch64": {
"signature": "...",
"url": "https://releases.example.com/mqttx_1.0.0_aarch64.dmg"
},
"linux-x86_64": {
"signature": "...",
"url": "https://releases.example.com/mqttx_1.0.0_amd64.AppImage"
}
}
}
```
参考文档:[Tauri 更新器插件](https://v2.tauri.org.cn/plugin/updater/)
## 项目结构
```
mqttx/
├── src/ # 前端源码
│ ├── components/ # Vue 组件
│ │ ├── common/ # 通用组件 (ContextMenu, HoverPreview)
│ │ ├── connections/ # 连接管理相关组件
│ │ ├── dashboard/ # 看板相关组件 (消息流、发送面板、日志等)
│ │ ├── presets/ # 预设管理相关组件
│ │ ├── AppLayout.vue # 主布局
│ │ └── App.vue # 根组件
│ ├── composables/ # 组合式函数
│ │ ├── useContextMenu.ts # 右键菜单
│ │ └── useUpdater.ts # 自动更新器
│ ├── stores/ # Pinia 状态管理
│ │ ├── useConnections.ts # 连接状态
│ │ ├── useMqttBridge.ts # MQTT 桥接 (Tauri 命令调用)
│ │ ├── useMessages.ts # 消息存储
│ │ ├── useLogs.ts # 日志管理
│ │ └── ...
│ ├── types/ # TypeScript 类型定义
│ ├── utils/ # 工具函数
│ ├── styles/ # 全局样式
│ └── main.ts # 入口文件
├── src-tauri/ # Rust 后端源码
│ ├── src/
│ │ ├── lib.rs # Tauri 应用入口、命令定义
│ │ └── main.rs # 桌面入口点
│ ├── Cargo.toml # Rust 依赖
│ └── tauri.conf.json # Tauri 配置
├── package.json
├── vite.config.ts
└── README.md
```
## 命令参考
| 命令 | 说明 |
| --- | --- | | --- | --- |
| `pnpm dev` | 启动 Vite 前端开发服务器 | | Windows | `%APPDATA%\MQTTX-GPUI` |
| `pnpm build` | 构建前端 | | macOS | `~/Library/Application Support/MQTTX-GPUI` |
| `pnpm tauri dev` | 启动 Tauri 开发模式(前端 + 桌面窗口) | | Linux | `~/.local/share/MQTTX-GPUI` |
| `pnpm tauri build` | 生产构建 |
| `pnpm tauri signer generate` | 生成更新签名密钥对 |
## 协议 刻意与官方 Electron MQTTX 的 `MQTTX/` 目录区分,互不影响。读取时容忍 UTF-8 BOM。
[MIT](./LICENSE) ## 与 Tauri 版本的差异
- 单一 Rust 二进制(release ~17MB),无需打包 Node/WebView 资源
- MQTT 引擎从 Tauri 命令模型改为独立 tokio runtime + 事件通道,UI 直接订阅
- 数据存储从浏览器 `localStorage` 迁移到系统数据目录下的 JSON 文件
- 新增 TLS/WSS 传输、负载格式(Base64/Hex)、遗嘱消息、连接测试等能力
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "mqttx-desktop"
version = "1.0.0"
edition.workspace = true
description = "MQTT X —— 基于 GPUI 的跨平台 MQTT 调试客户端"
default-run = "mqttx"
[[bin]]
name = "mqttx"
path = "src/main.rs"
[dependencies]
gpui-kit = "0.6"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# MQTT 引擎运行在独立的 tokio runtime 线程上
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "net", "time", "io-util"] }
# GPUI 侧与引擎线程通信使用 smol 通道(GPUI 执行器兼容)
smol = "2"
rumqttc = { package = "rumqttc-v5-next", version = "0.34", features = ["websocket"] }
rumqttc-v4 = { package = "rumqttc-v4-next", version = "0.34", features = ["websocket"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["clock"] }
dirs = "6"
# 阿里云 MQTT 签名: HMAC-SHA1 -> Base64
hmac = "0.12"
sha1 = "0.10"
base64 = "0.22"
+152
View File
@@ -0,0 +1,152 @@
//! 阿里云 IoT 平台一键接入预设(对标官方 MQTTX 的阿里云模板)。
//!
//! Token 模式鉴权参数:
//! - clientId: `{GroupId}@@@{DeviceId}`
//! - username: `Signature|{AccessKeyId}|{InstanceId}`
//! - password: base64(HMAC-SHA1(AccessKeySecret, clientId))
use base64::Engine as _;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use crate::model::{ConnectionConfig, ProtocolVersion, TransportKind};
type HmacSha1 = Hmac<Sha1>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AliyunAuthMode {
/// Group ID + 设备 ID,使用账号 AccessKey 签名
#[default]
Token,
/// 一机一密:设备独立 DeviceName + DeviceSecret(直接以设备密钥鉴权)
DeviceCredential,
}
impl AliyunAuthMode {
pub const ALL: [Self; 2] = [Self::Token, Self::DeviceCredential];
pub fn label(self) -> &'static str {
match self {
Self::Token => "Group 令牌 (Token)",
Self::DeviceCredential => "一机一密",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AliyunPreset {
pub id: String,
pub name: String,
/// 分组(仅 UI 分类用)
#[serde(default)]
pub group: String,
/// 实例 ID,如 post-cn-4591dq2ra1i
pub instance_id: String,
/// 地域 host 后缀前的 region,如 cn-shanghai;留空则使用公共接入点
#[serde(default)]
pub region: String,
#[serde(default)]
pub auth_mode: AliyunAuthMode,
// Token 模式
#[serde(default)]
pub access_key_id: String,
#[serde(default)]
pub access_key_secret: String,
#[serde(default)]
pub group_id: String,
// 共用
pub device_id: String,
// 一机一密模式
#[serde(default)]
pub device_secret: String,
}
impl AliyunPreset {
pub fn new() -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name: "阿里云设备".into(),
group: String::new(),
instance_id: String::new(),
region: String::new(),
auth_mode: AliyunAuthMode::Token,
access_key_id: String::new(),
access_key_secret: String::new(),
group_id: String::new(),
device_id: format!("device_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]),
device_secret: String::new(),
}
}
/// HMAC-SHA1(secret, client_id) 的 Base64 编码。
pub fn signature(secret: &str, client_id: &str) -> Result<String, String> {
let mut mac =
HmacSha1::new_from_slice(secret.as_bytes()).map_err(|e| format!("HMAC 初始化失败: {e}"))?;
mac.update(client_id.as_bytes());
Ok(base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
}
/// 接入点 host:`{instanceId}.mqtt.iothub.aliyuncs.com`
/// (实例级接入点在各区域通用;region 为空时同样可用)
pub fn host(&self) -> String {
if self.region.is_empty() {
format!("{}.mqtt.iothub.aliyuncs.com", self.instance_id)
} else {
format!(
"{}.mqtt.{}.aliyuncs.com",
self.instance_id, self.region
)
}
}
/// 由预设生成一份连接配置(不写入 id/name 之外的覆盖项)。
pub fn to_connection(&self) -> Result<ConnectionConfig, String> {
let mut conn = ConnectionConfig::new();
conn.name = self.name.clone();
conn.host = self.host();
conn.port = TransportKind::Tcp.default_port();
conn.transport = TransportKind::Tcp;
conn.protocol = ProtocolVersion::V311;
conn.clean_start = true;
match self.auth_mode {
AliyunAuthMode::Token => {
if self.group_id.is_empty()
|| self.access_key_id.is_empty()
|| self.access_key_secret.is_empty()
{
return Err("Group ID、AccessKey ID 与 AccessKey Secret 不能为空".into());
}
let client_id = format!("{}@@@{}", self.group_id, self.device_id);
let password = Self::signature(&self.access_key_secret, &client_id)?;
conn.client_id = client_id;
conn.username =
format!("Signature|{}|{}", self.access_key_id, self.instance_id);
conn.password = password;
}
AliyunAuthMode::DeviceCredential => {
if self.device_secret.is_empty() {
return Err("设备密钥 (DeviceSecret) 不能为空".into());
}
// 一机一密三元组:clientId=设备名,username=设备名&实例,password=HMAC-SHA1(DeviceSecret, clientId)
let client_id = self.device_id.clone();
let password = Self::signature(&self.device_secret, &client_id)?;
conn.client_id = client_id;
conn.username = format!("{}&{}", self.device_id, self.instance_id);
conn.password = password;
}
}
Ok(conn)
}
}
impl Default for AliyunPreset {
fn default() -> Self {
Self::new()
}
}
+7
View File
@@ -0,0 +1,7 @@
//! MQTTX:数据模型、持久化、MQTT 引擎与 GPUI 界面。
pub mod aliyun;
pub mod model;
pub mod mqtt;
pub mod store;
pub mod ui;
+27
View File
@@ -0,0 +1,27 @@
use gpui_kit::component::{Root, TitleBar};
use gpui_kit::*;
use mqttx_desktop::ui;
fn main() {
application()
.with_assets(gpui_kit::assets::Assets)
.run(|cx: &mut App| {
gpui_kit::init(cx);
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(1440.), px(900.)),
cx,
))),
..TitleBar::window_options()
};
cx.open_window(options, |window, cx| {
let view = cx.new(|cx| ui::app::MqttXApp::new(window, cx));
cx.new(|cx| Root::new(view.clone(), window, cx))
})
.expect("Failed to open window");
});
}
+658
View File
@@ -0,0 +1,658 @@
//! 应用数据模型:连接配置、订阅、消息、日志、预设、全局变量、设置。
//!
//! 本层不依赖 GPUI,可被持久化层与 MQTT 引擎共用。
use serde::{Deserialize, Serialize};
// ─── 连接配置 ────────────────────────────────────────────────────────────────
/// MQTT 协议版本。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum ProtocolVersion {
/// MQTT 3.1.1
V311,
/// MQTT 5.0
#[default]
V5,
}
impl ProtocolVersion {
pub const ALL: [Self; 2] = [Self::V5, Self::V311];
pub fn label(self) -> &'static str {
match self {
Self::V311 => "MQTT 3.1.1",
Self::V5 => "MQTT 5.0",
}
}
pub fn is_v5(self) -> bool {
matches!(self, Self::V5)
}
}
/// 传输方式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum TransportKind {
/// 明文 TCP,默认 1883
#[default]
Tcp,
/// TLS over TCP,默认 8883
Tls,
/// WebSocket (ws://),默认 8083
Ws,
/// WebSocket over TLS (wss://),默认 8084
Wss,
}
impl TransportKind {
pub const ALL: [Self; 4] = [Self::Tcp, Self::Tls, Self::Ws, Self::Wss];
pub fn label(self) -> &'static str {
match self {
Self::Tcp => "TCP",
Self::Tls => "TLS",
Self::Ws => "WebSocket",
Self::Wss => "WSS",
}
}
pub fn default_port(self) -> u16 {
match self {
Self::Tcp => 1883,
Self::Tls => 8883,
Self::Ws => 8083,
Self::Wss => 8084,
}
}
pub fn is_tls(self) -> bool {
matches!(self, Self::Tls | Self::Wss)
}
pub fn is_websocket(self) -> bool {
matches!(self, Self::Ws | Self::Wss)
}
}
/// MQTT 5 遗嘱消息(3.1.1 下忽略 v5 专属属性)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LastWill {
pub topic: String,
pub payload: String,
pub qos: u8,
pub retain: bool,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub response_topic: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionConfig {
pub id: String,
pub name: String,
/// 主机(不含端口、协议前缀)
pub host: String,
pub port: u16,
/// WebSocket 路径,如 /mqtt
#[serde(default)]
pub path: String,
#[serde(default)]
pub protocol: ProtocolVersion,
#[serde(default)]
pub transport: TransportKind,
pub client_id: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
#[serde(default = "default_true")]
pub clean_start: bool,
#[serde(default = "default_keep_alive")]
pub keep_alive: u16,
// ── MQTT 5 专属 ──
#[serde(default)]
pub session_expiry_interval: u32,
#[serde(default)]
pub receive_maximum: Option<u16>,
#[serde(default)]
pub maximum_packet_size: Option<u32>,
#[serde(default)]
pub topic_alias_maximum: Option<u16>,
/// 连接成功后自动恢复已保存的订阅
#[serde(default = "default_true")]
pub auto_resubscribe: bool,
/// 断线自动重连
#[serde(default = "default_true")]
pub auto_reconnect: bool,
#[serde(default)]
pub last_will: Option<LastWill>,
/// 创建时间(unix 秒),用于排序
#[serde(default)]
pub created_at: i64,
}
fn default_true() -> bool {
true
}
fn default_keep_alive() -> u16 {
60
}
impl ConnectionConfig {
pub fn new() -> Self {
let transport = TransportKind::default();
Self {
id: uuid::Uuid::new_v4().to_string(),
name: "新建连接".into(),
host: "broker.emqx.io".into(),
port: transport.default_port(),
path: "/mqtt".into(),
protocol: ProtocolVersion::V5,
transport,
client_id: format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]),
username: String::new(),
password: String::new(),
clean_start: true,
keep_alive: 60,
session_expiry_interval: 0,
receive_maximum: None,
maximum_packet_size: None,
topic_alias_maximum: None,
auto_resubscribe: true,
auto_reconnect: true,
last_will: None,
created_at: chrono::Local::now().timestamp(),
}
}
/// broker.emqx.io 等公共服务器的快速预设。
pub fn with_public(name: &str, host: &str, port: u16, transport: TransportKind) -> Self {
let mut c = Self::new();
c.name = name.into();
c.host = host.into();
c.port = port;
c.transport = transport;
c
}
pub fn display_address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
impl Default for ConnectionConfig {
fn default() -> Self {
Self::new()
}
}
// ─── 订阅 ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subscription {
pub id: String,
pub connection_id: String,
pub topic: String,
pub qos: u8,
/// 本地备注颜色(与 MQTTX 一致,按订阅给消息着色),0..=359 色相
#[serde(default)]
pub color: Option<f32>,
}
impl Subscription {
pub fn new(connection_id: impl Into<String>, topic: impl Into<String>, qos: u8) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
connection_id: connection_id.into(),
topic: topic.into(),
qos,
color: None,
}
}
}
// ─── 消息 ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
Received,
Published,
}
impl Direction {
pub fn label(self) -> &'static str {
match self {
Self::Received => "接收",
Self::Published => "发布",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum PayloadFormat {
#[default]
Plaintext,
Json,
Base64,
Hex,
}
impl PayloadFormat {
pub const ALL: [Self; 4] = [Self::Plaintext, Self::Json, Self::Base64, Self::Hex];
pub fn label(self) -> &'static str {
match self {
Self::Plaintext => "Plaintext",
Self::Json => "JSON",
Self::Base64 => "Base64",
Self::Hex => "Hex",
}
}
pub fn encode(self, text: &str) -> Result<Vec<u8>, String> {
match self {
Self::Plaintext | Self::Json => Ok(text.as_bytes().to_vec()),
Self::Base64 => {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD
.decode(text.trim())
.map_err(|e| format!("Base64 解码失败: {e}"))
}
Self::Hex => {
let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect();
if !cleaned.len().is_multiple_of(2) {
return Err("Hex 长度必须为偶数(每两个字符一个字节)".into());
}
(0..cleaned.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&cleaned[i..i + 2], 16)
.map_err(|e| format!("Hex 解码失败: {e}"))
})
.collect()
}
}
}
}
/// 一条收到/发出的消息记录。payload 以 UTF-8 文本保存(非文本按 lossy 显示)。
#[derive(Debug, Clone)]
pub struct MqttRecord {
pub seq: u64,
pub connection_id: String,
pub direction: Direction,
pub topic: String,
pub payload: String,
pub qos: u8,
pub retain: bool,
/// unix 毫秒
pub timestamp: i64,
pub user_properties: Vec<(String, String)>,
pub content_type: Option<String>,
pub response_topic: Option<String>,
pub correlation_data: Option<String>,
pub message_expiry_interval: Option<u32>,
pub subscription_identifier: Option<u32>,
}
// ─── 发布参数 / 发布预设 ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PublishParams {
pub topic: String,
/// 用于界面展示与记录的文本负载。
pub payload: String,
#[serde(default)]
pub payload_format: PayloadFormat,
pub qos: u8,
pub retain: bool,
/// MQTT 5 用户属性
#[serde(default)]
pub user_properties: Vec<(String, String)>,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub message_expiry_interval: Option<u32>,
#[serde(default)]
pub response_topic: Option<String>,
#[serde(default)]
pub correlation_data: Option<String>,
/// 实际发送的字节。Base64/Hex 时为解码结果;不持久化、不参与预设。
/// None 时引擎回退为 `payload.as_bytes()`。
#[serde(skip)]
pub raw_bytes: Option<Vec<u8>>,
}
/// 发布预设:保存常用的发布参数,一键复用(对标 MQTTX Publish Scripts/Preset)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishPreset {
pub id: String,
pub name: String,
#[serde(default)]
pub connection_id: Option<String>,
#[serde(flatten)]
pub params: PublishParams,
}
impl PublishPreset {
pub fn new(name: impl Into<String>, params: PublishParams) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
name: name.into(),
connection_id: None,
params,
}
}
}
// ─── 全局变量 ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalVariable {
pub key: String,
pub value: String,
}
/// 解析 `{{key}}` 模板:
/// - 用户定义的全局变量优先;
/// - 内置变量:`{{$ts}}`(unix 秒)、`{{$ts_ms}}`(毫秒)、`{{$uuid}}`;
/// - 找不到的占位符保持原样。
pub fn render_template(input: &str, vars: &[GlobalVariable]) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
// 同一次渲染内时间基准保持一致,避免一条消息里多个时间变量取值不同。
let now_ms = chrono::Local::now().timestamp_millis();
let now_s = now_ms / 1000;
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'{' && bytes[i + 1] == b'{'
&& let Some(rel_end) = input[i + 2..].find("}}") {
let key = &input[i + 2..i + 2 + rel_end];
let resolved = match key.trim() {
"$ts" => Some(now_s.to_string()),
"$ts_ms" => Some(now_ms.to_string()),
"$uuid" => Some(uuid::Uuid::new_v4().to_string()),
k => vars
.iter()
.find(|v| v.key == k)
.map(|v| v.value.clone()),
};
match resolved {
Some(v) => out.push_str(&v),
None => out.push_str(&input[i..i + 2 + rel_end + 2]),
}
i = i + 2 + rel_end + 2;
continue;
}
// 按字符推进,避免把多字节 UTF-8 拆开
let ch = input[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}
// ─── 日志 ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}
impl LogLevel {
pub fn label(self) -> &'static str {
match self {
Self::Debug => "DEBUG",
Self::Info => "INFO",
Self::Warn => "WARN",
Self::Error => "ERROR",
}
}
pub fn parse(s: &str) -> Self {
match s {
"debug" => Self::Debug,
"warn" => Self::Warn,
"error" => Self::Error,
_ => Self::Info,
}
}
}
#[derive(Debug, Clone)]
pub struct LogEntry {
pub timestamp: i64,
pub connection_id: String,
pub level: LogLevel,
pub event: String,
pub message: String,
pub details: Option<String>,
}
// ─── 连接状态 ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConnectionStatus {
#[default]
Disconnected,
Connecting,
Connected,
Error,
}
impl ConnectionStatus {
pub fn label(self) -> &'static str {
match self {
Self::Disconnected => "未连接",
Self::Connecting => "连接中",
Self::Connected => "已连接",
Self::Error => "错误",
}
}
}
// ─── 应用设置 ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThemeModePref {
Light,
Dark,
#[default]
System,
}
impl ThemeModePref {
pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
pub fn label(self) -> &'static str {
match self {
Self::System => "跟随系统",
Self::Light => "浅色",
Self::Dark => "深色",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default)]
pub theme: ThemeModePref,
/// 每条连接在内存中保留的最大消息条数
#[serde(default = "default_max_messages")]
pub max_messages: usize,
/// 时间戳是否显示毫秒
#[serde(default = "default_true")]
pub show_millis: bool,
/// 是否自动检查更新(占位,暂不实现更新)
#[serde(default = "default_true")]
pub auto_check_update: bool,
}
fn default_max_messages() -> usize {
2000
}
impl Default for AppSettings {
fn default() -> Self {
Self {
theme: ThemeModePref::default(),
max_messages: default_max_messages(),
show_millis: true,
auto_check_update: true,
}
}
}
// ─── 单元测试 ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// 修复回归:Hex 奇数长度必须报错,不能静默截断
#[test]
fn hex_odd_length_is_rejected() {
let err = PayloadFormat::Hex.encode("abc").unwrap_err();
assert!(err.contains("偶数"), "应提示偶数长度,实际: {err}");
assert!(PayloadFormat::Hex.encode("").is_ok());
assert_eq!(
PayloadFormat::Hex.encode("68 69").unwrap(),
vec![0x68, 0x69],
"空白应被忽略"
);
assert!(PayloadFormat::Hex.encode("zz").is_err());
}
#[test]
fn base64_encode_roundtrip() {
use base64::Engine as _;
let text = "hello 世界";
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
assert_eq!(PayloadFormat::Base64.encode(&encoded).unwrap(), text.as_bytes());
assert!(PayloadFormat::Base64.encode("not base64!!!").is_err());
}
#[test]
fn plaintext_and_json_encode_as_utf8() {
assert_eq!(
PayloadFormat::Plaintext.encode("中文").unwrap(),
"中文".as_bytes()
);
assert_eq!(
PayloadFormat::Json.encode(r#"{"a":1}"#).unwrap(),
r#"{"a":1}"#.as_bytes()
);
}
// 修复回归:raw_bytes 是运行时字段,不得写入持久化 JSON
#[test]
fn raw_bytes_not_serialized() {
let p = PublishParams {
topic: "t".into(),
payload: "hi".into(),
raw_bytes: Some(vec![0x00, 0xff]),
..Default::default()
};
let json = serde_json::to_string(&p).unwrap();
assert!(!json.contains("raw_bytes"), "raw_bytes 不应被序列化: {json}");
let back: PublishParams = serde_json::from_str(&json).unwrap();
assert!(back.raw_bytes.is_none());
// 反序列化时输入中的 raw_bytes 字段应被忽略(skip 双向生效)
let mut with_junk = serde_json::to_string(&PublishParams::default()).unwrap();
with_junk.pop(); // 去掉结尾 '}'
with_junk.push_str(r#", "raw_bytes":[1,2]}"#);
let back: PublishParams = serde_json::from_str(&with_junk).unwrap();
assert!(back.raw_bytes.is_none(), "#[serde(skip)] 应忽略输入字段");
}
// 修复回归:同一条消息内 $ts 与 $ts_ms 基于同一时刻
#[test]
fn template_timestamps_share_one_timebase() {
let out = render_template("s={{$ts}} ms={{$ts_ms}}", &[]);
let (s_part, ms_part) = out.split_once(" ms=").expect("格式 s=.. ms=..");
let s: i64 = s_part
.strip_prefix("s=")
.expect("秒字段")
.parse()
.unwrap();
let ms: i64 = ms_part.parse().expect("毫秒字段");
assert_eq!(s, ms / 1000, "秒与毫秒应来自同一时间基准: {out}");
}
#[test]
fn template_unknown_placeholder_kept_and_custom_var_resolved() {
assert_eq!(render_template("{{$unknown}}", &[]), "{{$unknown}}");
let vars = vec![GlobalVariable {
key: "device".into(),
value: "sensor-1".into(),
}];
assert!(render_template("d={{device}}/{{$uuid}}", &vars).starts_with("d=sensor-1/"));
// 多字节字符不被拆坏
assert_eq!(render_template("中文{{$no}}", &[]), "中文{{$no}}");
}
// 预设持久化往返:raw_bytes 不落盘,参数完整保留
#[test]
fn publish_preset_roundtrip_skips_raw_bytes() {
let preset = PublishPreset {
id: "p1".into(),
name: "报警".into(),
connection_id: Some("c1".into()),
params: PublishParams {
topic: "alarm/{{device}}".into(),
payload: "6869".into(),
payload_format: PayloadFormat::Hex,
qos: 1,
retain: true,
content_type: Some("application/json".into()),
user_properties: vec![("k".into(), "v".into())],
raw_bytes: Some(vec![0x68, 0x69]),
..Default::default()
},
};
let json = serde_json::to_string(&preset).unwrap();
let back: PublishPreset = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "报警");
assert_eq!(back.params.topic, "alarm/{{device}}");
assert_eq!(back.params.payload_format, PayloadFormat::Hex);
assert_eq!(back.params.qos, 1);
assert!(back.params.retain);
assert_eq!(back.params.content_type.as_deref(), Some("application/json"));
assert_eq!(back.params.user_properties.len(), 1);
assert!(back.params.raw_bytes.is_none());
}
#[test]
fn payload_format_equality_and_default() {
assert_eq!(PayloadFormat::default(), PayloadFormat::Plaintext);
assert_ne!(PayloadFormat::Hex, PayloadFormat::Base64);
let json = serde_json::to_string(&PayloadFormat::Hex).unwrap();
assert_eq!(
serde_json::from_str::<PayloadFormat>(&json).unwrap(),
PayloadFormat::Hex
);
}
}
+981
View File
@@ -0,0 +1,981 @@
//! MQTT 引擎:在独立 tokio runtime 上管理多个连接的生命周期,
//! 通过 smol 无界通道把状态/消息/日志事件送回 GPUI 线程。
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use rumqttc::mqttbytes::v5::{
ConnectReturnCode as V5ReturnCode, Packet as V5Packet, Publish as V5Publish,
PublishProperties as V5PublishProperties,
};
use rumqttc::{
AsyncClient as V5AsyncClient, Broker as V5Broker, ConnectionError as V5Error,
Event as V5Event, EventLoop as V5EventLoop, MqttOptions as V5Options, Outgoing as V5Outgoing,
PublishOptions as V5PublishOptions, QoS as V5QoS, Transport as V5Transport,
};
use rumqttc_v4 as rv4;
use tokio::sync::watch;
use crate::model::{
ConnectionConfig, ConnectionStatus, Direction, LogEntry, LogLevel, MqttRecord, PublishParams,
Subscription, TransportKind,
};// ─── 引擎事件 ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum EngineEvent {
/// 连接状态变化
Status {
connection_id: String,
status: ConnectionStatus,
error: Option<String>,
reason_code: Option<u8>,
session_present: Option<bool>,
},
/// 收到消息
Message(MqttRecord),
/// 一条本地发出的消息(已成功交给客户端)
Published {
connection_id: String,
topic: String,
payload: String,
qos: u8,
retain: bool,
content_type: Option<String>,
user_properties: Vec<(String, String)>,
},
/// 订阅请求结果(tracked,等待 SUBACK)
SubscribeResult {
connection_id: String,
topic: String,
qos: u8,
ok: bool,
error: Option<String>,
},
/// 日志
Log(LogEntry),
}
pub type EventSender = smol::channel::Sender<EngineEvent>;
// ─── 句柄 ────────────────────────────────────────────────────────────────────
enum ClientKind {
V5(V5AsyncClient),
V4(rv4::AsyncClient),
}
struct ConnHandle {
client: ClientKind,
cancel_tx: watch::Sender<bool>,
connected: Arc<AtomicBool>,
}
pub struct MqttEngine {
runtime: tokio::runtime::Runtime,
conns: Mutex<HashMap<String, ConnHandle>>,
tx: EventSender,
seq: Arc<AtomicU64>,
}
fn now_ms() -> i64 {
chrono::Local::now().timestamp_millis()
}
impl MqttEngine {
pub fn new(tx: EventSender) -> Arc<Self> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.thread_name("mqtt-engine")
.build()
.expect("创建 tokio runtime 失败");
Arc::new(Self {
runtime,
conns: Mutex::new(HashMap::new()),
tx,
seq: Arc::new(AtomicU64::new(1)),
})
}
fn next_seq(&self) -> u64 {
self.seq.fetch_add(1, Ordering::Relaxed)
}
fn emit(&self, event: EngineEvent) {
let _ = self.tx.try_send(event);
}
fn log(&self, conn_id: &str, level: LogLevel, event: &str, message: String, details: Option<String>) {
self.emit(EngineEvent::Log(LogEntry {
timestamp: now_ms(),
connection_id: conn_id.to_string(),
level,
event: event.to_string(),
message,
details,
}));
}
pub fn is_connected(&self, id: &str) -> bool {
let Ok(map) = self.conns.lock() else {
return false;
};
map.get(id)
.map(|h| h.connected.load(Ordering::SeqCst))
.unwrap_or(false)
}
// ── 连接 ──────────────────────────────────────────────────────────────
pub fn connect(self: &Arc<Self>, cfg: ConnectionConfig) {
// 关闭同名旧连接
self.close(&cfg.id, false);
let id = cfg.id.clone();
self.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Connecting,
error: None,
reason_code: None,
session_present: None,
});
self.log(
&id,
LogLevel::Info,
"connect",
format!(
"正在连接 {}:{} ({}, {})",
cfg.host,
cfg.port,
cfg.protocol.label(),
cfg.transport.label()
),
None,
);
let engine = Arc::clone(self);
self.runtime.spawn(async move {
let build = if cfg.protocol.is_v5() {
build_v5(&cfg)
.map(|(client, el)| (ClientKind::V5(client), EventLoopKind::V5(Box::new(el))))
} else {
build_v4(&cfg)
.map(|(client, el)| (ClientKind::V4(client), EventLoopKind::V4(Box::new(el))))
};
let (client, eventloop) = match build {
Ok(pair) => pair,
Err(e) => {
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Error,
error: Some(e.clone()),
reason_code: None,
session_present: None,
});
engine.log(&id, LogLevel::Error, "connect_error", format!("连接失败: {e}"), None);
return;
}
};
let connected = Arc::new(AtomicBool::new(false));
let (cancel_tx, cancel_rx) = watch::channel(false);
let handle = ConnHandle {
client,
cancel_tx,
connected: connected.clone(),
};
engine
.conns
.lock()
.unwrap()
.insert(id.clone(), handle);
match eventloop {
EventLoopKind::V5(el) => {
spawn_v5_loop(
engine.clone(),
id,
*el,
cancel_rx,
connected,
cfg.auto_reconnect,
);
}
EventLoopKind::V4(el) => {
spawn_v4_loop(
engine.clone(),
id,
*el,
cancel_rx,
connected,
cfg.auto_reconnect,
);
}
}
});
}
/// 主动断开。`notify` 为 true 时发出 Disconnected 状态(用户操作)。
pub fn close(&self, id: &str, user_initiated: bool) {
let handle = self.conns.lock().unwrap().remove(id);
if let Some(h) = handle {
let _ = h.cancel_tx.send(true);
let connected = h.connected.clone();
// 发送 MQTT DISCONNECT 报文后丢弃客户端
self.runtime.spawn(async move {
match h.client {
ClientKind::V5(c) => {
let _ = c.disconnect().await;
}
ClientKind::V4(c) => {
let _ = c.disconnect().await;
}
}
connected.store(false, Ordering::SeqCst);
});
if user_initiated {
self.log(id, LogLevel::Info, "disconnect", "用户请求断开连接".into(), None);
self.emit(EngineEvent::Status {
connection_id: id.to_string(),
status: ConnectionStatus::Disconnected,
error: None,
reason_code: None,
session_present: None,
});
}
}
}
// ── 发布 ──────────────────────────────────────────────────────────────
pub fn publish(self: &Arc<Self>, connection_id: String, params: PublishParams) {
let engine = Arc::clone(self);
self.runtime.spawn(async move {
let Some(client) = engine.client_of(&connection_id) else {
engine.log(
&connection_id,
LogLevel::Error,
"publish_error",
"发布失败: 连接不存在".into(),
None,
);
return;
};
let payload = params
.raw_bytes
.clone()
.unwrap_or_else(|| params.payload.as_bytes().to_vec());
// v5 与 v4 两个 crate 各自定义 ClientError,统一归一化为 String。
let result: Result<(), String> = match &client {
ClientKind::V5(client) => {
let props = V5PublishProperties {
user_properties: params.user_properties.clone(),
content_type: params.content_type.clone(),
message_expiry_interval: params.message_expiry_interval,
response_topic: params.response_topic.clone(),
correlation_data: params
.correlation_data
.clone()
.map(|s| s.into_bytes().into()),
..Default::default()
};
let options = V5PublishOptions::new(v5_qos(params.qos))
.retain(params.retain)
.properties(props);
client
.publish(params.topic.clone(), payload, options)
.await
.map_err(|e| e.to_string())
}
ClientKind::V4(client) => {
let options =
rv4::PublishOptions::new(v4_qos(params.qos)).retain(params.retain);
client
.publish(params.topic.clone(), payload, options)
.await
.map_err(|e| e.to_string())
}
};
match result {
Ok(_) => {
engine.log(
&connection_id,
LogLevel::Info,
"publish_sent",
format!(
"发布消息到 {} (QoS {}, retain={})",
params.topic, params.qos, params.retain
),
None,
);
engine.emit(EngineEvent::Published {
connection_id,
topic: params.topic,
payload: params.payload,
qos: params.qos,
retain: params.retain,
content_type: params.content_type,
user_properties: params.user_properties,
});
}
Err(e) => {
engine.log(
&connection_id,
LogLevel::Error,
"publish_error",
format!("发布失败: {e}"),
None,
);
}
}
});
}
// ── 订阅 / 取消订阅 ─────────────────────────────────────────────────────
pub fn subscribe(self: &Arc<Self>, connection_id: String, sub: Subscription) {
let engine = Arc::clone(self);
self.runtime.spawn(async move {
let Some(client) = engine.client_of(&connection_id) else {
engine.emit(EngineEvent::SubscribeResult {
connection_id,
topic: sub.topic,
qos: sub.qos,
ok: false,
error: Some("连接不存在".into()),
});
return;
};
engine.log(
&connection_id,
LogLevel::Info,
"subscribe",
format!("订阅主题: {} (QoS {})", sub.topic, sub.qos),
None,
);
let result = match &client {
ClientKind::V5(client) => {
match client
.subscribe_tracked(sub.topic.clone(), v5_qos(sub.qos))
.await
{
Ok(notice) => notice.wait_completion_async().await.map_err(|e| {
format!("订阅被拒绝: {e}")
}),
Err(e) => Err(format!("订阅请求失败: {e}")),
}
}
ClientKind::V4(client) => {
match client
.subscribe_tracked(sub.topic.clone(), v4_qos(sub.qos))
.await
{
Ok(notice) => notice.wait_completion_async().await.map_err(|e| {
format!("订阅被拒绝: {e}")
}),
Err(e) => Err(format!("订阅请求失败: {e}")),
}
}
};
let (ok, error) = match result {
Ok(()) => (true, None),
Err(e) => {
engine.log(
&connection_id,
LogLevel::Error,
"subscribe_error",
e.clone(),
None,
);
(false, Some(e))
}
};
engine.emit(EngineEvent::SubscribeResult {
connection_id,
topic: sub.topic,
qos: sub.qos,
ok,
error,
});
});
}
pub fn unsubscribe(self: &Arc<Self>, connection_id: String, topic: String) {
let engine = Arc::clone(self);
self.runtime.spawn(async move {
let Some(client) = engine.client_of(&connection_id) else {
return;
};
let result: Result<(), String> = match &client {
ClientKind::V5(client) => client
.unsubscribe(topic.clone())
.await
.map_err(|e| e.to_string()),
ClientKind::V4(client) => client
.unsubscribe(topic.clone())
.await
.map_err(|e| e.to_string()),
};
match result {
Ok(()) => {
engine.log(
&connection_id,
LogLevel::Info,
"unsubscribe",
format!("取消订阅: {topic}"),
None,
);
}
Err(e) => {
engine.log(
&connection_id,
LogLevel::Error,
"unsubscribe_error",
format!("取消订阅失败: {e}"),
None,
);
}
}
});
}
/// 测试连接:5 秒内等待 CONNACK,结果通过 channel 返回。
pub fn test_connection(
self: &Arc<Self>,
cfg: ConnectionConfig,
) -> smol::channel::Receiver<Result<(), String>> {
let (tx, rx) = smol::channel::bounded(1);
self.runtime.spawn(async move {
let result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
test_handshake(&cfg).await
})
.await;
let reply = match result {
Ok(inner) => inner,
Err(_) => Err("连接测试超时(5 秒),请检查主机地址与端口".into()),
};
let _ = tx.try_send(reply);
});
rx
}
// ── 内部工具 ───────────────────────────────────────────────────────────
/// 克隆一个连接的异步客户端用于发命令。
/// 不持有 std Mutex 守卫跨 await,避免与 tokio runtime 的 Send 要求冲突。
fn client_of(&self, id: &str) -> Option<ClientKind> {
let map = self.conns.lock().ok()?;
let h = map.get(id)?;
Some(match &h.client {
ClientKind::V5(c) => ClientKind::V5(c.clone()),
ClientKind::V4(c) => ClientKind::V4(c.clone()),
})
}
}
enum EventLoopKind {
// 两个事件循环都在 KB 级,装箱让枚举本身只有指针大小
V5(Box<V5EventLoop>),
V4(Box<rv4::EventLoop>),
}
// ─── 选项构建 ────────────────────────────────────────────────────────────────
fn build_v5(
cfg: &ConnectionConfig,
) -> Result<(V5AsyncClient, V5EventLoop), String> {
let mut opts = match cfg.transport {
TransportKind::Tcp | TransportKind::Tls => {
V5Options::new(cfg.client_id.clone(), V5Broker::tcp(&cfg.host, cfg.port))
}
TransportKind::Ws => {
let url = format!("ws://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
V5Options::new(cfg.client_id.clone(), V5Broker::websocket(url).map_err(|e| format!("{e:?}"))?)
}
TransportKind::Wss => {
let url = format!("wss://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
V5Options::try_websocket_with_default_tls(cfg.client_id.clone(), url)
.map_err(|e| format!("{e:?}"))?
}
};
if cfg.transport == TransportKind::Tls {
opts.set_transport(
V5Transport::try_tls_with_default_config().map_err(|e| format!("TLS 初始化失败: {e:?}"))?,
);
}
opts.set_keep_alive(cfg.keep_alive);
opts.set_clean_start(cfg.clean_start);
if cfg.session_expiry_interval > 0 {
opts.set_session_expiry_interval(Some(cfg.session_expiry_interval));
}
if !cfg.username.is_empty() {
opts.set_credentials(cfg.username.clone(), cfg.password.clone());
}
if let Some(v) = cfg.receive_maximum {
opts.set_receive_maximum(Some(v));
}
if let Some(v) = cfg.maximum_packet_size {
opts.set_max_packet_size(Some(v));
}
if let Some(v) = cfg.topic_alias_maximum {
opts.set_topic_alias_max(Some(v));
}
if let Some(will) = &cfg.last_will
&& !will.topic.is_empty() {
let lw = rumqttc::mqttbytes::v5::LastWill::new(
will.topic.clone(),
will.payload.as_bytes().to_vec(),
v5_qos(will.qos),
will.retain,
None,
);
opts.set_last_will(lw);
}
Ok(V5AsyncClient::builder(opts).build())
}
fn build_v4(
cfg: &ConnectionConfig,
) -> Result<(rv4::AsyncClient, rv4::EventLoop), String> {
use rv4::{Broker as V4Broker, MqttOptions as V4Options, Transport as V4Transport};
let mut opts = match cfg.transport {
TransportKind::Tcp | TransportKind::Tls => {
V4Options::new(cfg.client_id.clone(), V4Broker::tcp(&cfg.host, cfg.port))
}
TransportKind::Ws => {
let url = format!("ws://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
V4Options::new(cfg.client_id.clone(), V4Broker::websocket(url).map_err(|e| format!("{e:?}"))?)
}
TransportKind::Wss => {
let url = format!("wss://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
V4Options::try_websocket_with_default_tls(cfg.client_id.clone(), url)
.map_err(|e| format!("{e:?}"))?
}
};
if cfg.transport == TransportKind::Tls {
opts.set_transport(V4Transport::try_tls_with_default_config().map_err(
|e| format!("TLS 初始化失败: {e:?}"),
)?);
}
opts.set_keep_alive(cfg.keep_alive);
opts.set_clean_session(cfg.clean_start);
if !cfg.username.is_empty() {
opts.set_credentials(cfg.username.clone(), cfg.password.clone());
}
if let Some(will) = &cfg.last_will
&& !will.topic.is_empty() {
let lw = rv4::mqttbytes::v4::LastWill::new(
will.topic.clone(),
will.payload.as_bytes().to_vec(),
v4_qos(will.qos),
will.retain,
);
opts.set_last_will(lw);
}
Ok(rv4::AsyncClient::builder(opts).build())
}
fn normalize_path(path: &str) -> String {
if path.is_empty() {
"/mqtt".to_string()
} else if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
}
}
fn v5_qos(qos: u8) -> V5QoS {
match qos {
1 => V5QoS::AtLeastOnce,
2 => V5QoS::ExactlyOnce,
_ => V5QoS::AtMostOnce,
}
}
fn v4_qos(qos: u8) -> rv4::QoS {
match qos {
1 => rv4::QoS::AtLeastOnce,
2 => rv4::QoS::ExactlyOnce,
_ => rv4::QoS::AtMostOnce,
}
}
// ─── 测试连接握手 ────────────────────────────────────────────────────────────
async fn test_handshake(cfg_in: &ConnectionConfig) -> Result<(), String> {
// 握手测试必须使用独立 client_id,否则会把使用相同 client_id 的现有连接
// 从 broker 顶下线(MQTT 同 client_id 单会话规则)。
let mut cfg = cfg_in.clone();
cfg.client_id = format!("mqttx_test_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
let cfg = &cfg;
if cfg.protocol.is_v5() {
let (client, mut eventloop) = build_v5(cfg)?;
loop {
match eventloop.poll().await {
Ok(V5Event::Incoming(V5Packet::ConnAck(ack))) => {
if ack.code == V5ReturnCode::Success {
// 测试完成,主动断开避免在 broker 侧残留会话
let _ = client.disconnect().await;
return Ok(());
}
return Err(format!("连接被拒绝: {:?}", ack.code));
}
Ok(V5Event::Incoming(V5Packet::Disconnect(d))) => {
return Err(format!("服务端断开: {:?}", d.reason_code));
}
Err(e) => return Err(format!("连接失败: {e}")),
_ => {}
}
}
} else {
let (client, mut eventloop) = build_v4(cfg)?;
loop {
match eventloop.poll().await {
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
if ack.code == rv4::ConnectReturnCode::Success {
let _ = client.disconnect().await;
return Ok(());
}
return Err(format!("连接被拒绝: {:?}", ack.code));
}
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
return Err("服务端主动断开".into());
}
Err(e) => return Err(format!("连接失败: {e}")),
_ => {}
}
}
}
}
// ─── 事件循环 ────────────────────────────────────────────────────────────────
fn v5_record(seq: u64, conn_id: &str, p: &V5Publish) -> MqttRecord {
let props = p.properties.as_ref();
MqttRecord {
seq,
connection_id: conn_id.to_string(),
direction: Direction::Received,
topic: String::from_utf8_lossy(&p.topic).to_string(),
payload: String::from_utf8_lossy(&p.payload).to_string(),
qos: v5_qos_u8(p.qos),
retain: p.retain,
timestamp: now_ms(),
user_properties: props
.map(|p| p.user_properties.clone())
.unwrap_or_default(),
content_type: props.and_then(|p| p.content_type.clone()),
response_topic: props.and_then(|p| p.response_topic.clone()),
correlation_data: props.and_then(|p| {
p.correlation_data
.as_ref()
.map(|d| d.iter().map(|b| format!("{b:02x}")).collect())
}),
message_expiry_interval: props.and_then(|p| p.message_expiry_interval),
subscription_identifier: props
.and_then(|p| p.subscription_identifiers.first().map(|v| *v as u32)),
}
}
fn v5_qos_u8(qos: V5QoS) -> u8 {
match qos {
V5QoS::AtMostOnce => 0,
V5QoS::AtLeastOnce => 1,
V5QoS::ExactlyOnce => 2,
}
}
fn spawn_v5_loop(
engine: Arc<MqttEngine>,
id: String,
mut eventloop: V5EventLoop,
mut cancel_rx: watch::Receiver<bool>,
connected: Arc<AtomicBool>,
auto_reconnect: bool,
) {
let runtime = engine.runtime.handle().clone();
runtime.spawn(async move {
let mut first_connect = true;
loop {
tokio::select! {
event = eventloop.poll() => {
match event {
Ok(V5Event::Incoming(V5Packet::Publish(p))) => {
let topic = String::from_utf8_lossy(&p.topic).to_string();
let record = v5_record(engine.next_seq(), &id, &p);
engine.log(
&id,
LogLevel::Info,
"publish_received",
format!("收到消息: {topic} ({} 字节, QoS {})", p.payload.len(), v5_qos_u8(p.qos)),
None,
);
engine.emit(EngineEvent::Message(record));
}
Ok(V5Event::Incoming(V5Packet::ConnAck(ack))) => {
let code = ack.code as u8;
connected.store(ack.code == V5ReturnCode::Success, Ordering::SeqCst);
if ack.code == V5ReturnCode::Success {
first_connect = false;
engine.log(
&id,
LogLevel::Info,
"connack",
format!("连接成功 (session_present: {})", ack.session_present),
None,
);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Connected,
error: None,
reason_code: Some(code),
session_present: Some(ack.session_present),
});
} else {
engine.log(
&id,
LogLevel::Error,
"connack",
format!("连接被拒绝: {:?} (code {code})", ack.code),
None,
);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Error,
error: Some(format!("{:?}", ack.code)),
reason_code: Some(code),
session_present: Some(ack.session_present),
});
break;
}
}
Ok(V5Event::Incoming(V5Packet::Disconnect(d))) => {
connected.store(false, Ordering::SeqCst);
engine.log(
&id,
LogLevel::Warn,
"disconnect_received",
format!("收到断开连接: {:?} (code {})", d.reason_code, d.reason_code as u8),
None,
);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Disconnected,
error: Some(format!("{:?}", d.reason_code)),
reason_code: Some(d.reason_code as u8),
session_present: None,
});
break;
}
Ok(V5Event::Incoming(V5Packet::SubAck(s))) => {
let granted: Vec<String> = s
.return_codes
.iter()
.map(|c| format!("{c:?}"))
.collect();
engine.log(&id, LogLevel::Info, "suback",
format!("订阅确认 (packet {}): {}", s.pkid, granted.join(", ")), None);
}
Ok(V5Event::Incoming(V5Packet::UnsubAck(_))) => {
engine.log(&id, LogLevel::Info, "unsuback", "取消订阅确认".into(), None);
}
Ok(V5Event::Incoming(V5Packet::PubAck(a))) => {
engine.log(&id, LogLevel::Debug, "puback",
format!("发布确认 (packet {}, reason {:?})", a.pkid, a.reason), None);
}
Ok(V5Event::Incoming(V5Packet::PubRec(a))) => {
engine.log(&id, LogLevel::Debug, "pubrec",
format!("QoS2 发布收到 (packet {})", a.pkid), None);
}
Ok(V5Event::Incoming(V5Packet::PubComp(a))) => {
engine.log(&id, LogLevel::Debug, "pubcomp",
format!("QoS2 发布完成 (packet {})", a.pkid), None);
}
Ok(V5Event::Outgoing(V5Outgoing::Publish(pkid))) => {
engine.log(&id, LogLevel::Debug, "publish_outgoing",
format!("消息已发出 (packet {pkid})"), None);
}
Ok(_) => {}
Err(V5Error::RequestsDone) => {
connected.store(false, Ordering::SeqCst);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Disconnected,
error: None,
reason_code: None,
session_present: None,
});
break;
}
Err(e) => {
connected.store(false, Ordering::SeqCst);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Error,
error: Some(e.to_string()),
reason_code: None,
session_present: None,
});
if !auto_reconnect || first_connect {
engine.log(&id, LogLevel::Error, "connection_error",
format!("连接错误: {e}"), None);
break;
}
engine.log(&id, LogLevel::Warn, "connection_error",
format!("连接错误,2 秒后重连: {e}"), None);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
}
_ = cancel_rx.changed() => {
engine.log(&id, LogLevel::Info, "disconnect_cancelled", "连接已取消".into(), None);
break;
}
}
}
engine.conns.lock().ok().and_then(|mut m| m.remove(&id));
});
}
fn spawn_v4_loop(
engine: Arc<MqttEngine>,
id: String,
mut eventloop: rv4::EventLoop,
mut cancel_rx: watch::Receiver<bool>,
connected: Arc<AtomicBool>,
auto_reconnect: bool,
) {
let runtime = engine.runtime.handle().clone();
runtime.spawn(async move {
let mut first_connect = true;
loop {
tokio::select! {
event = eventloop.poll() => {
match event {
Ok(rv4::Event::Incoming(rv4::Packet::Publish(p))) => {
let record = MqttRecord {
seq: engine.next_seq(),
connection_id: id.clone(),
direction: Direction::Received,
topic: String::from_utf8_lossy(&p.topic).to_string(),
payload: String::from_utf8_lossy(&p.payload).to_string(),
qos: match p.qos {
rv4::QoS::AtMostOnce => 0,
rv4::QoS::AtLeastOnce => 1,
rv4::QoS::ExactlyOnce => 2,
},
retain: p.retain,
timestamp: now_ms(),
user_properties: Vec::new(),
content_type: None,
response_topic: None,
correlation_data: None,
message_expiry_interval: None,
subscription_identifier: None,
};
engine.log(
&id, LogLevel::Info, "publish_received",
format!("收到消息: {} ({} 字节)", record.topic, p.payload.len()), None);
engine.emit(EngineEvent::Message(record));
}
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
connected.store(ack.code == rv4::ConnectReturnCode::Success, Ordering::SeqCst);
if ack.code == rv4::ConnectReturnCode::Success {
first_connect = false;
engine.log(&id, LogLevel::Info, "connack",
format!("连接成功 (session_present: {})", ack.session_present), None);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Connected,
error: None,
reason_code: Some(0),
session_present: Some(ack.session_present),
});
} else {
engine.log(&id, LogLevel::Error, "connack",
format!("连接被拒绝: {:?}", ack.code), None);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Error,
error: Some(format!("{:?}", ack.code)),
reason_code: Some(1),
session_present: Some(ack.session_present),
});
break;
}
}
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
connected.store(false, Ordering::SeqCst);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Disconnected,
error: Some("服务端断开".into()),
reason_code: None,
session_present: None,
});
break;
}
Ok(rv4::Event::Incoming(rv4::Packet::SubAck(s))) => {
engine.log(&id, LogLevel::Info, "suback",
format!("订阅确认 (packet {})", s.pkid), None);
}
Ok(rv4::Event::Incoming(rv4::Packet::UnsubAck(a))) => {
engine.log(&id, LogLevel::Info, "unsuback",
format!("取消订阅确认 (packet {})", a.pkid), None);
}
Ok(rv4::Event::Incoming(rv4::Packet::PubAck(a))) => {
engine.log(&id, LogLevel::Debug, "puback",
format!("发布确认 (packet {})", a.pkid), None);
}
Ok(rv4::Event::Outgoing(rv4::Outgoing::Publish(pkid))) => {
engine.log(&id, LogLevel::Debug, "publish_outgoing",
format!("消息已发出 (packet {pkid})"), None);
}
Ok(_) => {}
Err(rv4::ConnectionError::RequestsDone) => {
connected.store(false, Ordering::SeqCst);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Disconnected,
error: None, reason_code: None, session_present: None,
});
break;
}
Err(e) => {
connected.store(false, Ordering::SeqCst);
engine.emit(EngineEvent::Status {
connection_id: id.clone(),
status: ConnectionStatus::Error,
error: Some(e.to_string()),
reason_code: None, session_present: None,
});
if !auto_reconnect || first_connect {
engine.log(&id, LogLevel::Error, "connection_error",
format!("连接错误: {e}"), None);
break;
}
engine.log(&id, LogLevel::Warn, "connection_error",
format!("连接错误,2 秒后重连: {e}"), None);
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
}
_ = cancel_rx.changed() => {
engine.log(&id, LogLevel::Info, "disconnect_cancelled", "连接已取消".into(), None);
break;
}
}
}
engine.conns.lock().ok().and_then(|mut m| m.remove(&id));
});
}
+166
View File
@@ -0,0 +1,166 @@
//! JSON 文件持久化。数据存放在系统应用数据目录的 `MQTTX-GPUI/` 下:
//! Windows: %APPDATA%\MQTTX-GPUI,macOS: ~/Library/Application Support/MQTTX-GPUI,
//! Linux: ~/.local/share/MQTTX-GPUI。
//!
//! 刻意与官方 Electron MQTTX 的 `MQTTX/` 目录区分,避免互相覆盖数据。
/// 存储层诊断输出到 stderr(避免仅为文件持久化引入日志框架)。
macro_rules! elog {
($($arg:tt)*) => {
eprintln!("[store][{}] {}",
chrono::Local::now().format("%H:%M:%S"),
format!($($arg)*))
};
}
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::{
aliyun::AliyunPreset,
model::{AppSettings, ConnectionConfig, GlobalVariable, PublishPreset, Subscription},
};
#[derive(Debug, Clone)]
pub struct Storage {
dir: PathBuf,
}
impl Storage {
pub fn new() -> Self {
let dir = dirs::data_dir()
.unwrap_or_else(std::env::temp_dir)
.join("MQTTX-GPUI");
if let Err(e) = std::fs::create_dir_all(&dir) {
elog!("创建数据目录 {} 失败: {e}", dir.display());
}
Self { dir }
}
fn path(&self, name: &str) -> PathBuf {
self.dir.join(name)
}
fn read_json<T: for<'de> serde::Deserialize<'de>>(&self, name: &str) -> Result<Option<T>> {
let p = self.path(name);
if !p.exists() {
return Ok(None);
}
let mut text = std::fs::read_to_string(&p)
.with_context(|| format!("读取 {} 失败", p.display()))?;
// 容忍 Windows 编辑器/PowerShell 写入的 UTF-8 BOM
if text.starts_with('\u{feff}') {
text.drain(..3);
}
if text.trim().is_empty() {
return Ok(None);
}
let value = serde_json::from_str(&text)
.with_context(|| format!("解析 {} 失败", p.display()))?;
Ok(Some(value))
}
fn write_json<T: serde::Serialize + ?Sized>(&self, name: &str, value: &T) -> Result<()> {
let target = self.path(name);
let tmp = target.with_extension("json.tmp");
let text = serde_json::to_string_pretty(value)?;
std::fs::write(&tmp, text)
.with_context(|| format!("写入 {} 失败", tmp.display()))?;
std::fs::rename(&tmp, &target)
.with_context(|| format!("替换 {} 失败", target.display()))?;
Ok(())
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn log_dir(&self) -> PathBuf {
self.dir.join("logs")
}
// ── connections ──
pub fn load_connections(&self) -> Vec<ConnectionConfig> {
self.read_json("connections.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_connections(&self, conns: &[ConnectionConfig]) {
if let Err(e) = self.write_json("connections.json", conns) {
elog!("保存连接失败: {e}");
}
}
// ── subscriptions ──
pub fn load_subscriptions(&self) -> Vec<Subscription> {
self.read_json("subscriptions.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_subscriptions(&self, subs: &[Subscription]) {
if let Err(e) = self.write_json("subscriptions.json", subs) {
elog!("保存订阅失败: {e}");
}
}
// ── presets ──
pub fn load_presets(&self) -> Vec<PublishPreset> {
self.read_json("presets.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_presets(&self, presets: &[PublishPreset]) {
if let Err(e) = self.write_json("presets.json", presets) {
elog!("保存预设失败: {e}");
}
}
// ── variables ──
pub fn load_variables(&self) -> Vec<GlobalVariable> {
self.read_json("variables.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_variables(&self, vars: &[GlobalVariable]) {
if let Err(e) = self.write_json("variables.json", vars) {
elog!("保存全局变量失败: {e}");
}
}
// ── aliyun presets ──
pub fn load_aliyun(&self) -> Vec<AliyunPreset> {
self.read_json("aliyun.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_aliyun(&self, presets: &[AliyunPreset]) {
if let Err(e) = self.write_json("aliyun.json", presets) {
elog!("保存阿里云预设失败: {e}");
}
}
// ── settings ──
pub fn load_settings(&self) -> AppSettings {
self.read_json("settings.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_settings(&self, settings: &AppSettings) {
if let Err(e) = self.write_json("settings.json", settings) {
elog!("保存设置失败: {e}");
}
}
}
impl Default for Storage {
fn default() -> Self {
Self::new()
}
}
+298
View File
@@ -0,0 +1,298 @@
//! 阿里云 IoT 设备接入对话框:保存预设,并可一键生成连接并连接。
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::select::{Select, SelectState};
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, ActiveTheme as _, Sizable as _, StyledExt as _,
WindowExt as _,
};
use gpui_kit::prelude::FluentBuilder as _;
use crate::ui::IconName;
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, StatefulInteractiveElement as _,
IntoElement, ParentElement as _, Render, Styled as _, Window,
};
use crate::aliyun::{AliyunAuthMode, AliyunPreset};
use crate::model::ConnectionConfig;
use crate::ui::app::MqttXApp;
use crate::ui::widgets::{field, make_select, OptionDelegate};
const AUTH_MODES: [&str; 2] = ["Group 令牌 (Token)", "一机一密"];
struct AliyunDialog {
app: Entity<MqttXApp>,
presets: Vec<AliyunPreset>,
selected: Option<usize>,
name: Entity<InputState>,
group: Entity<InputState>,
instance_id: Entity<InputState>,
region: Entity<InputState>,
auth_mode: Entity<SelectState<OptionDelegate>>,
access_key_id: Entity<InputState>,
access_key_secret: Entity<InputState>,
group_id: Entity<InputState>,
device_id: Entity<InputState>,
device_secret: Entity<InputState>,
}
macro_rules! text_field {
($cx:expr, $window:expr, $value:expr, $ph:expr) => {
$cx.new(|cx| {
InputState::new($window, cx)
.default_value($value)
.placeholder($ph)
})
};
}
impl AliyunDialog {
fn new(app: Entity<MqttXApp>, presets: Vec<AliyunPreset>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let first = presets.first().cloned().unwrap_or_else(AliyunPreset::new);
Self {
app,
presets,
selected: None,
name: text_field!(cx, window, &first.name, "预设名称"),
group: text_field!(cx, window, &first.group, "分组(可选)"),
instance_id: text_field!(cx, window, &first.instance_id, "post-cn-xxxx"),
region: text_field!(cx, window, &first.region, "如 cn-shanghai(可留空)"),
auth_mode: make_select(
&AUTH_MODES,
match first.auth_mode {
AliyunAuthMode::Token => 0,
AliyunAuthMode::DeviceCredential => 1,
},
window,
cx,
),
access_key_id: text_field!(cx, window, &first.access_key_id, "AccessKey ID"),
access_key_secret: cx.new(|cx| {
InputState::new(window, cx)
.default_value(&first.access_key_secret)
.placeholder("AccessKey Secret")
.masked(true)
}),
group_id: text_field!(cx, window, &first.group_id, "GID-xxx"),
device_id: text_field!(cx, window, &first.device_id, "设备 ID"),
device_secret: cx.new(|cx| {
InputState::new(window, cx)
.default_value(&first.device_secret)
.placeholder("一机一密:DeviceSecret")
.masked(true)
}),
}
}
fn collect(&self, cx: &App) -> AliyunPreset {
let v = |e: &Entity<InputState>| e.read(cx).value().to_string();
let id = self
.selected
.and_then(|i| self.presets.get(i))
.map(|p| p.id.clone())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
AliyunPreset {
id,
name: v(&self.name),
group: v(&self.group),
instance_id: v(&self.instance_id),
region: v(&self.region),
auth_mode: match self.auth_mode.read(cx).selected_value() {
Some(1) => AliyunAuthMode::DeviceCredential,
_ => AliyunAuthMode::Token,
},
access_key_id: v(&self.access_key_id),
access_key_secret: v(&self.access_key_secret),
group_id: v(&self.group_id),
device_id: v(&self.device_id),
device_secret: v(&self.device_secret),
}
}
fn save_preset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let preset = self.collect(cx);
if preset.name.trim().is_empty() {
window.push_notification(Notification::warning("请填写预设名称"), cx);
return;
}
match self.selected {
Some(i) if i < self.presets.len() => self.presets[i] = preset,
_ => self.presets.push(preset),
}
let saved = self.presets.clone();
self.app.update(cx, |a, cx| {
a.save_aliyun(saved);
cx.notify();
});
window.push_notification(Notification::success("预设已保存"), cx);
}
fn connect(&self, window: &mut Window, cx: &mut Context<Self>) {
let preset = self.collect(cx);
match preset.to_connection() {
Ok(conn) => {
let mut conn: ConnectionConfig = conn;
conn.name = preset.name.clone();
self.app.update(cx, |a, cx| {
a.save_connection(conn.clone());
a.engine.connect(conn);
cx.notify();
});
window.close_dialog(cx);
}
Err(e) => {
window.push_notification(Notification::error(format!("无法生成连接: {e}")), cx);
}
}
}
}
impl Render for AliyunDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.gap_3()
.w_full()
// 左:预设列表
.child(
v_flex()
.w(px(160.))
.gap_1()
.child(div().text_xs().font_semibold().child("已保存预设"))
.children(
self.presets
.iter()
.enumerate()
.map(|(i, p)| {
let selected = self.selected == Some(i);
div()
.id(("aliyun-preset", i))
.px_2()
.py_1()
.rounded_md()
.text_sm()
.when(selected, |d| d.bg(cx.theme().secondary))
.cursor_pointer()
.child(p.name.clone())
.on_click(cx.listener(move |this, _, _, cx| {
// 选中仅做高亮;表单编辑的是“新建/另存”工作流
this.selected = Some(i);
cx.notify();
}))
}),
)
.child(
Button::new("aliyun-new")
.icon(IconName::Plus)
.label("新建")
.ghost()
.small()
.on_click(cx.listener(|this, _, _, cx| {
this.selected = None;
cx.notify();
})),
),
)
// 右:表单
.child(
v_flex()
.flex_1()
.min_w(px(0.))
.gap_2()
.child(field("预设名称", Input::new(&self.name)))
.child(
h_flex()
.gap_2()
.child(
div().flex_1().min_w(px(0.)).child(field(
"实例 ID",
Input::new(&self.instance_id),
)),
)
.child(
div().flex_1().min_w(px(0.)).child(field(
"地域",
Input::new(&self.region),
)),
),
)
.child(field("鉴权方式", Select::new(&self.auth_mode)))
.child(
h_flex()
.gap_2()
.child(
div().flex_1().min_w(px(0.)).child(field(
"AccessKey ID",
Input::new(&self.access_key_id),
)),
)
.child(
div().flex_1().min_w(px(0.)).child(field(
"AccessKey Secret",
Input::new(&self.access_key_secret).mask_toggle(),
)),
),
)
.child(
h_flex()
.gap_2()
.child(
div().flex_1().min_w(px(0.)).child(field(
"Group ID",
Input::new(&self.group_id),
)),
)
.child(
div().flex_1().min_w(px(0.)).child(field(
"设备 ID",
Input::new(&self.device_id),
)),
),
)
.child(field(
"DeviceSecret(一机一密)",
Input::new(&self.device_secret).mask_toggle(),
))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Token 模式使用 MQTT 3.1.1,clientId={GroupId}@@@{设备ID},密码为 HMAC-SHA1 签名"),
)
.child(
h_flex()
.gap_2()
.justify_end()
.child(
Button::new("aliyun-save")
.label("保存预设")
.outline()
.on_click(cx.listener(|this, _, window, cx| {
this.save_preset(window, cx)
})),
)
.child(
Button::new("aliyun-connect")
.label("生成并连接")
.primary()
.on_click(cx.listener(|this, _, window, cx| {
this.connect(window, cx)
})),
),
),
)
}
}
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
let presets = app.read(cx).aliyun_presets.clone();
let dialog_view = cx.new(|cx| AliyunDialog::new(app, presets, window, cx));
window.open_dialog(cx, move |dialog, _, _| {
dialog
.w(px(760.))
.title("阿里云 IoT 设备")
.child(dialog_view.clone())
.footer(gpui_kit::div())
});
}
+878
View File
@@ -0,0 +1,878 @@
//! 应用外壳:标题栏 + 连接侧边栏 + 多标签连接视图 + 引擎事件泵。
use std::collections::{HashMap, VecDeque};
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem};
use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, ActiveTheme as _, Selectable as _, Sizable as _,
StyledExt as _, Theme, ThemeMode, TitleBar, WindowExt as _,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, StatefulInteractiveElement as _,
IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window,
};
use crate::aliyun::AliyunPreset;
use crate::model::{
AppSettings, ConnectionConfig, ConnectionStatus, Direction, GlobalVariable, LogEntry,
LogLevel, MqttRecord, PublishParams, PublishPreset, Subscription, ThemeModePref,
};
use crate::mqtt::{EngineEvent, MqttEngine};
use crate::store::Storage;
use crate::ui::IconName;
use crate::ui::{
connection_form::ConnectionForm,
connection_view::ConnectionView,
widgets::status_color,
};
const MAX_LOGS: usize = 3000;
pub struct MqttXApp {
storage: Storage,
pub engine: std::sync::Arc<MqttEngine>,
_pump: gpui_kit::Task<()>,
// ── 持久化数据 ──
pub connections: Vec<ConnectionConfig>,
pub subscriptions: Vec<Subscription>,
pub presets: Vec<PublishPreset>,
pub variables: Vec<GlobalVariable>,
pub aliyun_presets: Vec<AliyunPreset>,
pub settings: AppSettings,
// ── 运行时状态 ──
pub statuses: HashMap<String, ConnectionStatus>,
pub errors: HashMap<String, String>,
pub messages: HashMap<String, Vec<MqttRecord>>,
pub logs: VecDeque<LogEntry>,
pub seq: u64,
// ── 标签与视图 ──
open_tabs: Vec<String>,
active_tab: Option<String>,
views: HashMap<String, Entity<ConnectionView>>,
search: Entity<InputState>,
}
impl MqttXApp {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let storage = Storage::new();
let connections = storage.load_connections();
let subscriptions = storage.load_subscriptions();
let presets = storage.load_presets();
let variables = storage.load_variables();
let aliyun_presets = storage.load_aliyun();
let settings = storage.load_settings();
apply_theme(settings.theme, cx);
let (tx, rx) = smol::channel::unbounded();
let engine = MqttEngine::new(tx);
let weak = cx.entity().downgrade();
let pump = cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
while let Ok(event) = rx.recv().await {
weak.update_in(cx, |app, window, cx| {
app.on_engine_event(event, window, cx);
})
.ok();
}
});
let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…"));
Self {
storage,
engine,
_pump: pump,
connections,
subscriptions,
presets,
variables,
aliyun_presets,
settings,
statuses: HashMap::new(),
errors: HashMap::new(),
messages: HashMap::new(),
logs: VecDeque::with_capacity(MAX_LOGS),
seq: 0,
open_tabs: Vec::new(),
active_tab: None,
views: HashMap::new(),
search,
}
}
// ── 引擎事件 ──────────────────────────────────────────────────────────
fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window, cx: &mut Context<Self>) {
match event {
EngineEvent::Status {
connection_id,
status,
error,
..
} => {
self.statuses.insert(connection_id.clone(), status);
match error {
Some(e) => {
self.errors.insert(connection_id.clone(), e);
}
None => {
self.errors.remove(&connection_id);
}
}
if status == ConnectionStatus::Connected {
self.on_connected(&connection_id);
}
if let Some(view) = self.views.get(&connection_id) {
view.update(cx, |_, cx| cx.notify());
}
cx.notify();
}
EngineEvent::Message(mut record) => {
let id = record.connection_id.clone();
record.seq = self.next_seq();
self.push_message(id.clone(), record);
if let Some(view) = self.views.get(&id) {
view.update(cx, |_, cx| cx.notify());
}
}
EngineEvent::Published {
connection_id,
topic,
payload,
qos,
retain,
content_type,
user_properties,
} => {
let record = MqttRecord {
seq: self.next_seq(),
connection_id: connection_id.clone(),
direction: Direction::Published,
topic,
payload,
qos,
retain,
timestamp: chrono::Local::now().timestamp_millis(),
user_properties,
content_type,
response_topic: None,
correlation_data: None,
message_expiry_interval: None,
subscription_identifier: None,
};
self.push_message(connection_id.clone(), record);
if let Some(view) = self.views.get(&connection_id) {
view.update(cx, |_, cx| cx.notify());
}
}
EngineEvent::SubscribeResult {
connection_id,
topic,
qos,
ok,
error,
} => {
if ok {
self.add_subscription(Subscription::new(connection_id.clone(), topic, qos));
} else if let Some(e) = error {
window.push_notification(
Notification::error(format!("订阅失败: {e}")),
cx,
);
}
if let Some(view) = self.views.get(&connection_id) {
view.update(cx, |_, cx| cx.notify());
}
}
EngineEvent::Log(entry) => {
self.push_log(entry);
if let Some(id) = self.active_tab.clone()
&& let Some(view) = self.views.get(&id) {
view.update(cx, |_, cx| cx.notify());
}
}
}
}
fn on_connected(&mut self, connection_id: &str) {
// 连接成功后自动恢复已保存订阅(auto resubscribe)。
let Some(config) = self.connections.iter().find(|c| c.id == connection_id).cloned() else {
return;
};
if !config.auto_resubscribe {
return;
}
let subs: Vec<Subscription> = self
.subscriptions
.iter()
.filter(|s| s.connection_id == connection_id)
.cloned()
.collect();
for sub in subs {
self.engine.subscribe(connection_id.to_string(), sub);
}
}
// ── 数据工具 ──────────────────────────────────────────────────────────
fn next_seq(&mut self) -> u64 {
self.seq += 1;
self.seq
}
fn push_message(&mut self, connection_id: String, record: MqttRecord) {
let cap = self.settings.max_messages.max(100);
let list = self.messages.entry(connection_id).or_default();
if list.len() >= cap {
let drop_n = list.len() - cap + 1;
list.drain(0..drop_n);
}
list.push(record);
}
fn push_log(&mut self, entry: LogEntry) {
if self.logs.len() >= MAX_LOGS {
self.logs.pop_front();
}
self.logs.push_back(entry);
}
pub fn log(&mut self, conn: &str, level: LogLevel, event: &str, message: String) {
self.push_log(LogEntry {
timestamp: chrono::Local::now().timestamp_millis(),
connection_id: conn.to_string(),
level,
event: event.to_string(),
message,
details: None,
});
}
// ── 连接 CRUD / 生命周期 ──────────────────────────────────────────────
pub fn save_connection(&mut self, cfg: ConnectionConfig) {
if let Some(slot) = self.connections.iter_mut().find(|c| c.id == cfg.id) {
*slot = cfg;
} else {
self.connections.push(cfg);
}
self.storage.save_connections(&self.connections);
}
pub fn duplicate_connection(&mut self, id: &str) {
let Some(src) = self.connections.iter().find(|c| c.id == id).cloned() else {
return;
};
let mut copy = src;
copy.id = uuid::Uuid::new_v4().to_string();
copy.name = format!("{} 副本", copy.name);
copy.client_id =
format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
self.connections.push(copy);
self.storage.save_connections(&self.connections);
}
pub fn delete_connection(&mut self, id: &str, cx: &mut Context<Self>) {
self.engine.close(id, false);
self.connections.retain(|c| c.id != id);
self.subscriptions.retain(|s| s.connection_id != id);
self.messages.remove(id);
self.statuses.remove(id);
self.views.remove(id);
self.open_tabs.retain(|t| t != id);
if self.active_tab.as_deref() == Some(id) {
self.active_tab = self.open_tabs.last().cloned();
}
self.storage.save_connections(&self.connections);
self.storage.save_subscriptions(&self.subscriptions);
cx.notify();
}
pub fn toggle_connection(&self, cfg: &ConnectionConfig) {
if self.engine.is_connected(&cfg.id) {
self.engine.close(&cfg.id, true);
} else {
self.engine.connect(cfg.clone());
}
}
pub fn open_tab(&mut self, conn_id: &str, window: &mut Window, cx: &mut Context<Self>) {
if !self.open_tabs.contains(&conn_id.to_string()) {
self.open_tabs.push(conn_id.to_string());
let weak = cx.entity().downgrade();
let engine = self.engine.clone();
let view = cx.new(|cx| ConnectionView::new(conn_id.to_string(), weak, engine, window, cx));
self.views.insert(conn_id.to_string(), view);
}
self.active_tab = Some(conn_id.to_string());
cx.notify();
}
fn close_tab(&mut self, id: &str, cx: &mut Context<Self>) {
self.open_tabs.retain(|t| t != id);
self.views.remove(id);
if self.active_tab.as_deref() == Some(id) {
self.active_tab = self.open_tabs.last().cloned();
}
cx.notify();
}
// ── 订阅 ──────────────────────────────────────────────────────────────
pub fn add_subscription(&mut self, sub: Subscription) {
if !self
.subscriptions
.iter()
.any(|s| s.connection_id == sub.connection_id && s.topic == sub.topic)
{
self.subscriptions.push(sub);
self.storage.save_subscriptions(&self.subscriptions);
}
}
pub fn remove_subscription(&mut self, connection_id: &str, topic: &str) {
self.engine
.unsubscribe(connection_id.to_string(), topic.to_string());
self.subscriptions
.retain(|s| !(s.connection_id == connection_id && s.topic == topic));
self.storage.save_subscriptions(&self.subscriptions);
}
// ── 变量 / 预设 / 阿里云 / 设置 ──────────────────────────────────────────
pub fn save_variables(&mut self, vars: Vec<GlobalVariable>) {
self.variables = vars;
self.storage.save_variables(&self.variables);
}
pub fn save_presets(&mut self, presets: Vec<PublishPreset>) {
self.presets = presets;
self.storage.save_presets(&self.presets);
}
/// 保存一个发布预设(按 name 去重更新)。
pub fn upsert_publish_preset(&mut self, name: String, params: PublishParams) {
match self.presets.iter_mut().find(|p| p.name == name) {
Some(p) => p.params = params,
None => self.presets.push(PublishPreset::new(name, params)),
}
self.storage.save_presets(&self.presets);
}
pub fn delete_publish_preset(&mut self, id: &str) {
self.presets.retain(|p| p.id != id);
self.storage.save_presets(&self.presets);
}
pub fn save_aliyun(&mut self, presets: Vec<AliyunPreset>) {
self.aliyun_presets = presets;
self.storage.save_aliyun(&self.aliyun_presets);
}
pub fn save_settings(&mut self, settings: AppSettings, cx: &mut App) {
apply_theme(settings.theme, cx);
self.settings = settings;
self.storage.save_settings(&self.settings);
}
// ── 对话框 ────────────────────────────────────────────────────────────
pub fn open_connection_form(
&mut self,
edit: Option<ConnectionConfig>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let app = cx.entity().downgrade();
let engine = self.engine.clone();
let form = cx.new(|cx| ConnectionForm::new(engine, edit, window, cx));
window.open_dialog(cx, move |dialog, _, cx| {
let form_ok = form.clone();
let form_test = form.clone();
let app_ok = app.clone();
let testing = form_test.read(cx).is_testing();
dialog
.w(px(680.))
.title("连接配置")
.child(form.clone())
.footer(
h_flex()
.gap_2()
.w_full()
// 测试连接常驻左下角,保存前即可验证配置
.child(
Button::new("form-test")
.icon(if testing {
IconName::LoaderCircle
} else {
IconName::PlugZap
})
.label(if testing { "测试中…" } else { "测试连接" })
.outline()
.loading(testing)
.on_click(move |_, window, cx| {
form_test.update(cx, |f, cx| f.run_test(window, cx));
}),
)
.child(div().flex_1())
.child(
Button::new("form-cancel")
.label("取消")
.outline()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("form-ok")
.label("保存")
.primary()
.on_click(move |_, window, cx| {
let built = form_ok.read(cx).build(cx);
match built {
Ok(cfg) => {
app_ok
.update(cx, |app, cx| {
app.save_connection(cfg);
cx.notify();
})
.ok();
window.close_dialog(cx);
}
Err(e) => {
window.push_notification(
Notification::error(e),
cx,
);
}
}
}),
),
)
});
}
}
// ─── 主题 ────────────────────────────────────────────────────────────────────
pub fn apply_theme(pref: ThemeModePref, cx: &mut App) {
let mode = match pref {
ThemeModePref::Light => ThemeMode::Light,
ThemeModePref::Dark => ThemeMode::Dark,
// 未做系统外观检测前,“跟随系统”暂按浅色处理
ThemeModePref::System => ThemeMode::Light,
};
Theme::change(mode, None, cx);
}
// ─── 渲染 ────────────────────────────────────────────────────────────────────
impl Render for MqttXApp {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// 0.6.x 的 Root 不自动渲染 dialog/sheet/notification 层,需由内容视图挂载。
v_flex()
.size_full()
.bg(cx.theme().background)
.text_color(cx.theme().foreground)
.child(self.render_titlebar(window, cx))
.child(
h_flex()
.flex_1()
.min_h(px(0.))
.child(self.render_sidebar(window, cx))
.child(self.render_main(window, cx)),
)
.children(gpui_kit::component::Root::render_sheet_layer(window, cx))
.children(gpui_kit::component::Root::render_dialog_layer(window, cx))
.children(gpui_kit::component::Root::render_notification_layer(window, cx))
}
}
impl MqttXApp {
fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let app_weak = cx.entity().downgrade();
TitleBar::new().child(
h_flex()
.w_full()
.h_full()
.items_center()
.justify_between()
.pl_2()
.child(
h_flex()
.gap_2()
.items_center()
.child(crate::ui::logo::logo(
px(26.),
cx.theme().primary,
cx.theme().primary_foreground,
))
.child(div().text_sm().font_semibold().child("MQTTX"))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("MQTT 调试客户端"),
),
)
.child(
h_flex()
.gap_1()
.pr_2()
.items_center()
.child(
Button::new("new-connection")
.icon(IconName::Plus)
.label("新建连接")
.primary()
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.open_connection_form(None, window, cx);
})),
)
.child(self.render_theme_toggle(cx))
.child(
Button::new("more-menu")
.icon(IconName::Ellipsis)
.ghost()
.small()
.dropdown_menu(move |menu, _, _| {
let w1 = app_weak.clone();
let w2 = app_weak.clone();
let w3 = app_weak.clone();
menu.item(
PopupMenuItem::new("阿里云设备")
.on_click(move |_, window, cx| {
if let Some(entity) = w1.upgrade() {
crate::ui::aliyun_dialog::open(entity, window, cx);
}
}),
)
.item(PopupMenuItem::new("全局变量").on_click(
move |_, window, cx| {
if let Some(entity) = w2.upgrade() {
crate::ui::variables_dialog::open(entity, window, cx);
}
},
))
.separator()
.item(PopupMenuItem::new("设置").on_click(
move |_, window, cx| {
if let Some(entity) = w3.upgrade() {
crate::ui::settings_dialog::open(entity, window, cx);
}
},
))
}),
),
),
)
}
fn render_theme_toggle(&self, cx: &mut Context<Self>) -> Button {
let dark = cx.theme().mode == ThemeMode::Dark;
Button::new("toggle-theme")
.icon(if dark { IconName::Sun } else { IconName::Moon })
.ghost()
.small()
.tooltip(if dark { "切换为浅色" } else { "切换为深色" })
.on_click(cx.listener(|this, _, _, cx| {
let next = if cx.theme().mode == ThemeMode::Dark {
ThemeModePref::Light
} else {
ThemeModePref::Dark
};
let mut s = this.settings.clone();
s.theme = next;
this.save_settings(s, cx);
cx.notify();
}))
}
fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let query = self.search.read(cx).value().to_lowercase();
let search = self.search.clone();
let total = self.connections.len();
let hover_bg = cx.theme().muted;
let mut rows = v_flex().gap_0p5().flex_1().overflow_y_scrollbar();
let mut shown = 0usize;
for conn in self.connections.clone() {
if !query.is_empty() && !conn.name.to_lowercase().contains(&query) {
continue;
}
shown += 1;
let status = self
.statuses
.get(&conn.id)
.copied()
.unwrap_or(ConnectionStatus::Disconnected);
let active = self.active_tab.as_deref() == Some(conn.id.as_str());
let cfg = conn.clone();
let name = conn.name.clone();
let address = conn.display_address();
let row_id = conn.id.clone();
rows = rows.child(
h_flex()
.id(SharedString::from(format!("conn-row-{}", conn.id)))
.w_full()
.items_center()
.gap_2()
.mx_2()
.my_0p5()
.px_2()
.py_2()
.rounded_md()
.when(active, |this| this.bg(cx.theme().secondary))
.when(!active, |this| {
this.hover(move |this| this.bg(hover_bg))
})
.cursor_pointer()
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tab(&row_id, window, cx);
}))
.child(
div()
.size(px(7.))
.rounded_full()
.bg(status_color(status, cx)),
)
.child(
v_flex()
.flex_1()
.min_w(px(0.))
.gap_0p5()
.child(
div()
.text_sm()
.when(active, |t| t.font_semibold())
.overflow_hidden()
.text_ellipsis()
.child(name),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.overflow_hidden()
.text_ellipsis()
.child(address),
),
)
.child(self.render_conn_power(&cfg, status, cx))
.child(self.render_conn_menu(&conn.id, cx)),
);
}
let body = if self.connections.is_empty() {
v_flex()
.flex_1()
.items_center()
.justify_center()
.gap_2()
.p_4()
.child(
gpui_kit::component::Icon::new(IconName::PlugZap)
.text_color(cx.theme().muted_foreground),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("还没有连接,点击上方 + 新建"),
)
} else if query.is_empty() {
v_flex().flex_1().child(rows)
} else if shown == 0 {
v_flex()
.flex_1()
.items_center()
.justify_center()
.p_4()
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("没有匹配的连接"),
)
} else {
v_flex().flex_1().child(rows)
};
v_flex()
.w(px(264.))
.h_full()
.flex_shrink_0()
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.child(
h_flex()
.h_11()
.px_3()
.items_center()
.justify_between()
.child(
h_flex()
.gap_1p5()
.items_center()
.child(div().text_sm().font_semibold().child("连接"))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(total.to_string()),
),
)
.child(
Button::new("sidebar-add")
.icon(IconName::Plus)
.tooltip("新建连接")
.ghost()
.xsmall()
.on_click(cx.listener(|this, _, window, cx| {
this.open_connection_form(None, window, cx);
})),
),
)
.child(v_flex().px_2().pb_2().child(Input::new(&search).small().prefix(
gpui_kit::component::Icon::new(IconName::Search).small(),
)))
.child(body)
}
fn render_conn_power(
&self,
cfg: &ConnectionConfig,
status: ConnectionStatus,
cx: &mut Context<Self>,
) -> Button {
let cfg = cfg.clone();
let connected = matches!(status, ConnectionStatus::Connected);
Button::new(SharedString::from(format!("conn-power-{}", cfg.id)))
.icon(if connected { IconName::Square } else { IconName::PlugZap })
.ghost()
.xsmall()
.tooltip(if connected { "断开" } else { "连接" })
.on_click(cx.listener(move |this, _, _, _| {
this.toggle_connection(&cfg);
}))
}
fn render_conn_menu(&self, conn_id: &str, cx: &mut Context<Self>) -> impl IntoElement {
let weak = cx.entity().downgrade();
let eid = conn_id.to_string();
Button::new(SharedString::from(format!("conn-menu-{}", conn_id)))
.icon(IconName::Ellipsis)
.ghost()
.xsmall()
.dropdown_menu(move |menu, _, _| {
let (e1, e2, e3) = (eid.clone(), eid.clone(), eid.clone());
let (w1, w2, w3) = (weak.clone(), weak.clone(), weak.clone());
menu.item(PopupMenuItem::new("编辑").on_click(move |_, window, cx| {
w1.update(cx, |app, cx| {
let cfg = app.connections.iter().find(|c| c.id == e1).cloned();
if let Some(cfg) = cfg {
app.open_connection_form(Some(cfg), window, cx);
}
})
.ok();
}))
.item(PopupMenuItem::new("复制连接").on_click(move |_, _, cx| {
w2.update(cx, |app, cx| {
app.duplicate_connection(&e2);
cx.notify();
})
.ok();
}))
.separator()
.item(PopupMenuItem::new("删除").on_click(move |_, _, cx| {
w3.update(cx, |app, cx| app.delete_connection(&e3, cx)).ok();
}))
})
}
fn render_main(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.open_tabs.is_empty() {
return v_flex()
.flex_1()
.items_center()
.justify_center()
.gap_3()
.child(crate::ui::logo::logo(
px(72.),
cx.theme().muted,
cx.theme().muted_foreground,
))
.child(div().text_base().font_medium().child("开始使用 MQTTX"))
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.text_center()
.child("从左侧选择一个连接,或新建连接开始调试"),
)
.child(
Button::new("empty-new")
.icon(IconName::Plus)
.label("新建连接")
.outline()
.small()
.mt_1()
.on_click(cx.listener(|this, _, window, cx| {
this.open_connection_form(None, window, cx);
})),
)
.into_any_element();
}
let active = self.active_tab.clone().unwrap_or_default();
let mut bar = TabBar::new("conn-tabs");
for id in self.open_tabs.clone() {
let name = self
.connections
.iter()
.find(|c| c.id == id)
.map(|c| c.name.clone())
.unwrap_or_else(|| id.clone());
let tab_id = id.clone();
let close_id = id.clone();
let is_active = id == active;
bar = bar.child(
Tab::new()
.label(SharedString::from(name))
.selected(is_active)
.suffix(
Button::new(SharedString::from(format!("tab-close-{}", id)))
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, _, cx| {
this.close_tab(&close_id, cx);
})),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tab(&tab_id, window, cx);
})),
);
}
let view = self.views.get(&active).cloned();
v_flex()
.flex_1()
.min_w(px(0.))
.child(div().flex_shrink_0().px_2().pt_1().child(bar))
.when_some(view, |this, v| this.child(v))
.into_any_element()
}
}
+583
View File
@@ -0,0 +1,583 @@
//! 新建/编辑连接的表单对话框内容。
use std::sync::Arc;
use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::select::{Select, SelectEvent, SelectState};
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, ActiveTheme as _, Icon, Sizable as _,
StyledExt as _, WindowExt as _,
};
use gpui_kit::prelude::FluentBuilder as _;
use crate::ui::IconName;
use gpui_kit::{
div, percentage, px, App, AppContext as _, Context, Entity, InteractiveElement as _,
IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _,
Styled as _, Subscription, Window,
};
use crate::model::{ConnectionConfig, LastWill, ProtocolVersion, TransportKind};
use crate::mqtt::MqttEngine;
use crate::ui::widgets::{field, make_select, OptionDelegate};
const PROTOCOLS: [&str; 2] = ["MQTT 5.0", "MQTT 3.1.1"];
const TRANSPORTS: [&str; 4] = ["TCP", "TLS", "WebSocket", "WSS"];
const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"];
pub struct ConnectionForm {
engine: Arc<MqttEngine>,
editing_id: Option<String>,
created_at: i64,
name: Entity<InputState>,
host: Entity<InputState>,
port: Entity<InputState>,
path: Entity<InputState>,
client_id: Entity<InputState>,
username: Entity<InputState>,
password: Entity<InputState>,
keep_alive: Entity<InputState>,
session_expiry: Entity<InputState>,
receive_max: Entity<InputState>,
max_packet: Entity<InputState>,
topic_alias: Entity<InputState>,
will_topic: Entity<InputState>,
will_payload: Entity<InputState>,
protocol: Entity<SelectState<OptionDelegate>>,
transport: Entity<SelectState<OptionDelegate>>,
will_qos: Entity<SelectState<OptionDelegate>>,
clean_start: bool,
auto_resubscribe: bool,
auto_reconnect: bool,
will_enabled: bool,
will_retain: bool,
// 按需显示:高级分组默认折叠
show_auth: bool,
show_mqtt5: bool,
show_will: bool,
testing: bool,
_subs: Vec<Subscription>,
}
impl ConnectionForm {
/// 对话框 footer 的测试按钮需要读取测试进行中的状态。
pub fn is_testing(&self) -> bool {
self.testing
}
}
fn input(
value: &str,
placeholder: &str,
window: &mut Window,
cx: &mut Context<ConnectionForm>,
) -> Entity<InputState> {
cx.new(|cx| {
InputState::new(window, cx)
.placeholder(placeholder)
.default_value(value)
})
}
fn optional_input(
value: Option<u32>,
placeholder: &str,
window: &mut Window,
cx: &mut Context<ConnectionForm>,
) -> Entity<InputState> {
cx.new(|cx| {
let s = InputState::new(window, cx).placeholder(placeholder);
match value {
Some(v) => s.default_value(v.to_string()),
None => s,
}
})
}
impl ConnectionForm {
pub fn new(
engine: Arc<MqttEngine>,
edit: Option<ConnectionConfig>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let c = edit.clone().unwrap_or_default();
let editing_id = edit.as_ref().map(|e| e.id.clone());
let created_at = edit.as_ref().map(|e| e.created_at).unwrap_or_else(|| {
chrono::Local::now().timestamp()
});
let name = input(&c.name, "连接名称", window, cx);
let host = input(&c.host, "broker.example.com", window, cx);
let port = input(&c.port.to_string(), "端口", window, cx);
let path = input(&c.path, "/mqtt", window, cx);
let client_id = input(&c.client_id, "Client ID", window, cx);
let username = input(&c.username, "用户名(可选)", window, cx);
let password = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("密码(可选)")
.default_value(&c.password)
.masked(true)
});
let keep_alive = input(&c.keep_alive.to_string(), "60", window, cx);
let session_expiry = input(
&if c.session_expiry_interval > 0 {
c.session_expiry_interval.to_string()
} else {
String::new()
},
"秒(0=会话随连接结束)",
window,
cx,
);
let receive_max = optional_input(c.receive_maximum.map(u32::from), "服务端接收上限", window, cx);
let max_packet = optional_input(c.maximum_packet_size, "最大报文长度", window, cx);
let topic_alias = optional_input(c.topic_alias_maximum.map(u32::from), "主题别名上限", window, cx);
let (will_topic, will_payload, will_enabled, will_retain, will_qos_idx) =
match &c.last_will {
Some(w) => (
input(&w.topic, "遗嘱主题", window, cx),
input(&w.payload, "遗嘱消息", window, cx),
true,
w.retain,
w.qos as usize,
),
None => (
input("", "遗嘱主题", window, cx),
input("", "遗嘱消息", window, cx),
false,
false,
0,
),
};
let protocol = make_select(
&PROTOCOLS,
if c.protocol.is_v5() { 0 } else { 1 },
window,
cx,
);
let transport_idx = TransportKind::ALL
.iter()
.position(|t| *t == c.transport)
.unwrap_or(0);
let transport = make_select(&TRANSPORTS, transport_idx, window, cx);
let will_qos = make_select(&QOS, will_qos_idx, window, cx);
// 切换传输方式时,把端口同步成该方式的默认端口,并刷新 WS 路径的按需显示。
let port_for_sub = port.clone();
let sub_transport = cx.subscribe_in(
&transport,
window,
move |_this, state, event, window, cx| {
if matches!(event, SelectEvent::Confirm(Some(_)))
&& let Some(idx) = state.read(cx).selected_value() {
let kind = TransportKind::ALL[*idx];
port_for_sub.update(cx, |p, cx| {
p.set_value(kind.default_port().to_string(), window, cx);
});
cx.notify();
}
},
);
// 切换协议时刷新 MQTT 5 属性分组的按需显示。
let sub_protocol = cx.subscribe_in(
&protocol,
window,
move |_this, _state, event, _window, cx| {
if matches!(event, SelectEvent::Confirm(Some(_))) {
cx.notify();
}
},
);
Self {
engine,
editing_id,
created_at,
name,
host,
port,
path,
client_id,
username,
password,
keep_alive,
session_expiry,
receive_max,
max_packet,
topic_alias,
will_topic,
will_payload,
protocol,
transport,
will_qos,
clean_start: c.clean_start,
auto_resubscribe: c.auto_resubscribe,
auto_reconnect: c.auto_reconnect,
will_enabled,
will_retain,
show_auth: true,
show_mqtt5: false,
show_will: false,
testing: false,
_subs: vec![sub_transport, sub_protocol],
}
}
fn val(&self, e: &Entity<InputState>, cx: &App) -> String {
e.read(cx).value().to_string()
}
fn parse_u16(s: &str, label: &str) -> Result<u16, String> {
s.trim()
.parse::<u16>()
.map_err(|_| format!("{label} 需要是 0~65535 的整数"))
}
fn parse_opt_u32(s: &str, label: &str) -> Result<Option<u32>, String> {
let s = s.trim();
if s.is_empty() {
return Ok(None);
}
s.parse::<u32>()
.map(Some)
.map_err(|_| format!("{label} 需要是非负整数"))
}
pub fn build(&self, cx: &App) -> Result<ConnectionConfig, String> {
self.build_inner(cx, false)
}
/// 测试连接专用的宽松构建:名称与 Client ID 不阻塞测试
///(握手时会生成临时唯一 Client ID)。
fn build_for_test(&self, cx: &App) -> Result<ConnectionConfig, String> {
self.build_inner(cx, true)
}
fn build_inner(&self, cx: &App, for_test: bool) -> Result<ConnectionConfig, String> {
let mut name = self.val(&self.name, cx);
if name.trim().is_empty() {
if !for_test {
return Err("连接名称不能为空".into());
}
name = "测试连接".into();
}
let host = self.val(&self.host, cx);
if host.trim().is_empty() {
return Err("主机地址不能为空".into());
}
let port = Self::parse_u16(&self.val(&self.port, cx), "端口")?;
let mut client_id = self.val(&self.client_id, cx);
if client_id.trim().is_empty() {
if !for_test {
return Err("Client ID 不能为空".into());
}
client_id = format!("mqttx-test-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
}
let keep_alive = Self::parse_u16(&self.val(&self.keep_alive, cx), "Keep Alive")?;
let session_expiry = self
.val(&self.session_expiry, cx)
.trim()
.parse::<u32>()
.unwrap_or(0);
let receive_maximum = Self::parse_opt_u32(&self.val(&self.receive_max, cx), "接收上限")?
.map(|v| u16::try_from(v).map_err(|_| "接收上限不能超过 65535".to_string()))
.transpose()?;
let maximum_packet_size =
Self::parse_opt_u32(&self.val(&self.max_packet, cx), "最大报文长度")?;
let topic_alias_maximum = Self::parse_opt_u32(&self.val(&self.topic_alias, cx), "主题别名")?
.map(|v| u16::try_from(v).map_err(|_| "主题别名上限不能超过 65535".to_string()))
.transpose()?;
let protocol = match self.protocol.read(cx).selected_value() {
Some(1) => ProtocolVersion::V311,
_ => ProtocolVersion::V5,
};
let transport = self
.transport
.read(cx)
.selected_value()
.and_then(|i| TransportKind::ALL.get(*i).copied())
.unwrap_or_default();
let last_will = if self.will_enabled {
let topic = self.val(&self.will_topic, cx);
if topic.trim().is_empty() {
return Err("遗嘱主题不能为空".into());
}
Some(LastWill {
topic,
payload: self.val(&self.will_payload, cx),
qos: self.will_qos.read(cx).selected_value().copied().unwrap_or(0) as u8,
retain: self.will_retain,
content_type: None,
response_topic: None,
})
} else {
None
};
let id = self.editing_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
Ok(ConnectionConfig {
id,
name,
host,
port,
path: self.val(&self.path, cx),
protocol,
transport,
client_id,
username: self.val(&self.username, cx),
password: self.val(&self.password, cx),
clean_start: self.clean_start,
keep_alive,
session_expiry_interval: session_expiry,
receive_maximum,
maximum_packet_size,
topic_alias_maximum,
auto_resubscribe: self.auto_resubscribe,
auto_reconnect: self.auto_reconnect,
last_will,
created_at: self.created_at,
})
}
pub fn run_test(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let mut cfg = match self.build_for_test(cx) {
Ok(c) => c,
Err(e) => {
window.push_notification(Notification::error(e), cx);
return;
}
};
// 测试连接用临时 id,不影响已有连接
cfg.id = uuid::Uuid::new_v4().to_string();
let rx = self.engine.test_connection(cfg);
self.testing = true;
cx.notify();
cx.spawn_in(window, async move |this, cx| {
let result = rx.recv().await.unwrap_or(Err("测试任务丢失".into()));
this.update_in(cx, |form, window, cx| {
form.testing = false;
match result {
Ok(()) => window.push_notification(
Notification::success("连接测试成功"),
cx,
),
Err(e) => window.push_notification(Notification::error(format!(
"连接测试失败: {e}"
)), cx),
}
cx.notify();
})
.ok();
})
.detach();
}
fn switch_row(
&self,
id: &'static str,
label: &'static str,
checked: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
h_flex()
.justify_between()
.child(div().text_sm().child(label))
.child(
Switch::new(id)
.checked(checked)
.on_change(cx.listener(move |this, v, _, cx| {
let v = *v;
match id {
"clean_start" => this.clean_start = v,
"auto_resubscribe" => this.auto_resubscribe = v,
"auto_reconnect" => this.auto_reconnect = v,
"will_enabled" => this.will_enabled = v,
"will_retain" => this.will_retain = v,
_ => {}
}
cx.notify();
})),
)
}
/// 可折叠分组:点击标题行切换展开/收起,收起时不渲染内容。
#[allow(clippy::too_many_arguments)]
fn collapsible_section(
&self,
id: &'static str,
title: &'static str,
hint: Option<SharedString>,
hint_accent: bool,
open: bool,
toggle: fn(&mut Self),
border: gpui_kit::Hsla,
bg: gpui_kit::Hsla,
body: impl IntoElement,
cx: &mut Context<Self>,
) -> impl IntoElement {
let title: SharedString = title.into();
v_flex().gap_2().child(
v_flex()
.child(
h_flex()
.id(id)
.justify_between()
.rounded_md()
.px_1()
.py_1()
.cursor_pointer()
.hover(|s| s.bg(cx.theme().secondary_hover))
.on_click(cx.listener(move |this, _, _, cx| {
toggle(this);
cx.notify();
}))
.child(
h_flex()
.gap_1p5()
.items_center()
.child(div().text_sm().font_semibold().child(title))
.children(hint.map(|h| {
let color = if hint_accent {
cx.theme().accent
} else {
cx.theme().muted_foreground
};
div().text_xs().text_color(color).child(h)
})),
)
.child(
Icon::new(IconName::ChevronDown)
.xsmall()
.text_color(cx.theme().muted_foreground)
.rotate(percentage(if open { 0.5 } else { 0. })),
),
)
.when(open, |w| {
w.child(
div()
.rounded_lg()
.border_1()
.border_color(border)
.bg(bg)
.p_3()
.child(body),
)
}),
)
}
}
impl Render for ConnectionForm {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let border = cx.theme().border;
let card_bg = cx.theme().secondary;
// 协议/传输决定高级分组的按需显示
let is_v5 = self.protocol.read(cx).selected_value() != Some(&1);
let is_ws = matches!(
self.transport.read(cx).selected_value(),
Some(2) | Some(3)
);
v_flex()
.gap_3()
.max_h(px(560.))
.overflow_y_scrollbar()
.pr_1()
// ── 基本信息(始终展开) ──
.child(section("基本信息", border, card_bg,
v_flex().gap_2().child(
h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("名称", Input::new(&self.name))))
.child(div().w(px(120.)).child(field("端口", Input::new(&self.port))))
)
.child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("主机", Input::new(&self.host))))
.child(div().flex_1().min_w(px(0.)).child(field("协议", Select::new(&self.protocol))))
.child(div().flex_1().min_w(px(0.)).child(field("传输", Select::new(&self.transport))))
)
.child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("Client ID", Input::new(&self.client_id))))
// WS 路径仅对 WebSocket/WSS 有意义,按需显示
.when(is_ws, |w| w.child(
div().flex_1().min_w(px(0.)).child(field("WS 路径", Input::new(&self.path)))
))
)
))
// ── 认证与心跳 ──
.child(self.collapsible_section(
"sec-auth", "认证与心跳", None, false, self.show_auth,
|f| f.show_auth = !f.show_auth, border, card_bg,
v_flex().gap_2()
.child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("用户名", Input::new(&self.username))))
.child(div().flex_1().min_w(px(0.)).child(field("密码", Input::new(&self.password).mask_toggle())))
.child(div().w(px(110.)).child(field("Keep Alive (秒)", Input::new(&self.keep_alive))))
)
.child(self.switch_row("clean_start", "Clean Start / Clean Session", self.clean_start, cx))
.child(self.switch_row("auto_reconnect", "断线自动重连", self.auto_reconnect, cx))
.child(self.switch_row("auto_resubscribe", "连接后自动恢复订阅", self.auto_resubscribe, cx)),
cx,
))
// ── MQTT 5 高级属性(仅 v5 协议显示) ──
.when(is_v5, |w| w.child(self.collapsible_section(
"sec-mqtt5", "MQTT 5 属性", None, false, self.show_mqtt5,
|f| f.show_mqtt5 = !f.show_mqtt5, border, card_bg,
h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("会话过期间隔(秒)", Input::new(&self.session_expiry))))
.child(div().flex_1().min_w(px(0.)).child(field("接收最大值", Input::new(&self.receive_max))))
.child(div().flex_1().min_w(px(0.)).child(field("最大报文长度", Input::new(&self.max_packet))))
.child(div().flex_1().min_w(px(0.)).child(field("主题别名上限", Input::new(&self.topic_alias)))),
cx,
)))
// ── 遗嘱 ──
.child(self.collapsible_section(
"sec-will", "遗嘱消息 (Last Will)",
self.will_enabled.then(|| "已启用".into()), true, self.show_will,
|f| f.show_will = !f.show_will, border, card_bg,
v_flex().gap_2()
.child(self.switch_row("will_enabled", "启用遗嘱消息", self.will_enabled, cx))
.when(self.will_enabled, |w| w
.child(h_flex().gap_2()
.child(div().flex_1().min_w(px(0.)).child(field("遗嘱主题", Input::new(&self.will_topic))))
.child(div().w(px(130.)).child(field("QoS", Select::new(&self.will_qos))))
)
.child(field("遗嘱内容", Input::new(&self.will_payload)))
.child(self.switch_row("will_retain", "Retain", self.will_retain, cx))
),
cx,
))
}
}
fn section(
title: impl Into<gpui_kit::SharedString>,
border: gpui_kit::Hsla,
bg: gpui_kit::Hsla,
body: impl IntoElement,
) -> impl IntoElement {
let title: gpui_kit::SharedString = title.into();
v_flex()
.gap_2()
.child(div().text_sm().font_semibold().child(title))
.child(
div()
.rounded_lg()
.border_1()
.border_color(border)
.bg(bg)
.p_3()
.child(body),
)
}
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
//! MQTTX 品牌 Logo:纯 GPUI 原语绘制的 pub/sub 拓扑徽标。
//!
//! - 圆角方块作为品牌底(主色);
//! - 中心圆点代表 broker,连线连向发布端与订阅端,
//! 表达 MQTT “发布 / 代理 / 订阅” 的星型拓扑;
//! - 末端节点用环形描边,与中心实心 broker 区分。
use gpui_kit::{
canvas, div, px, App, Bounds, Corners, Edges, Hsla, IntoElement, ParentElement,
PathBuilder, Pixels, Point, Size, Styled as _, Window,
};
/// 绘制一个指定边长的 Logo(容器被设置为该尺寸)。
pub fn logo(side: Pixels, bg: Hsla, fg: Hsla) -> impl IntoElement {
let inner = canvas(
move |_bounds, _window, _cx| (bg, fg, side),
move |bounds, (bg, fg, side), window, _cx| {
// 品牌圆角方块
let radius = side * 0.26;
window.paint_quad(gpui_kit::quad(
bounds,
Corners::all(radius),
bg,
Edges::all(px(0.)),
bg,
gpui_kit::BorderStyle::default(),
));
let origin = bounds.origin;
// 归一化比例坐标 → 像素
let at = move |x: f32, y: f32| Point::new(origin.x + side * x, origin.y + side * y);
// 中心 broker 与三个客户端节点(右上任一发布、右下与左侧订阅)
let center = (0.5_f32, 0.5_f32);
let nodes = [(0.78_f32, 0.27_f32), (0.78, 0.73), (0.23, 0.5)];
// 连线,两端补圆点形成圆头效果(不依赖 lyon LineCap)
for &(nx, ny) in &nodes {
let a = at(center.0, center.1);
let b = at(nx, ny);
if let Some(p) = line_path(a, b, side * 0.04) {
window.paint_path(p, fg.alpha(0.9));
}
paint_dot(window, a, side * 0.05, fg.alpha(0.9));
paint_dot(window, b, side * 0.05, fg.alpha(0.9));
}
// 实心中心 broker(覆盖端点)
paint_dot(window, at(center.0, center.1), side * 0.17, fg);
// 环形末端节点
for &(nx, ny) in &nodes {
paint_ring(window, at(nx, ny), side * 0.15, side * 0.03, bg, fg);
}
},
)
.absolute()
.size_full();
div()
.relative()
.w(side)
.h(side)
.child(inner)
}
fn paint_dot(window: &mut Window, center: Point<Pixels>, d: Pixels, color: Hsla) {
let r = d * 0.5;
let bounds = Bounds {
origin: Point::new(center.x - r, center.y - r),
size: Size {
width: d,
height: d,
},
};
window.paint_quad(gpui_kit::quad(
bounds,
Corners::all(r),
color,
Edges::all(px(0.)),
color,
gpui_kit::BorderStyle::default(),
));
}
fn paint_ring(
window: &mut Window,
center: Point<Pixels>,
d: Pixels,
thickness: Pixels,
fill: Hsla,
border: Hsla,
) {
let r = d * 0.5;
let bounds = Bounds {
origin: Point::new(center.x - r, center.y - r),
size: Size {
width: d,
height: d,
},
};
window.paint_quad(gpui_kit::quad(
bounds,
Corners::all(r),
fill,
Edges::all(thickness),
border,
gpui_kit::BorderStyle::Solid,
));
}
fn line_path(
a: Point<Pixels>,
b: Point<Pixels>,
width: Pixels,
) -> Option<gpui_kit::Path<Pixels>> {
let mut pb = PathBuilder::stroke(width);
pb.move_to(a);
pb.line_to(b);
pb.build().ok()
}
#[allow(unused_imports)]
use App as _App;
+13
View File
@@ -0,0 +1,13 @@
//! GPUI 界面层。
pub mod aliyun_dialog;
pub mod app;
pub mod connection_form;
pub mod connection_view;
pub mod logo;
pub mod settings_dialog;
pub mod variables_dialog;
pub mod widgets;
/// 统一使用全量 Lucide 图标(gpui-kit-assets 已随应用加载)。
pub use gpui_kit::assets::IconName;
+138
View File
@@ -0,0 +1,138 @@
//! 应用设置对话框:主题、消息缓存条数、时间戳显示。
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::select::{Select, SelectState};
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, WindowExt as _,
};
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
Styled as _, Window,
};
use crate::model::{AppSettings, ThemeModePref};
use crate::ui::app::MqttXApp;
use crate::ui::widgets::{field, make_select, OptionDelegate};
const THEMES: [&str; 3] = ["跟随系统", "浅色", "深色"];
struct SettingsDialog {
theme: Entity<SelectState<OptionDelegate>>,
max_messages: Entity<InputState>,
show_millis: bool,
}
impl SettingsDialog {
fn new(initial: AppSettings, window: &mut Window, cx: &mut Context<Self>) -> Self {
let theme_idx = match initial.theme {
ThemeModePref::System => 0,
ThemeModePref::Light => 1,
ThemeModePref::Dark => 2,
};
Self {
theme: make_select(&THEMES, theme_idx, window, cx),
max_messages: cx.new(|cx| {
InputState::new(window, cx).default_value(initial.max_messages.to_string())
}),
show_millis: initial.show_millis,
}
}
fn collect(&self, cx: &App) -> Result<AppSettings, String> {
let theme = match self.theme.read(cx).selected_value() {
Some(1) => ThemeModePref::Light,
Some(2) => ThemeModePref::Dark,
_ => ThemeModePref::System,
};
let max_messages = self
.max_messages
.read(cx)
.value()
.trim()
.parse::<usize>()
.map_err(|_| "消息缓存条数必须是正整数".to_string())?
.max(100);
Ok(AppSettings {
theme,
max_messages,
show_millis: self.show_millis,
..Default::default()
})
}
}
impl Render for SettingsDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_4()
.w(px(420.))
.child(field("主题", Select::new(&self.theme)))
.child(field(
"每条连接内存中保留的消息条数",
Input::new(&self.max_messages),
))
.child(
h_flex()
.justify_between()
.child(div().text_sm().child("时间戳显示毫秒"))
.child(
Switch::new("show-millis")
.checked(self.show_millis)
.on_change(cx.listener(|this, v, _, cx| {
this.show_millis = *v;
cx.notify();
})),
),
)
}
}
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
let initial = app.read(cx).settings.clone();
let dialog_view: Entity<SettingsDialog> =
cx.new(|cx| SettingsDialog::new(initial, window, cx));
let app_save = app.clone();
window.open_dialog(cx, move |dialog, _window, _cx| {
let body = dialog_view.clone();
let for_collect = dialog_view.clone();
let app_for_save = app_save.clone();
dialog
.w(px(480.))
.title("设置")
.child(body)
.footer(
h_flex()
.gap_2()
.justify_end()
.w_full()
.child(
Button::new("settings-cancel")
.label("取消")
.outline()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("settings-ok")
.label("保存")
.primary()
.on_click(move |_, window, cx| {
let result = for_collect.read(cx).collect(cx);
match result {
Ok(settings) => {
app_for_save.update(cx, |a, cx| {
a.save_settings(settings, cx);
cx.notify();
});
window.close_dialog(cx);
}
Err(e) => {
window.push_notification(Notification::error(e), cx);
}
}
}),
),
)
});
}
+74
View File
@@ -0,0 +1,74 @@
//! 全局变量管理对话框。变量可在发布主题/负载中以 `{{key}}` 引用。
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::{h_flex, v_flex, WindowExt as _};
use gpui_kit::{
px, App, AppContext as _, Entity, ParentElement as _, Styled as _, Window,
};
use crate::model::GlobalVariable;
use crate::ui::app::MqttXApp;
use crate::ui::widgets::KvEditor;
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
let pairs: Vec<(String, String)> = app
.read(cx)
.variables
.iter()
.map(|v| (v.key.clone(), v.value.clone()))
.collect();
let editor: Entity<KvEditor> =
cx.new(|cx| KvEditor::new("global-var", &pairs, window, cx));
let app = app.clone();
window.open_dialog(cx, move |dialog, _, _| {
let editor_body = editor.clone();
let for_ok = editor.clone();
let app_ok = app.clone();
dialog
.w(px(560.))
.title("全局变量")
.child(
v_flex()
.gap_2()
.child(
v_flex()
.text_sm()
.gap_1()
.child("在发布主题、消息负载中使用 {{变量名}} 引用。")
.child("内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}"),
)
.child(editor_body),
)
.footer(
h_flex()
.gap_2()
.justify_end()
.w_full()
.child(
Button::new("vars-cancel")
.label("取消")
.outline()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("vars-ok")
.label("保存")
.primary()
.on_click(move |_, window, cx| {
let vars = for_ok
.read(cx)
.pairs(cx)
.into_iter()
.map(|(key, value)| GlobalVariable { key, value })
.collect();
app_ok.update(cx, |a, cx| {
a.save_variables(vars);
cx.notify();
});
window.close_dialog(cx);
}),
),
)
});
}
+240
View File
@@ -0,0 +1,240 @@
//! 跨界面复用的小组件:枚举下拉、键值对编辑器、状态指示、字段行等。
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::searchable_list::{SearchableListDelegate, SearchableListItem};
use gpui_kit::component::select::{Select, SelectState};
use gpui_kit::component::{h_flex, v_flex, ActiveTheme as _, Disableable as _, IndexPath, Sizable as _};
use crate::ui::IconName;
use gpui_kit::{App, AppContext as _, Context, Entity, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div, px};
use crate::model::ConnectionStatus;
// ─── 枚举下拉 ────────────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct OptionItem {
pub value: usize,
pub label: SharedString,
}
impl SearchableListItem for OptionItem {
type Value = usize;
fn title(&self) -> SharedString {
self.label.clone()
}
fn value(&self) -> &usize {
&self.value
}
}
#[derive(Clone)]
pub struct OptionDelegate {
pub items: Vec<OptionItem>,
}
impl OptionDelegate {
pub fn new(labels: &[&str]) -> Self {
Self {
items: labels
.iter()
.enumerate()
.map(|(i, l)| OptionItem {
value: i,
label: (*l).into(),
})
.collect(),
}
}
}
impl SearchableListDelegate for OptionDelegate {
type Item = OptionItem;
fn items_count(&self, _section: usize) -> usize {
self.items.len()
}
fn item(&self, ix: IndexPath) -> Option<&Self::Item> {
self.items.get(ix.row)
}
fn position<V>(&self, _value: &V) -> Option<IndexPath>
where
Self::Item: SearchableListItem<Value = V>,
V: PartialEq,
{
// 选中项统一通过 IndexPath 构造,无需按值反查
None
}
}
/// 创建字符串枚举下拉状态(在任意实体上下文里调用)。
pub fn make_select<C: gpui_kit::AppContext>(
labels: &[&str],
selected: usize,
window: &mut Window,
cx: &mut C,
) -> Entity<SelectState<OptionDelegate>> {
let selected = selected.min(labels.len().saturating_sub(1));
cx.new(|cx| {
SelectState::new(
OptionDelegate::new(labels),
Some(IndexPath::new(selected)),
window,
cx,
)
})
}
// ─── 字段行 ──────────────────────────────────────────────────────────────────
/// 小号标签 + 控件的垂直字段容器。
pub fn field(label: impl Into<SharedString>, control: impl IntoElement) -> impl IntoElement {
v_flex()
.gap_1()
.min_w(px(0.))
.child(div().text_xs().child(label.into()))
.child(control)
}
// ─── 状态颜色 ────────────────────────────────────────────────────────────────
pub fn status_color(status: ConnectionStatus, cx: &App) -> Hsla {
let t = cx.theme();
match status {
ConnectionStatus::Connected => t.success,
ConnectionStatus::Connecting => t.warning,
ConnectionStatus::Error => t.danger,
ConnectionStatus::Disconnected => t.muted_foreground,
}
}
pub fn status_dot(status: ConnectionStatus, cx: &App) -> impl IntoElement {
div()
.size(px(8.))
.rounded_full()
.bg(status_color(status, cx))
}
// ─── 键值对编辑器 ────────────────────────────────────────────────────────────
/// MQTT 5 用户属性等 key/value 列表编辑器。
pub struct KvEditor {
id_prefix: SharedString,
pub rows: Vec<(Entity<InputState>, Entity<InputState>)>,
}
impl KvEditor {
pub fn new(
id_prefix: impl Into<SharedString>,
pairs: &[(String, String)],
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut editor = Self {
id_prefix: id_prefix.into(),
rows: Vec::new(),
};
for (k, v) in pairs {
editor.push(Some((k.as_str(), v.as_str())), window, cx);
}
if editor.rows.is_empty() {
editor.push(None, window, cx);
}
editor
}
fn push(&mut self, initial: Option<(&str, &str)>, window: &mut Window, cx: &mut Context<Self>) {
let key = cx.new(|cx| {
let mut s = InputState::new(window, cx).placeholder("key");
if let Some((k, _)) = initial {
s.set_value(k, window, cx);
}
s
});
let value = cx.new(|cx| {
let mut s = InputState::new(window, cx).placeholder("value");
if let Some((_, v)) = initial {
s.set_value(v, window, cx);
}
s
});
self.rows.push((key, value));
}
pub fn pairs(&self, cx: &App) -> Vec<(String, String)> {
self.rows
.iter()
.map(|(k, v)| (k.read(cx).value().to_string(), v.read(cx).value().to_string()))
.filter(|(k, _)| !k.trim().is_empty())
.collect()
}
}
impl Render for KvEditor {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let prefix = self.id_prefix.clone();
let count = self.rows.len();
let mut rows = v_flex().gap_1();
for (i, (key, value)) in self.rows.iter().cloned().enumerate() {
rows = rows.child(
h_flex()
.id(SharedString::from(format!("kv-row-{prefix}-{i}")))
.gap_1()
.child(div().flex_1().min_w(px(0.)).child(Input::new(&key)))
.child(div().flex_1().min_w(px(0.)).child(Input::new(&value)))
.child(
Button::new(SharedString::from(format!("kv-del-{prefix}-{i}")))
.icon(IconName::Close)
.ghost()
.xsmall()
.disabled(count <= 1)
.on_click(cx.listener(move |this, _, _, cx| {
if this.rows.len() > 1 {
this.rows.remove(i);
cx.notify();
}
})),
),
);
}
v_flex()
.gap_2()
.child(rows)
.child(
Button::new(SharedString::from(format!("kv-add-{prefix}")))
.icon(IconName::Plus)
.label("添加属性")
.ghost()
.xsmall()
.on_click(cx.listener(|this, _, window, cx| {
this.push(None, window, cx);
cx.notify();
})),
)
}
}
// ─── 时间格式化 ──────────────────────────────────────────────────────────────
pub fn format_time(ts_ms: i64, show_millis: bool) -> String {
use chrono::TimeZone as _;
let Some(dt) = chrono::Local.timestamp_millis_opt(ts_ms).single() else {
return String::new();
};
if show_millis {
dt.format("%H:%M:%S%.3f").to_string()
} else {
dt.format("%H:%M:%S").to_string()
}
}
/// Select::new 的包装,统一外观。
pub fn select_element<D: SearchableListDelegate>(
state: &Entity<SelectState<D>>,
) -> Select<D>
where
<D::Item as SearchableListItem>::Value: PartialEq + Clone,
{
Select::new(state)
}
+114
View File
@@ -0,0 +1,114 @@
//! 真实 MQTT 引擎连通性测试:连接公共 broker,订阅并验证消息回环。
//! 默认忽略;设置 MQTTX_LIVE=1 并加 --ignored 运行(依赖外网)。
use std::time::{Duration, Instant};
use mqttx_desktop::model::{
ConnectionConfig, ConnectionStatus, ProtocolVersion, PublishParams, Subscription, TransportKind,
};
use mqttx_desktop::mqtt::{EngineEvent, MqttEngine};
#[test]
#[ignore = "需外网:MQTTX_LIVE=1 cargo test --test engine_live -- --ignored"]
fn v5_tcp_pubsub_roundtrip() {
let (tx, rx) = smol::channel::unbounded();
let engine = MqttEngine::new(tx);
let mut cfg = ConnectionConfig::new();
cfg.name = "live-v5".into();
cfg.host = "broker.emqx.io".into();
cfg.port = 1883;
cfg.transport = TransportKind::Tcp;
cfg.protocol = ProtocolVersion::V5;
cfg.auto_reconnect = false;
cfg.auto_resubscribe = false;
let cid = cfg.id.clone();
let topic = format!("mqttx/gpui-test/{}/{}", cid, uuid::Uuid::new_v4());
engine.connect(cfg);
assert!(wait_connected(&rx), "未能在 15 秒内连接 broker.emqx.io");
engine.subscribe(cid.clone(), Subscription::new(cid.clone(), topic.clone(), 0));
assert!(wait_subscribe(&rx, &topic), "订阅未确认");
// 等 broker 传播订阅状态
std::thread::sleep(Duration::from_millis(500));
engine.publish(
cid.clone(),
PublishParams {
topic: topic.clone(),
payload: "hello-gpui".into(),
qos: 0,
..Default::default()
},
);
assert!(wait_message(&rx, &topic), "未收到自己发布的消息(回环失败)");
}
#[test]
#[ignore = "需外网:MQTTX_LIVE=1 cargo test --test engine_live -- --ignored"]
fn v311_tcp_connect() {
let (tx, rx) = smol::channel::unbounded();
let engine = MqttEngine::new(tx);
let mut cfg = ConnectionConfig::new();
cfg.host = "broker.emqx.io".into();
cfg.port = 1883;
cfg.protocol = ProtocolVersion::V311;
cfg.auto_reconnect = false;
cfg.auto_resubscribe = false;
engine.connect(cfg);
assert!(wait_connected(&rx), "MQTT 3.1.1 连接失败");
}
fn pump<F: FnMut(&EngineEvent) -> bool>(
rx: &smol::channel::Receiver<EngineEvent>,
timeout: Duration,
mut pred: F,
) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
match rx.try_recv() {
Ok(ev) => {
if pred(&ev) {
return true;
}
}
Err(_) => std::thread::sleep(Duration::from_millis(30)),
}
}
false
}
fn wait_connected(rx: &smol::channel::Receiver<EngineEvent>) -> bool {
pump(rx, Duration::from_secs(15), |ev| match ev {
EngineEvent::Status {
status: ConnectionStatus::Connected,
..
} => true,
EngineEvent::Status {
status: ConnectionStatus::Error,
error,
..
} => panic!("连接错误: {error:?}"),
_ => false,
})
}
fn wait_subscribe(rx: &smol::channel::Receiver<EngineEvent>, topic: &str) -> bool {
pump(rx, Duration::from_secs(8), |ev| match ev {
EngineEvent::SubscribeResult { topic: t, ok, .. } => t == topic && *ok,
_ => false,
})
}
fn wait_message(rx: &smol::channel::Receiver<EngineEvent>, topic: &str) -> bool {
pump(rx, Duration::from_secs(10), |ev| match ev {
EngineEvent::Message(m) => m.topic == topic && m.payload == "hello-gpui",
_ => false,
})
}
+143
View File
@@ -0,0 +1,143 @@
<#
.SYNOPSIS
发布构建产物到 Gitea Package Registry(Generic 包)。
.DESCRIPTION
用 Gitea Generic Package API 上传文件:
PUT {GITEA_URL}/api/packages/{owner}/generic/{pkg}/{version}/{filename}
认证:Authorization: token <GITEA_TOKEN>
同名版本重复上传会得到 409,脚本按"已存在"处理。
上传成功后附加 .sha256 校验文件。
.EXAMPLE
$env:GITEA_URL = "https://gitea.example.com"
$env:GITEA_TOKEN = "xxxxxxxx"
.\scripts\publish-gitea.ps1 # 上传默认产物,版本自动推断
.\scripts\publish-gitea.ps1 -Version v1.0.1 # 指定版本
.\scripts\publish-gitea.ps1 -Files a.exe, b.zip # 指定文件
.PARAMETER Files
待上传文件列表。缺省时自动探测 target/release/mqttx(.exe)。
.PARAMETER Version
软件包版本。缺省时依次尝试 git describe --tags、gpui-app/Cargo.toml。
.PARAMETER Owner
覆盖 GITEA_OWNER(缺省时从 git remote origin 推断)。
.PARAMETER PackageName
覆盖 GITEA_PKG(默认 mqttx)。
#>
[CmdletBinding()]
param(
[string[]]$Files = @(),
[string]$Version = "",
[string]$Owner = "",
[string]$PackageName = ""
)
$ErrorActionPreference = "Stop"
# Windows PowerShell 5.1 默认可能协商 TLS1.0,现代 Gitea 实例要求 TLS1.2+
try {
[Net.ServicePointManager]::SecurityProtocol = `
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
} catch { }
function Die($msg) { Write-Error "错误: $msg"; exit 1 }
# ── 必填配置 ────────────────────────────────────────────────────────────────
$GiteaUrl = $env:GITEA_URL
$Token = $env:GITEA_TOKEN
if (-not $GiteaUrl) { Die "缺少环境变量 GITEA_URL(如 https://gitea.example.com)" }
if (-not $Token) { Die "缺少环境变量 GITEA_TOKEN(需 package 写权限)" }
$GiteaUrl = $GiteaUrl.TrimEnd("/")
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$Pkg = if ($PackageName) { $PackageName } elseif ($env:GITEA_PKG) { $env:GITEA_PKG } else { "mqttx" }
# ── owner:参数 > GITEA_OWNER > git remote origin 推断 ──────────────────────
if (-not $Owner) { $Owner = $env:GITEA_OWNER }
if (-not $Owner) {
$remote = git -C $Root remote get-url origin 2>$null
if ($remote) {
# 兼容 git@host:owner/repo.git 与 https://host/owner/repo.git
if ($remote -match "://") {
# URL 形式:取 host 之后的路径段
$path = ($remote -split "/", 4)[-1]
}
else {
# SCP 形式 git@host:owner/repo
$path = ($remote -split ":", 2)[-1]
}
$path = $path -replace "\.git$", ""
$seg = @($path -split "/" | Where-Object { $_ })
if ($seg.Count -ge 2) { $Owner = $seg[0] }
}
}
if (-not $Owner) { Die "无法推断 owner,请用 -Owner 或 GITEA_OWNER 指定" }
# ── 版本:参数 > git describe > Cargo.toml ──────────────────────────────────
if (-not $Version) {
$Version = (git -C $Root describe --tags --always 2>$null)
}
if (-not $Version) {
$m = Select-String -Path (Join-Path $Root "gpui-app\Cargo.toml") -Pattern '^version = "(.+)"' | Select-Object -First 1
if ($m) { $Version = $m.Matches[0].Groups[1].Value }
}
if (-not $Version) { Die "无法确定版本号" }
$Version = $Version -replace "/", "-" # Gitea 版本段不允许 "/"
# ── 默认产物 ────────────────────────────────────────────────────────────────
if (-not $Files -or $Files.Count -eq 0) {
$exe = Join-Path $Root "target\release\mqttx.exe"
$bin = Join-Path $Root "target\release\mqttx"
if (Test-Path $exe) { $Files = @($exe) }
elseif (Test-Path $bin) { $Files = @($bin) }
else { Die "未找到产物,请先 cargo build --release,或用 -Files 指定" }
}
Write-Host "→ Gitea: $GiteaUrl"
Write-Host "→ 软件包: $Owner/$Pkg@$Version"
Write-Host "→ 产物: $($Files -join ', ')"
foreach ($f in $Files) {
if (-not (Test-Path $f)) { Die "文件不存在: $f" }
$name = Split-Path -Leaf $f
$size = "{0:N1} MB" -f ((Get-Item $f).Length / 1MB)
Write-Host "→ 上传 $name ($size)"
$url = "$GiteaUrl/api/packages/$Owner/generic/$Pkg/$Version/$name"
# Windows PowerShell 5.1 无 -InFile 的 token 认证上传也走 Invoke-WebRequest
try {
Invoke-RestMethod -Method Put `
-Headers @{ Authorization = "token $Token" } `
-InFile $f `
-ContentType "application/octet-stream" `
-Uri $url | Out-Null
Write-Host " ✓ 已发布 $name" -ForegroundColor Green
}
catch {
$status = $null
if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
if ($status -eq 409) {
Write-Host " ! 已存在同名版本(409),跳过:$name" -ForegroundColor Yellow
continue
}
Die "上传失败: $($_.Exception.Message)"
}
# 附带 sha256 校验文件
$sum = (Get-FileHash -Algorithm SHA256 $f).Hash.ToLower()
$sidecar = "$f.sha256"
"$sum $name" | Set-Content -NoNewline -Encoding ascii $sidecar
try {
Invoke-RestMethod -Method Put `
-Headers @{ Authorization = "token $Token" } `
-InFile $sidecar `
-ContentType "text/plain" `
-Uri "$url.sha256" | Out-Null
Write-Host " ✓ 已发布 $name.sha256" -ForegroundColor Green
}
catch {
Write-Host " ! sha256 文件上传失败" -ForegroundColor Yellow
}
}
Write-Host "完成。软件包页面: $GiteaUrl/$Owner?tab=packages" -ForegroundColor Cyan
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# 发布构建产物到 Gitea Package Registry(Generic 包)。
#
# 用法:
# GITEA_URL=https://gitea.example.com GITEA_TOKEN=xxx \
# ./scripts/publish-gitea.sh [文件...] [版本]
#
# 环境变量:
# GITEA_URL 实例地址,如 https://gitea.example.com(必填)
# GITEA_TOKEN 访问令牌,需 package 写权限(必填)
# GITEA_OWNER 软件包所属用户/组织(默认:由 git remote 推断,失败则报错)
# GITEA_PKG 软件包名(默认 mqttx)
#
# 版本推断顺序:参数 > git describe --tags > gpui-app/Cargo.toml。
set -euo pipefail
die() { echo "错误: $*" >&2; exit 1; }
[ -n "${GITEA_URL:-}" ] || die "缺少 GITEA_URL"
[ -n "${GITEA_TOKEN:-}" ] || die "缺少 GITEA_TOKEN"
GITEA_URL="${GITEA_URL%/}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PKG="${GITEA_PKG:-mqttx}"
# ── owner:优先环境变量,其次从 git remote 推断 ──────────────────────────────
owner_of_remote() {
local url path
url="$(git -C "$ROOT" remote get-url origin 2>/dev/null)" || return 1
case "$url" in
*://*) path="${url#*://}"; path="${path#*/}" ;; # https://host/owner/repo.git
*:*) path="${url#*:}" ;; # git@host:owner/repo.git
*) return 1 ;;
esac
path="${path%.git}"
printf '%s\n' "${path%%/*}"
}
OWNER="${GITEA_OWNER:-$(owner_of_remote || true)}"
[ -n "${OWNER:-}" ] || die "无法推断 GITEA_OWNER,请显式设置"
# ── 版本:参数 > git describe > Cargo.toml ───────────────────────────────────
FILES=()
for arg in "$@"; do
if [ -f "$arg" ]; then FILES+=("$arg"); else VERSION="$arg"; fi
done
if [ -z "${VERSION:-}" ]; then
VERSION="$(git -C "$ROOT" describe --tags --always 2>/dev/null || true)"
fi
if [ -z "${VERSION:-}" ]; then
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/gpui-app/Cargo.toml" | head -1)"
fi
[ -n "${VERSION:-}" ] || die "无法确定版本号"
# Gitea 版本段不允许 "/",tag 形如 v1.0.0-3-gabc123 时规范化
VERSION="${VERSION//\//-}"
# ── 默认产物 ────────────────────────────────────────────────────────────────
if [ "${#FILES[@]}" -eq 0 ]; then
if [ -x "$ROOT/target/release/mqttx.exe" ]; then
FILES=("$ROOT/target/release/mqttx.exe")
elif [ -x "$ROOT/target/release/mqttx" ]; then
FILES=("$ROOT/target/release/mqttx")
else
die "未找到产物,请先 cargo build --release,或显式传入文件"
fi
fi
echo "→ Gitea: $GITEA_URL"
echo "→ 软件包: $OWNER/$PKG@$VERSION"
echo "→ 产物: ${FILES[*]}"
sha256() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
else shasum -a 256 "$1" | cut -d' ' -f1; fi
}
for f in "${FILES[@]}"; do
[ -f "$f" ] || die "文件不存在: $f"
name="$(basename "$f")"
echo "→ 上传 $name ($(du -h "$f" | cut -f1))"
code="$(curl -sS -o /tmp/gitea-pkg-resp.txt -w '%{http_code}' \
-X PUT \
-H "Authorization: token ${GITEA_TOKEN}" \
--upload-file "$f" \
"${GITEA_URL}/api/packages/${OWNER}/generic/${PKG}/${VERSION}/${name}")"
case "$code" in
201|200|409) # 409 = 同名版本已存在,Gitea 拒绝覆盖
if [ "$code" = "409" ]; then
echo " ! 已存在同名版本(409),跳过:$name"
else
echo " ✓ 已发布 $name (HTTP $code)"
# 附带 sha256 校验文件,便于下游验证
sum="$(sha256 "$f")"
echo "$sum $name" > "${f}.sha256"
code2="$(curl -sS -o /dev/null -w '%{http_code}' \
-X PUT \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: text/plain" \
--data-binary "@${f}.sha256" \
"${GITEA_URL}/api/packages/${OWNER}/generic/${PKG}/${VERSION}/${name}.sha256")"
[ "$code2" = "201" ] || echo " ! sha256 文件上传失败 (HTTP $code2)"
fi
;;
*)
echo " ✗ 上传失败 (HTTP $code)" >&2
cat /tmp/gitea-pkg-resp.txt >&2 || true
exit 1
;;
esac
done
echo "完成。软件包页面: ${GITEA_URL}/${OWNER}?tab=packages"