feat: 重写为 GPUI 原生桌面应用并接入 Gitea 软件包发布
- gpui-kit 0.6 原生 UI:连接管理、消息流/日志、发布预设、全局变量、 设置、阿里云连接预设、原生 Logo - MQTT 5.0/3.1.1 引擎(rumqttc,TCP/TLS/WS/WSS),独立 tokio runtime + 事件泵,真实 broker 端到端测试 - 连接表单:保存前可一键测试连接(宽松校验,自动生成临时 Client ID), 高级属性(认证/MQTT 5 属性/遗嘱)按需折叠显示,WS 路径仅 WS/WSS 显示 - bug 修复:Hex 奇数长度显式报错、测试连接唯一 Client ID、Base64/Hex 发布发送真实字节、v5 发布记录携带 Content-Type/用户属性、预设应用 清除旧 Content-Type、模板时间基准统一;补 8 个单元测试 - Gitea 发布:Generic Package 上传脚本(bash/PowerShell)+ Actions release 工作流(tag 触发,双平台构建),README 使用说明
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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 }}"
|
||||
@@ -32,3 +32,9 @@ src-tauri/target_d
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Workspace-level Rust build artifacts (GPUI app)
|
||||
/target
|
||||
*.pdb
|
||||
/.v2c
|
||||
/.video_agent
|
||||
|
||||
Generated
+9147
File diff suppressed because it is too large
Load Diff
+14
@@ -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
|
||||
@@ -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 连接,连接状态实时可见
|
||||
- **消息流监控** — 实时查看收发消息,支持消息过滤和搜索
|
||||
- **主题树** — 以树形结构组织订阅的主题,方便浏览
|
||||
- **预设模板** — 保存常用的连接/发布/订阅配置为预设,快速复用
|
||||
- **全局变量** — 支持在消息负载中使用 `{{变量名}}` 模板语法
|
||||
- **用户属性编辑器** — 可视化编辑 MQTT v5 用户属性键值对
|
||||
- **日志系统** — 实时日志面板 + 文件日志持久化,方便排查问题
|
||||
- **暗色模式** — 支持亮色/暗色主题切换,自动跟随系统
|
||||
- **自动更新** — 内置 Tauri 更新器,支持应用自动升级
|
||||
- **双协议支持** — MQTT 5.0 与 MQTT 3.1.1([`rumqttc-next`](https://crates.io/crates/rumqttc-v5-next))
|
||||
- **多传输方式** — 明文 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 属性、主题/内容过滤
|
||||
- **全局变量** — 发布主题与负载中使用 `{{变量名}}`,内置 `{{$ts}}`、`{{$ts_ms}}`、`{{$uuid}}`
|
||||
- **负载格式** — Plaintext / JSON(发送前校验)/ Base64 / Hex
|
||||
- **阿里云 IoT** — Token(GroupId@@@DeviceId + HMAC-SHA1 签名)与一机一密两种鉴权方式一键生成连接
|
||||
- **连接测试** — 表单内 5 秒握手测试,保存前验证连通性
|
||||
- **日志面板** — 连接级事件日志(CONNACK/SUBACK/PUBACK/错误/重连等)
|
||||
- **主题** — 浅色 / 深色切换
|
||||
- **本地持久化** — 连接、订阅、预设、变量、设置以 JSON 存于系统数据目录
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| 桌面框架 | [Tauri v2](https://v2.tauri.app/) |
|
||||
| 前端框架 | [Vue 3](https://vuejs.org/) (Composition API + `<script setup>`) |
|
||||
| 语言 | TypeScript / Rust |
|
||||
| UI 组件库 | [Element Plus](https://element-plus.org/) |
|
||||
| MQTT 客户端 | [rumqttc v5-next](https://crates.io/crates/rumqttc-v5-next) |
|
||||
| 构建工具 | [Vite](https://vitejs.dev/) |
|
||||
| 包管理 | pnpm |
|
||||
| --- | --- |
|
||||
| UI | GPUI 0.3 + gpui-kit(gpui-component)0.6 |
|
||||
| MQTT | rumqttc-v5-next 0.34 / rumqttc-v4-next 0.34(TCP/TLS/WS) |
|
||||
| 异步 | 引擎跑在独立 tokio runtime,事件经 smol channel 泵回 UI 线程 |
|
||||
| 持久化 | serde + serde_json,原子写(临时文件 + rename) |
|
||||
| 其他 | chrono、dirs、uuid、hmac/sha1/base64(阿里云签名) |
|
||||
|
||||
## 快速开始
|
||||
## 目录结构
|
||||
|
||||
### 前置要求
|
||||
```
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
pnpm tauri signer generate -w ~/.tauri/mqttx.key
|
||||
```
|
||||
需要在仓库 **Settings → Actions → Secrets** 配置 `GITEA_TOKEN`;
|
||||
若 runner 使用自定义 label,请相应修改工作流的 `runs-on`。
|
||||
|
||||
### 2. 配置公钥
|
||||
## 数据目录
|
||||
|
||||
将生成的公钥粘贴到 `src-tauri/tauri.conf.json` 中的 `plugins.updater.pubkey` 字段。
|
||||
| 平台 | 路径 |
|
||||
| --- | --- |
|
||||
| Windows | `%APPDATA%\MQTTX-GPUI` |
|
||||
| macOS | `~/Library/Application Support/MQTTX-GPUI` |
|
||||
| Linux | `~/.local/share/MQTTX-GPUI` |
|
||||
|
||||
### 3. 部署更新服务器
|
||||
刻意与官方 Electron MQTTX 的 `MQTTX/` 目录区分,互不影响。读取时容忍 UTF-8 BOM。
|
||||
|
||||
更新端点默认配置为 `https://releases.mqttx.app/update/{{target}}-{{arch}}/{{current_version}}`,需要替换为你自己的更新服务器地址。更新服务器需返回如下格式的 JSON:
|
||||
## 与 Tauri 版本的差异
|
||||
|
||||
```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 前端开发服务器 |
|
||||
| `pnpm build` | 构建前端 |
|
||||
| `pnpm tauri dev` | 启动 Tauri 开发模式(前端 + 桌面窗口) |
|
||||
| `pnpm tauri build` | 生产构建 |
|
||||
| `pnpm tauri signer generate` | 生成更新签名密钥对 |
|
||||
|
||||
## 协议
|
||||
|
||||
[MIT](./LICENSE)
|
||||
- 单一 Rust 二进制(release ~17MB),无需打包 Node/WebView 资源
|
||||
- MQTT 引擎从 Tauri 命令模型改为独立 tokio runtime + 事件通道,UI 直接订阅
|
||||
- 数据存储从浏览器 `localStorage` 迁移到系统数据目录下的 JSON 文件
|
||||
- 新增 TLS/WSS 传输、负载格式(Base64/Hex)、遗嘱消息、连接测试等能力
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 阿里云 IoT 平台一键接入预设(对标官方 MQTTX 的阿里云模板)。
|
||||
//!
|
||||
//! Token 模式鉴权参数:
|
||||
//! - clientId: `{GroupId}@@@{DeviceId}`
|
||||
//! - username: `Signature|{AccessKeyId}|{InstanceId}`
|
||||
//! - password: base64(HMAC-SHA1(AccessKeySecret, clientId))
|
||||
|
||||
use base64::Engine as _;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha1::Sha1;
|
||||
|
||||
use crate::model::{ConnectionConfig, ProtocolVersion, TransportKind};
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AliyunAuthMode {
|
||||
/// Group ID + 设备 ID,使用账号 AccessKey 签名
|
||||
#[default]
|
||||
Token,
|
||||
/// 一机一密:设备独立 DeviceName + DeviceSecret(直接以设备密钥鉴权)
|
||||
DeviceCredential,
|
||||
}
|
||||
|
||||
impl AliyunAuthMode {
|
||||
pub const ALL: [Self; 2] = [Self::Token, Self::DeviceCredential];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Token => "Group 令牌 (Token)",
|
||||
Self::DeviceCredential => "一机一密",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AliyunPreset {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 分组(仅 UI 分类用)
|
||||
#[serde(default)]
|
||||
pub group: String,
|
||||
/// 实例 ID,如 post-cn-4591dq2ra1i
|
||||
pub instance_id: String,
|
||||
/// 地域 host 后缀前的 region,如 cn-shanghai;留空则使用公共接入点
|
||||
#[serde(default)]
|
||||
pub region: String,
|
||||
#[serde(default)]
|
||||
pub auth_mode: AliyunAuthMode,
|
||||
|
||||
// Token 模式
|
||||
#[serde(default)]
|
||||
pub access_key_id: String,
|
||||
#[serde(default)]
|
||||
pub access_key_secret: String,
|
||||
#[serde(default)]
|
||||
pub group_id: String,
|
||||
|
||||
// 共用
|
||||
pub device_id: String,
|
||||
|
||||
// 一机一密模式
|
||||
#[serde(default)]
|
||||
pub device_secret: String,
|
||||
}
|
||||
|
||||
impl AliyunPreset {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "阿里云设备".into(),
|
||||
group: String::new(),
|
||||
instance_id: String::new(),
|
||||
region: String::new(),
|
||||
auth_mode: AliyunAuthMode::Token,
|
||||
access_key_id: String::new(),
|
||||
access_key_secret: String::new(),
|
||||
group_id: String::new(),
|
||||
device_id: format!("device_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]),
|
||||
device_secret: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// HMAC-SHA1(secret, client_id) 的 Base64 编码。
|
||||
pub fn signature(secret: &str, client_id: &str) -> Result<String, String> {
|
||||
let mut mac =
|
||||
HmacSha1::new_from_slice(secret.as_bytes()).map_err(|e| format!("HMAC 初始化失败: {e}"))?;
|
||||
mac.update(client_id.as_bytes());
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
/// 接入点 host:`{instanceId}.mqtt.iothub.aliyuncs.com`
|
||||
/// (实例级接入点在各区域通用;region 为空时同样可用)
|
||||
pub fn host(&self) -> String {
|
||||
if self.region.is_empty() {
|
||||
format!("{}.mqtt.iothub.aliyuncs.com", self.instance_id)
|
||||
} else {
|
||||
format!(
|
||||
"{}.mqtt.{}.aliyuncs.com",
|
||||
self.instance_id, self.region
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 由预设生成一份连接配置(不写入 id/name 之外的覆盖项)。
|
||||
pub fn to_connection(&self) -> Result<ConnectionConfig, String> {
|
||||
let mut conn = ConnectionConfig::new();
|
||||
conn.name = self.name.clone();
|
||||
conn.host = self.host();
|
||||
conn.port = TransportKind::Tcp.default_port();
|
||||
conn.transport = TransportKind::Tcp;
|
||||
conn.protocol = ProtocolVersion::V311;
|
||||
conn.clean_start = true;
|
||||
|
||||
match self.auth_mode {
|
||||
AliyunAuthMode::Token => {
|
||||
if self.group_id.is_empty()
|
||||
|| self.access_key_id.is_empty()
|
||||
|| self.access_key_secret.is_empty()
|
||||
{
|
||||
return Err("Group ID、AccessKey ID 与 AccessKey Secret 不能为空".into());
|
||||
}
|
||||
let client_id = format!("{}@@@{}", self.group_id, self.device_id);
|
||||
let password = Self::signature(&self.access_key_secret, &client_id)?;
|
||||
conn.client_id = client_id;
|
||||
conn.username =
|
||||
format!("Signature|{}|{}", self.access_key_id, self.instance_id);
|
||||
conn.password = password;
|
||||
}
|
||||
AliyunAuthMode::DeviceCredential => {
|
||||
if self.device_secret.is_empty() {
|
||||
return Err("设备密钥 (DeviceSecret) 不能为空".into());
|
||||
}
|
||||
// 一机一密三元组:clientId=设备名,username=设备名&实例,password=HMAC-SHA1(DeviceSecret, clientId)
|
||||
let client_id = self.device_id.clone();
|
||||
let password = Self::signature(&self.device_secret, &client_id)?;
|
||||
conn.client_id = client_id;
|
||||
conn.username = format!("{}&{}", self.device_id, self.instance_id);
|
||||
conn.password = password;
|
||||
}
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AliyunPreset {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! MQTTX:数据模型、持久化、MQTT 引擎与 GPUI 界面。
|
||||
|
||||
pub mod aliyun;
|
||||
pub mod model;
|
||||
pub mod mqtt;
|
||||
pub mod store;
|
||||
pub mod ui;
|
||||
@@ -0,0 +1,27 @@
|
||||
use gpui_kit::component::{Root, TitleBar};
|
||||
use gpui_kit::*;
|
||||
|
||||
use mqttx_desktop::ui;
|
||||
|
||||
fn main() {
|
||||
application()
|
||||
.with_assets(gpui_kit::assets::Assets)
|
||||
.run(|cx: &mut App| {
|
||||
gpui_kit::init(cx);
|
||||
|
||||
let options = WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
|
||||
None,
|
||||
size(px(1440.), px(900.)),
|
||||
cx,
|
||||
))),
|
||||
..TitleBar::window_options()
|
||||
};
|
||||
|
||||
cx.open_window(options, |window, cx| {
|
||||
let view = cx.new(|cx| ui::app::MqttXApp::new(window, cx));
|
||||
cx.new(|cx| Root::new(view.clone(), window, cx))
|
||||
})
|
||||
.expect("Failed to open window");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
//! 应用数据模型:连接配置、订阅、消息、日志、预设、全局变量、设置。
|
||||
//!
|
||||
//! 本层不依赖 GPUI,可被持久化层与 MQTT 引擎共用。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ─── 连接配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// MQTT 协议版本。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[derive(Default)]
|
||||
pub enum ProtocolVersion {
|
||||
/// MQTT 3.1.1
|
||||
V311,
|
||||
/// MQTT 5.0
|
||||
#[default]
|
||||
V5,
|
||||
}
|
||||
|
||||
impl ProtocolVersion {
|
||||
pub const ALL: [Self; 2] = [Self::V5, Self::V311];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::V311 => "MQTT 3.1.1",
|
||||
Self::V5 => "MQTT 5.0",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_v5(self) -> bool {
|
||||
matches!(self, Self::V5)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 传输方式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum TransportKind {
|
||||
/// 明文 TCP,默认 1883
|
||||
#[default]
|
||||
Tcp,
|
||||
/// TLS over TCP,默认 8883
|
||||
Tls,
|
||||
/// WebSocket (ws://),默认 8083
|
||||
Ws,
|
||||
/// WebSocket over TLS (wss://),默认 8084
|
||||
Wss,
|
||||
}
|
||||
|
||||
impl TransportKind {
|
||||
pub const ALL: [Self; 4] = [Self::Tcp, Self::Tls, Self::Ws, Self::Wss];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Tcp => "TCP",
|
||||
Self::Tls => "TLS",
|
||||
Self::Ws => "WebSocket",
|
||||
Self::Wss => "WSS",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_port(self) -> u16 {
|
||||
match self {
|
||||
Self::Tcp => 1883,
|
||||
Self::Tls => 8883,
|
||||
Self::Ws => 8083,
|
||||
Self::Wss => 8084,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_tls(self) -> bool {
|
||||
matches!(self, Self::Tls | Self::Wss)
|
||||
}
|
||||
|
||||
pub fn is_websocket(self) -> bool {
|
||||
matches!(self, Self::Ws | Self::Wss)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// MQTT 5 遗嘱消息(3.1.1 下忽略 v5 专属属性)。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LastWill {
|
||||
pub topic: String,
|
||||
pub payload: String,
|
||||
pub qos: u8,
|
||||
pub retain: bool,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub response_topic: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 主机(不含端口、协议前缀)
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// WebSocket 路径,如 /mqtt
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub protocol: ProtocolVersion,
|
||||
#[serde(default)]
|
||||
pub transport: TransportKind,
|
||||
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
|
||||
#[serde(default = "default_true")]
|
||||
pub clean_start: bool,
|
||||
#[serde(default = "default_keep_alive")]
|
||||
pub keep_alive: u16,
|
||||
|
||||
// ── MQTT 5 专属 ──
|
||||
#[serde(default)]
|
||||
pub session_expiry_interval: u32,
|
||||
#[serde(default)]
|
||||
pub receive_maximum: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub maximum_packet_size: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub topic_alias_maximum: Option<u16>,
|
||||
|
||||
/// 连接成功后自动恢复已保存的订阅
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_resubscribe: bool,
|
||||
/// 断线自动重连
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_reconnect: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub last_will: Option<LastWill>,
|
||||
|
||||
/// 创建时间(unix 秒),用于排序
|
||||
#[serde(default)]
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_keep_alive() -> u16 {
|
||||
60
|
||||
}
|
||||
|
||||
impl ConnectionConfig {
|
||||
pub fn new() -> Self {
|
||||
let transport = TransportKind::default();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "新建连接".into(),
|
||||
host: "broker.emqx.io".into(),
|
||||
port: transport.default_port(),
|
||||
path: "/mqtt".into(),
|
||||
protocol: ProtocolVersion::V5,
|
||||
transport,
|
||||
client_id: format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]),
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
clean_start: true,
|
||||
keep_alive: 60,
|
||||
session_expiry_interval: 0,
|
||||
receive_maximum: None,
|
||||
maximum_packet_size: None,
|
||||
topic_alias_maximum: None,
|
||||
auto_resubscribe: true,
|
||||
auto_reconnect: true,
|
||||
last_will: None,
|
||||
created_at: chrono::Local::now().timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// broker.emqx.io 等公共服务器的快速预设。
|
||||
pub fn with_public(name: &str, host: &str, port: u16, transport: TransportKind) -> Self {
|
||||
let mut c = Self::new();
|
||||
c.name = name.into();
|
||||
c.host = host.into();
|
||||
c.port = port;
|
||||
c.transport = transport;
|
||||
c
|
||||
}
|
||||
|
||||
pub fn display_address(&self) -> String {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConnectionConfig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 订阅 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Subscription {
|
||||
pub id: String,
|
||||
pub connection_id: String,
|
||||
pub topic: String,
|
||||
pub qos: u8,
|
||||
/// 本地备注颜色(与 MQTTX 一致,按订阅给消息着色),0..=359 色相
|
||||
#[serde(default)]
|
||||
pub color: Option<f32>,
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
pub fn new(connection_id: impl Into<String>, topic: impl Into<String>, qos: u8) -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
connection_id: connection_id.into(),
|
||||
topic: topic.into(),
|
||||
qos,
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 消息 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Direction {
|
||||
Received,
|
||||
Published,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Received => "接收",
|
||||
Self::Published => "发布",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum PayloadFormat {
|
||||
#[default]
|
||||
Plaintext,
|
||||
Json,
|
||||
Base64,
|
||||
Hex,
|
||||
}
|
||||
|
||||
impl PayloadFormat {
|
||||
pub const ALL: [Self; 4] = [Self::Plaintext, Self::Json, Self::Base64, Self::Hex];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Plaintext => "Plaintext",
|
||||
Self::Json => "JSON",
|
||||
Self::Base64 => "Base64",
|
||||
Self::Hex => "Hex",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(self, text: &str) -> Result<Vec<u8>, String> {
|
||||
match self {
|
||||
Self::Plaintext | Self::Json => Ok(text.as_bytes().to_vec()),
|
||||
Self::Base64 => {
|
||||
use base64::Engine as _;
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(text.trim())
|
||||
.map_err(|e| format!("Base64 解码失败: {e}"))
|
||||
}
|
||||
Self::Hex => {
|
||||
let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if !cleaned.len().is_multiple_of(2) {
|
||||
return Err("Hex 长度必须为偶数(每两个字符一个字节)".into());
|
||||
}
|
||||
(0..cleaned.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
u8::from_str_radix(&cleaned[i..i + 2], 16)
|
||||
.map_err(|e| format!("Hex 解码失败: {e}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 一条收到/发出的消息记录。payload 以 UTF-8 文本保存(非文本按 lossy 显示)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MqttRecord {
|
||||
pub seq: u64,
|
||||
pub connection_id: String,
|
||||
pub direction: Direction,
|
||||
pub topic: String,
|
||||
pub payload: String,
|
||||
pub qos: u8,
|
||||
pub retain: bool,
|
||||
/// unix 毫秒
|
||||
pub timestamp: i64,
|
||||
pub user_properties: Vec<(String, String)>,
|
||||
pub content_type: Option<String>,
|
||||
pub response_topic: Option<String>,
|
||||
pub correlation_data: Option<String>,
|
||||
pub message_expiry_interval: Option<u32>,
|
||||
pub subscription_identifier: Option<u32>,
|
||||
}
|
||||
|
||||
// ─── 发布参数 / 发布预设 ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PublishParams {
|
||||
pub topic: String,
|
||||
/// 用于界面展示与记录的文本负载。
|
||||
pub payload: String,
|
||||
#[serde(default)]
|
||||
pub payload_format: PayloadFormat,
|
||||
pub qos: u8,
|
||||
pub retain: bool,
|
||||
/// MQTT 5 用户属性
|
||||
#[serde(default)]
|
||||
pub user_properties: Vec<(String, String)>,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub message_expiry_interval: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub response_topic: Option<String>,
|
||||
#[serde(default)]
|
||||
pub correlation_data: Option<String>,
|
||||
/// 实际发送的字节。Base64/Hex 时为解码结果;不持久化、不参与预设。
|
||||
/// None 时引擎回退为 `payload.as_bytes()`。
|
||||
#[serde(skip)]
|
||||
pub raw_bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// 发布预设:保存常用的发布参数,一键复用(对标 MQTTX Publish Scripts/Preset)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublishPreset {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub connection_id: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub params: PublishParams,
|
||||
}
|
||||
|
||||
impl PublishPreset {
|
||||
pub fn new(name: impl Into<String>, params: PublishParams) -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: name.into(),
|
||||
connection_id: None,
|
||||
params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 全局变量 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GlobalVariable {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// 解析 `{{key}}` 模板:
|
||||
/// - 用户定义的全局变量优先;
|
||||
/// - 内置变量:`{{$ts}}`(unix 秒)、`{{$ts_ms}}`(毫秒)、`{{$uuid}}`;
|
||||
/// - 找不到的占位符保持原样。
|
||||
pub fn render_template(input: &str, vars: &[GlobalVariable]) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let bytes = input.as_bytes();
|
||||
// 同一次渲染内时间基准保持一致,避免一条消息里多个时间变量取值不同。
|
||||
let now_ms = chrono::Local::now().timestamp_millis();
|
||||
let now_s = now_ms / 1000;
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if i + 1 < bytes.len() && bytes[i] == b'{' && bytes[i + 1] == b'{'
|
||||
&& let Some(rel_end) = input[i + 2..].find("}}") {
|
||||
let key = &input[i + 2..i + 2 + rel_end];
|
||||
let resolved = match key.trim() {
|
||||
"$ts" => Some(now_s.to_string()),
|
||||
"$ts_ms" => Some(now_ms.to_string()),
|
||||
"$uuid" => Some(uuid::Uuid::new_v4().to_string()),
|
||||
k => vars
|
||||
.iter()
|
||||
.find(|v| v.key == k)
|
||||
.map(|v| v.value.clone()),
|
||||
};
|
||||
match resolved {
|
||||
Some(v) => out.push_str(&v),
|
||||
None => out.push_str(&input[i..i + 2 + rel_end + 2]),
|
||||
}
|
||||
i = i + 2 + rel_end + 2;
|
||||
continue;
|
||||
}
|
||||
// 按字符推进,避免把多字节 UTF-8 拆开
|
||||
let ch = input[i..].chars().next().unwrap();
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ─── 日志 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LogLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl LogLevel {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Debug => "DEBUG",
|
||||
Self::Info => "INFO",
|
||||
Self::Warn => "WARN",
|
||||
Self::Error => "ERROR",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"debug" => Self::Debug,
|
||||
"warn" => Self::Warn,
|
||||
"error" => Self::Error,
|
||||
_ => Self::Info,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: i64,
|
||||
pub connection_id: String,
|
||||
pub level: LogLevel,
|
||||
pub event: String,
|
||||
pub message: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
// ─── 连接状态 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ConnectionStatus {
|
||||
#[default]
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ConnectionStatus {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Disconnected => "未连接",
|
||||
Self::Connecting => "连接中",
|
||||
Self::Connected => "已连接",
|
||||
Self::Error => "错误",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 应用设置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThemeModePref {
|
||||
Light,
|
||||
Dark,
|
||||
#[default]
|
||||
System,
|
||||
}
|
||||
|
||||
impl ThemeModePref {
|
||||
pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "跟随系统",
|
||||
Self::Light => "浅色",
|
||||
Self::Dark => "深色",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppSettings {
|
||||
#[serde(default)]
|
||||
pub theme: ThemeModePref,
|
||||
/// 每条连接在内存中保留的最大消息条数
|
||||
#[serde(default = "default_max_messages")]
|
||||
pub max_messages: usize,
|
||||
/// 时间戳是否显示毫秒
|
||||
#[serde(default = "default_true")]
|
||||
pub show_millis: bool,
|
||||
/// 是否自动检查更新(占位,暂不实现更新)
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_check_update: bool,
|
||||
}
|
||||
|
||||
fn default_max_messages() -> usize {
|
||||
2000
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: ThemeModePref::default(),
|
||||
max_messages: default_max_messages(),
|
||||
show_millis: true,
|
||||
auto_check_update: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 单元测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// 修复回归:Hex 奇数长度必须报错,不能静默截断
|
||||
#[test]
|
||||
fn hex_odd_length_is_rejected() {
|
||||
let err = PayloadFormat::Hex.encode("abc").unwrap_err();
|
||||
assert!(err.contains("偶数"), "应提示偶数长度,实际: {err}");
|
||||
assert!(PayloadFormat::Hex.encode("").is_ok());
|
||||
assert_eq!(
|
||||
PayloadFormat::Hex.encode("68 69").unwrap(),
|
||||
vec![0x68, 0x69],
|
||||
"空白应被忽略"
|
||||
);
|
||||
assert!(PayloadFormat::Hex.encode("zz").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_encode_roundtrip() {
|
||||
use base64::Engine as _;
|
||||
let text = "hello 世界";
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
|
||||
assert_eq!(PayloadFormat::Base64.encode(&encoded).unwrap(), text.as_bytes());
|
||||
assert!(PayloadFormat::Base64.encode("not base64!!!").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_and_json_encode_as_utf8() {
|
||||
assert_eq!(
|
||||
PayloadFormat::Plaintext.encode("中文").unwrap(),
|
||||
"中文".as_bytes()
|
||||
);
|
||||
assert_eq!(
|
||||
PayloadFormat::Json.encode(r#"{"a":1}"#).unwrap(),
|
||||
r#"{"a":1}"#.as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
// 修复回归:raw_bytes 是运行时字段,不得写入持久化 JSON
|
||||
#[test]
|
||||
fn raw_bytes_not_serialized() {
|
||||
let p = PublishParams {
|
||||
topic: "t".into(),
|
||||
payload: "hi".into(),
|
||||
raw_bytes: Some(vec![0x00, 0xff]),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(!json.contains("raw_bytes"), "raw_bytes 不应被序列化: {json}");
|
||||
let back: PublishParams = serde_json::from_str(&json).unwrap();
|
||||
assert!(back.raw_bytes.is_none());
|
||||
// 反序列化时输入中的 raw_bytes 字段应被忽略(skip 双向生效)
|
||||
let mut with_junk = serde_json::to_string(&PublishParams::default()).unwrap();
|
||||
with_junk.pop(); // 去掉结尾 '}'
|
||||
with_junk.push_str(r#", "raw_bytes":[1,2]}"#);
|
||||
let back: PublishParams = serde_json::from_str(&with_junk).unwrap();
|
||||
assert!(back.raw_bytes.is_none(), "#[serde(skip)] 应忽略输入字段");
|
||||
}
|
||||
|
||||
// 修复回归:同一条消息内 $ts 与 $ts_ms 基于同一时刻
|
||||
#[test]
|
||||
fn template_timestamps_share_one_timebase() {
|
||||
let out = render_template("s={{$ts}} ms={{$ts_ms}}", &[]);
|
||||
let (s_part, ms_part) = out.split_once(" ms=").expect("格式 s=.. ms=..");
|
||||
let s: i64 = s_part
|
||||
.strip_prefix("s=")
|
||||
.expect("秒字段")
|
||||
.parse()
|
||||
.unwrap();
|
||||
let ms: i64 = ms_part.parse().expect("毫秒字段");
|
||||
assert_eq!(s, ms / 1000, "秒与毫秒应来自同一时间基准: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_unknown_placeholder_kept_and_custom_var_resolved() {
|
||||
assert_eq!(render_template("{{$unknown}}", &[]), "{{$unknown}}");
|
||||
let vars = vec![GlobalVariable {
|
||||
key: "device".into(),
|
||||
value: "sensor-1".into(),
|
||||
}];
|
||||
assert!(render_template("d={{device}}/{{$uuid}}", &vars).starts_with("d=sensor-1/"));
|
||||
// 多字节字符不被拆坏
|
||||
assert_eq!(render_template("中文{{$no}}", &[]), "中文{{$no}}");
|
||||
}
|
||||
|
||||
// 预设持久化往返:raw_bytes 不落盘,参数完整保留
|
||||
#[test]
|
||||
fn publish_preset_roundtrip_skips_raw_bytes() {
|
||||
let preset = PublishPreset {
|
||||
id: "p1".into(),
|
||||
name: "报警".into(),
|
||||
connection_id: Some("c1".into()),
|
||||
params: PublishParams {
|
||||
topic: "alarm/{{device}}".into(),
|
||||
payload: "6869".into(),
|
||||
payload_format: PayloadFormat::Hex,
|
||||
qos: 1,
|
||||
retain: true,
|
||||
content_type: Some("application/json".into()),
|
||||
user_properties: vec![("k".into(), "v".into())],
|
||||
raw_bytes: Some(vec![0x68, 0x69]),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&preset).unwrap();
|
||||
let back: PublishPreset = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.name, "报警");
|
||||
assert_eq!(back.params.topic, "alarm/{{device}}");
|
||||
assert_eq!(back.params.payload_format, PayloadFormat::Hex);
|
||||
assert_eq!(back.params.qos, 1);
|
||||
assert!(back.params.retain);
|
||||
assert_eq!(back.params.content_type.as_deref(), Some("application/json"));
|
||||
assert_eq!(back.params.user_properties.len(), 1);
|
||||
assert!(back.params.raw_bytes.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_format_equality_and_default() {
|
||||
assert_eq!(PayloadFormat::default(), PayloadFormat::Plaintext);
|
||||
assert_ne!(PayloadFormat::Hex, PayloadFormat::Base64);
|
||||
let json = serde_json::to_string(&PayloadFormat::Hex).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<PayloadFormat>(&json).unwrap(),
|
||||
PayloadFormat::Hex
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
//! MQTT 引擎:在独立 tokio runtime 上管理多个连接的生命周期,
|
||||
//! 通过 smol 无界通道把状态/消息/日志事件送回 GPUI 线程。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rumqttc::mqttbytes::v5::{
|
||||
ConnectReturnCode as V5ReturnCode, Packet as V5Packet, Publish as V5Publish,
|
||||
PublishProperties as V5PublishProperties,
|
||||
};
|
||||
use rumqttc::{
|
||||
AsyncClient as V5AsyncClient, Broker as V5Broker, ConnectionError as V5Error,
|
||||
Event as V5Event, EventLoop as V5EventLoop, MqttOptions as V5Options, Outgoing as V5Outgoing,
|
||||
PublishOptions as V5PublishOptions, QoS as V5QoS, Transport as V5Transport,
|
||||
};
|
||||
use rumqttc_v4 as rv4;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::model::{
|
||||
ConnectionConfig, ConnectionStatus, Direction, LogEntry, LogLevel, MqttRecord, PublishParams,
|
||||
Subscription, TransportKind,
|
||||
};// ─── 引擎事件 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EngineEvent {
|
||||
/// 连接状态变化
|
||||
Status {
|
||||
connection_id: String,
|
||||
status: ConnectionStatus,
|
||||
error: Option<String>,
|
||||
reason_code: Option<u8>,
|
||||
session_present: Option<bool>,
|
||||
},
|
||||
/// 收到消息
|
||||
Message(MqttRecord),
|
||||
/// 一条本地发出的消息(已成功交给客户端)
|
||||
Published {
|
||||
connection_id: String,
|
||||
topic: String,
|
||||
payload: String,
|
||||
qos: u8,
|
||||
retain: bool,
|
||||
content_type: Option<String>,
|
||||
user_properties: Vec<(String, String)>,
|
||||
},
|
||||
/// 订阅请求结果(tracked,等待 SUBACK)
|
||||
SubscribeResult {
|
||||
connection_id: String,
|
||||
topic: String,
|
||||
qos: u8,
|
||||
ok: bool,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// 日志
|
||||
Log(LogEntry),
|
||||
}
|
||||
|
||||
pub type EventSender = smol::channel::Sender<EngineEvent>;
|
||||
|
||||
// ─── 句柄 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum ClientKind {
|
||||
V5(V5AsyncClient),
|
||||
V4(rv4::AsyncClient),
|
||||
}
|
||||
|
||||
struct ConnHandle {
|
||||
client: ClientKind,
|
||||
cancel_tx: watch::Sender<bool>,
|
||||
connected: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
pub struct MqttEngine {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
conns: Mutex<HashMap<String, ConnHandle>>,
|
||||
tx: EventSender,
|
||||
seq: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
chrono::Local::now().timestamp_millis()
|
||||
}
|
||||
|
||||
impl MqttEngine {
|
||||
pub fn new(tx: EventSender) -> Arc<Self> {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.thread_name("mqtt-engine")
|
||||
.build()
|
||||
.expect("创建 tokio runtime 失败");
|
||||
Arc::new(Self {
|
||||
runtime,
|
||||
conns: Mutex::new(HashMap::new()),
|
||||
tx,
|
||||
seq: Arc::new(AtomicU64::new(1)),
|
||||
})
|
||||
}
|
||||
|
||||
fn next_seq(&self) -> u64 {
|
||||
self.seq.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn emit(&self, event: EngineEvent) {
|
||||
let _ = self.tx.try_send(event);
|
||||
}
|
||||
|
||||
fn log(&self, conn_id: &str, level: LogLevel, event: &str, message: String, details: Option<String>) {
|
||||
self.emit(EngineEvent::Log(LogEntry {
|
||||
timestamp: now_ms(),
|
||||
connection_id: conn_id.to_string(),
|
||||
level,
|
||||
event: event.to_string(),
|
||||
message,
|
||||
details,
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn is_connected(&self, id: &str) -> bool {
|
||||
let Ok(map) = self.conns.lock() else {
|
||||
return false;
|
||||
};
|
||||
map.get(id)
|
||||
.map(|h| h.connected.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ── 连接 ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn connect(self: &Arc<Self>, cfg: ConnectionConfig) {
|
||||
// 关闭同名旧连接
|
||||
self.close(&cfg.id, false);
|
||||
|
||||
let id = cfg.id.clone();
|
||||
self.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Connecting,
|
||||
error: None,
|
||||
reason_code: None,
|
||||
session_present: None,
|
||||
});
|
||||
self.log(
|
||||
&id,
|
||||
LogLevel::Info,
|
||||
"connect",
|
||||
format!(
|
||||
"正在连接 {}:{} ({}, {})",
|
||||
cfg.host,
|
||||
cfg.port,
|
||||
cfg.protocol.label(),
|
||||
cfg.transport.label()
|
||||
),
|
||||
None,
|
||||
);
|
||||
|
||||
let engine = Arc::clone(self);
|
||||
self.runtime.spawn(async move {
|
||||
let build = if cfg.protocol.is_v5() {
|
||||
build_v5(&cfg)
|
||||
.map(|(client, el)| (ClientKind::V5(client), EventLoopKind::V5(Box::new(el))))
|
||||
} else {
|
||||
build_v4(&cfg)
|
||||
.map(|(client, el)| (ClientKind::V4(client), EventLoopKind::V4(Box::new(el))))
|
||||
};
|
||||
|
||||
let (client, eventloop) = match build {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Error,
|
||||
error: Some(e.clone()),
|
||||
reason_code: None,
|
||||
session_present: None,
|
||||
});
|
||||
engine.log(&id, LogLevel::Error, "connect_error", format!("连接失败: {e}"), None);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let connected = Arc::new(AtomicBool::new(false));
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let handle = ConnHandle {
|
||||
client,
|
||||
cancel_tx,
|
||||
connected: connected.clone(),
|
||||
};
|
||||
engine
|
||||
.conns
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(id.clone(), handle);
|
||||
|
||||
match eventloop {
|
||||
EventLoopKind::V5(el) => {
|
||||
spawn_v5_loop(
|
||||
engine.clone(),
|
||||
id,
|
||||
*el,
|
||||
cancel_rx,
|
||||
connected,
|
||||
cfg.auto_reconnect,
|
||||
);
|
||||
}
|
||||
EventLoopKind::V4(el) => {
|
||||
spawn_v4_loop(
|
||||
engine.clone(),
|
||||
id,
|
||||
*el,
|
||||
cancel_rx,
|
||||
connected,
|
||||
cfg.auto_reconnect,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 主动断开。`notify` 为 true 时发出 Disconnected 状态(用户操作)。
|
||||
pub fn close(&self, id: &str, user_initiated: bool) {
|
||||
let handle = self.conns.lock().unwrap().remove(id);
|
||||
if let Some(h) = handle {
|
||||
let _ = h.cancel_tx.send(true);
|
||||
let connected = h.connected.clone();
|
||||
// 发送 MQTT DISCONNECT 报文后丢弃客户端
|
||||
self.runtime.spawn(async move {
|
||||
match h.client {
|
||||
ClientKind::V5(c) => {
|
||||
let _ = c.disconnect().await;
|
||||
}
|
||||
ClientKind::V4(c) => {
|
||||
let _ = c.disconnect().await;
|
||||
}
|
||||
}
|
||||
connected.store(false, Ordering::SeqCst);
|
||||
});
|
||||
if user_initiated {
|
||||
self.log(id, LogLevel::Info, "disconnect", "用户请求断开连接".into(), None);
|
||||
self.emit(EngineEvent::Status {
|
||||
connection_id: id.to_string(),
|
||||
status: ConnectionStatus::Disconnected,
|
||||
error: None,
|
||||
reason_code: None,
|
||||
session_present: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 发布 ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn publish(self: &Arc<Self>, connection_id: String, params: PublishParams) {
|
||||
let engine = Arc::clone(self);
|
||||
self.runtime.spawn(async move {
|
||||
let Some(client) = engine.client_of(&connection_id) else {
|
||||
engine.log(
|
||||
&connection_id,
|
||||
LogLevel::Error,
|
||||
"publish_error",
|
||||
"发布失败: 连接不存在".into(),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let payload = params
|
||||
.raw_bytes
|
||||
.clone()
|
||||
.unwrap_or_else(|| params.payload.as_bytes().to_vec());
|
||||
// v5 与 v4 两个 crate 各自定义 ClientError,统一归一化为 String。
|
||||
let result: Result<(), String> = match &client {
|
||||
ClientKind::V5(client) => {
|
||||
let props = V5PublishProperties {
|
||||
user_properties: params.user_properties.clone(),
|
||||
content_type: params.content_type.clone(),
|
||||
message_expiry_interval: params.message_expiry_interval,
|
||||
response_topic: params.response_topic.clone(),
|
||||
correlation_data: params
|
||||
.correlation_data
|
||||
.clone()
|
||||
.map(|s| s.into_bytes().into()),
|
||||
..Default::default()
|
||||
};
|
||||
let options = V5PublishOptions::new(v5_qos(params.qos))
|
||||
.retain(params.retain)
|
||||
.properties(props);
|
||||
client
|
||||
.publish(params.topic.clone(), payload, options)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
ClientKind::V4(client) => {
|
||||
let options =
|
||||
rv4::PublishOptions::new(v4_qos(params.qos)).retain(params.retain);
|
||||
client
|
||||
.publish(params.topic.clone(), payload, options)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
engine.log(
|
||||
&connection_id,
|
||||
LogLevel::Info,
|
||||
"publish_sent",
|
||||
format!(
|
||||
"发布消息到 {} (QoS {}, retain={})",
|
||||
params.topic, params.qos, params.retain
|
||||
),
|
||||
None,
|
||||
);
|
||||
engine.emit(EngineEvent::Published {
|
||||
connection_id,
|
||||
topic: params.topic,
|
||||
payload: params.payload,
|
||||
qos: params.qos,
|
||||
retain: params.retain,
|
||||
content_type: params.content_type,
|
||||
user_properties: params.user_properties,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
engine.log(
|
||||
&connection_id,
|
||||
LogLevel::Error,
|
||||
"publish_error",
|
||||
format!("发布失败: {e}"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 订阅 / 取消订阅 ─────────────────────────────────────────────────────
|
||||
|
||||
pub fn subscribe(self: &Arc<Self>, connection_id: String, sub: Subscription) {
|
||||
let engine = Arc::clone(self);
|
||||
self.runtime.spawn(async move {
|
||||
let Some(client) = engine.client_of(&connection_id) else {
|
||||
engine.emit(EngineEvent::SubscribeResult {
|
||||
connection_id,
|
||||
topic: sub.topic,
|
||||
qos: sub.qos,
|
||||
ok: false,
|
||||
error: Some("连接不存在".into()),
|
||||
});
|
||||
return;
|
||||
};
|
||||
engine.log(
|
||||
&connection_id,
|
||||
LogLevel::Info,
|
||||
"subscribe",
|
||||
format!("订阅主题: {} (QoS {})", sub.topic, sub.qos),
|
||||
None,
|
||||
);
|
||||
let result = match &client {
|
||||
ClientKind::V5(client) => {
|
||||
match client
|
||||
.subscribe_tracked(sub.topic.clone(), v5_qos(sub.qos))
|
||||
.await
|
||||
{
|
||||
Ok(notice) => notice.wait_completion_async().await.map_err(|e| {
|
||||
format!("订阅被拒绝: {e}")
|
||||
}),
|
||||
Err(e) => Err(format!("订阅请求失败: {e}")),
|
||||
}
|
||||
}
|
||||
ClientKind::V4(client) => {
|
||||
match client
|
||||
.subscribe_tracked(sub.topic.clone(), v4_qos(sub.qos))
|
||||
.await
|
||||
{
|
||||
Ok(notice) => notice.wait_completion_async().await.map_err(|e| {
|
||||
format!("订阅被拒绝: {e}")
|
||||
}),
|
||||
Err(e) => Err(format!("订阅请求失败: {e}")),
|
||||
}
|
||||
}
|
||||
};
|
||||
let (ok, error) = match result {
|
||||
Ok(()) => (true, None),
|
||||
Err(e) => {
|
||||
engine.log(
|
||||
&connection_id,
|
||||
LogLevel::Error,
|
||||
"subscribe_error",
|
||||
e.clone(),
|
||||
None,
|
||||
);
|
||||
(false, Some(e))
|
||||
}
|
||||
};
|
||||
engine.emit(EngineEvent::SubscribeResult {
|
||||
connection_id,
|
||||
topic: sub.topic,
|
||||
qos: sub.qos,
|
||||
ok,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn unsubscribe(self: &Arc<Self>, connection_id: String, topic: String) {
|
||||
let engine = Arc::clone(self);
|
||||
self.runtime.spawn(async move {
|
||||
let Some(client) = engine.client_of(&connection_id) else {
|
||||
return;
|
||||
};
|
||||
let result: Result<(), String> = match &client {
|
||||
ClientKind::V5(client) => client
|
||||
.unsubscribe(topic.clone())
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
ClientKind::V4(client) => client
|
||||
.unsubscribe(topic.clone())
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
engine.log(
|
||||
&connection_id,
|
||||
LogLevel::Info,
|
||||
"unsubscribe",
|
||||
format!("取消订阅: {topic}"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
engine.log(
|
||||
&connection_id,
|
||||
LogLevel::Error,
|
||||
"unsubscribe_error",
|
||||
format!("取消订阅失败: {e}"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 测试连接:5 秒内等待 CONNACK,结果通过 channel 返回。
|
||||
pub fn test_connection(
|
||||
self: &Arc<Self>,
|
||||
cfg: ConnectionConfig,
|
||||
) -> smol::channel::Receiver<Result<(), String>> {
|
||||
let (tx, rx) = smol::channel::bounded(1);
|
||||
self.runtime.spawn(async move {
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
test_handshake(&cfg).await
|
||||
})
|
||||
.await;
|
||||
let reply = match result {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => Err("连接测试超时(5 秒),请检查主机地址与端口".into()),
|
||||
};
|
||||
let _ = tx.try_send(reply);
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
// ── 内部工具 ───────────────────────────────────────────────────────────
|
||||
|
||||
/// 克隆一个连接的异步客户端用于发命令。
|
||||
/// 不持有 std Mutex 守卫跨 await,避免与 tokio runtime 的 Send 要求冲突。
|
||||
fn client_of(&self, id: &str) -> Option<ClientKind> {
|
||||
let map = self.conns.lock().ok()?;
|
||||
let h = map.get(id)?;
|
||||
Some(match &h.client {
|
||||
ClientKind::V5(c) => ClientKind::V5(c.clone()),
|
||||
ClientKind::V4(c) => ClientKind::V4(c.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enum EventLoopKind {
|
||||
// 两个事件循环都在 KB 级,装箱让枚举本身只有指针大小
|
||||
V5(Box<V5EventLoop>),
|
||||
V4(Box<rv4::EventLoop>),
|
||||
}
|
||||
|
||||
// ─── 选项构建 ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn build_v5(
|
||||
cfg: &ConnectionConfig,
|
||||
) -> Result<(V5AsyncClient, V5EventLoop), String> {
|
||||
let mut opts = match cfg.transport {
|
||||
TransportKind::Tcp | TransportKind::Tls => {
|
||||
V5Options::new(cfg.client_id.clone(), V5Broker::tcp(&cfg.host, cfg.port))
|
||||
}
|
||||
TransportKind::Ws => {
|
||||
let url = format!("ws://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
|
||||
V5Options::new(cfg.client_id.clone(), V5Broker::websocket(url).map_err(|e| format!("{e:?}"))?)
|
||||
}
|
||||
TransportKind::Wss => {
|
||||
let url = format!("wss://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
|
||||
V5Options::try_websocket_with_default_tls(cfg.client_id.clone(), url)
|
||||
.map_err(|e| format!("{e:?}"))?
|
||||
}
|
||||
};
|
||||
|
||||
if cfg.transport == TransportKind::Tls {
|
||||
opts.set_transport(
|
||||
V5Transport::try_tls_with_default_config().map_err(|e| format!("TLS 初始化失败: {e:?}"))?,
|
||||
);
|
||||
}
|
||||
|
||||
opts.set_keep_alive(cfg.keep_alive);
|
||||
opts.set_clean_start(cfg.clean_start);
|
||||
if cfg.session_expiry_interval > 0 {
|
||||
opts.set_session_expiry_interval(Some(cfg.session_expiry_interval));
|
||||
}
|
||||
if !cfg.username.is_empty() {
|
||||
opts.set_credentials(cfg.username.clone(), cfg.password.clone());
|
||||
}
|
||||
if let Some(v) = cfg.receive_maximum {
|
||||
opts.set_receive_maximum(Some(v));
|
||||
}
|
||||
if let Some(v) = cfg.maximum_packet_size {
|
||||
opts.set_max_packet_size(Some(v));
|
||||
}
|
||||
if let Some(v) = cfg.topic_alias_maximum {
|
||||
opts.set_topic_alias_max(Some(v));
|
||||
}
|
||||
if let Some(will) = &cfg.last_will
|
||||
&& !will.topic.is_empty() {
|
||||
let lw = rumqttc::mqttbytes::v5::LastWill::new(
|
||||
will.topic.clone(),
|
||||
will.payload.as_bytes().to_vec(),
|
||||
v5_qos(will.qos),
|
||||
will.retain,
|
||||
None,
|
||||
);
|
||||
opts.set_last_will(lw);
|
||||
}
|
||||
|
||||
Ok(V5AsyncClient::builder(opts).build())
|
||||
}
|
||||
|
||||
fn build_v4(
|
||||
cfg: &ConnectionConfig,
|
||||
) -> Result<(rv4::AsyncClient, rv4::EventLoop), String> {
|
||||
use rv4::{Broker as V4Broker, MqttOptions as V4Options, Transport as V4Transport};
|
||||
|
||||
let mut opts = match cfg.transport {
|
||||
TransportKind::Tcp | TransportKind::Tls => {
|
||||
V4Options::new(cfg.client_id.clone(), V4Broker::tcp(&cfg.host, cfg.port))
|
||||
}
|
||||
TransportKind::Ws => {
|
||||
let url = format!("ws://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
|
||||
V4Options::new(cfg.client_id.clone(), V4Broker::websocket(url).map_err(|e| format!("{e:?}"))?)
|
||||
}
|
||||
TransportKind::Wss => {
|
||||
let url = format!("wss://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
|
||||
V4Options::try_websocket_with_default_tls(cfg.client_id.clone(), url)
|
||||
.map_err(|e| format!("{e:?}"))?
|
||||
}
|
||||
};
|
||||
|
||||
if cfg.transport == TransportKind::Tls {
|
||||
opts.set_transport(V4Transport::try_tls_with_default_config().map_err(
|
||||
|e| format!("TLS 初始化失败: {e:?}"),
|
||||
)?);
|
||||
}
|
||||
|
||||
opts.set_keep_alive(cfg.keep_alive);
|
||||
opts.set_clean_session(cfg.clean_start);
|
||||
if !cfg.username.is_empty() {
|
||||
opts.set_credentials(cfg.username.clone(), cfg.password.clone());
|
||||
}
|
||||
if let Some(will) = &cfg.last_will
|
||||
&& !will.topic.is_empty() {
|
||||
let lw = rv4::mqttbytes::v4::LastWill::new(
|
||||
will.topic.clone(),
|
||||
will.payload.as_bytes().to_vec(),
|
||||
v4_qos(will.qos),
|
||||
will.retain,
|
||||
);
|
||||
opts.set_last_will(lw);
|
||||
}
|
||||
|
||||
Ok(rv4::AsyncClient::builder(opts).build())
|
||||
}
|
||||
|
||||
fn normalize_path(path: &str) -> String {
|
||||
if path.is_empty() {
|
||||
"/mqtt".to_string()
|
||||
} else if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
fn v5_qos(qos: u8) -> V5QoS {
|
||||
match qos {
|
||||
1 => V5QoS::AtLeastOnce,
|
||||
2 => V5QoS::ExactlyOnce,
|
||||
_ => V5QoS::AtMostOnce,
|
||||
}
|
||||
}
|
||||
|
||||
fn v4_qos(qos: u8) -> rv4::QoS {
|
||||
match qos {
|
||||
1 => rv4::QoS::AtLeastOnce,
|
||||
2 => rv4::QoS::ExactlyOnce,
|
||||
_ => rv4::QoS::AtMostOnce,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 测试连接握手 ────────────────────────────────────────────────────────────
|
||||
|
||||
async fn test_handshake(cfg_in: &ConnectionConfig) -> Result<(), String> {
|
||||
// 握手测试必须使用独立 client_id,否则会把使用相同 client_id 的现有连接
|
||||
// 从 broker 顶下线(MQTT 同 client_id 单会话规则)。
|
||||
let mut cfg = cfg_in.clone();
|
||||
cfg.client_id = format!("mqttx_test_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
let cfg = &cfg;
|
||||
if cfg.protocol.is_v5() {
|
||||
let (client, mut eventloop) = build_v5(cfg)?;
|
||||
loop {
|
||||
match eventloop.poll().await {
|
||||
Ok(V5Event::Incoming(V5Packet::ConnAck(ack))) => {
|
||||
if ack.code == V5ReturnCode::Success {
|
||||
// 测试完成,主动断开避免在 broker 侧残留会话
|
||||
let _ = client.disconnect().await;
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("连接被拒绝: {:?}", ack.code));
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::Disconnect(d))) => {
|
||||
return Err(format!("服务端断开: {:?}", d.reason_code));
|
||||
}
|
||||
Err(e) => return Err(format!("连接失败: {e}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let (client, mut eventloop) = build_v4(cfg)?;
|
||||
loop {
|
||||
match eventloop.poll().await {
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
|
||||
if ack.code == rv4::ConnectReturnCode::Success {
|
||||
let _ = client.disconnect().await;
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("连接被拒绝: {:?}", ack.code));
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
|
||||
return Err("服务端主动断开".into());
|
||||
}
|
||||
Err(e) => return Err(format!("连接失败: {e}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 事件循环 ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn v5_record(seq: u64, conn_id: &str, p: &V5Publish) -> MqttRecord {
|
||||
let props = p.properties.as_ref();
|
||||
MqttRecord {
|
||||
seq,
|
||||
connection_id: conn_id.to_string(),
|
||||
direction: Direction::Received,
|
||||
topic: String::from_utf8_lossy(&p.topic).to_string(),
|
||||
payload: String::from_utf8_lossy(&p.payload).to_string(),
|
||||
qos: v5_qos_u8(p.qos),
|
||||
retain: p.retain,
|
||||
timestamp: now_ms(),
|
||||
user_properties: props
|
||||
.map(|p| p.user_properties.clone())
|
||||
.unwrap_or_default(),
|
||||
content_type: props.and_then(|p| p.content_type.clone()),
|
||||
response_topic: props.and_then(|p| p.response_topic.clone()),
|
||||
correlation_data: props.and_then(|p| {
|
||||
p.correlation_data
|
||||
.as_ref()
|
||||
.map(|d| d.iter().map(|b| format!("{b:02x}")).collect())
|
||||
}),
|
||||
message_expiry_interval: props.and_then(|p| p.message_expiry_interval),
|
||||
subscription_identifier: props
|
||||
.and_then(|p| p.subscription_identifiers.first().map(|v| *v as u32)),
|
||||
}
|
||||
}
|
||||
|
||||
fn v5_qos_u8(qos: V5QoS) -> u8 {
|
||||
match qos {
|
||||
V5QoS::AtMostOnce => 0,
|
||||
V5QoS::AtLeastOnce => 1,
|
||||
V5QoS::ExactlyOnce => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_v5_loop(
|
||||
engine: Arc<MqttEngine>,
|
||||
id: String,
|
||||
mut eventloop: V5EventLoop,
|
||||
mut cancel_rx: watch::Receiver<bool>,
|
||||
connected: Arc<AtomicBool>,
|
||||
auto_reconnect: bool,
|
||||
) {
|
||||
let runtime = engine.runtime.handle().clone();
|
||||
runtime.spawn(async move {
|
||||
let mut first_connect = true;
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = eventloop.poll() => {
|
||||
match event {
|
||||
Ok(V5Event::Incoming(V5Packet::Publish(p))) => {
|
||||
let topic = String::from_utf8_lossy(&p.topic).to_string();
|
||||
let record = v5_record(engine.next_seq(), &id, &p);
|
||||
engine.log(
|
||||
&id,
|
||||
LogLevel::Info,
|
||||
"publish_received",
|
||||
format!("收到消息: {topic} ({} 字节, QoS {})", p.payload.len(), v5_qos_u8(p.qos)),
|
||||
None,
|
||||
);
|
||||
engine.emit(EngineEvent::Message(record));
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::ConnAck(ack))) => {
|
||||
let code = ack.code as u8;
|
||||
connected.store(ack.code == V5ReturnCode::Success, Ordering::SeqCst);
|
||||
if ack.code == V5ReturnCode::Success {
|
||||
first_connect = false;
|
||||
engine.log(
|
||||
&id,
|
||||
LogLevel::Info,
|
||||
"connack",
|
||||
format!("连接成功 (session_present: {})", ack.session_present),
|
||||
None,
|
||||
);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Connected,
|
||||
error: None,
|
||||
reason_code: Some(code),
|
||||
session_present: Some(ack.session_present),
|
||||
});
|
||||
} else {
|
||||
engine.log(
|
||||
&id,
|
||||
LogLevel::Error,
|
||||
"connack",
|
||||
format!("连接被拒绝: {:?} (code {code})", ack.code),
|
||||
None,
|
||||
);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Error,
|
||||
error: Some(format!("{:?}", ack.code)),
|
||||
reason_code: Some(code),
|
||||
session_present: Some(ack.session_present),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::Disconnect(d))) => {
|
||||
connected.store(false, Ordering::SeqCst);
|
||||
engine.log(
|
||||
&id,
|
||||
LogLevel::Warn,
|
||||
"disconnect_received",
|
||||
format!("收到断开连接: {:?} (code {})", d.reason_code, d.reason_code as u8),
|
||||
None,
|
||||
);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Disconnected,
|
||||
error: Some(format!("{:?}", d.reason_code)),
|
||||
reason_code: Some(d.reason_code as u8),
|
||||
session_present: None,
|
||||
});
|
||||
break;
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::SubAck(s))) => {
|
||||
let granted: Vec<String> = s
|
||||
.return_codes
|
||||
.iter()
|
||||
.map(|c| format!("{c:?}"))
|
||||
.collect();
|
||||
engine.log(&id, LogLevel::Info, "suback",
|
||||
format!("订阅确认 (packet {}): {}", s.pkid, granted.join(", ")), None);
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::UnsubAck(_))) => {
|
||||
engine.log(&id, LogLevel::Info, "unsuback", "取消订阅确认".into(), None);
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::PubAck(a))) => {
|
||||
engine.log(&id, LogLevel::Debug, "puback",
|
||||
format!("发布确认 (packet {}, reason {:?})", a.pkid, a.reason), None);
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::PubRec(a))) => {
|
||||
engine.log(&id, LogLevel::Debug, "pubrec",
|
||||
format!("QoS2 发布收到 (packet {})", a.pkid), None);
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::PubComp(a))) => {
|
||||
engine.log(&id, LogLevel::Debug, "pubcomp",
|
||||
format!("QoS2 发布完成 (packet {})", a.pkid), None);
|
||||
}
|
||||
Ok(V5Event::Outgoing(V5Outgoing::Publish(pkid))) => {
|
||||
engine.log(&id, LogLevel::Debug, "publish_outgoing",
|
||||
format!("消息已发出 (packet {pkid})"), None);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(V5Error::RequestsDone) => {
|
||||
connected.store(false, Ordering::SeqCst);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Disconnected,
|
||||
error: None,
|
||||
reason_code: None,
|
||||
session_present: None,
|
||||
});
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
connected.store(false, Ordering::SeqCst);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Error,
|
||||
error: Some(e.to_string()),
|
||||
reason_code: None,
|
||||
session_present: None,
|
||||
});
|
||||
if !auto_reconnect || first_connect {
|
||||
engine.log(&id, LogLevel::Error, "connection_error",
|
||||
format!("连接错误: {e}"), None);
|
||||
break;
|
||||
}
|
||||
engine.log(&id, LogLevel::Warn, "connection_error",
|
||||
format!("连接错误,2 秒后重连: {e}"), None);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = cancel_rx.changed() => {
|
||||
engine.log(&id, LogLevel::Info, "disconnect_cancelled", "连接已取消".into(), None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
engine.conns.lock().ok().and_then(|mut m| m.remove(&id));
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_v4_loop(
|
||||
engine: Arc<MqttEngine>,
|
||||
id: String,
|
||||
mut eventloop: rv4::EventLoop,
|
||||
mut cancel_rx: watch::Receiver<bool>,
|
||||
connected: Arc<AtomicBool>,
|
||||
auto_reconnect: bool,
|
||||
) {
|
||||
let runtime = engine.runtime.handle().clone();
|
||||
runtime.spawn(async move {
|
||||
let mut first_connect = true;
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = eventloop.poll() => {
|
||||
match event {
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::Publish(p))) => {
|
||||
let record = MqttRecord {
|
||||
seq: engine.next_seq(),
|
||||
connection_id: id.clone(),
|
||||
direction: Direction::Received,
|
||||
topic: String::from_utf8_lossy(&p.topic).to_string(),
|
||||
payload: String::from_utf8_lossy(&p.payload).to_string(),
|
||||
qos: match p.qos {
|
||||
rv4::QoS::AtMostOnce => 0,
|
||||
rv4::QoS::AtLeastOnce => 1,
|
||||
rv4::QoS::ExactlyOnce => 2,
|
||||
},
|
||||
retain: p.retain,
|
||||
timestamp: now_ms(),
|
||||
user_properties: Vec::new(),
|
||||
content_type: None,
|
||||
response_topic: None,
|
||||
correlation_data: None,
|
||||
message_expiry_interval: None,
|
||||
subscription_identifier: None,
|
||||
};
|
||||
engine.log(
|
||||
&id, LogLevel::Info, "publish_received",
|
||||
format!("收到消息: {} ({} 字节)", record.topic, p.payload.len()), None);
|
||||
engine.emit(EngineEvent::Message(record));
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
|
||||
connected.store(ack.code == rv4::ConnectReturnCode::Success, Ordering::SeqCst);
|
||||
if ack.code == rv4::ConnectReturnCode::Success {
|
||||
first_connect = false;
|
||||
engine.log(&id, LogLevel::Info, "connack",
|
||||
format!("连接成功 (session_present: {})", ack.session_present), None);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Connected,
|
||||
error: None,
|
||||
reason_code: Some(0),
|
||||
session_present: Some(ack.session_present),
|
||||
});
|
||||
} else {
|
||||
engine.log(&id, LogLevel::Error, "connack",
|
||||
format!("连接被拒绝: {:?}", ack.code), None);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Error,
|
||||
error: Some(format!("{:?}", ack.code)),
|
||||
reason_code: Some(1),
|
||||
session_present: Some(ack.session_present),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
|
||||
connected.store(false, Ordering::SeqCst);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Disconnected,
|
||||
error: Some("服务端断开".into()),
|
||||
reason_code: None,
|
||||
session_present: None,
|
||||
});
|
||||
break;
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::SubAck(s))) => {
|
||||
engine.log(&id, LogLevel::Info, "suback",
|
||||
format!("订阅确认 (packet {})", s.pkid), None);
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::UnsubAck(a))) => {
|
||||
engine.log(&id, LogLevel::Info, "unsuback",
|
||||
format!("取消订阅确认 (packet {})", a.pkid), None);
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::PubAck(a))) => {
|
||||
engine.log(&id, LogLevel::Debug, "puback",
|
||||
format!("发布确认 (packet {})", a.pkid), None);
|
||||
}
|
||||
Ok(rv4::Event::Outgoing(rv4::Outgoing::Publish(pkid))) => {
|
||||
engine.log(&id, LogLevel::Debug, "publish_outgoing",
|
||||
format!("消息已发出 (packet {pkid})"), None);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(rv4::ConnectionError::RequestsDone) => {
|
||||
connected.store(false, Ordering::SeqCst);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Disconnected,
|
||||
error: None, reason_code: None, session_present: None,
|
||||
});
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
connected.store(false, Ordering::SeqCst);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Error,
|
||||
error: Some(e.to_string()),
|
||||
reason_code: None, session_present: None,
|
||||
});
|
||||
if !auto_reconnect || first_connect {
|
||||
engine.log(&id, LogLevel::Error, "connection_error",
|
||||
format!("连接错误: {e}"), None);
|
||||
break;
|
||||
}
|
||||
engine.log(&id, LogLevel::Warn, "connection_error",
|
||||
format!("连接错误,2 秒后重连: {e}"), None);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = cancel_rx.changed() => {
|
||||
engine.log(&id, LogLevel::Info, "disconnect_cancelled", "连接已取消".into(), None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
engine.conns.lock().ok().and_then(|mut m| m.remove(&id));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! JSON 文件持久化。数据存放在系统应用数据目录的 `MQTTX-GPUI/` 下:
|
||||
//! Windows: %APPDATA%\MQTTX-GPUI,macOS: ~/Library/Application Support/MQTTX-GPUI,
|
||||
//! Linux: ~/.local/share/MQTTX-GPUI。
|
||||
//!
|
||||
//! 刻意与官方 Electron MQTTX 的 `MQTTX/` 目录区分,避免互相覆盖数据。
|
||||
|
||||
/// 存储层诊断输出到 stderr(避免仅为文件持久化引入日志框架)。
|
||||
macro_rules! elog {
|
||||
($($arg:tt)*) => {
|
||||
eprintln!("[store][{}] {}",
|
||||
chrono::Local::now().format("%H:%M:%S"),
|
||||
format!($($arg)*))
|
||||
};
|
||||
}
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::{
|
||||
aliyun::AliyunPreset,
|
||||
model::{AppSettings, ConnectionConfig, GlobalVariable, PublishPreset, Subscription},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Storage {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new() -> Self {
|
||||
let dir = dirs::data_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("MQTTX-GPUI");
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
elog!("创建数据目录 {} 失败: {e}", dir.display());
|
||||
}
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.dir.join(name)
|
||||
}
|
||||
|
||||
fn read_json<T: for<'de> serde::Deserialize<'de>>(&self, name: &str) -> Result<Option<T>> {
|
||||
let p = self.path(name);
|
||||
if !p.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut text = std::fs::read_to_string(&p)
|
||||
.with_context(|| format!("读取 {} 失败", p.display()))?;
|
||||
// 容忍 Windows 编辑器/PowerShell 写入的 UTF-8 BOM
|
||||
if text.starts_with('\u{feff}') {
|
||||
text.drain(..3);
|
||||
}
|
||||
if text.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = serde_json::from_str(&text)
|
||||
.with_context(|| format!("解析 {} 失败", p.display()))?;
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn write_json<T: serde::Serialize + ?Sized>(&self, name: &str, value: &T) -> Result<()> {
|
||||
let target = self.path(name);
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
let text = serde_json::to_string_pretty(value)?;
|
||||
std::fs::write(&tmp, text)
|
||||
.with_context(|| format!("写入 {} 失败", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &target)
|
||||
.with_context(|| format!("替换 {} 失败", target.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
pub fn log_dir(&self) -> PathBuf {
|
||||
self.dir.join("logs")
|
||||
}
|
||||
|
||||
// ── connections ──
|
||||
pub fn load_connections(&self) -> Vec<ConnectionConfig> {
|
||||
self.read_json("connections.json")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn save_connections(&self, conns: &[ConnectionConfig]) {
|
||||
if let Err(e) = self.write_json("connections.json", conns) {
|
||||
elog!("保存连接失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── subscriptions ──
|
||||
pub fn load_subscriptions(&self) -> Vec<Subscription> {
|
||||
self.read_json("subscriptions.json")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn save_subscriptions(&self, subs: &[Subscription]) {
|
||||
if let Err(e) = self.write_json("subscriptions.json", subs) {
|
||||
elog!("保存订阅失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── presets ──
|
||||
pub fn load_presets(&self) -> Vec<PublishPreset> {
|
||||
self.read_json("presets.json")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn save_presets(&self, presets: &[PublishPreset]) {
|
||||
if let Err(e) = self.write_json("presets.json", presets) {
|
||||
elog!("保存预设失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── variables ──
|
||||
pub fn load_variables(&self) -> Vec<GlobalVariable> {
|
||||
self.read_json("variables.json")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn save_variables(&self, vars: &[GlobalVariable]) {
|
||||
if let Err(e) = self.write_json("variables.json", vars) {
|
||||
elog!("保存全局变量失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── aliyun presets ──
|
||||
pub fn load_aliyun(&self) -> Vec<AliyunPreset> {
|
||||
self.read_json("aliyun.json")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn save_aliyun(&self, presets: &[AliyunPreset]) {
|
||||
if let Err(e) = self.write_json("aliyun.json", presets) {
|
||||
elog!("保存阿里云预设失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── settings ──
|
||||
pub fn load_settings(&self) -> AppSettings {
|
||||
self.read_json("settings.json")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn save_settings(&self, settings: &AppSettings) {
|
||||
if let Err(e) = self.write_json("settings.json", settings) {
|
||||
elog!("保存设置失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Storage {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! 阿里云 IoT 设备接入对话框:保存预设,并可一键生成连接并连接。
|
||||
|
||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||
use gpui_kit::component::input::{Input, InputState};
|
||||
use gpui_kit::component::select::{Select, SelectState};
|
||||
use gpui_kit::component::{
|
||||
h_flex, notification::Notification, v_flex, ActiveTheme as _, Sizable as _, StyledExt as _,
|
||||
WindowExt as _,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use crate::ui::IconName;
|
||||
use gpui_kit::{
|
||||
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, StatefulInteractiveElement as _,
|
||||
IntoElement, ParentElement as _, Render, Styled as _, Window,
|
||||
};
|
||||
|
||||
use crate::aliyun::{AliyunAuthMode, AliyunPreset};
|
||||
use crate::model::ConnectionConfig;
|
||||
use crate::ui::app::MqttXApp;
|
||||
use crate::ui::widgets::{field, make_select, OptionDelegate};
|
||||
|
||||
const AUTH_MODES: [&str; 2] = ["Group 令牌 (Token)", "一机一密"];
|
||||
|
||||
struct AliyunDialog {
|
||||
app: Entity<MqttXApp>,
|
||||
presets: Vec<AliyunPreset>,
|
||||
selected: Option<usize>,
|
||||
|
||||
name: Entity<InputState>,
|
||||
group: Entity<InputState>,
|
||||
instance_id: Entity<InputState>,
|
||||
region: Entity<InputState>,
|
||||
auth_mode: Entity<SelectState<OptionDelegate>>,
|
||||
access_key_id: Entity<InputState>,
|
||||
access_key_secret: Entity<InputState>,
|
||||
group_id: Entity<InputState>,
|
||||
device_id: Entity<InputState>,
|
||||
device_secret: Entity<InputState>,
|
||||
}
|
||||
|
||||
macro_rules! text_field {
|
||||
($cx:expr, $window:expr, $value:expr, $ph:expr) => {
|
||||
$cx.new(|cx| {
|
||||
InputState::new($window, cx)
|
||||
.default_value($value)
|
||||
.placeholder($ph)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
impl AliyunDialog {
|
||||
fn new(app: Entity<MqttXApp>, presets: Vec<AliyunPreset>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let first = presets.first().cloned().unwrap_or_else(AliyunPreset::new);
|
||||
Self {
|
||||
app,
|
||||
presets,
|
||||
selected: None,
|
||||
name: text_field!(cx, window, &first.name, "预设名称"),
|
||||
group: text_field!(cx, window, &first.group, "分组(可选)"),
|
||||
instance_id: text_field!(cx, window, &first.instance_id, "post-cn-xxxx"),
|
||||
region: text_field!(cx, window, &first.region, "如 cn-shanghai(可留空)"),
|
||||
auth_mode: make_select(
|
||||
&AUTH_MODES,
|
||||
match first.auth_mode {
|
||||
AliyunAuthMode::Token => 0,
|
||||
AliyunAuthMode::DeviceCredential => 1,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
access_key_id: text_field!(cx, window, &first.access_key_id, "AccessKey ID"),
|
||||
access_key_secret: cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.default_value(&first.access_key_secret)
|
||||
.placeholder("AccessKey Secret")
|
||||
.masked(true)
|
||||
}),
|
||||
group_id: text_field!(cx, window, &first.group_id, "GID-xxx"),
|
||||
device_id: text_field!(cx, window, &first.device_id, "设备 ID"),
|
||||
device_secret: cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.default_value(&first.device_secret)
|
||||
.placeholder("一机一密:DeviceSecret")
|
||||
.masked(true)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect(&self, cx: &App) -> AliyunPreset {
|
||||
let v = |e: &Entity<InputState>| e.read(cx).value().to_string();
|
||||
let id = self
|
||||
.selected
|
||||
.and_then(|i| self.presets.get(i))
|
||||
.map(|p| p.id.clone())
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
AliyunPreset {
|
||||
id,
|
||||
name: v(&self.name),
|
||||
group: v(&self.group),
|
||||
instance_id: v(&self.instance_id),
|
||||
region: v(&self.region),
|
||||
auth_mode: match self.auth_mode.read(cx).selected_value() {
|
||||
Some(1) => AliyunAuthMode::DeviceCredential,
|
||||
_ => AliyunAuthMode::Token,
|
||||
},
|
||||
access_key_id: v(&self.access_key_id),
|
||||
access_key_secret: v(&self.access_key_secret),
|
||||
group_id: v(&self.group_id),
|
||||
device_id: v(&self.device_id),
|
||||
device_secret: v(&self.device_secret),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_preset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let preset = self.collect(cx);
|
||||
if preset.name.trim().is_empty() {
|
||||
window.push_notification(Notification::warning("请填写预设名称"), cx);
|
||||
return;
|
||||
}
|
||||
match self.selected {
|
||||
Some(i) if i < self.presets.len() => self.presets[i] = preset,
|
||||
_ => self.presets.push(preset),
|
||||
}
|
||||
let saved = self.presets.clone();
|
||||
self.app.update(cx, |a, cx| {
|
||||
a.save_aliyun(saved);
|
||||
cx.notify();
|
||||
});
|
||||
window.push_notification(Notification::success("预设已保存"), cx);
|
||||
}
|
||||
|
||||
fn connect(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let preset = self.collect(cx);
|
||||
match preset.to_connection() {
|
||||
Ok(conn) => {
|
||||
let mut conn: ConnectionConfig = conn;
|
||||
conn.name = preset.name.clone();
|
||||
self.app.update(cx, |a, cx| {
|
||||
a.save_connection(conn.clone());
|
||||
a.engine.connect(conn);
|
||||
cx.notify();
|
||||
});
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(format!("无法生成连接: {e}")), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AliyunDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
// 左:预设列表
|
||||
.child(
|
||||
v_flex()
|
||||
.w(px(160.))
|
||||
.gap_1()
|
||||
.child(div().text_xs().font_semibold().child("已保存预设"))
|
||||
.children(
|
||||
self.presets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let selected = self.selected == Some(i);
|
||||
div()
|
||||
.id(("aliyun-preset", i))
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.text_sm()
|
||||
.when(selected, |d| d.bg(cx.theme().secondary))
|
||||
.cursor_pointer()
|
||||
.child(p.name.clone())
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
// 选中仅做高亮;表单编辑的是“新建/另存”工作流
|
||||
this.selected = Some(i);
|
||||
cx.notify();
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("aliyun-new")
|
||||
.icon(IconName::Plus)
|
||||
.label("新建")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.selected = None;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
// 右:表单
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.gap_2()
|
||||
.child(field("预设名称", Input::new(&self.name)))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"实例 ID",
|
||||
Input::new(&self.instance_id),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"地域",
|
||||
Input::new(&self.region),
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(field("鉴权方式", Select::new(&self.auth_mode)))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"AccessKey ID",
|
||||
Input::new(&self.access_key_id),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"AccessKey Secret",
|
||||
Input::new(&self.access_key_secret).mask_toggle(),
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"Group ID",
|
||||
Input::new(&self.group_id),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"设备 ID",
|
||||
Input::new(&self.device_id),
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(field(
|
||||
"DeviceSecret(一机一密)",
|
||||
Input::new(&self.device_secret).mask_toggle(),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Token 模式使用 MQTT 3.1.1,clientId={GroupId}@@@{设备ID},密码为 HMAC-SHA1 签名"),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("aliyun-save")
|
||||
.label("保存预设")
|
||||
.outline()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.save_preset(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("aliyun-connect")
|
||||
.label("生成并连接")
|
||||
.primary()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.connect(window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
let presets = app.read(cx).aliyun_presets.clone();
|
||||
let dialog_view = cx.new(|cx| AliyunDialog::new(app, presets, window, cx));
|
||||
window.open_dialog(cx, move |dialog, _, _| {
|
||||
dialog
|
||||
.w(px(760.))
|
||||
.title("阿里云 IoT 设备")
|
||||
.child(dialog_view.clone())
|
||||
.footer(gpui_kit::div())
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
//! 应用外壳:标题栏 + 连接侧边栏 + 多标签连接视图 + 引擎事件泵。
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||
use gpui_kit::component::scroll::ScrollableElement as _;
|
||||
use gpui_kit::component::input::{Input, InputState};
|
||||
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem};
|
||||
use gpui_kit::component::tab::{Tab, TabBar};
|
||||
use gpui_kit::component::{
|
||||
h_flex, notification::Notification, v_flex, ActiveTheme as _, Selectable as _, Sizable as _,
|
||||
StyledExt as _, Theme, ThemeMode, TitleBar, WindowExt as _,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::{
|
||||
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, StatefulInteractiveElement as _,
|
||||
IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window,
|
||||
};
|
||||
|
||||
use crate::aliyun::AliyunPreset;
|
||||
use crate::model::{
|
||||
AppSettings, ConnectionConfig, ConnectionStatus, Direction, GlobalVariable, LogEntry,
|
||||
LogLevel, MqttRecord, PublishParams, PublishPreset, Subscription, ThemeModePref,
|
||||
};
|
||||
use crate::mqtt::{EngineEvent, MqttEngine};
|
||||
use crate::store::Storage;
|
||||
use crate::ui::IconName;
|
||||
use crate::ui::{
|
||||
connection_form::ConnectionForm,
|
||||
connection_view::ConnectionView,
|
||||
widgets::status_color,
|
||||
};
|
||||
|
||||
const MAX_LOGS: usize = 3000;
|
||||
|
||||
pub struct MqttXApp {
|
||||
storage: Storage,
|
||||
pub engine: std::sync::Arc<MqttEngine>,
|
||||
_pump: gpui_kit::Task<()>,
|
||||
|
||||
// ── 持久化数据 ──
|
||||
pub connections: Vec<ConnectionConfig>,
|
||||
pub subscriptions: Vec<Subscription>,
|
||||
pub presets: Vec<PublishPreset>,
|
||||
pub variables: Vec<GlobalVariable>,
|
||||
pub aliyun_presets: Vec<AliyunPreset>,
|
||||
pub settings: AppSettings,
|
||||
|
||||
// ── 运行时状态 ──
|
||||
pub statuses: HashMap<String, ConnectionStatus>,
|
||||
pub errors: HashMap<String, String>,
|
||||
pub messages: HashMap<String, Vec<MqttRecord>>,
|
||||
pub logs: VecDeque<LogEntry>,
|
||||
pub seq: u64,
|
||||
|
||||
// ── 标签与视图 ──
|
||||
open_tabs: Vec<String>,
|
||||
active_tab: Option<String>,
|
||||
views: HashMap<String, Entity<ConnectionView>>,
|
||||
|
||||
search: Entity<InputState>,
|
||||
}
|
||||
|
||||
impl MqttXApp {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let storage = Storage::new();
|
||||
let connections = storage.load_connections();
|
||||
let subscriptions = storage.load_subscriptions();
|
||||
let presets = storage.load_presets();
|
||||
let variables = storage.load_variables();
|
||||
let aliyun_presets = storage.load_aliyun();
|
||||
let settings = storage.load_settings();
|
||||
|
||||
apply_theme(settings.theme, cx);
|
||||
|
||||
let (tx, rx) = smol::channel::unbounded();
|
||||
let engine = MqttEngine::new(tx);
|
||||
|
||||
let weak = cx.entity().downgrade();
|
||||
let pump = cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
weak.update_in(cx, |app, window, cx| {
|
||||
app.on_engine_event(event, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
|
||||
let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…"));
|
||||
|
||||
|
||||
Self {
|
||||
storage,
|
||||
engine,
|
||||
_pump: pump,
|
||||
connections,
|
||||
subscriptions,
|
||||
presets,
|
||||
variables,
|
||||
aliyun_presets,
|
||||
settings,
|
||||
statuses: HashMap::new(),
|
||||
errors: HashMap::new(),
|
||||
messages: HashMap::new(),
|
||||
logs: VecDeque::with_capacity(MAX_LOGS),
|
||||
seq: 0,
|
||||
open_tabs: Vec::new(),
|
||||
active_tab: None,
|
||||
views: HashMap::new(),
|
||||
search,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 引擎事件 ──────────────────────────────────────────────────────────
|
||||
|
||||
fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
EngineEvent::Status {
|
||||
connection_id,
|
||||
status,
|
||||
error,
|
||||
..
|
||||
} => {
|
||||
self.statuses.insert(connection_id.clone(), status);
|
||||
match error {
|
||||
Some(e) => {
|
||||
self.errors.insert(connection_id.clone(), e);
|
||||
}
|
||||
None => {
|
||||
self.errors.remove(&connection_id);
|
||||
}
|
||||
}
|
||||
if status == ConnectionStatus::Connected {
|
||||
self.on_connected(&connection_id);
|
||||
}
|
||||
if let Some(view) = self.views.get(&connection_id) {
|
||||
view.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
EngineEvent::Message(mut record) => {
|
||||
let id = record.connection_id.clone();
|
||||
record.seq = self.next_seq();
|
||||
self.push_message(id.clone(), record);
|
||||
if let Some(view) = self.views.get(&id) {
|
||||
view.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
}
|
||||
EngineEvent::Published {
|
||||
connection_id,
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
content_type,
|
||||
user_properties,
|
||||
} => {
|
||||
let record = MqttRecord {
|
||||
seq: self.next_seq(),
|
||||
connection_id: connection_id.clone(),
|
||||
direction: Direction::Published,
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
timestamp: chrono::Local::now().timestamp_millis(),
|
||||
user_properties,
|
||||
content_type,
|
||||
response_topic: None,
|
||||
correlation_data: None,
|
||||
message_expiry_interval: None,
|
||||
subscription_identifier: None,
|
||||
};
|
||||
self.push_message(connection_id.clone(), record);
|
||||
if let Some(view) = self.views.get(&connection_id) {
|
||||
view.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
}
|
||||
EngineEvent::SubscribeResult {
|
||||
connection_id,
|
||||
topic,
|
||||
qos,
|
||||
ok,
|
||||
error,
|
||||
} => {
|
||||
if ok {
|
||||
self.add_subscription(Subscription::new(connection_id.clone(), topic, qos));
|
||||
} else if let Some(e) = error {
|
||||
window.push_notification(
|
||||
Notification::error(format!("订阅失败: {e}")),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
if let Some(view) = self.views.get(&connection_id) {
|
||||
view.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
}
|
||||
EngineEvent::Log(entry) => {
|
||||
self.push_log(entry);
|
||||
if let Some(id) = self.active_tab.clone()
|
||||
&& let Some(view) = self.views.get(&id) {
|
||||
view.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_connected(&mut self, connection_id: &str) {
|
||||
// 连接成功后自动恢复已保存订阅(auto resubscribe)。
|
||||
let Some(config) = self.connections.iter().find(|c| c.id == connection_id).cloned() else {
|
||||
return;
|
||||
};
|
||||
if !config.auto_resubscribe {
|
||||
return;
|
||||
}
|
||||
let subs: Vec<Subscription> = self
|
||||
.subscriptions
|
||||
.iter()
|
||||
.filter(|s| s.connection_id == connection_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
for sub in subs {
|
||||
self.engine.subscribe(connection_id.to_string(), sub);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据工具 ──────────────────────────────────────────────────────────
|
||||
|
||||
fn next_seq(&mut self) -> u64 {
|
||||
self.seq += 1;
|
||||
self.seq
|
||||
}
|
||||
|
||||
fn push_message(&mut self, connection_id: String, record: MqttRecord) {
|
||||
let cap = self.settings.max_messages.max(100);
|
||||
let list = self.messages.entry(connection_id).or_default();
|
||||
if list.len() >= cap {
|
||||
let drop_n = list.len() - cap + 1;
|
||||
list.drain(0..drop_n);
|
||||
}
|
||||
list.push(record);
|
||||
}
|
||||
|
||||
fn push_log(&mut self, entry: LogEntry) {
|
||||
if self.logs.len() >= MAX_LOGS {
|
||||
self.logs.pop_front();
|
||||
}
|
||||
self.logs.push_back(entry);
|
||||
}
|
||||
|
||||
pub fn log(&mut self, conn: &str, level: LogLevel, event: &str, message: String) {
|
||||
self.push_log(LogEntry {
|
||||
timestamp: chrono::Local::now().timestamp_millis(),
|
||||
connection_id: conn.to_string(),
|
||||
level,
|
||||
event: event.to_string(),
|
||||
message,
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 连接 CRUD / 生命周期 ──────────────────────────────────────────────
|
||||
|
||||
pub fn save_connection(&mut self, cfg: ConnectionConfig) {
|
||||
if let Some(slot) = self.connections.iter_mut().find(|c| c.id == cfg.id) {
|
||||
*slot = cfg;
|
||||
} else {
|
||||
self.connections.push(cfg);
|
||||
}
|
||||
self.storage.save_connections(&self.connections);
|
||||
}
|
||||
|
||||
pub fn duplicate_connection(&mut self, id: &str) {
|
||||
let Some(src) = self.connections.iter().find(|c| c.id == id).cloned() else {
|
||||
return;
|
||||
};
|
||||
let mut copy = src;
|
||||
copy.id = uuid::Uuid::new_v4().to_string();
|
||||
copy.name = format!("{} 副本", copy.name);
|
||||
copy.client_id =
|
||||
format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
self.connections.push(copy);
|
||||
self.storage.save_connections(&self.connections);
|
||||
}
|
||||
|
||||
pub fn delete_connection(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||
self.engine.close(id, false);
|
||||
self.connections.retain(|c| c.id != id);
|
||||
self.subscriptions.retain(|s| s.connection_id != id);
|
||||
self.messages.remove(id);
|
||||
self.statuses.remove(id);
|
||||
self.views.remove(id);
|
||||
self.open_tabs.retain(|t| t != id);
|
||||
if self.active_tab.as_deref() == Some(id) {
|
||||
self.active_tab = self.open_tabs.last().cloned();
|
||||
}
|
||||
self.storage.save_connections(&self.connections);
|
||||
self.storage.save_subscriptions(&self.subscriptions);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn toggle_connection(&self, cfg: &ConnectionConfig) {
|
||||
if self.engine.is_connected(&cfg.id) {
|
||||
self.engine.close(&cfg.id, true);
|
||||
} else {
|
||||
self.engine.connect(cfg.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_tab(&mut self, conn_id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.open_tabs.contains(&conn_id.to_string()) {
|
||||
self.open_tabs.push(conn_id.to_string());
|
||||
let weak = cx.entity().downgrade();
|
||||
let engine = self.engine.clone();
|
||||
let view = cx.new(|cx| ConnectionView::new(conn_id.to_string(), weak, engine, window, cx));
|
||||
self.views.insert(conn_id.to_string(), view);
|
||||
}
|
||||
self.active_tab = Some(conn_id.to_string());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn close_tab(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||
self.open_tabs.retain(|t| t != id);
|
||||
self.views.remove(id);
|
||||
if self.active_tab.as_deref() == Some(id) {
|
||||
self.active_tab = self.open_tabs.last().cloned();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
// ── 订阅 ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn add_subscription(&mut self, sub: Subscription) {
|
||||
if !self
|
||||
.subscriptions
|
||||
.iter()
|
||||
.any(|s| s.connection_id == sub.connection_id && s.topic == sub.topic)
|
||||
{
|
||||
self.subscriptions.push(sub);
|
||||
self.storage.save_subscriptions(&self.subscriptions);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_subscription(&mut self, connection_id: &str, topic: &str) {
|
||||
self.engine
|
||||
.unsubscribe(connection_id.to_string(), topic.to_string());
|
||||
self.subscriptions
|
||||
.retain(|s| !(s.connection_id == connection_id && s.topic == topic));
|
||||
self.storage.save_subscriptions(&self.subscriptions);
|
||||
}
|
||||
|
||||
// ── 变量 / 预设 / 阿里云 / 设置 ──────────────────────────────────────────
|
||||
|
||||
pub fn save_variables(&mut self, vars: Vec<GlobalVariable>) {
|
||||
self.variables = vars;
|
||||
self.storage.save_variables(&self.variables);
|
||||
}
|
||||
|
||||
pub fn save_presets(&mut self, presets: Vec<PublishPreset>) {
|
||||
self.presets = presets;
|
||||
self.storage.save_presets(&self.presets);
|
||||
}
|
||||
|
||||
/// 保存一个发布预设(按 name 去重更新)。
|
||||
pub fn upsert_publish_preset(&mut self, name: String, params: PublishParams) {
|
||||
match self.presets.iter_mut().find(|p| p.name == name) {
|
||||
Some(p) => p.params = params,
|
||||
None => self.presets.push(PublishPreset::new(name, params)),
|
||||
}
|
||||
self.storage.save_presets(&self.presets);
|
||||
}
|
||||
|
||||
pub fn delete_publish_preset(&mut self, id: &str) {
|
||||
self.presets.retain(|p| p.id != id);
|
||||
self.storage.save_presets(&self.presets);
|
||||
}
|
||||
|
||||
pub fn save_aliyun(&mut self, presets: Vec<AliyunPreset>) {
|
||||
self.aliyun_presets = presets;
|
||||
self.storage.save_aliyun(&self.aliyun_presets);
|
||||
}
|
||||
|
||||
pub fn save_settings(&mut self, settings: AppSettings, cx: &mut App) {
|
||||
apply_theme(settings.theme, cx);
|
||||
self.settings = settings;
|
||||
self.storage.save_settings(&self.settings);
|
||||
}
|
||||
|
||||
// ── 对话框 ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn open_connection_form(
|
||||
&mut self,
|
||||
edit: Option<ConnectionConfig>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let app = cx.entity().downgrade();
|
||||
let engine = self.engine.clone();
|
||||
let form = cx.new(|cx| ConnectionForm::new(engine, edit, window, cx));
|
||||
window.open_dialog(cx, move |dialog, _, cx| {
|
||||
let form_ok = form.clone();
|
||||
let form_test = form.clone();
|
||||
let app_ok = app.clone();
|
||||
let testing = form_test.read(cx).is_testing();
|
||||
dialog
|
||||
.w(px(680.))
|
||||
.title("连接配置")
|
||||
.child(form.clone())
|
||||
.footer(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
// 测试连接常驻左下角,保存前即可验证配置
|
||||
.child(
|
||||
Button::new("form-test")
|
||||
.icon(if testing {
|
||||
IconName::LoaderCircle
|
||||
} else {
|
||||
IconName::PlugZap
|
||||
})
|
||||
.label(if testing { "测试中…" } else { "测试连接" })
|
||||
.outline()
|
||||
.loading(testing)
|
||||
.on_click(move |_, window, cx| {
|
||||
form_test.update(cx, |f, cx| f.run_test(window, cx));
|
||||
}),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
Button::new("form-cancel")
|
||||
.label("取消")
|
||||
.outline()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("form-ok")
|
||||
.label("保存")
|
||||
.primary()
|
||||
.on_click(move |_, window, cx| {
|
||||
let built = form_ok.read(cx).build(cx);
|
||||
match built {
|
||||
Ok(cfg) => {
|
||||
app_ok
|
||||
.update(cx, |app, cx| {
|
||||
app.save_connection(cfg);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(
|
||||
Notification::error(e),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 主题 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn apply_theme(pref: ThemeModePref, cx: &mut App) {
|
||||
let mode = match pref {
|
||||
ThemeModePref::Light => ThemeMode::Light,
|
||||
ThemeModePref::Dark => ThemeMode::Dark,
|
||||
// 未做系统外观检测前,“跟随系统”暂按浅色处理
|
||||
ThemeModePref::System => ThemeMode::Light,
|
||||
};
|
||||
Theme::change(mode, None, cx);
|
||||
}
|
||||
|
||||
// ─── 渲染 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
impl Render for MqttXApp {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// 0.6.x 的 Root 不自动渲染 dialog/sheet/notification 层,需由内容视图挂载。
|
||||
v_flex()
|
||||
.size_full()
|
||||
.bg(cx.theme().background)
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(self.render_titlebar(window, cx))
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.min_h(px(0.))
|
||||
.child(self.render_sidebar(window, cx))
|
||||
.child(self.render_main(window, cx)),
|
||||
)
|
||||
.children(gpui_kit::component::Root::render_sheet_layer(window, cx))
|
||||
.children(gpui_kit::component::Root::render_dialog_layer(window, cx))
|
||||
.children(gpui_kit::component::Root::render_notification_layer(window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl MqttXApp {
|
||||
fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let app_weak = cx.entity().downgrade();
|
||||
|
||||
TitleBar::new().child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h_full()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.pl_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(crate::ui::logo::logo(
|
||||
px(26.),
|
||||
cx.theme().primary,
|
||||
cx.theme().primary_foreground,
|
||||
))
|
||||
.child(div().text_sm().font_semibold().child("MQTTX"))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("MQTT 调试客户端"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.pr_2()
|
||||
.items_center()
|
||||
.child(
|
||||
Button::new("new-connection")
|
||||
.icon(IconName::Plus)
|
||||
.label("新建连接")
|
||||
.primary()
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.open_connection_form(None, window, cx);
|
||||
})),
|
||||
)
|
||||
.child(self.render_theme_toggle(cx))
|
||||
.child(
|
||||
Button::new("more-menu")
|
||||
.icon(IconName::Ellipsis)
|
||||
.ghost()
|
||||
.small()
|
||||
.dropdown_menu(move |menu, _, _| {
|
||||
let w1 = app_weak.clone();
|
||||
let w2 = app_weak.clone();
|
||||
let w3 = app_weak.clone();
|
||||
menu.item(
|
||||
PopupMenuItem::new("阿里云设备")
|
||||
.on_click(move |_, window, cx| {
|
||||
if let Some(entity) = w1.upgrade() {
|
||||
crate::ui::aliyun_dialog::open(entity, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.item(PopupMenuItem::new("全局变量").on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(entity) = w2.upgrade() {
|
||||
crate::ui::variables_dialog::open(entity, window, cx);
|
||||
}
|
||||
},
|
||||
))
|
||||
.separator()
|
||||
.item(PopupMenuItem::new("设置").on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(entity) = w3.upgrade() {
|
||||
crate::ui::settings_dialog::open(entity, window, cx);
|
||||
}
|
||||
},
|
||||
))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_theme_toggle(&self, cx: &mut Context<Self>) -> Button {
|
||||
let dark = cx.theme().mode == ThemeMode::Dark;
|
||||
Button::new("toggle-theme")
|
||||
.icon(if dark { IconName::Sun } else { IconName::Moon })
|
||||
.ghost()
|
||||
.small()
|
||||
.tooltip(if dark { "切换为浅色" } else { "切换为深色" })
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
let next = if cx.theme().mode == ThemeMode::Dark {
|
||||
ThemeModePref::Light
|
||||
} else {
|
||||
ThemeModePref::Dark
|
||||
};
|
||||
let mut s = this.settings.clone();
|
||||
s.theme = next;
|
||||
this.save_settings(s, cx);
|
||||
cx.notify();
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let query = self.search.read(cx).value().to_lowercase();
|
||||
let search = self.search.clone();
|
||||
|
||||
let total = self.connections.len();
|
||||
let hover_bg = cx.theme().muted;
|
||||
let mut rows = v_flex().gap_0p5().flex_1().overflow_y_scrollbar();
|
||||
let mut shown = 0usize;
|
||||
for conn in self.connections.clone() {
|
||||
if !query.is_empty() && !conn.name.to_lowercase().contains(&query) {
|
||||
continue;
|
||||
}
|
||||
shown += 1;
|
||||
let status = self
|
||||
.statuses
|
||||
.get(&conn.id)
|
||||
.copied()
|
||||
.unwrap_or(ConnectionStatus::Disconnected);
|
||||
let active = self.active_tab.as_deref() == Some(conn.id.as_str());
|
||||
let cfg = conn.clone();
|
||||
let name = conn.name.clone();
|
||||
let address = conn.display_address();
|
||||
let row_id = conn.id.clone();
|
||||
|
||||
rows = rows.child(
|
||||
h_flex()
|
||||
.id(SharedString::from(format!("conn-row-{}", conn.id)))
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.mx_2()
|
||||
.my_0p5()
|
||||
.px_2()
|
||||
.py_2()
|
||||
.rounded_md()
|
||||
.when(active, |this| this.bg(cx.theme().secondary))
|
||||
.when(!active, |this| {
|
||||
this.hover(move |this| this.bg(hover_bg))
|
||||
})
|
||||
.cursor_pointer()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open_tab(&row_id, window, cx);
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.size(px(7.))
|
||||
.rounded_full()
|
||||
.bg(status_color(status, cx)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.when(active, |t| t.font_semibold())
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.child(address),
|
||||
),
|
||||
)
|
||||
.child(self.render_conn_power(&cfg, status, cx))
|
||||
.child(self.render_conn_menu(&conn.id, cx)),
|
||||
);
|
||||
}
|
||||
|
||||
let body = if self.connections.is_empty() {
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.p_4()
|
||||
.child(
|
||||
gpui_kit::component::Icon::new(IconName::PlugZap)
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("还没有连接,点击上方 + 新建"),
|
||||
)
|
||||
} else if query.is_empty() {
|
||||
v_flex().flex_1().child(rows)
|
||||
} else if shown == 0 {
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("没有匹配的连接"),
|
||||
)
|
||||
} else {
|
||||
v_flex().flex_1().child(rows)
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.w(px(264.))
|
||||
.h_full()
|
||||
.flex_shrink_0()
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_11()
|
||||
.px_3()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.child(div().text_sm().font_semibold().child("连接"))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(total.to_string()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("sidebar-add")
|
||||
.icon(IconName::Plus)
|
||||
.tooltip("新建连接")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.open_connection_form(None, window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(v_flex().px_2().pb_2().child(Input::new(&search).small().prefix(
|
||||
gpui_kit::component::Icon::new(IconName::Search).small(),
|
||||
)))
|
||||
.child(body)
|
||||
}
|
||||
|
||||
fn render_conn_power(
|
||||
&self,
|
||||
cfg: &ConnectionConfig,
|
||||
status: ConnectionStatus,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Button {
|
||||
let cfg = cfg.clone();
|
||||
let connected = matches!(status, ConnectionStatus::Connected);
|
||||
Button::new(SharedString::from(format!("conn-power-{}", cfg.id)))
|
||||
.icon(if connected { IconName::Square } else { IconName::PlugZap })
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(if connected { "断开" } else { "连接" })
|
||||
.on_click(cx.listener(move |this, _, _, _| {
|
||||
this.toggle_connection(&cfg);
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_conn_menu(&self, conn_id: &str, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let weak = cx.entity().downgrade();
|
||||
let eid = conn_id.to_string();
|
||||
Button::new(SharedString::from(format!("conn-menu-{}", conn_id)))
|
||||
.icon(IconName::Ellipsis)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.dropdown_menu(move |menu, _, _| {
|
||||
let (e1, e2, e3) = (eid.clone(), eid.clone(), eid.clone());
|
||||
let (w1, w2, w3) = (weak.clone(), weak.clone(), weak.clone());
|
||||
menu.item(PopupMenuItem::new("编辑").on_click(move |_, window, cx| {
|
||||
w1.update(cx, |app, cx| {
|
||||
let cfg = app.connections.iter().find(|c| c.id == e1).cloned();
|
||||
if let Some(cfg) = cfg {
|
||||
app.open_connection_form(Some(cfg), window, cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}))
|
||||
.item(PopupMenuItem::new("复制连接").on_click(move |_, _, cx| {
|
||||
w2.update(cx, |app, cx| {
|
||||
app.duplicate_connection(&e2);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}))
|
||||
.separator()
|
||||
.item(PopupMenuItem::new("删除").on_click(move |_, _, cx| {
|
||||
w3.update(cx, |app, cx| app.delete_connection(&e3, cx)).ok();
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn render_main(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if self.open_tabs.is_empty() {
|
||||
return v_flex()
|
||||
.flex_1()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_3()
|
||||
.child(crate::ui::logo::logo(
|
||||
px(72.),
|
||||
cx.theme().muted,
|
||||
cx.theme().muted_foreground,
|
||||
))
|
||||
.child(div().text_base().font_medium().child("开始使用 MQTTX"))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_center()
|
||||
.child("从左侧选择一个连接,或新建连接开始调试"),
|
||||
)
|
||||
.child(
|
||||
Button::new("empty-new")
|
||||
.icon(IconName::Plus)
|
||||
.label("新建连接")
|
||||
.outline()
|
||||
.small()
|
||||
.mt_1()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.open_connection_form(None, window, cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let active = self.active_tab.clone().unwrap_or_default();
|
||||
let mut bar = TabBar::new("conn-tabs");
|
||||
for id in self.open_tabs.clone() {
|
||||
let name = self
|
||||
.connections
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_else(|| id.clone());
|
||||
let tab_id = id.clone();
|
||||
let close_id = id.clone();
|
||||
let is_active = id == active;
|
||||
bar = bar.child(
|
||||
Tab::new()
|
||||
.label(SharedString::from(name))
|
||||
.selected(is_active)
|
||||
.suffix(
|
||||
Button::new(SharedString::from(format!("tab-close-{}", id)))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.close_tab(&close_id, cx);
|
||||
})),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open_tab(&tab_id, window, cx);
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let view = self.views.get(&active).cloned();
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.child(div().flex_shrink_0().px_2().pt_1().child(bar))
|
||||
.when_some(view, |this, v| this.child(v))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
//! 新建/编辑连接的表单对话框内容。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui_kit::component::scroll::ScrollableElement as _;
|
||||
use gpui_kit::component::input::{Input, InputState};
|
||||
use gpui_kit::component::select::{Select, SelectEvent, SelectState};
|
||||
use gpui_kit::component::switch::Switch;
|
||||
use gpui_kit::component::{
|
||||
h_flex, notification::Notification, v_flex, ActiveTheme as _, Icon, Sizable as _,
|
||||
StyledExt as _, WindowExt as _,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use crate::ui::IconName;
|
||||
use gpui_kit::{
|
||||
div, percentage, px, App, AppContext as _, Context, Entity, InteractiveElement as _,
|
||||
IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Subscription, Window,
|
||||
};
|
||||
|
||||
use crate::model::{ConnectionConfig, LastWill, ProtocolVersion, TransportKind};
|
||||
use crate::mqtt::MqttEngine;
|
||||
use crate::ui::widgets::{field, make_select, OptionDelegate};
|
||||
|
||||
const PROTOCOLS: [&str; 2] = ["MQTT 5.0", "MQTT 3.1.1"];
|
||||
const TRANSPORTS: [&str; 4] = ["TCP", "TLS", "WebSocket", "WSS"];
|
||||
const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"];
|
||||
|
||||
pub struct ConnectionForm {
|
||||
engine: Arc<MqttEngine>,
|
||||
editing_id: Option<String>,
|
||||
created_at: i64,
|
||||
|
||||
name: Entity<InputState>,
|
||||
host: Entity<InputState>,
|
||||
port: Entity<InputState>,
|
||||
path: Entity<InputState>,
|
||||
client_id: Entity<InputState>,
|
||||
username: Entity<InputState>,
|
||||
password: Entity<InputState>,
|
||||
keep_alive: Entity<InputState>,
|
||||
session_expiry: Entity<InputState>,
|
||||
receive_max: Entity<InputState>,
|
||||
max_packet: Entity<InputState>,
|
||||
topic_alias: Entity<InputState>,
|
||||
|
||||
will_topic: Entity<InputState>,
|
||||
will_payload: Entity<InputState>,
|
||||
|
||||
protocol: Entity<SelectState<OptionDelegate>>,
|
||||
transport: Entity<SelectState<OptionDelegate>>,
|
||||
will_qos: Entity<SelectState<OptionDelegate>>,
|
||||
|
||||
clean_start: bool,
|
||||
auto_resubscribe: bool,
|
||||
auto_reconnect: bool,
|
||||
will_enabled: bool,
|
||||
will_retain: bool,
|
||||
|
||||
// 按需显示:高级分组默认折叠
|
||||
show_auth: bool,
|
||||
show_mqtt5: bool,
|
||||
show_will: bool,
|
||||
|
||||
testing: bool,
|
||||
_subs: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl ConnectionForm {
|
||||
/// 对话框 footer 的测试按钮需要读取测试进行中的状态。
|
||||
pub fn is_testing(&self) -> bool {
|
||||
self.testing
|
||||
}
|
||||
}
|
||||
|
||||
fn input(
|
||||
value: &str,
|
||||
placeholder: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ConnectionForm>,
|
||||
) -> Entity<InputState> {
|
||||
cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(placeholder)
|
||||
.default_value(value)
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_input(
|
||||
value: Option<u32>,
|
||||
placeholder: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ConnectionForm>,
|
||||
) -> Entity<InputState> {
|
||||
cx.new(|cx| {
|
||||
let s = InputState::new(window, cx).placeholder(placeholder);
|
||||
match value {
|
||||
Some(v) => s.default_value(v.to_string()),
|
||||
None => s,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl ConnectionForm {
|
||||
pub fn new(
|
||||
engine: Arc<MqttEngine>,
|
||||
edit: Option<ConnectionConfig>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let c = edit.clone().unwrap_or_default();
|
||||
let editing_id = edit.as_ref().map(|e| e.id.clone());
|
||||
let created_at = edit.as_ref().map(|e| e.created_at).unwrap_or_else(|| {
|
||||
chrono::Local::now().timestamp()
|
||||
});
|
||||
|
||||
let name = input(&c.name, "连接名称", window, cx);
|
||||
let host = input(&c.host, "broker.example.com", window, cx);
|
||||
let port = input(&c.port.to_string(), "端口", window, cx);
|
||||
let path = input(&c.path, "/mqtt", window, cx);
|
||||
let client_id = input(&c.client_id, "Client ID", window, cx);
|
||||
let username = input(&c.username, "用户名(可选)", window, cx);
|
||||
let password = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("密码(可选)")
|
||||
.default_value(&c.password)
|
||||
.masked(true)
|
||||
});
|
||||
let keep_alive = input(&c.keep_alive.to_string(), "60", window, cx);
|
||||
let session_expiry = input(
|
||||
&if c.session_expiry_interval > 0 {
|
||||
c.session_expiry_interval.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
"秒(0=会话随连接结束)",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let receive_max = optional_input(c.receive_maximum.map(u32::from), "服务端接收上限", window, cx);
|
||||
let max_packet = optional_input(c.maximum_packet_size, "最大报文长度", window, cx);
|
||||
let topic_alias = optional_input(c.topic_alias_maximum.map(u32::from), "主题别名上限", window, cx);
|
||||
|
||||
let (will_topic, will_payload, will_enabled, will_retain, will_qos_idx) =
|
||||
match &c.last_will {
|
||||
Some(w) => (
|
||||
input(&w.topic, "遗嘱主题", window, cx),
|
||||
input(&w.payload, "遗嘱消息", window, cx),
|
||||
true,
|
||||
w.retain,
|
||||
w.qos as usize,
|
||||
),
|
||||
None => (
|
||||
input("", "遗嘱主题", window, cx),
|
||||
input("", "遗嘱消息", window, cx),
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
),
|
||||
};
|
||||
|
||||
let protocol = make_select(
|
||||
&PROTOCOLS,
|
||||
if c.protocol.is_v5() { 0 } else { 1 },
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let transport_idx = TransportKind::ALL
|
||||
.iter()
|
||||
.position(|t| *t == c.transport)
|
||||
.unwrap_or(0);
|
||||
let transport = make_select(&TRANSPORTS, transport_idx, window, cx);
|
||||
let will_qos = make_select(&QOS, will_qos_idx, window, cx);
|
||||
|
||||
// 切换传输方式时,把端口同步成该方式的默认端口,并刷新 WS 路径的按需显示。
|
||||
let port_for_sub = port.clone();
|
||||
let sub_transport = cx.subscribe_in(
|
||||
&transport,
|
||||
window,
|
||||
move |_this, state, event, window, cx| {
|
||||
if matches!(event, SelectEvent::Confirm(Some(_)))
|
||||
&& let Some(idx) = state.read(cx).selected_value() {
|
||||
let kind = TransportKind::ALL[*idx];
|
||||
port_for_sub.update(cx, |p, cx| {
|
||||
p.set_value(kind.default_port().to_string(), window, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
// 切换协议时刷新 MQTT 5 属性分组的按需显示。
|
||||
let sub_protocol = cx.subscribe_in(
|
||||
&protocol,
|
||||
window,
|
||||
move |_this, _state, event, _window, cx| {
|
||||
if matches!(event, SelectEvent::Confirm(Some(_))) {
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
engine,
|
||||
editing_id,
|
||||
created_at,
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
path,
|
||||
client_id,
|
||||
username,
|
||||
password,
|
||||
keep_alive,
|
||||
session_expiry,
|
||||
receive_max,
|
||||
max_packet,
|
||||
topic_alias,
|
||||
will_topic,
|
||||
will_payload,
|
||||
protocol,
|
||||
transport,
|
||||
will_qos,
|
||||
clean_start: c.clean_start,
|
||||
auto_resubscribe: c.auto_resubscribe,
|
||||
auto_reconnect: c.auto_reconnect,
|
||||
will_enabled,
|
||||
will_retain,
|
||||
show_auth: true,
|
||||
show_mqtt5: false,
|
||||
show_will: false,
|
||||
testing: false,
|
||||
_subs: vec![sub_transport, sub_protocol],
|
||||
}
|
||||
}
|
||||
|
||||
fn val(&self, e: &Entity<InputState>, cx: &App) -> String {
|
||||
e.read(cx).value().to_string()
|
||||
}
|
||||
|
||||
fn parse_u16(s: &str, label: &str) -> Result<u16, String> {
|
||||
s.trim()
|
||||
.parse::<u16>()
|
||||
.map_err(|_| format!("{label} 需要是 0~65535 的整数"))
|
||||
}
|
||||
|
||||
fn parse_opt_u32(s: &str, label: &str) -> Result<Option<u32>, String> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
s.parse::<u32>()
|
||||
.map(Some)
|
||||
.map_err(|_| format!("{label} 需要是非负整数"))
|
||||
}
|
||||
|
||||
pub fn build(&self, cx: &App) -> Result<ConnectionConfig, String> {
|
||||
self.build_inner(cx, false)
|
||||
}
|
||||
|
||||
/// 测试连接专用的宽松构建:名称与 Client ID 不阻塞测试
|
||||
///(握手时会生成临时唯一 Client ID)。
|
||||
fn build_for_test(&self, cx: &App) -> Result<ConnectionConfig, String> {
|
||||
self.build_inner(cx, true)
|
||||
}
|
||||
|
||||
fn build_inner(&self, cx: &App, for_test: bool) -> Result<ConnectionConfig, String> {
|
||||
let mut name = self.val(&self.name, cx);
|
||||
if name.trim().is_empty() {
|
||||
if !for_test {
|
||||
return Err("连接名称不能为空".into());
|
||||
}
|
||||
name = "测试连接".into();
|
||||
}
|
||||
let host = self.val(&self.host, cx);
|
||||
if host.trim().is_empty() {
|
||||
return Err("主机地址不能为空".into());
|
||||
}
|
||||
let port = Self::parse_u16(&self.val(&self.port, cx), "端口")?;
|
||||
let mut client_id = self.val(&self.client_id, cx);
|
||||
if client_id.trim().is_empty() {
|
||||
if !for_test {
|
||||
return Err("Client ID 不能为空".into());
|
||||
}
|
||||
client_id = format!("mqttx-test-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
}
|
||||
let keep_alive = Self::parse_u16(&self.val(&self.keep_alive, cx), "Keep Alive")?;
|
||||
let session_expiry = self
|
||||
.val(&self.session_expiry, cx)
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
let receive_maximum = Self::parse_opt_u32(&self.val(&self.receive_max, cx), "接收上限")?
|
||||
.map(|v| u16::try_from(v).map_err(|_| "接收上限不能超过 65535".to_string()))
|
||||
.transpose()?;
|
||||
let maximum_packet_size =
|
||||
Self::parse_opt_u32(&self.val(&self.max_packet, cx), "最大报文长度")?;
|
||||
let topic_alias_maximum = Self::parse_opt_u32(&self.val(&self.topic_alias, cx), "主题别名")?
|
||||
.map(|v| u16::try_from(v).map_err(|_| "主题别名上限不能超过 65535".to_string()))
|
||||
.transpose()?;
|
||||
|
||||
let protocol = match self.protocol.read(cx).selected_value() {
|
||||
Some(1) => ProtocolVersion::V311,
|
||||
_ => ProtocolVersion::V5,
|
||||
};
|
||||
let transport = self
|
||||
.transport
|
||||
.read(cx)
|
||||
.selected_value()
|
||||
.and_then(|i| TransportKind::ALL.get(*i).copied())
|
||||
.unwrap_or_default();
|
||||
|
||||
let last_will = if self.will_enabled {
|
||||
let topic = self.val(&self.will_topic, cx);
|
||||
if topic.trim().is_empty() {
|
||||
return Err("遗嘱主题不能为空".into());
|
||||
}
|
||||
Some(LastWill {
|
||||
topic,
|
||||
payload: self.val(&self.will_payload, cx),
|
||||
qos: self.will_qos.read(cx).selected_value().copied().unwrap_or(0) as u8,
|
||||
retain: self.will_retain,
|
||||
content_type: None,
|
||||
response_topic: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let id = self.editing_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
Ok(ConnectionConfig {
|
||||
id,
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
path: self.val(&self.path, cx),
|
||||
protocol,
|
||||
transport,
|
||||
client_id,
|
||||
username: self.val(&self.username, cx),
|
||||
password: self.val(&self.password, cx),
|
||||
clean_start: self.clean_start,
|
||||
keep_alive,
|
||||
session_expiry_interval: session_expiry,
|
||||
receive_maximum,
|
||||
maximum_packet_size,
|
||||
topic_alias_maximum,
|
||||
auto_resubscribe: self.auto_resubscribe,
|
||||
auto_reconnect: self.auto_reconnect,
|
||||
last_will,
|
||||
created_at: self.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_test(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let mut cfg = match self.build_for_test(cx) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e), cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// 测试连接用临时 id,不影响已有连接
|
||||
cfg.id = uuid::Uuid::new_v4().to_string();
|
||||
let rx = self.engine.test_connection(cfg);
|
||||
self.testing = true;
|
||||
cx.notify();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let result = rx.recv().await.unwrap_or(Err("测试任务丢失".into()));
|
||||
this.update_in(cx, |form, window, cx| {
|
||||
form.testing = false;
|
||||
match result {
|
||||
Ok(()) => window.push_notification(
|
||||
Notification::success("连接测试成功"),
|
||||
cx,
|
||||
),
|
||||
Err(e) => window.push_notification(Notification::error(format!(
|
||||
"连接测试失败: {e}"
|
||||
)), cx),
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn switch_row(
|
||||
&self,
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
checked: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(div().text_sm().child(label))
|
||||
.child(
|
||||
Switch::new(id)
|
||||
.checked(checked)
|
||||
.on_change(cx.listener(move |this, v, _, cx| {
|
||||
let v = *v;
|
||||
match id {
|
||||
"clean_start" => this.clean_start = v,
|
||||
"auto_resubscribe" => this.auto_resubscribe = v,
|
||||
"auto_reconnect" => this.auto_reconnect = v,
|
||||
"will_enabled" => this.will_enabled = v,
|
||||
"will_retain" => this.will_retain = v,
|
||||
_ => {}
|
||||
}
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// 可折叠分组:点击标题行切换展开/收起,收起时不渲染内容。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn collapsible_section(
|
||||
&self,
|
||||
id: &'static str,
|
||||
title: &'static str,
|
||||
hint: Option<SharedString>,
|
||||
hint_accent: bool,
|
||||
open: bool,
|
||||
toggle: fn(&mut Self),
|
||||
border: gpui_kit::Hsla,
|
||||
bg: gpui_kit::Hsla,
|
||||
body: impl IntoElement,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let title: SharedString = title.into();
|
||||
v_flex().gap_2().child(
|
||||
v_flex()
|
||||
.child(
|
||||
h_flex()
|
||||
.id(id)
|
||||
.justify_between()
|
||||
.rounded_md()
|
||||
.px_1()
|
||||
.py_1()
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(cx.theme().secondary_hover))
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
toggle(this);
|
||||
cx.notify();
|
||||
}))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.child(div().text_sm().font_semibold().child(title))
|
||||
.children(hint.map(|h| {
|
||||
let color = if hint_accent {
|
||||
cx.theme().accent
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
};
|
||||
div().text_xs().text_color(color).child(h)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.xsmall()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rotate(percentage(if open { 0.5 } else { 0. })),
|
||||
),
|
||||
)
|
||||
.when(open, |w| {
|
||||
w.child(
|
||||
div()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.bg(bg)
|
||||
.p_3()
|
||||
.child(body),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ConnectionForm {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let border = cx.theme().border;
|
||||
let card_bg = cx.theme().secondary;
|
||||
// 协议/传输决定高级分组的按需显示
|
||||
let is_v5 = self.protocol.read(cx).selected_value() != Some(&1);
|
||||
let is_ws = matches!(
|
||||
self.transport.read(cx).selected_value(),
|
||||
Some(2) | Some(3)
|
||||
);
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.max_h(px(560.))
|
||||
.overflow_y_scrollbar()
|
||||
.pr_1()
|
||||
// ── 基本信息(始终展开) ──
|
||||
.child(section("基本信息", border, card_bg,
|
||||
v_flex().gap_2().child(
|
||||
h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("名称", Input::new(&self.name))))
|
||||
.child(div().w(px(120.)).child(field("端口", Input::new(&self.port))))
|
||||
)
|
||||
.child(h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("主机", Input::new(&self.host))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("协议", Select::new(&self.protocol))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("传输", Select::new(&self.transport))))
|
||||
)
|
||||
.child(h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("Client ID", Input::new(&self.client_id))))
|
||||
// WS 路径仅对 WebSocket/WSS 有意义,按需显示
|
||||
.when(is_ws, |w| w.child(
|
||||
div().flex_1().min_w(px(0.)).child(field("WS 路径", Input::new(&self.path)))
|
||||
))
|
||||
)
|
||||
))
|
||||
// ── 认证与心跳 ──
|
||||
.child(self.collapsible_section(
|
||||
"sec-auth", "认证与心跳", None, false, self.show_auth,
|
||||
|f| f.show_auth = !f.show_auth, border, card_bg,
|
||||
v_flex().gap_2()
|
||||
.child(h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("用户名", Input::new(&self.username))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("密码", Input::new(&self.password).mask_toggle())))
|
||||
.child(div().w(px(110.)).child(field("Keep Alive (秒)", Input::new(&self.keep_alive))))
|
||||
)
|
||||
.child(self.switch_row("clean_start", "Clean Start / Clean Session", self.clean_start, cx))
|
||||
.child(self.switch_row("auto_reconnect", "断线自动重连", self.auto_reconnect, cx))
|
||||
.child(self.switch_row("auto_resubscribe", "连接后自动恢复订阅", self.auto_resubscribe, cx)),
|
||||
cx,
|
||||
))
|
||||
// ── MQTT 5 高级属性(仅 v5 协议显示) ──
|
||||
.when(is_v5, |w| w.child(self.collapsible_section(
|
||||
"sec-mqtt5", "MQTT 5 属性", None, false, self.show_mqtt5,
|
||||
|f| f.show_mqtt5 = !f.show_mqtt5, border, card_bg,
|
||||
h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("会话过期间隔(秒)", Input::new(&self.session_expiry))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("接收最大值", Input::new(&self.receive_max))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("最大报文长度", Input::new(&self.max_packet))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("主题别名上限", Input::new(&self.topic_alias)))),
|
||||
cx,
|
||||
)))
|
||||
// ── 遗嘱 ──
|
||||
.child(self.collapsible_section(
|
||||
"sec-will", "遗嘱消息 (Last Will)",
|
||||
self.will_enabled.then(|| "已启用".into()), true, self.show_will,
|
||||
|f| f.show_will = !f.show_will, border, card_bg,
|
||||
v_flex().gap_2()
|
||||
.child(self.switch_row("will_enabled", "启用遗嘱消息", self.will_enabled, cx))
|
||||
.when(self.will_enabled, |w| w
|
||||
.child(h_flex().gap_2()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("遗嘱主题", Input::new(&self.will_topic))))
|
||||
.child(div().w(px(130.)).child(field("QoS", Select::new(&self.will_qos))))
|
||||
)
|
||||
.child(field("遗嘱内容", Input::new(&self.will_payload)))
|
||||
.child(self.switch_row("will_retain", "Retain", self.will_retain, cx))
|
||||
),
|
||||
cx,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn section(
|
||||
title: impl Into<gpui_kit::SharedString>,
|
||||
border: gpui_kit::Hsla,
|
||||
bg: gpui_kit::Hsla,
|
||||
body: impl IntoElement,
|
||||
) -> impl IntoElement {
|
||||
let title: gpui_kit::SharedString = title.into();
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(div().text_sm().font_semibold().child(title))
|
||||
.child(
|
||||
div()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.bg(bg)
|
||||
.p_3()
|
||||
.child(body),
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
||||
//! MQTTX 品牌 Logo:纯 GPUI 原语绘制的 pub/sub 拓扑徽标。
|
||||
//!
|
||||
//! - 圆角方块作为品牌底(主色);
|
||||
//! - 中心圆点代表 broker,连线连向发布端与订阅端,
|
||||
//! 表达 MQTT “发布 / 代理 / 订阅” 的星型拓扑;
|
||||
//! - 末端节点用环形描边,与中心实心 broker 区分。
|
||||
|
||||
use gpui_kit::{
|
||||
canvas, div, px, App, Bounds, Corners, Edges, Hsla, IntoElement, ParentElement,
|
||||
PathBuilder, Pixels, Point, Size, Styled as _, Window,
|
||||
};
|
||||
|
||||
/// 绘制一个指定边长的 Logo(容器被设置为该尺寸)。
|
||||
pub fn logo(side: Pixels, bg: Hsla, fg: Hsla) -> impl IntoElement {
|
||||
let inner = canvas(
|
||||
move |_bounds, _window, _cx| (bg, fg, side),
|
||||
move |bounds, (bg, fg, side), window, _cx| {
|
||||
// 品牌圆角方块
|
||||
let radius = side * 0.26;
|
||||
window.paint_quad(gpui_kit::quad(
|
||||
bounds,
|
||||
Corners::all(radius),
|
||||
bg,
|
||||
Edges::all(px(0.)),
|
||||
bg,
|
||||
gpui_kit::BorderStyle::default(),
|
||||
));
|
||||
|
||||
let origin = bounds.origin;
|
||||
// 归一化比例坐标 → 像素
|
||||
let at = move |x: f32, y: f32| Point::new(origin.x + side * x, origin.y + side * y);
|
||||
|
||||
// 中心 broker 与三个客户端节点(右上任一发布、右下与左侧订阅)
|
||||
let center = (0.5_f32, 0.5_f32);
|
||||
let nodes = [(0.78_f32, 0.27_f32), (0.78, 0.73), (0.23, 0.5)];
|
||||
|
||||
// 连线,两端补圆点形成圆头效果(不依赖 lyon LineCap)
|
||||
for &(nx, ny) in &nodes {
|
||||
let a = at(center.0, center.1);
|
||||
let b = at(nx, ny);
|
||||
if let Some(p) = line_path(a, b, side * 0.04) {
|
||||
window.paint_path(p, fg.alpha(0.9));
|
||||
}
|
||||
paint_dot(window, a, side * 0.05, fg.alpha(0.9));
|
||||
paint_dot(window, b, side * 0.05, fg.alpha(0.9));
|
||||
}
|
||||
|
||||
// 实心中心 broker(覆盖端点)
|
||||
paint_dot(window, at(center.0, center.1), side * 0.17, fg);
|
||||
// 环形末端节点
|
||||
for &(nx, ny) in &nodes {
|
||||
paint_ring(window, at(nx, ny), side * 0.15, side * 0.03, bg, fg);
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.size_full();
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.w(side)
|
||||
.h(side)
|
||||
.child(inner)
|
||||
}
|
||||
|
||||
fn paint_dot(window: &mut Window, center: Point<Pixels>, d: Pixels, color: Hsla) {
|
||||
let r = d * 0.5;
|
||||
let bounds = Bounds {
|
||||
origin: Point::new(center.x - r, center.y - r),
|
||||
size: Size {
|
||||
width: d,
|
||||
height: d,
|
||||
},
|
||||
};
|
||||
window.paint_quad(gpui_kit::quad(
|
||||
bounds,
|
||||
Corners::all(r),
|
||||
color,
|
||||
Edges::all(px(0.)),
|
||||
color,
|
||||
gpui_kit::BorderStyle::default(),
|
||||
));
|
||||
}
|
||||
|
||||
fn paint_ring(
|
||||
window: &mut Window,
|
||||
center: Point<Pixels>,
|
||||
d: Pixels,
|
||||
thickness: Pixels,
|
||||
fill: Hsla,
|
||||
border: Hsla,
|
||||
) {
|
||||
let r = d * 0.5;
|
||||
let bounds = Bounds {
|
||||
origin: Point::new(center.x - r, center.y - r),
|
||||
size: Size {
|
||||
width: d,
|
||||
height: d,
|
||||
},
|
||||
};
|
||||
window.paint_quad(gpui_kit::quad(
|
||||
bounds,
|
||||
Corners::all(r),
|
||||
fill,
|
||||
Edges::all(thickness),
|
||||
border,
|
||||
gpui_kit::BorderStyle::Solid,
|
||||
));
|
||||
}
|
||||
|
||||
fn line_path(
|
||||
a: Point<Pixels>,
|
||||
b: Point<Pixels>,
|
||||
width: Pixels,
|
||||
) -> Option<gpui_kit::Path<Pixels>> {
|
||||
let mut pb = PathBuilder::stroke(width);
|
||||
pb.move_to(a);
|
||||
pb.line_to(b);
|
||||
pb.build().ok()
|
||||
}
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use App as _App;
|
||||
@@ -0,0 +1,13 @@
|
||||
//! GPUI 界面层。
|
||||
|
||||
pub mod aliyun_dialog;
|
||||
pub mod app;
|
||||
pub mod connection_form;
|
||||
pub mod connection_view;
|
||||
pub mod logo;
|
||||
pub mod settings_dialog;
|
||||
pub mod variables_dialog;
|
||||
pub mod widgets;
|
||||
|
||||
/// 统一使用全量 Lucide 图标(gpui-kit-assets 已随应用加载)。
|
||||
pub use gpui_kit::assets::IconName;
|
||||
@@ -0,0 +1,138 @@
|
||||
//! 应用设置对话框:主题、消息缓存条数、时间戳显示。
|
||||
|
||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||
use gpui_kit::component::input::{Input, InputState};
|
||||
use gpui_kit::component::select::{Select, SelectState};
|
||||
use gpui_kit::component::switch::Switch;
|
||||
use gpui_kit::component::{
|
||||
h_flex, notification::Notification, v_flex, WindowExt as _,
|
||||
};
|
||||
use gpui_kit::{
|
||||
div, px, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
Styled as _, Window,
|
||||
};
|
||||
|
||||
use crate::model::{AppSettings, ThemeModePref};
|
||||
use crate::ui::app::MqttXApp;
|
||||
use crate::ui::widgets::{field, make_select, OptionDelegate};
|
||||
|
||||
const THEMES: [&str; 3] = ["跟随系统", "浅色", "深色"];
|
||||
|
||||
struct SettingsDialog {
|
||||
theme: Entity<SelectState<OptionDelegate>>,
|
||||
max_messages: Entity<InputState>,
|
||||
show_millis: bool,
|
||||
}
|
||||
|
||||
impl SettingsDialog {
|
||||
fn new(initial: AppSettings, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let theme_idx = match initial.theme {
|
||||
ThemeModePref::System => 0,
|
||||
ThemeModePref::Light => 1,
|
||||
ThemeModePref::Dark => 2,
|
||||
};
|
||||
Self {
|
||||
theme: make_select(&THEMES, theme_idx, window, cx),
|
||||
max_messages: cx.new(|cx| {
|
||||
InputState::new(window, cx).default_value(initial.max_messages.to_string())
|
||||
}),
|
||||
show_millis: initial.show_millis,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect(&self, cx: &App) -> Result<AppSettings, String> {
|
||||
let theme = match self.theme.read(cx).selected_value() {
|
||||
Some(1) => ThemeModePref::Light,
|
||||
Some(2) => ThemeModePref::Dark,
|
||||
_ => ThemeModePref::System,
|
||||
};
|
||||
let max_messages = self
|
||||
.max_messages
|
||||
.read(cx)
|
||||
.value()
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "消息缓存条数必须是正整数".to_string())?
|
||||
.max(100);
|
||||
Ok(AppSettings {
|
||||
theme,
|
||||
max_messages,
|
||||
show_millis: self.show_millis,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SettingsDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.w(px(420.))
|
||||
.child(field("主题", Select::new(&self.theme)))
|
||||
.child(field(
|
||||
"每条连接内存中保留的消息条数",
|
||||
Input::new(&self.max_messages),
|
||||
))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(div().text_sm().child("时间戳显示毫秒"))
|
||||
.child(
|
||||
Switch::new("show-millis")
|
||||
.checked(self.show_millis)
|
||||
.on_change(cx.listener(|this, v, _, cx| {
|
||||
this.show_millis = *v;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
let initial = app.read(cx).settings.clone();
|
||||
let dialog_view: Entity<SettingsDialog> =
|
||||
cx.new(|cx| SettingsDialog::new(initial, window, cx));
|
||||
let app_save = app.clone();
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
let body = dialog_view.clone();
|
||||
let for_collect = dialog_view.clone();
|
||||
let app_for_save = app_save.clone();
|
||||
dialog
|
||||
.w(px(480.))
|
||||
.title("设置")
|
||||
.child(body)
|
||||
.footer(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("settings-cancel")
|
||||
.label("取消")
|
||||
.outline()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("settings-ok")
|
||||
.label("保存")
|
||||
.primary()
|
||||
.on_click(move |_, window, cx| {
|
||||
let result = for_collect.read(cx).collect(cx);
|
||||
match result {
|
||||
Ok(settings) => {
|
||||
app_for_save.update(cx, |a, cx| {
|
||||
a.save_settings(settings, cx);
|
||||
cx.notify();
|
||||
});
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e), cx);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! 全局变量管理对话框。变量可在发布主题/负载中以 `{{key}}` 引用。
|
||||
|
||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||
use gpui_kit::component::{h_flex, v_flex, WindowExt as _};
|
||||
use gpui_kit::{
|
||||
px, App, AppContext as _, Entity, ParentElement as _, Styled as _, Window,
|
||||
};
|
||||
|
||||
use crate::model::GlobalVariable;
|
||||
use crate::ui::app::MqttXApp;
|
||||
use crate::ui::widgets::KvEditor;
|
||||
|
||||
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
let pairs: Vec<(String, String)> = app
|
||||
.read(cx)
|
||||
.variables
|
||||
.iter()
|
||||
.map(|v| (v.key.clone(), v.value.clone()))
|
||||
.collect();
|
||||
let editor: Entity<KvEditor> =
|
||||
cx.new(|cx| KvEditor::new("global-var", &pairs, window, cx));
|
||||
let app = app.clone();
|
||||
|
||||
window.open_dialog(cx, move |dialog, _, _| {
|
||||
let editor_body = editor.clone();
|
||||
let for_ok = editor.clone();
|
||||
let app_ok = app.clone();
|
||||
dialog
|
||||
.w(px(560.))
|
||||
.title("全局变量")
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.gap_1()
|
||||
.child("在发布主题、消息负载中使用 {{变量名}} 引用。")
|
||||
.child("内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}"),
|
||||
)
|
||||
.child(editor_body),
|
||||
)
|
||||
.footer(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("vars-cancel")
|
||||
.label("取消")
|
||||
.outline()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("vars-ok")
|
||||
.label("保存")
|
||||
.primary()
|
||||
.on_click(move |_, window, cx| {
|
||||
let vars = for_ok
|
||||
.read(cx)
|
||||
.pairs(cx)
|
||||
.into_iter()
|
||||
.map(|(key, value)| GlobalVariable { key, value })
|
||||
.collect();
|
||||
app_ok.update(cx, |a, cx| {
|
||||
a.save_variables(vars);
|
||||
cx.notify();
|
||||
});
|
||||
window.close_dialog(cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! 跨界面复用的小组件:枚举下拉、键值对编辑器、状态指示、字段行等。
|
||||
|
||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||
use gpui_kit::component::input::{Input, InputState};
|
||||
use gpui_kit::component::searchable_list::{SearchableListDelegate, SearchableListItem};
|
||||
use gpui_kit::component::select::{Select, SelectState};
|
||||
use gpui_kit::component::{h_flex, v_flex, ActiveTheme as _, Disableable as _, IndexPath, Sizable as _};
|
||||
use crate::ui::IconName;
|
||||
use gpui_kit::{App, AppContext as _, Context, Entity, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div, px};
|
||||
|
||||
use crate::model::ConnectionStatus;
|
||||
|
||||
// ─── 枚举下拉 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OptionItem {
|
||||
pub value: usize,
|
||||
pub label: SharedString,
|
||||
}
|
||||
|
||||
impl SearchableListItem for OptionItem {
|
||||
type Value = usize;
|
||||
fn title(&self) -> SharedString {
|
||||
self.label.clone()
|
||||
}
|
||||
fn value(&self) -> &usize {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OptionDelegate {
|
||||
pub items: Vec<OptionItem>,
|
||||
}
|
||||
|
||||
impl OptionDelegate {
|
||||
pub fn new(labels: &[&str]) -> Self {
|
||||
Self {
|
||||
items: labels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| OptionItem {
|
||||
value: i,
|
||||
label: (*l).into(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchableListDelegate for OptionDelegate {
|
||||
type Item = OptionItem;
|
||||
fn items_count(&self, _section: usize) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
fn item(&self, ix: IndexPath) -> Option<&Self::Item> {
|
||||
self.items.get(ix.row)
|
||||
}
|
||||
fn position<V>(&self, _value: &V) -> Option<IndexPath>
|
||||
where
|
||||
Self::Item: SearchableListItem<Value = V>,
|
||||
V: PartialEq,
|
||||
{
|
||||
// 选中项统一通过 IndexPath 构造,无需按值反查
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建字符串枚举下拉状态(在任意实体上下文里调用)。
|
||||
pub fn make_select<C: gpui_kit::AppContext>(
|
||||
labels: &[&str],
|
||||
selected: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut C,
|
||||
) -> Entity<SelectState<OptionDelegate>> {
|
||||
let selected = selected.min(labels.len().saturating_sub(1));
|
||||
cx.new(|cx| {
|
||||
SelectState::new(
|
||||
OptionDelegate::new(labels),
|
||||
Some(IndexPath::new(selected)),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 字段行 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 小号标签 + 控件的垂直字段容器。
|
||||
pub fn field(label: impl Into<SharedString>, control: impl IntoElement) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.min_w(px(0.))
|
||||
.child(div().text_xs().child(label.into()))
|
||||
.child(control)
|
||||
}
|
||||
|
||||
// ─── 状态颜色 ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn status_color(status: ConnectionStatus, cx: &App) -> Hsla {
|
||||
let t = cx.theme();
|
||||
match status {
|
||||
ConnectionStatus::Connected => t.success,
|
||||
ConnectionStatus::Connecting => t.warning,
|
||||
ConnectionStatus::Error => t.danger,
|
||||
ConnectionStatus::Disconnected => t.muted_foreground,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_dot(status: ConnectionStatus, cx: &App) -> impl IntoElement {
|
||||
div()
|
||||
.size(px(8.))
|
||||
.rounded_full()
|
||||
.bg(status_color(status, cx))
|
||||
}
|
||||
|
||||
// ─── 键值对编辑器 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// MQTT 5 用户属性等 key/value 列表编辑器。
|
||||
pub struct KvEditor {
|
||||
id_prefix: SharedString,
|
||||
pub rows: Vec<(Entity<InputState>, Entity<InputState>)>,
|
||||
}
|
||||
|
||||
impl KvEditor {
|
||||
pub fn new(
|
||||
id_prefix: impl Into<SharedString>,
|
||||
pairs: &[(String, String)],
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let mut editor = Self {
|
||||
id_prefix: id_prefix.into(),
|
||||
rows: Vec::new(),
|
||||
};
|
||||
for (k, v) in pairs {
|
||||
editor.push(Some((k.as_str(), v.as_str())), window, cx);
|
||||
}
|
||||
if editor.rows.is_empty() {
|
||||
editor.push(None, window, cx);
|
||||
}
|
||||
editor
|
||||
}
|
||||
|
||||
fn push(&mut self, initial: Option<(&str, &str)>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let key = cx.new(|cx| {
|
||||
let mut s = InputState::new(window, cx).placeholder("key");
|
||||
if let Some((k, _)) = initial {
|
||||
s.set_value(k, window, cx);
|
||||
}
|
||||
s
|
||||
});
|
||||
let value = cx.new(|cx| {
|
||||
let mut s = InputState::new(window, cx).placeholder("value");
|
||||
if let Some((_, v)) = initial {
|
||||
s.set_value(v, window, cx);
|
||||
}
|
||||
s
|
||||
});
|
||||
self.rows.push((key, value));
|
||||
}
|
||||
|
||||
pub fn pairs(&self, cx: &App) -> Vec<(String, String)> {
|
||||
self.rows
|
||||
.iter()
|
||||
.map(|(k, v)| (k.read(cx).value().to_string(), v.read(cx).value().to_string()))
|
||||
.filter(|(k, _)| !k.trim().is_empty())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for KvEditor {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let prefix = self.id_prefix.clone();
|
||||
let count = self.rows.len();
|
||||
|
||||
let mut rows = v_flex().gap_1();
|
||||
for (i, (key, value)) in self.rows.iter().cloned().enumerate() {
|
||||
rows = rows.child(
|
||||
h_flex()
|
||||
.id(SharedString::from(format!("kv-row-{prefix}-{i}")))
|
||||
.gap_1()
|
||||
.child(div().flex_1().min_w(px(0.)).child(Input::new(&key)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(Input::new(&value)))
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("kv-del-{prefix}-{i}")))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.disabled(count <= 1)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
if this.rows.len() > 1 {
|
||||
this.rows.remove(i);
|
||||
cx.notify();
|
||||
}
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(rows)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("kv-add-{prefix}")))
|
||||
.icon(IconName::Plus)
|
||||
.label("添加属性")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.push(None, window, cx);
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 时间格式化 ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn format_time(ts_ms: i64, show_millis: bool) -> String {
|
||||
use chrono::TimeZone as _;
|
||||
let Some(dt) = chrono::Local.timestamp_millis_opt(ts_ms).single() else {
|
||||
return String::new();
|
||||
};
|
||||
if show_millis {
|
||||
dt.format("%H:%M:%S%.3f").to_string()
|
||||
} else {
|
||||
dt.format("%H:%M:%S").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Select::new 的包装,统一外观。
|
||||
pub fn select_element<D: SearchableListDelegate>(
|
||||
state: &Entity<SelectState<D>>,
|
||||
) -> Select<D>
|
||||
where
|
||||
<D::Item as SearchableListItem>::Value: PartialEq + Clone,
|
||||
{
|
||||
Select::new(state)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user