Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d63b93f20 | ||
|
|
60501b6465 | ||
|
|
5b8edede46 | ||
|
|
61d8d6fa77 | ||
|
|
82f26652a4 | ||
|
|
dd57077e0c | ||
|
|
ac5b6e9862 | ||
|
|
c2c89014d1 | ||
|
|
05b37a8997 | ||
|
|
762ae4bddb | ||
|
|
5db0972263 | ||
|
|
35e2b6fb60 | ||
|
|
0406dee7a4 | ||
|
|
2330d362f6 | ||
|
|
b23c0039db | ||
|
|
94fdb6a507 | ||
|
|
c05b258c5c | ||
|
|
1dca18873c | ||
|
|
967bf9dd78 | ||
|
|
f5cd0cdc4f | ||
|
|
f4315dce1e | ||
|
|
4e7ddfd4e9 | ||
|
|
f01601114c | ||
|
|
5783a500e8 | ||
|
|
22e0c70b90 | ||
|
|
7bff64e477 | ||
|
|
8614c66131 | ||
|
|
7b41e7f13d | ||
|
|
68273e8e63 | ||
|
|
1132cdd41d | ||
|
|
6f09a5729f | ||
|
|
b22a645588 | ||
|
|
5ad4536703 | ||
|
|
7684eedd0f | ||
|
|
9b6df8b9eb | ||
|
|
2661a734ee | ||
|
|
bc9f55c889 | ||
|
|
b5089f5898 | ||
|
|
303b348ffe |
+4
-10
@@ -1,10 +1,4 @@
|
||||
# 本工程专属 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
|
||||
# Windows 主线程栈 64MB:Dialog 覆盖层内 Select 渲染的调用链很深,
|
||||
# 默认 8MB 在注册多主题后会栈溢出(溢出点在 Dialog paint,非无限递归)
|
||||
[target.'cfg(target_os = "windows")']
|
||||
rustflags = ["-C", "link-arg=/STACK:67108864"]
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# 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
|
||||
|
||||
# ─── 构建矩阵 ─────────────────────────────────────────────────
|
||||
# 目前 Gitea 实例只注册了 ubuntu-latest 标签的 Linux runner,
|
||||
# Windows 产物由本地 scripts/publish-gitea.ps1/.sh 上传补充。
|
||||
build:
|
||||
name: Build (${{ matrix.os }})
|
||||
needs: [meta, test]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
asset: mqttx
|
||||
target: x86_64-unknown-linux-gnu
|
||||
# runner 就绪后可恢复 Windows 构建:
|
||||
# - os: windows-latest
|
||||
# asset: mqttx.exe
|
||||
# target: x86_64-pc-windows-msvc
|
||||
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 }}"
|
||||
@@ -1,111 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ─── Frontend: lint + type-check + build ──────────────────────
|
||||
frontend:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: pnpm exec vue-tsc --noEmit
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
# ─── Rust: check + clippy ─────────────────────────────────────
|
||||
rust:
|
||||
name: Rust
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
target: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Install system deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
|
||||
librsvg2-dev patchelf libgtk-3-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
|
||||
|
||||
- name: Check
|
||||
run: cargo check --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
|
||||
|
||||
# ─── Tauri build (Windows) ────────────────────────────────────
|
||||
tauri-build:
|
||||
name: Tauri Build
|
||||
needs: [frontend, rust]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
|
||||
- name: Install Linux deps
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \
|
||||
librsvg2-dev patchelf libgtk-3-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Tauri app
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mqttx-${{ runner.os }}
|
||||
path: src-tauri/target/release/bundle/
|
||||
@@ -0,0 +1,197 @@
|
||||
# GitHub Actions:打 tag 时构建 release 产物并发布到 GitHub Release。
|
||||
#
|
||||
# 产物即 OTA 更新源,命名约定(与客户端 update 模块一致):
|
||||
# mqttx-v<ver>-<triple>-mqttx[.exe] 裸二进制
|
||||
# mqttx-v<ver>-<triple>-mqttx[.exe].sha256 侧车校验文件
|
||||
# mqttx-v<ver>-<triple>-mqttx[.exe].7z 压缩包(单条目,内部文件名 = 裸二进制名)
|
||||
# mqttx-v<ver>-<triple>-mqttx[.exe].7z.sha256
|
||||
# 7z 是 OTA 主通道;裸二进制供不识别 7z 的旧客户端(≤v1.0.3)升级。
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ─── 版本与元信息 ─────────────────────────────────────────────
|
||||
meta:
|
||||
name: Meta
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: ver
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME}"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
# 版本双真源校验:tag 必须与 gpui-app/Cargo.toml 一致。否则新包装完仍自报旧
|
||||
# 版本号,客户端表现为"每次都提示同一个更新",装多少次都消不掉。
|
||||
- name: Verify tag matches Cargo.toml version
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
cargo_ver="$(grep -m1 '^version' gpui-app/Cargo.toml | cut -d'"' -f2)"
|
||||
if [ -z "$cargo_ver" ]; then
|
||||
echo "::error::无法从 gpui-app/Cargo.toml 解析版本号"
|
||||
exit 1
|
||||
fi
|
||||
if [ "v${cargo_ver}" != "$tag" ]; then
|
||||
echo "::error::tag(${tag}) 与 gpui-app/Cargo.toml(v${cargo_ver}) 不一致:请先 bump 版本再打 tag"
|
||||
exit 1
|
||||
fi
|
||||
echo "版本一致:${tag}"
|
||||
|
||||
# ─── 测试 ─────────────────────────────────────────────────────
|
||||
# 完整编译 lib(GPUI 会链接 Linux 系统库),缺库会在链接期失败。
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- name: Install Linux system deps (GPUI)
|
||||
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
|
||||
# --tests:让 tests/engine_live.rs 参与编译(#[ignore] 只跳过执行,不跳过编译)
|
||||
- name: Unit tests
|
||||
run: cargo test -p mqttx-desktop --lib --tests
|
||||
|
||||
# ─── 构建矩阵 ─────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (${{ matrix.os }})
|
||||
needs: [meta, test]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
asset: mqttx
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- os: windows-latest
|
||||
asset: mqttx.exe
|
||||
target: x86_64-pc-windows-msvc
|
||||
- os: macos-latest
|
||||
asset: mqttx
|
||||
target: aarch64-apple-darwin
|
||||
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
|
||||
|
||||
# rustls 的 aws_lc_rs 后端在 windows-msvc 上编译需要 NASM(托管镜像缺省不在 PATH)
|
||||
- name: Ensure NASM (aws-lc-rs on MSVC)
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
if ! command -v nasm >/dev/null 2>&1; then
|
||||
choco install nasm -y
|
||||
echo "C:\Program Files\NASM" >> "$GITHUB_PATH"
|
||||
fi
|
||||
|
||||
- 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 }}/*
|
||||
|
||||
# ─── 发布到 GitHub Release ────────────────────────────────────
|
||||
publish:
|
||||
name: Publish to GitHub Release
|
||||
needs: [meta, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# fetch-depth: 0 拉取附注 tag 对象,发布正文取自 tag message
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Install 7-Zip
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y p7zip-full
|
||||
|
||||
# 7z 归档内必须恰好一个条目且文件名 = 裸二进制名(客户端解压后按此名校验安装),
|
||||
# 因此先复制到临时目录以产物名命名再压缩。
|
||||
- name: Pack 7z and sidecars (OTA conventions)
|
||||
env:
|
||||
VER: ${{ needs.meta.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
stage="$(mktemp -d)"
|
||||
for f in artifacts/mqttx-${VER}-*; do
|
||||
name="$(basename "$f")"
|
||||
case "$name" in *.sha256) continue ;; esac
|
||||
cp -f "$f" "$stage/$name"
|
||||
(cd "$stage" && 7z a -y -mx=9 "$OLDPWD/artifacts/$name.7z" "$name")
|
||||
(cd artifacts && sha256sum "$name.7z" > "$name.7z.sha256")
|
||||
rm -f "$stage/$name"
|
||||
done
|
||||
rm -rf "$stage"
|
||||
ls -la artifacts/
|
||||
|
||||
- name: Create release and upload assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VER: ${{ needs.meta.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 发布正文优先取附注 tag 的 message。actions/checkout 会把 tag 重建为
|
||||
# 轻量 tag(%(contents) 退化成 commit message),这里显式拉取 tag 对象
|
||||
git fetch -q origin "refs/tags/$VER:refs/tags/$VER" --force --no-tags
|
||||
notes="$(git for-each-ref "refs/tags/$VER" --format='%(contents)' | awk '/^-----BEGIN PGP SIGNATURE----$/{sig=1} !sig' | awk 'NF')"
|
||||
if gh release view "$VER" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "Release ${VER} 已存在,覆盖上传资产"
|
||||
if [ -n "$notes" ]; then
|
||||
gh release edit "$VER" -R "$GITHUB_REPOSITORY" --notes "$notes"
|
||||
fi
|
||||
elif [ -n "$notes" ]; then
|
||||
gh release create "$VER" -R "$GITHUB_REPOSITORY" --title "$VER" --notes "$notes"
|
||||
else
|
||||
gh release create "$VER" -R "$GITHUB_REPOSITORY" --title "$VER" --generate-notes
|
||||
fi
|
||||
gh release upload "$VER" -R "$GITHUB_REPOSITORY" artifacts/* --clobber
|
||||
+14
-38
@@ -1,43 +1,19 @@
|
||||
# DSH usage records
|
||||
dsh-usage/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
src-tauri/logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Rust / Tauri build artifacts
|
||||
src-tauri/target
|
||||
src-tauri/target_d
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Workspace-level Rust build artifacts (GPUI app)
|
||||
# Rust 构建产物(GPUI 工作区)
|
||||
/target
|
||||
*.pdb
|
||||
|
||||
# 日志
|
||||
logs
|
||||
*.log
|
||||
|
||||
# 编辑器
|
||||
.vscode/*
|
||||
.idea
|
||||
.DS_Store
|
||||
*.sw?
|
||||
|
||||
# 本仓库专属
|
||||
/.v2c
|
||||
/.video_agent
|
||||
|
||||
# 发布产物暂存目录
|
||||
/dist-publish
|
||||
dsh-usage/
|
||||
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Rust 构建产物(GPUI 工作区)
|
||||
/target
|
||||
*.pdb
|
||||
|
||||
# 日志
|
||||
logs
|
||||
*.log
|
||||
|
||||
# 编辑器
|
||||
.vscode/*
|
||||
.idea
|
||||
.DS_Store
|
||||
*.sw?
|
||||
|
||||
# 本仓库专属
|
||||
/.v2c
|
||||
/.video_agent
|
||||
/dist-publish
|
||||
dsh-usage/
|
||||
|
||||
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
|
||||
.git/
|
||||
.hg/
|
||||
.svn/
|
||||
node_modules/
|
||||
bower_components/
|
||||
jspm_packages/
|
||||
__pycache__/
|
||||
site-packages/
|
||||
venv/
|
||||
coverage/
|
||||
htmlcov/
|
||||
lcov-report/
|
||||
cmakefiles/
|
||||
cmake-build-*/
|
||||
bazel-*/
|
||||
pods/
|
||||
deriveddata/
|
||||
storybook-static/
|
||||
playwright-report/
|
||||
test-results/
|
||||
allure-results/
|
||||
allure-report/
|
||||
cdk.out/
|
||||
*.egg-info/
|
||||
*.dist-info/
|
||||
eggs/
|
||||
pip-wheel-metadata/
|
||||
wheels/
|
||||
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
|
||||
# 自定义规则写在下方(本行提示可删除)
|
||||
Generated
+232
-622
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["gpui-app"]
|
||||
exclude = ["src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
使用 **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/`。
|
||||
> 早期 Tauri + Vue 实现已归档到 [`tauri-legacy`](../../archive/tauri-legacy.tar.gz) 分支,本项目仅维护 `gpui-app/` 原生实现。
|
||||
|
||||
## 功能特性
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
- **完整 MQTT 5 属性** — 用户属性(可视化键值对编辑器)、Content-Type、消息过期间隔、Response Topic、Correlation Data(连接/遗嘱/发布三处均支持)
|
||||
- **发布 / 订阅** — QoS 0/1/2、Retain、遗嘱消息(Last Will)、订阅自动恢复(auto resubscribe)、断线自动重连(可配置最大重连次数与连接超时)
|
||||
- **订阅增强** — 逗号/换行多主题批量订阅、订阅别名、颜色标签(消息按订阅着色)、启用/禁用、点击订阅过滤消息流、MQTT 5 订阅选项(订阅标识符 / No Local / Retain As Published / Retain Handling)
|
||||
- **消息流** — 收发方向区分、时间戳、QoS/Retain 标记、点击展开查看格式化 JSON 与 v5 属性、全部/接收/发布过滤、主题/内容搜索、逐条 payload 格式切换(自动/文本/Hex/Base64)、复制主题/负载/详情、清空历史
|
||||
- **消息流** — 收发方向区分、时间戳、QoS/Retain 标记、点击展开查看格式化 JSON 与 v5 属性、全部/接收/发布过滤、主题/内容搜索、逐条 payload 格式切换(自动/文本/Hex/Base64)、复制主题/负载/详情、暂停滚动(高频消息流下锁定画面阅读,消息仍后台接收)、清空历史
|
||||
- **JSON 预览** — 折叠态对 JSON 负载按原始数据美化展示(多行缩进,常规负载完整显示,超大负载兜底截断)
|
||||
- **连接导入 / 导出** — 原生文件对话框(rfd)导出/导入连接 JSON,按 id 去重
|
||||
- **SSL/TLS 证书** — 自定义 CA、客户端证书/私钥(双向认证)、忽略 CA 校验
|
||||
- **全局变量** — 发布主题与负载中使用 `{{变量名}}`,内置 `{{$ts}}`、`{{$ts_ms}}`、`{{$uuid}}`,用户属性同样参与渲染
|
||||
@@ -21,7 +22,11 @@
|
||||
- **阿里云 IoT** — Token(GroupId@@@DeviceId + HMAC-SHA1 签名)与一机一密两种鉴权方式一键生成连接
|
||||
- **连接测试** — 表单内握手测试(超时可配置),保存前验证连通性
|
||||
- **日志面板** — 连接级事件日志(CONNACK/SUBACK/PUBACK/错误/重连等),按日落盘 `mqttx-YYYY-MM-DD.log`,设置内一键打开日志目录
|
||||
- **快捷键** — `Ctrl+N` 新建连接、`Ctrl+,` 设置、`Ctrl+Shift+E` 导出、`Ctrl+Shift+I` 导入、`Ctrl+Enter` 发送消息
|
||||
- **快捷键** — `Ctrl+N` 新建连接、`Ctrl+,` 设置、`Ctrl+Shift+E` 导出、`Ctrl+Shift+I` 导入、`Ctrl+Shift+R` 资源监控、`Ctrl+Enter` 发送消息(全局生效)、`Ctrl+B` 收起/展开连接侧栏、`Ctrl+W` 关闭标签页、`Ctrl+Tab` / `Ctrl+Shift+Tab` 切换标签、`Ctrl+F` 聚焦消息过滤、`Enter` 提交订阅、`Esc` 退出编辑/清空输入
|
||||
- **抽屉式布局** — 连接侧栏(持久化,Ctrl+B)、订阅面板、底部发布面板均可一键收起为窄条抽屉,最大化消息流空间
|
||||
- **国际化** — 中文 / English 双语界面,默认跟随系统语言,设置内可切换即时生效
|
||||
- **无障碍** — 全部图标按钮带可访问名称(屏幕阅读器 / UIA 可识别),完整键盘操作路径
|
||||
- **性能** — 消息预览(JSON 美化/截断)入缓冲时计算一次并随记录缓存,渲染热路径零重复解析
|
||||
- **主题** — Linear 风格配色(近黑/纯白扁平底 + 靛蓝 #5e6ad2 主色,14px 紧凑排版、6px 圆角),浅色 / 深色 / 跟随系统
|
||||
- **设置** — 自动检查更新开关、消息缓存条数、时间戳毫秒、数据目录展示与打开、关于页(版本 / 许可)
|
||||
- **本地持久化** — 连接、订阅、预设、变量、设置以 JSON 存于系统数据目录
|
||||
@@ -38,26 +43,60 @@
|
||||
|
||||
## 目录结构
|
||||
|
||||
每个文件与目录单一职责;模块门面(`mod.rs`)只做聚合导出与再分派。
|
||||
|
||||
```
|
||||
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 签名与连接生成
|
||||
│ ├── main.rs # 入口:Sentry 初始化、创建窗口、挂载 gpui-kit Root
|
||||
│ ├── lib.rs # 库导出(模型/引擎/UI 可被集成测试复用)
|
||||
│ ├── model/ # 数据模型(纯数据层,不依赖 GPUI)
|
||||
│ │ ├── mod.rs # 门面:聚合导出 + 模型测试
|
||||
│ │ ├── connection.rs # 连接配置:协议/传输/遗嘱/TLS/会话参数
|
||||
│ │ ├── subscription.rs # 订阅条目与订阅选项
|
||||
│ │ ├── message.rs # 消息记录 + 条数/字节双预算环形缓冲
|
||||
│ │ ├── publish.rs # 发布参数与发布预设
|
||||
│ │ ├── variable.rs # 全局变量与 {{占位符}} 模板渲染
|
||||
│ │ ├── log.rs # 运行日志级别与条目
|
||||
│ │ └── settings.rs # 应用设置与主题偏好
|
||||
│ ├── mqtt/ # MQTT 引擎(独立 tokio runtime)
|
||||
│ │ ├── mod.rs # 门面:EngineEvent / MqttEngine 连接生命周期
|
||||
│ │ ├── tls.rs # rustls 配置与跳过校验
|
||||
│ │ ├── v5.rs # MQTT 5.0 客户端构建与事件循环
|
||||
│ │ └── v4.rs # MQTT 3.1.1 客户端构建与事件循环
|
||||
│ ├── update/ # OTA 自更新(GitHub Releases 源)
|
||||
│ │ ├── mod.rs # 门面:版本比较、资产命名约定
|
||||
│ │ ├── check.rs # 检查最新版本与资产挑选
|
||||
│ │ └── install.rs # 7z 下载暂存、sha256 校验、自替换安装
|
||||
│ ├── store.rs # JSON 持久化(损坏备份与 fsync)
|
||||
│ ├── sysmon.rs # 自身进程资源采样(CPU/内存/线程/句柄)
|
||||
│ ├── platform.rs # 平台窗口操作(Windows 置顶)
|
||||
│ ├── aliyun.rs # 阿里云 IoT 签名与连接生成
|
||||
│ └── ui/
|
||||
│ ├── app.rs # 应用外壳:标题栏/侧边栏/标签页/事件泵
|
||||
│ ├── connection_form.rs # 新建/编辑连接对话框
|
||||
│ ├── connection_view.rs # 连接工作区:订阅、消息流、发布面板、日志
|
||||
│ ├── aliyun_dialog.rs # 阿里云设备对话框
|
||||
│ ├── variables_dialog.rs # 全局变量对话框
|
||||
│ ├── settings_dialog.rs # 设置对话框
|
||||
│ └── widgets.rs # 复用组件(枚举下拉、键值对编辑器等)
|
||||
│ ├── mod.rs # 界面层导出
|
||||
│ ├── app/ # 应用外壳
|
||||
│ │ ├── mod.rs # MqttXApp 状态、初始化与渲染分派
|
||||
│ │ ├── events.rs # 引擎事件 → 应用状态(消息/日志缓冲)
|
||||
│ │ ├── actions.rs # 连接/订阅/预设/设置等用户动作
|
||||
│ │ ├── io.rs # 连接配置导入导出
|
||||
│ │ ├── theme.rs # Linear 风格主题应用
|
||||
│ │ ├── titlebar.rs # 标题栏(置顶钉子/主题切换/动作菜单)
|
||||
│ │ ├── sidebar.rs # 侧边栏(搜索/分组/连接列表)
|
||||
│ │ └── main_area.rs # 标签页栏与内容装配
|
||||
│ ├── connection_view/ # 连接工作区
|
||||
│ │ ├── mod.rs # ConnectionView 状态与渲染分派
|
||||
│ │ ├── subscribe.rs # 订阅表单/列表
|
||||
│ │ ├── messages.rs # 消息流、单条消息行与日志
|
||||
│ │ ├── publish.rs # 发布表单与发布预设
|
||||
│ │ ├── vars.rs # 模板变量面板
|
||||
│ │ └── util.rs # topic 匹配/格式化纯函数
|
||||
│ ├── connection_form.rs # 新建/编辑连接表单
|
||||
│ ├── dialogs/ # 模态对话框集合(设置/资源监控/发布预设/阿里云/变量)
|
||||
│ ├── widgets/ # 复用小组件(枚举下拉/键值对编辑器/字段行/状态指示)
|
||||
│ └── logo.rs # 品牌图标绘制
|
||||
└── tests/
|
||||
└── engine_live.rs # 真实 broker 端到端测试(默认 ignored)
|
||||
└── engine_live.rs # 真实 broker 端到端测试(默认 ignored)
|
||||
```
|
||||
|
||||
## 构建与运行
|
||||
@@ -89,45 +128,25 @@ MQTTX_LIVE=1 cargo test -p mqttx-desktop --test engine_live -- --ignored
|
||||
- MQTT 5.0 连接 → 订阅 → 发布 → 回环接收
|
||||
- MQTT 3.1.1 连接握手
|
||||
|
||||
## 发布到 Gitea Package Registry
|
||||
## 发布到 GitHub Releases
|
||||
|
||||
构建产物可通过 Gitea 的 Generic Package API 发布,两种方式:
|
||||
推送 `v*` tag 即触发 [.github/workflows/release.yml](.github/workflows/release.yml):
|
||||
|
||||
### 方式一:手动发布(本地脚本)
|
||||
1. 版本一致性校验:tag 必须等于 `v` + `gpui-app/Cargo.toml` 的版本,否则整条流水线失败
|
||||
2. 单元测试(`--lib --tests`,集成测试只编译不执行)
|
||||
3. Linux + Windows + macOS 三平台 release 构建(产物重命名为
|
||||
`mqttx-v<ver>-<target>-mqttx[.exe]` 并生成 `.sha256` 侧车;macOS 仅
|
||||
Apple Silicon)
|
||||
4. 打包 7z(单条目,内部文件名 = 裸二进制名)并创建 GitHub Release,上传
|
||||
7z / 裸二进制 / `.sha256` 附件
|
||||
|
||||
前置:构建 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
|
||||
# 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
|
||||
```
|
||||
|
||||
- **owner** 自动从 `git remote origin` 推断(可用 `GITEA_OWNER` / `-Owner` 覆盖)
|
||||
- **版本** 依次取参数 → `git describe --tags` → `Cargo.toml`
|
||||
- 每个文件附带 `.sha256` 校验侧车;同名版本重复上传按 409 跳过(Gitea 不允许覆盖)
|
||||
- 软件包页面:`{GITEA_URL}/{owner}?tab=packages`
|
||||
|
||||
### 方式二:CI 自动发布(Gitea Actions)
|
||||
|
||||
推送 `v*` tag 即触发 [.gitea/workflows/release.yml](.gitea/workflows/release.yml):
|
||||
|
||||
1. 单元测试
|
||||
2. Windows / Linux 双平台 release 构建(产物重命名为 `mqttx-<ver>-<target>-<ext>`)
|
||||
3. 调用 `scripts/publish-gitea.sh` 上传到包注册表
|
||||
|
||||
需要在仓库 **Settings → Actions → Secrets** 配置 `GITEA_TOKEN`;
|
||||
若 runner 使用自定义 label,请相应修改工作流的 `runs-on`。
|
||||
> **应用内 OTA 只认 Release 附件**,附件名必须匹配 `mqttx-v<ver>-<triple>-mqttx[.exe]`。
|
||||
> 7z 是 OTA 主通道;裸二进制供不识别 7z 的旧客户端(≤v1.0.3)升级。
|
||||
>
|
||||
> macOS 产物未做签名/公证:浏览器下载后首次打开会被 Gatekeeper 拦截,需
|
||||
> `xattr -d com.apple.quarantine <binary>`;应用内 OTA 自更新不走浏览器,无此问题。
|
||||
>
|
||||
> GitHub 侧发布历史自 v1.0.0 起独立开始,不向其他渠道镜像。
|
||||
|
||||
## 数据目录
|
||||
|
||||
|
||||
+17
-11
@@ -1,8 +1,9 @@
|
||||
[package]
|
||||
name = "mqttx-desktop"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
edition.workspace = true
|
||||
description = "MQTT X —— 基于 GPUI 的跨平台 MQTT 调试客户端"
|
||||
license = "Apache-2.0"
|
||||
default-run = "mqttx"
|
||||
|
||||
[[bin]]
|
||||
@@ -10,7 +11,7 @@ name = "mqttx"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gpui-kit = "0.6"
|
||||
gpui-kit = "0.7"
|
||||
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -24,13 +25,17 @@ rumqttc = { package = "rumqttc-v5-next", version = "0.34", features = ["websocke
|
||||
rumqttc-v4 = { package = "rumqttc-v4-next", version = "0.34", features = ["websocket"] }
|
||||
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
sys-locale = "0.3"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
rust-i18n = "4.2"
|
||||
chrono = { version = "0.4", features = ["clock"] }
|
||||
dirs = "6"
|
||||
dirs = "7"
|
||||
|
||||
# 阿里云 MQTT 签名: HMAC-SHA1 -> Base64
|
||||
hmac = "0.12"
|
||||
sha1 = "0.10"
|
||||
base64 = "0.22"
|
||||
hmac = "0.13"
|
||||
sha1 = "0.11"
|
||||
base64 = "0.23"
|
||||
|
||||
# 自定义 TLS(自定义 CA / 双向认证 / 跳过校验),版本与 rumqttc 内部使用的 rustls 一致
|
||||
rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "aws_lc_rs"] }
|
||||
@@ -40,9 +45,10 @@ rustls-native-certs = "0.8"
|
||||
# 原生文件对话框(连接导入/导出);Windows 走内建 COM 后端,Linux 用默认 xdg-portal
|
||||
rfd = "0.17"
|
||||
|
||||
# OTA 自更新:Gitea Release 检查/下载(ureq 用 rustls,与 rumqttc 依赖同源)
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
sha2 = "0.10"
|
||||
# OTA 自更新:GitHub Release 检查/下载(ureq 用 rustls,与 rumqttc 依赖同源;
|
||||
# win-system-proxy 使 Windows 系统代理注册表设置自动生效,环境变量代理本就默认支持)
|
||||
ureq = { version = "3", features = ["json", "win-system-proxy"] }
|
||||
sha2 = "0.11"
|
||||
# Release 附件为 7z 压缩包,纯 Rust 解压(客户端免装 7-Zip)
|
||||
sevenz-rust = "0.6"
|
||||
|
||||
@@ -58,7 +64,7 @@ sentry = { version = "0.49", default-features = false, features = [
|
||||
# 窗口置顶(raw HWND + SetWindowPos)与进程资源采样
|
||||
raw-window-handle = "0.6"
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_ProcessStatus",
|
||||
@@ -66,6 +72,6 @@ windows = { version = "0.58", features = [
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
# 嵌入窗口图标(gpui 从 exe 资源 ID 1 加载,缺失则任务栏显示默认图标)
|
||||
[target.'cfg(target_os = "windows")'.build-dependencies]
|
||||
# 嵌入窗口图标(gpui 从 exe 资源 ID 1 加载,缺失则任务栏显示默认图标)
|
||||
embed-resource = "3"
|
||||
|
||||
+4
-1
@@ -5,7 +5,10 @@
|
||||
//! 缺失时任务栏与资源管理器显示系统默认图标。
|
||||
|
||||
fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
// embed_resource 仅声明在 Windows 的 build-dependencies:必须用 cfg 门控,
|
||||
// 否则 Linux/macOS 编译 build script 时该路径无法解析(运行时 env 判断救不了编译期)
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
println!("cargo:rerun-if-changed=resources/app.rc");
|
||||
println!("cargo:rerun-if-changed=resources/icon.ico");
|
||||
embed_resource::compile("resources/app.rc", embed_resource::NONE)
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
"aliyun.access_key_id": "AccessKey ID"
|
||||
"aliyun.access_key_secret": "AccessKey Secret"
|
||||
"aliyun.auth_mode": "Auth mode"
|
||||
"aliyun.auth_token_mode": "Group token"
|
||||
"aliyun.auth_triple_mode": "Per-device secret"
|
||||
"aliyun.build_failed": "Failed to build connection: {e}"
|
||||
"aliyun.default_preset_name": "Alibaba Cloud device"
|
||||
"aliyun.device_id": "Device ID"
|
||||
"aliyun.device_id_required": "Device ID cannot be empty"
|
||||
"aliyun.device_secret": "DeviceSecret (per-device)"
|
||||
"aliyun.device_secret_ph": "Triple: DeviceSecret"
|
||||
"aliyun.generate_connect": "Generate & connect"
|
||||
"aliyun.group": "Group (optional)"
|
||||
"aliyun.group_id": "Group ID"
|
||||
"aliyun.help": "Token mode (Alibaba Cloud MQTT): endpoint {实例ID}.mqtt.aliyuncs.com, clientId={GroupId}@@@{设备ID}, password is the HMAC-SHA1 signature. Per-device secret (IoT Platform): endpoint {ProductKey}.iot-as-mqtt.{地域}.aliyuncs.com, signed per the official triple spec."
|
||||
"aliyun.hmac_init_failed": "HMAC init failed: {e}"
|
||||
"aliyun.instance_id": "Instance ID"
|
||||
"aliyun.instance_id_required": "Instance ID cannot be empty"
|
||||
"aliyun.preset_name": "Preset name"
|
||||
"aliyun.product_key": "ProductKey (per-device)"
|
||||
"aliyun.product_key_ph": "Triple: ProductKey"
|
||||
"aliyun.region": "Region"
|
||||
"aliyun.region_ph": "e.g. cn-shanghai (optional)"
|
||||
"aliyun.save_preset": "Save preset"
|
||||
"aliyun.saved_presets": "Saved presets"
|
||||
"aliyun.title": "Alibaba Cloud IoT"
|
||||
"aliyun.token_required_fields": "Group ID, AccessKey ID and AccessKey Secret cannot be empty"
|
||||
"aliyun.triple_requires_device_name": "Per-device secret mode requires DeviceName"
|
||||
"aliyun.triple_requires_pk_ds": "Per-device secret mode requires ProductKey and DeviceSecret"
|
||||
"color.blue": "Blue"
|
||||
"color.cyan": "Cyan"
|
||||
"color.green": "Green"
|
||||
"color.indigo": "Indigo"
|
||||
"color.magenta": "Magenta"
|
||||
"color.orange": "Orange"
|
||||
"color.purple": "Purple"
|
||||
"color.red": "Red"
|
||||
"color.rose": "Rose"
|
||||
"color.yellow": "Yellow"
|
||||
"common.advanced": "Advanced"
|
||||
"common.all": "All"
|
||||
"common.auto": "Auto"
|
||||
"common.browse": "Browse…"
|
||||
"common.cancel": "Cancel"
|
||||
"common.clear": "Clear"
|
||||
"common.close": "Close"
|
||||
"common.collapse": "Collapse"
|
||||
"common.delete": "Delete"
|
||||
"common.edit": "Edit"
|
||||
"common.new": "New"
|
||||
"common.pause": "Pause"
|
||||
"common.resume": "Resume"
|
||||
"common.retry": "Retry"
|
||||
"common.save": "Save"
|
||||
"common.send": "Send"
|
||||
"common.subscribe": "Subscribe"
|
||||
"common.text": "Text"
|
||||
"common.update": "Update"
|
||||
"connview.logs": "Logs"
|
||||
"connview.messages": "Messages"
|
||||
"connview.subscriptions": "Subscriptions"
|
||||
"dir.published": "Published"
|
||||
"dir.received": "Received"
|
||||
"encode.base64_failed": "Base64 decode failed: {e}"
|
||||
"encode.hex_failed": "Hex decode failed: {e}"
|
||||
"encode.hex_invalid": "Hex decode failed: non-hex character present"
|
||||
"encode.hex_odd": "Hex length must be even (two chars per byte)"
|
||||
"engine.cancelled": "Connection cancelled"
|
||||
"engine.connack_rejected": "Connection rejected: {code}"
|
||||
"engine.connack_rejected_code": "Connection rejected: {code} (code {n})"
|
||||
"engine.connect_failed": "Connect failed: {e}"
|
||||
"engine.connect_ok": "Connected (session_present: {sp})"
|
||||
"engine.connecting": "Connecting to {host}:{port} ({protocol}, {transport})"
|
||||
"engine.connection_error": "Connection error: {e}"
|
||||
"engine.connection_not_found": "Connection not found"
|
||||
"engine.disconnect_received": "DISCONNECT received: {code} (code {n})"
|
||||
"engine.disconnected_by_server": "Disconnected by server"
|
||||
"engine.disconnected_by_server_active": "Server closed the connection"
|
||||
"engine.disconnected_by_server_code": "Disconnected by server: {code}"
|
||||
"engine.events_dropped": "UI cannot keep up: dropped {n} high-frequency events (messages/logs) so far"
|
||||
"engine.internal_task_failed": "Internal task failed: {e}"
|
||||
"engine.puback": "Publish acknowledged (packet {n})"
|
||||
"engine.puback_reason": "Publish acknowledged (packet {n}, reason {reason})"
|
||||
"engine.pubcomp": "QoS2 publish complete (packet {n})"
|
||||
"engine.publish_failed": "Publish failed: {e}"
|
||||
"engine.publish_no_connection": "Publish failed: connection not found"
|
||||
"engine.publish_outgoing": "Message sent (packet {n})"
|
||||
"engine.publish_sent": "Published to {topic} (QoS {qos}, retain={retain})"
|
||||
"engine.pubrec": "QoS2 publish received (packet {n})"
|
||||
"engine.received_message": "Received: {topic} ({n} bytes)"
|
||||
"engine.received_message_qos": "Received: {topic} ({n} bytes, QoS {qos})"
|
||||
"engine.reconnect_give_up": "Reconnect failed {attempts} times, reached the max of {max}, giving up: {e}"
|
||||
"engine.reconnect_retry": "Connection error, retrying in {delay}s (attempt {attempts}): {e}"
|
||||
"engine.suback": "Subscribe confirmed (packet {n})"
|
||||
"engine.suback_codes": "Subscribe confirmed (packet {n}): {codes}"
|
||||
"engine.subscribe_rejected": "Subscribe rejected: {e}"
|
||||
"engine.subscribe_request_failed": "Subscribe request failed: {e}"
|
||||
"engine.subscribe_topic": "Subscribing: {topic} (QoS {qos})"
|
||||
"engine.test_timeout": "Connection test timed out ({secs}s) — check the host and port"
|
||||
"engine.unsuback": "Unsubscribe confirmed"
|
||||
"engine.unsuback_packet": "Unsubscribe confirmed (packet {n})"
|
||||
"engine.unsubscribe_failed": "Unsubscribe failed: {e}"
|
||||
"engine.unsubscribe_ok": "Unsubscribed: {topic}"
|
||||
"engine.user_disconnect": "Disconnected by user request"
|
||||
"form.auth_section": "Auth & Keep-alive"
|
||||
"form.auto_connect": "Connect on app start"
|
||||
"form.auto_reconnect": "Auto reconnect on disconnect"
|
||||
"form.auto_resub": "Auto-resubscribe on connect"
|
||||
"form.basics": "Basics"
|
||||
"form.cert_pair_required": "Client certificate and key must be provided together"
|
||||
"form.clean_start": "Clean Start / Clean Session"
|
||||
"form.client_id": "Client ID"
|
||||
"form.client_id_required": "Client ID cannot be empty"
|
||||
"form.conn_timeout": "Connect timeout (s)"
|
||||
"form.conn_timeout_invalid": "Connect timeout must be an integer 1-65535 (seconds)"
|
||||
"form.content_type_ph": "Content type (optional)"
|
||||
"form.err_positive": "must be > 0 (0 is an MQTT 5 protocol error)"
|
||||
"form.err_range": "must be an integer 0-65535"
|
||||
"form.err_uint": "must be a non-negative integer"
|
||||
"form.group": "Group"
|
||||
"form.host": "Host"
|
||||
"form.host_required": "Host cannot be empty"
|
||||
"form.keep_alive": "Keep Alive (s)"
|
||||
"form.keep_alive_short": "Keep Alive"
|
||||
"form.max_packet": "Maximum Packet Size"
|
||||
"form.max_reconnects": "Max reconnects (0 = unlimited)"
|
||||
"form.max_reconnects_invalid": "Max reconnects must be a non-negative integer (0 = unlimited)"
|
||||
"form.max_reconnects_ph": "0 = unlimited"
|
||||
"form.name": "Name"
|
||||
"form.name_ph": "Connection name"
|
||||
"form.name_required": "Connection name cannot be empty"
|
||||
"form.new_group": "New group"
|
||||
"form.new_group_hint": "Overrides the selected group when filled"
|
||||
"form.no_group": "No group"
|
||||
"form.password": "Password"
|
||||
"form.password_ph": "Password (optional)"
|
||||
"form.path_missing": "{label}: path not found: {p}"
|
||||
"form.port": "Port"
|
||||
"form.port_invalid": "Port must be an integer 1-65535"
|
||||
"form.protocol": "Protocol"
|
||||
"form.receive_max": "Receive Maximum"
|
||||
"form.receive_max_invalid": "Receive Maximum cannot exceed 65535"
|
||||
"form.receive_max_ph": "Server receive maximum"
|
||||
"form.receive_max_short": "Receive Maximum"
|
||||
"form.regen_client_id": "Regenerate Client ID"
|
||||
"form.response_topic_ph": "Response topic (optional)"
|
||||
"form.saved_reconnect": "Saved. Takes effect after reconnect"
|
||||
"form.session_expiry": "Session expiry (s)"
|
||||
"form.session_expiry_invalid": "Session expiry must be a non-negative integer (0 = session ends with connection)"
|
||||
"form.session_expiry_ph": "seconds (0 = session ends with connection)"
|
||||
"form.test": "Test Connection"
|
||||
"form.test_ok": "Connection test succeeded"
|
||||
"form.testing": "Testing…"
|
||||
"form.timeout_ph": "seconds (default 10)"
|
||||
"form.title": "Connection Settings"
|
||||
"form.topic_alias": "Topic Alias"
|
||||
"form.topic_alias_invalid": "Topic Alias Maximum cannot exceed 65535"
|
||||
"form.topic_alias_max": "Topic Alias Maximum"
|
||||
"form.transport": "Transport"
|
||||
"form.username": "Username"
|
||||
"form.username_ph": "Username (optional)"
|
||||
"form.will_content_type": "Content Type"
|
||||
"form.will_enable": "Enable last will"
|
||||
"form.will_payload": "Will payload"
|
||||
"form.will_payload_ph": "Will message"
|
||||
"form.will_response_topic": "Response Topic"
|
||||
"form.will_section": "Last Will"
|
||||
"form.will_topic": "Will topic"
|
||||
"form.ws_path": "WS Path"
|
||||
"form.ws_path_required": "WS path is required for WS/WSS transport"
|
||||
"io.err_item_id": "Item {n}: id must not be empty"
|
||||
"io.err_item_invalid": "Item {n} is not a valid connection: {e}"
|
||||
"io.err_missing_field": "JSON object lacks the \"connections\" array"
|
||||
"io.err_parse_json": "Failed to parse JSON: {e}"
|
||||
"io.err_parse_json_path": "Failed to parse {p}"
|
||||
"io.err_read": "Failed to read {p}: {e}"
|
||||
"io.err_serialize": "Failed to serialize connections: {e}"
|
||||
"io.err_utf8": "{p} is not valid UTF-8"
|
||||
"io.err_write": "Failed to write {p}: {e}"
|
||||
"io.exported": "Exported {n} connections to {path}"
|
||||
"io.imported": "Imported {n}, skipped {n2}"
|
||||
"kv.add": "Add property"
|
||||
"msg.binary_hint": " (binary — switch to Hex in details)"
|
||||
"msg.clear_confirm": "Clear all messages of this connection? This cannot be undone."
|
||||
"msg.clear_sub_filter": "Clear subscription filter"
|
||||
"msg.clear_title": "Clear Messages"
|
||||
"msg.clear_tooltip": "Clear messages of this connection"
|
||||
"msg.copy_details": "Copy full details (incl. MQTT 5 props)"
|
||||
"msg.copy_payload": "Copy raw payload"
|
||||
"msg.copy_topic": "Copy topic"
|
||||
"msg.count": "{n} msgs"
|
||||
"msg.details": "Details"
|
||||
"msg.details_copied": "Details copied"
|
||||
"msg.empty": "No messages yet — they will appear here once you subscribe"
|
||||
"msg.expiry": "Message Expiry"
|
||||
"msg.filter_placeholder": "Filter by topic or content…"
|
||||
"msg.latest_only": "Showing latest {n} only"
|
||||
"msg.log_title": "Connection Logs"
|
||||
"msg.no_logs": "No logs yet"
|
||||
"msg.no_match": "No matching messages"
|
||||
"msg.pause_tooltip": "Pause message stream (messages keep arriving in background)"
|
||||
"msg.payload": "Payload"
|
||||
"msg.payload_copied": "Payload copied"
|
||||
"msg.resume_tooltip": "Resume live scrolling"
|
||||
"msg.seconds": "{n}s"
|
||||
"msg.topic": "Topic"
|
||||
"msg.topic_copied": "Topic copied"
|
||||
"msg.truncated": "Truncated {n}KB"
|
||||
"preset.default_name": "New preset"
|
||||
"preset.default_name_n": "New preset {n}"
|
||||
"preset.empty": "No presets yet — click \"New\" above to create one"
|
||||
"preset.manage_title": "Manage Publish Presets"
|
||||
"preset.name_exists": "Preset name \"{name}\" already exists"
|
||||
"preset.payload_field": "Payload (stored raw; variables rendered on send)"
|
||||
"preset.payload_format": "Payload format"
|
||||
"preset.pick_delete_first": "Select a preset to delete first"
|
||||
"preset.pick_first": "Select or create a preset first"
|
||||
"preset.saved": "Preset \"{name}\" saved"
|
||||
"preset.section": "Publish Presets"
|
||||
"preset.user_props": "User Properties"
|
||||
"pub.collapse": "Collapse publish panel"
|
||||
"pub.collapsed_hint": "Publish panel collapsed"
|
||||
"pub.content_type_ph": "Content-Type (optional)"
|
||||
"pub.correlation_ph": "Correlation Data (optional)"
|
||||
"pub.delete_confirm": "Delete preset \"{name}\"? This cannot be undone."
|
||||
"pub.delete_item": "Delete \"{name}\""
|
||||
"pub.delete_title": "Delete Preset"
|
||||
"pub.expand": "Expand publish panel"
|
||||
"pub.expiry_field": "Expiry (s)"
|
||||
"pub.expiry_invalid": "Message expiry must be a non-negative integer (seconds)"
|
||||
"pub.json_invalid": "Invalid JSON: {e}"
|
||||
"pub.manage_presets": "Manage presets…"
|
||||
"pub.mqtt5_props": "MQTT 5 Properties"
|
||||
"pub.offline_not_sent": "Offline: message not sent"
|
||||
"pub.payload_placeholder": "Message payload; supports {{variables}} and {{$ts}} {{$uuid}}"
|
||||
"pub.payload_preset_ph": "Message payload; supports {{variables}}"
|
||||
"pub.preset_menu": "Message Presets"
|
||||
"pub.preset_name_hint": "Preset name, e.g. temperature-report"
|
||||
"pub.preset_name_required": "Preset name is required"
|
||||
"pub.preset_overwritten": "Preset \"{name}\" overwritten"
|
||||
"pub.preset_saved": "Preset saved"
|
||||
"pub.response_topic_ph": "Response Topic (optional)"
|
||||
"pub.save_preset": "Save current as preset…"
|
||||
"pub.save_title": "Save Message Preset"
|
||||
"pub.seconds_hint": "seconds, e.g. 60"
|
||||
"pub.send_disabled": "Offline — cannot send"
|
||||
"pub.sent_hidden": "Sent (hidden by current filter — not shown in the stream)"
|
||||
"pub.topic_placeholder": "Publish topic"
|
||||
"pub.topic_preset_ph": "Publish topic; supports {{variables}}"
|
||||
"pub.topic_required": "Publish topic cannot be empty"
|
||||
"pub.vars": "Variable injection"
|
||||
"pub.vars_count": "Variable injection ({n} placeholders)"
|
||||
"resmon.auto_refresh": "Auto refresh"
|
||||
"resmon.budget": "Budget {p}% used ({used} / {total})"
|
||||
"resmon.committed": "Committed memory"
|
||||
"resmon.committed_hint": "Physical pages committed by the process"
|
||||
"resmon.cpu": "CPU usage"
|
||||
"resmon.cpu_hint": "Normalized by core count"
|
||||
"resmon.n_msgs": "{n} msgs"
|
||||
"resmon.no_conn": "No connections"
|
||||
"resmon.peak": "Peak {v}"
|
||||
"resmon.refresh_now": "Refresh now"
|
||||
"resmon.section": "Messages & Connections"
|
||||
"resmon.stats": "{n} connections ({m} connected) · {t} tabs · {msgs} messages in memory (cap {cap}/conn) · {logs} log lines"
|
||||
"resmon.threads_handles": "Threads · handles"
|
||||
"resmon.threads_hint": "tokio engine + GPUI renderer"
|
||||
"resmon.uptime": "Up {t}"
|
||||
"resmon.version_live": "Version v{v} · refreshes every 1.5s"
|
||||
"resmon.version_paused": "Version v{v} · auto-refresh paused"
|
||||
"resmon.working_set": "Working set"
|
||||
"settings.about": "About"
|
||||
"settings.auto_update": "Check for updates on start"
|
||||
"settings.byte_budget": "Plus a per-connection byte budget of {n} MB: under heavy traffic, oldest messages are evicted by size first"
|
||||
"settings.data_dir": "Data directory"
|
||||
"settings.language": "Language"
|
||||
"settings.license": "Apache-2.0 license"
|
||||
"settings.log_dir": "Log directory"
|
||||
"settings.max_messages": "Messages kept in memory per connection (100-100000)"
|
||||
"settings.mkdir_failed": "Failed to create log folder: {e}"
|
||||
"settings.open_dir": "Open folder"
|
||||
"settings.open_dir_failed": "Failed to open folder: {e}"
|
||||
"settings.open_log_dir": "Open log folder"
|
||||
"settings.show_millis": "Show milliseconds in timestamps"
|
||||
"settings.tagline": "A cross-platform MQTT debug client built with GPUI, aiming at MQTTX"
|
||||
"settings.theme": "Theme"
|
||||
"settings.theme_preset": "Theme preset"
|
||||
"settings.version_line": "Version {v} (update source: {src})"
|
||||
"sidebar.collapse": "Collapse sidebar (Ctrl+B)"
|
||||
"sidebar.connect": "Connect"
|
||||
"sidebar.connections": "Connections"
|
||||
"sidebar.delete": "Delete Connection"
|
||||
"sidebar.delete_confirm": "Delete \"{name}\"? This also deletes {sub_count} subscriptions and disconnects. This cannot be undone."
|
||||
"sidebar.disconnect": "Disconnect"
|
||||
"sidebar.dup_suffix": "{name} (copy)"
|
||||
"sidebar.duplicate": "Duplicate"
|
||||
"sidebar.empty": "No connections yet. Click + above to create one"
|
||||
"sidebar.expand": "Expand sidebar (Ctrl+B)"
|
||||
"sidebar.no_match": "No matching connections"
|
||||
"sidebar.search": "Search connections…"
|
||||
"sidebar.ungrouped": "Ungrouped"
|
||||
"ssl.ca": "CA certificate (PEM)"
|
||||
"ssl.ca_ph": "Path to CA certificate (PEM, optional)"
|
||||
"ssl.cert": "Client certificate (PEM)"
|
||||
"ssl.cert_ph": "Path to client certificate (PEM, optional)"
|
||||
"ssl.key": "Client key (PEM)"
|
||||
"ssl.key_ph": "Path to client private key (PEM, optional)"
|
||||
"ssl.n_configured": "{n} configured"
|
||||
"ssl.pick_file": "Select certificate / key file"
|
||||
"ssl.section": "SSL/TLS"
|
||||
"ssl.skip_verify": "Skip CA verification (trust any server cert)"
|
||||
"status.connected": "Connected"
|
||||
"status.connecting": "Connecting"
|
||||
"status.disconnected": "Disconnected"
|
||||
"status.error": "Error"
|
||||
"sub.alias": "Alias (optional)"
|
||||
"sub.cancel_edit": "Cancel editing"
|
||||
"sub.collapse": "Collapse subscriptions panel"
|
||||
"sub.color": "Subscription color"
|
||||
"sub.color_clear": "Clear color"
|
||||
"sub.color_random": "Random color"
|
||||
"sub.disable": "Disable subscription"
|
||||
"sub.edit": "Edit subscription"
|
||||
"sub.editing": "Editing: {topic}"
|
||||
"sub.empty_hint": "Subscribe to a topic and messages will show up here"
|
||||
"sub.enable": "Enable subscription"
|
||||
"sub.expand": "Expand subscriptions panel"
|
||||
"sub.failed": "Subscribe failed: {e}"
|
||||
"sub.identifier": "Subscription Identifier"
|
||||
"sub.identifier_field": "Subscription Identifier"
|
||||
"sub.identifier_invalid": "Subscription identifier must be a positive integer"
|
||||
"sub.mqtt5_options": "MQTT 5 Subscription Options"
|
||||
"sub.no_local": "No Local"
|
||||
"sub.rap": "Retain As Published"
|
||||
"sub.retain_handling": "Retain Handling"
|
||||
"sub.rh0": "0 Send at every subscribe"
|
||||
"sub.rh1": "1 Send only for new subscription"
|
||||
"sub.rh2": "2 Do not send"
|
||||
"sub.saved_offline": "Offline: subscription saved"
|
||||
"sub.saved_offline_resub": "Offline: subscription saved, restored on connect"
|
||||
"sub.topic_placeholder": "Topic filter, supports # +; separate multiple with commas/spaces"
|
||||
"sub.topic_required": "Topic filter cannot be empty"
|
||||
"sub.unsubscribe": "Unsubscribe"
|
||||
"tab.welcome_hint": "Pick a connection on the left, or create a new one to start debugging"
|
||||
"tab.welcome_title": "Get started with MQTTX"
|
||||
"theme.dark": "Dark"
|
||||
"theme.light": "Light"
|
||||
"theme.system": "Follow system"
|
||||
"titlebar.aliyun": "Alibaba Cloud Devices"
|
||||
"titlebar.export": "Export Connections"
|
||||
"titlebar.import": "Import Connections"
|
||||
"titlebar.new_connection": "New Connection"
|
||||
"titlebar.pin": "Pin window on top"
|
||||
"titlebar.pin_failed": "Failed to set always-on-top"
|
||||
"titlebar.resmon": "Resource Monitor"
|
||||
"titlebar.settings": "Settings"
|
||||
"titlebar.subtitle": "MQTT debug client"
|
||||
"titlebar.switch_dark": "Switch to dark theme"
|
||||
"titlebar.switch_light": "Switch to light theme"
|
||||
"titlebar.unpin": "Unpin window"
|
||||
"titlebar.variables": "Global Variables"
|
||||
"tls.ca_invalid_cert": "Invalid certificate in CA file {p}: {e}"
|
||||
"tls.ca_no_certs": "No certificate found in CA file {p}"
|
||||
"tls.ca_parse_failed": "Failed to parse CA file {p}: {e}"
|
||||
"tls.ca_read_failed": "Failed to read CA file {p}: {e}"
|
||||
"tls.cert_key_pair_required": "Client certificate and client key must be configured together"
|
||||
"tls.client_auth_failed": "Failed to load client certificate/key: {e}"
|
||||
"tls.client_cert_no_certs": "No certificate found in client certificate file {p}"
|
||||
"tls.client_cert_parse_failed": "Failed to parse client certificate file {p}: {e}"
|
||||
"tls.client_cert_read_failed": "Failed to read client certificate file {p}: {e}"
|
||||
"tls.client_key_parse_failed": "Failed to parse client key file {p}: {e}"
|
||||
"tls.client_key_read_failed": "Failed to read client key file {p}: {e}"
|
||||
"tls.init_failed": "TLS init failed: {e}"
|
||||
"tls.native_roots_failed": "Failed to load system root certificates: {detail}"
|
||||
"update.apply": "Update to v{v} now"
|
||||
"update.check": "Check for updates"
|
||||
"update.check_hint": "Check GitHub Releases for a new version"
|
||||
"update.checking": "Checking…"
|
||||
"update.checking_long": "Checking for updates…"
|
||||
"update.discard": "Discard this update"
|
||||
"update.discarded": "Staged update discarded"
|
||||
"update.downloading": "Downloading {pct}% ({done} / {total})"
|
||||
"update.downloading_size": "Downloading {done}"
|
||||
"update.failed": "Update failed: {e}"
|
||||
"update.found": "New version v{v} found — downloads, replaces itself and restarts"
|
||||
"update.install_failed": "Install failed: {e}"
|
||||
"update.install_later": "Install on next start"
|
||||
"update.install_now": "Install now"
|
||||
"update.not_now": "Not now"
|
||||
"update.ready": "Version v{v} downloaded and verified, ready to install"
|
||||
"update.ready_body": "Version v{v} has been downloaded (sha256 verified)."
|
||||
"update.ready_title": "Update Ready"
|
||||
"update.recheck": "Check again"
|
||||
"update.staged": "Staged — it will install on next start"
|
||||
"update.up_to_date": "You are up to date"
|
||||
"var.builtin_inline": "Built-in: {{$ts}} (s), {{$ts_ms}} (ms), {{$uuid}} · injected on publish"
|
||||
"var.dialog_builtin": "Built-in: {{$ts}} (s), {{$ts_ms}} (ms), {{$uuid}}"
|
||||
"var.dialog_hint": "Reference {{variables}} in publish topics and payloads."
|
||||
"var.no_reference": "No {{variable}} reference in topic/payload"
|
||||
"var.preview": "Preview: {tp} | {pl}"
|
||||
"var.reextract": "Re-extract placeholders"
|
||||
"var.section": "Variables ({n})"
|
||||
"var.value": "Value"
|
||||
"will.enabled_hint": "Enabled"
|
||||
"will.topic_required": "Will topic cannot be empty"
|
||||
"will.topic_wildcard": "Will topic cannot contain wildcards + or #"
|
||||
# MQTTX en 文案(rust-i18n:文件名即 locale,扁平 key: value)。
|
||||
# key = 语义标识符 ns.name;en/zh-CN 两文件键集合必须一致——单测强制。
|
||||
@@ -0,0 +1,407 @@
|
||||
"aliyun.access_key_id": "AccessKey ID"
|
||||
"aliyun.access_key_secret": "AccessKey Secret"
|
||||
"aliyun.auth_mode": "鉴权方式"
|
||||
"aliyun.auth_token_mode": "Group 令牌 (Token)"
|
||||
"aliyun.auth_triple_mode": "一机一密"
|
||||
"aliyun.build_failed": "无法生成连接: {e}"
|
||||
"aliyun.default_preset_name": "阿里云设备"
|
||||
"aliyun.device_id": "设备 ID"
|
||||
"aliyun.device_id_required": "Device ID 不能为空"
|
||||
"aliyun.device_secret": "DeviceSecret(一机一密)"
|
||||
"aliyun.device_secret_ph": "一机一密:DeviceSecret"
|
||||
"aliyun.generate_connect": "生成并连接"
|
||||
"aliyun.group": "分组(可选)"
|
||||
"aliyun.group_id": "Group ID"
|
||||
"aliyun.help": "Token 模式(云消息队列 MQTT 版):接入点 {实例ID}.mqtt.aliyuncs.com,clientId={GroupId}@@@{设备ID},密码为 HMAC-SHA1 签名;一机一密(物联网平台):接入点 {ProductKey}.iot-as-mqtt.{地域}.aliyuncs.com,按官方三元组规范签名"
|
||||
"aliyun.hmac_init_failed": "HMAC 初始化失败: {e}"
|
||||
"aliyun.instance_id": "实例 ID"
|
||||
"aliyun.instance_id_required": "Instance ID 不能为空"
|
||||
"aliyun.preset_name": "预设名称"
|
||||
"aliyun.product_key": "ProductKey(一机一密)"
|
||||
"aliyun.product_key_ph": "一机一密:ProductKey"
|
||||
"aliyun.region": "地域"
|
||||
"aliyun.region_ph": "如 cn-shanghai(可留空)"
|
||||
"aliyun.save_preset": "保存预设"
|
||||
"aliyun.saved_presets": "已保存预设"
|
||||
"aliyun.title": "阿里云 IoT 设备"
|
||||
"aliyun.token_required_fields": "Group ID、AccessKey ID 与 AccessKey Secret 不能为空"
|
||||
"aliyun.triple_requires_device_name": "一机一密需要 DeviceName(设备名)"
|
||||
"aliyun.triple_requires_pk_ds": "一机一密需要 ProductKey 与 DeviceSecret (设备密钥)"
|
||||
"color.blue": "蓝"
|
||||
"color.cyan": "青"
|
||||
"color.green": "绿"
|
||||
"color.indigo": "靛"
|
||||
"color.magenta": "品红"
|
||||
"color.orange": "橙"
|
||||
"color.purple": "紫"
|
||||
"color.red": "红"
|
||||
"color.rose": "玫红"
|
||||
"color.yellow": "黄"
|
||||
"common.advanced": "高级"
|
||||
"common.all": "全部"
|
||||
"common.auto": "自动"
|
||||
"common.browse": "浏览…"
|
||||
"common.cancel": "取消"
|
||||
"common.clear": "清空"
|
||||
"common.close": "关闭"
|
||||
"common.collapse": "收起"
|
||||
"common.delete": "删除"
|
||||
"common.edit": "编辑"
|
||||
"common.new": "新建"
|
||||
"common.pause": "暂停"
|
||||
"common.resume": "恢复"
|
||||
"common.retry": "重试"
|
||||
"common.save": "保存"
|
||||
"common.send": "发送"
|
||||
"common.subscribe": "订阅"
|
||||
"common.text": "文本"
|
||||
"common.update": "更新"
|
||||
"connview.logs": "日志"
|
||||
"connview.messages": "消息流"
|
||||
"connview.subscriptions": "订阅"
|
||||
"dir.published": "发布"
|
||||
"dir.received": "接收"
|
||||
"encode.base64_failed": "Base64 解码失败: {e}"
|
||||
"encode.hex_failed": "Hex 解码失败: {e}"
|
||||
"encode.hex_invalid": "Hex 解码失败: 含非十六进制字符"
|
||||
"encode.hex_odd": "Hex 长度必须为偶数(每两个字符一个字节)"
|
||||
"engine.cancelled": "连接已取消"
|
||||
"engine.connack_rejected": "连接被拒绝: {code}"
|
||||
"engine.connack_rejected_code": "连接被拒绝: {code} (code {n})"
|
||||
"engine.connect_failed": "连接失败: {e}"
|
||||
"engine.connect_ok": "连接成功 (session_present: {sp})"
|
||||
"engine.connecting": "正在连接 {host}:{port} ({protocol}, {transport})"
|
||||
"engine.connection_error": "连接错误: {e}"
|
||||
"engine.connection_not_found": "连接不存在"
|
||||
"engine.disconnect_received": "收到断开连接: {code} (code {n})"
|
||||
"engine.disconnected_by_server": "服务端断开"
|
||||
"engine.disconnected_by_server_active": "服务端主动断开"
|
||||
"engine.disconnected_by_server_code": "服务端断开: {code}"
|
||||
"engine.events_dropped": "UI 处理不过来,已累计丢弃 {n} 条高频事件(消息/日志)"
|
||||
"engine.internal_task_failed": "内部任务失败: {e}"
|
||||
"engine.puback": "发布确认 (packet {n})"
|
||||
"engine.puback_reason": "发布确认 (packet {n}, reason {reason})"
|
||||
"engine.pubcomp": "QoS2 发布完成 (packet {n})"
|
||||
"engine.publish_failed": "发布失败: {e}"
|
||||
"engine.publish_no_connection": "发布失败: 连接不存在"
|
||||
"engine.publish_outgoing": "消息已发出 (packet {n})"
|
||||
"engine.publish_sent": "发布消息到 {topic} (QoS {qos}, retain={retain})"
|
||||
"engine.pubrec": "QoS2 发布收到 (packet {n})"
|
||||
"engine.received_message": "收到消息: {topic} ({n} 字节)"
|
||||
"engine.received_message_qos": "收到消息: {topic} ({n} 字节, QoS {qos})"
|
||||
"engine.reconnect_give_up": "已重连 {attempts} 次仍失败,达到最大重连次数 {max},停止重连: {e}"
|
||||
"engine.reconnect_retry": "连接错误,{delay} 秒后重连(第 {attempts} 次): {e}"
|
||||
"engine.suback": "订阅确认 (packet {n})"
|
||||
"engine.suback_codes": "订阅确认 (packet {n}): {codes}"
|
||||
"engine.subscribe_rejected": "订阅被拒绝: {e}"
|
||||
"engine.subscribe_request_failed": "订阅请求失败: {e}"
|
||||
"engine.subscribe_topic": "订阅主题: {topic} (QoS {qos})"
|
||||
"engine.test_timeout": "连接测试超时({secs} 秒),请检查主机地址与端口"
|
||||
"engine.unsuback": "取消订阅确认"
|
||||
"engine.unsuback_packet": "取消订阅确认 (packet {n})"
|
||||
"engine.unsubscribe_failed": "取消订阅失败: {e}"
|
||||
"engine.unsubscribe_ok": "取消订阅: {topic}"
|
||||
"engine.user_disconnect": "用户请求断开连接"
|
||||
"form.auth_section": "认证与心跳"
|
||||
"form.auto_connect": "启动时自动连接"
|
||||
"form.auto_reconnect": "断线自动重连"
|
||||
"form.auto_resub": "连接后自动恢复订阅"
|
||||
"form.basics": "基本信息"
|
||||
"form.cert_pair_required": "客户端证书与客户端密钥必须同时填写"
|
||||
"form.clean_start": "Clean Start / Clean Session"
|
||||
"form.client_id": "Client ID"
|
||||
"form.client_id_required": "Client ID 不能为空"
|
||||
"form.conn_timeout": "连接超时 (秒)"
|
||||
"form.conn_timeout_invalid": "连接超时需要是 1~65535 的整数(秒)"
|
||||
"form.content_type_ph": "内容类型(可选)"
|
||||
"form.err_positive": "必须大于 0(0 是 MQTT 5 协议错误)"
|
||||
"form.err_range": "需要是 0~65535 的整数"
|
||||
"form.err_uint": "需要是非负整数"
|
||||
"form.group": "分组"
|
||||
"form.host": "主机"
|
||||
"form.host_required": "主机地址不能为空"
|
||||
"form.keep_alive": "Keep Alive (秒)"
|
||||
"form.keep_alive_short": "Keep Alive"
|
||||
"form.max_packet": "最大报文长度"
|
||||
"form.max_reconnects": "最大重连次数 (0=无限)"
|
||||
"form.max_reconnects_invalid": "最大重连次数需要是非负整数(0=无限)"
|
||||
"form.max_reconnects_ph": "0=无限重连"
|
||||
"form.name": "名称"
|
||||
"form.name_ph": "连接名称"
|
||||
"form.name_required": "连接名称不能为空"
|
||||
"form.new_group": "新建分组"
|
||||
"form.new_group_hint": "填写后优先于左侧所选分组"
|
||||
"form.no_group": "无分组"
|
||||
"form.password": "密码"
|
||||
"form.password_ph": "密码(可选)"
|
||||
"form.path_missing": "{label}路径不存在:{p}"
|
||||
"form.port": "端口"
|
||||
"form.port_invalid": "端口需要是 1~65535 的整数"
|
||||
"form.protocol": "协议"
|
||||
"form.receive_max": "接收最大值"
|
||||
"form.receive_max_invalid": "接收上限不能超过 65535"
|
||||
"form.receive_max_ph": "服务端接收上限"
|
||||
"form.receive_max_short": "接收上限"
|
||||
"form.regen_client_id": "重新生成 Client ID"
|
||||
"form.response_topic_ph": "响应主题(可选)"
|
||||
"form.saved_reconnect": "配置已保存,重连后生效"
|
||||
"form.session_expiry": "会话过期间隔(秒)"
|
||||
"form.session_expiry_invalid": "会话过期间隔需要是非负整数(0=会话随连接结束)"
|
||||
"form.session_expiry_ph": "秒(0=会话随连接结束)"
|
||||
"form.test": "测试连接"
|
||||
"form.test_ok": "连接测试成功"
|
||||
"form.testing": "测试中…"
|
||||
"form.timeout_ph": "秒(默认 10)"
|
||||
"form.title": "连接配置"
|
||||
"form.topic_alias": "主题别名"
|
||||
"form.topic_alias_invalid": "主题别名上限不能超过 65535"
|
||||
"form.topic_alias_max": "主题别名上限"
|
||||
"form.transport": "传输"
|
||||
"form.username": "用户名"
|
||||
"form.username_ph": "用户名(可选)"
|
||||
"form.will_content_type": "内容类型 (Content Type)"
|
||||
"form.will_enable": "启用遗嘱消息"
|
||||
"form.will_payload": "遗嘱内容"
|
||||
"form.will_payload_ph": "遗嘱消息"
|
||||
"form.will_response_topic": "响应主题 (Response Topic)"
|
||||
"form.will_section": "遗嘱消息 (Last Will)"
|
||||
"form.will_topic": "遗嘱主题"
|
||||
"form.ws_path": "WS 路径"
|
||||
"form.ws_path_required": "WS/WSS 传输下 WS 路径不能为空"
|
||||
"io.err_item_id": "第 {n} 项的 id 不能为空"
|
||||
"io.err_item_invalid": "第 {n} 项不是合法的连接配置: {e}"
|
||||
"io.err_missing_field": "JSON 对象中缺少 \"connections\" 数组字段"
|
||||
"io.err_parse_json": "解析 JSON 失败: {e}"
|
||||
"io.err_parse_json_path": "解析 {p} 失败"
|
||||
"io.err_read": "读取 {p} 失败: {e}"
|
||||
"io.err_serialize": "序列化连接失败: {e}"
|
||||
"io.err_utf8": "{p} 不是有效的 UTF-8 文件"
|
||||
"io.err_write": "写入 {p} 失败: {e}"
|
||||
"io.exported": "导出 {n} 条连接到 {path}"
|
||||
"io.imported": "导入 {n} 条,跳过 {n2} 条"
|
||||
"kv.add": "添加属性"
|
||||
"msg.binary_hint": "(非文本,详情可切 Hex)"
|
||||
"msg.clear_confirm": "确定清空当前连接的全部消息吗?此操作不可撤销。"
|
||||
"msg.clear_sub_filter": "清除订阅过滤"
|
||||
"msg.clear_title": "清空消息"
|
||||
"msg.clear_tooltip": "清空当前连接的消息"
|
||||
"msg.copy_details": "复制完整详情(含 v5 属性)"
|
||||
"msg.copy_payload": "复制负载原文"
|
||||
"msg.copy_topic": "复制主题"
|
||||
"msg.count": "{n} 条"
|
||||
"msg.details": "详情"
|
||||
"msg.details_copied": "已复制详情"
|
||||
"msg.empty": "暂无消息,订阅主题后消息会显示在这里"
|
||||
"msg.expiry": "消息过期"
|
||||
"msg.filter_placeholder": "过滤主题或内容…"
|
||||
"msg.latest_only": "仅显示最新 {n} 条"
|
||||
"msg.log_title": "连接日志"
|
||||
"msg.no_logs": "暂无日志"
|
||||
"msg.no_match": "没有匹配的消息"
|
||||
"msg.pause_tooltip": "暂停消息流滚动(消息仍在后台接收)"
|
||||
"msg.payload": "负载"
|
||||
"msg.payload_copied": "已复制负载"
|
||||
"msg.resume_tooltip": "恢复实时滚动"
|
||||
"msg.seconds": "{n} 秒"
|
||||
"msg.topic": "主题"
|
||||
"msg.topic_copied": "已复制主题"
|
||||
"msg.truncated": "已截断 {n}KB"
|
||||
"preset.default_name": "新预设"
|
||||
"preset.default_name_n": "新预设 {n}"
|
||||
"preset.empty": "暂无发布预设,点击上方「新建」创建"
|
||||
"preset.manage_title": "管理发布预设"
|
||||
"preset.name_exists": "预设名称「{name}」已存在"
|
||||
"preset.payload_field": "Payload(原文保存,发送时渲染变量)"
|
||||
"preset.payload_format": "Payload 格式"
|
||||
"preset.pick_delete_first": "请先选择要删除的预设"
|
||||
"preset.pick_first": "请先选择或新建预设"
|
||||
"preset.saved": "预设「{name}」已保存"
|
||||
"preset.section": "发布预设"
|
||||
"preset.user_props": "用户属性"
|
||||
"pub.collapse": "收起发布面板"
|
||||
"pub.collapsed_hint": "发布面板已收起"
|
||||
"pub.content_type_ph": "Content-Type(可选)"
|
||||
"pub.correlation_ph": "Correlation Data(可选)"
|
||||
"pub.delete_confirm": "确定删除预设「{name}」吗?此操作不可撤销。"
|
||||
"pub.delete_item": "删除「{name}」"
|
||||
"pub.delete_title": "删除预设"
|
||||
"pub.expand": "展开发布面板"
|
||||
"pub.expiry_field": "消息过期(秒)"
|
||||
"pub.expiry_invalid": "消息过期须为非负整数(秒)"
|
||||
"pub.json_invalid": "JSON 格式无效: {e}"
|
||||
"pub.manage_presets": "管理预设…"
|
||||
"pub.mqtt5_props": "MQTT 5 属性"
|
||||
"pub.offline_not_sent": "未连接:消息未发送"
|
||||
"pub.payload_placeholder": "输入消息负载,支持 {{变量名}} 与 {{$ts}} {{$uuid}}"
|
||||
"pub.payload_preset_ph": "消息负载,支持 {{变量名}}"
|
||||
"pub.preset_menu": "消息预设"
|
||||
"pub.preset_name_hint": "预设名称,如 温度上报"
|
||||
"pub.preset_name_required": "请填写预设名称"
|
||||
"pub.preset_overwritten": "已覆盖预设「{name}」"
|
||||
"pub.preset_saved": "预设已保存"
|
||||
"pub.response_topic_ph": "Response Topic(可选)"
|
||||
"pub.save_preset": "保存当前为预设…"
|
||||
"pub.save_title": "保存消息预设"
|
||||
"pub.seconds_hint": "秒,如 60"
|
||||
"pub.send_disabled": "未连接,无法发送"
|
||||
"pub.sent_hidden": "已发送(被当前过滤条件隐藏,消息流中不显示)"
|
||||
"pub.topic_placeholder": "发布主题"
|
||||
"pub.topic_preset_ph": "发布主题,支持 {{变量名}}"
|
||||
"pub.topic_required": "发布主题不能为空"
|
||||
"pub.vars": "变量注入"
|
||||
"pub.vars_count": "变量注入({n} 个占位符)"
|
||||
"resmon.auto_refresh": "自动刷新"
|
||||
"resmon.budget": "预算占用 {p}%({used} / {total})"
|
||||
"resmon.committed": "提交内存"
|
||||
"resmon.committed_hint": "进程独占的物理页承诺"
|
||||
"resmon.cpu": "CPU 占用"
|
||||
"resmon.cpu_hint": "按可用核数归一"
|
||||
"resmon.n_msgs": "{n} 条消息"
|
||||
"resmon.no_conn": "暂无连接"
|
||||
"resmon.peak": "峰值 {v}"
|
||||
"resmon.refresh_now": "立即刷新"
|
||||
"resmon.section": "消息与连接"
|
||||
"resmon.stats": "{n} 个连接(已连接 {m})· 打开标签 {t} · 内存消息 {msgs} 条(每连接上限 {cap} 条)· 日志 {logs} 条"
|
||||
"resmon.threads_handles": "线程 · 句柄"
|
||||
"resmon.threads_hint": "tokio 引擎 + GPUI 渲染"
|
||||
"resmon.uptime": "运行 {t}"
|
||||
"resmon.version_live": "版本 v{v} · 每 1.5 秒自动刷新"
|
||||
"resmon.version_paused": "版本 v{v} · 自动刷新已暂停"
|
||||
"resmon.working_set": "工作集内存"
|
||||
"settings.about": "关于"
|
||||
"settings.auto_update": "自动检查更新(启动时)"
|
||||
"settings.byte_budget": "另有每连接 {n}MB 的字节预算兜底:大报文流会优先按字节从最旧开始驱逐"
|
||||
"settings.data_dir": "数据目录"
|
||||
"settings.language": "界面语言"
|
||||
"settings.license": "Apache-2.0 许可证"
|
||||
"settings.log_dir": "日志目录"
|
||||
"settings.max_messages": "每条连接内存中保留的消息条数(100~100000)"
|
||||
"settings.mkdir_failed": "创建日志目录失败: {e}"
|
||||
"settings.open_dir": "打开目录"
|
||||
"settings.open_dir_failed": "打开目录失败: {e}"
|
||||
"settings.open_log_dir": "打开日志目录"
|
||||
"settings.show_millis": "时间戳显示毫秒"
|
||||
"settings.tagline": "对标 MQTTX 的 GPUI 跨平台 MQTT 调试客户端"
|
||||
"settings.theme": "界面主题"
|
||||
"settings.theme_preset": "主题预设"
|
||||
"settings.version_line": "版本 {v}(更新源 {src})"
|
||||
"sidebar.collapse": "收起侧栏(Ctrl+B)"
|
||||
"sidebar.connect": "连接"
|
||||
"sidebar.connections": "连接"
|
||||
"sidebar.delete": "删除连接"
|
||||
"sidebar.delete_confirm": "确定删除「{name}」?将同时删除 {sub_count} 个订阅,并断开当前连接,此操作不可撤销。"
|
||||
"sidebar.disconnect": "断开"
|
||||
"sidebar.dup_suffix": "{name} 副本"
|
||||
"sidebar.duplicate": "复制连接"
|
||||
"sidebar.empty": "还没有连接,点击上方 + 新建"
|
||||
"sidebar.expand": "展开连接侧栏(Ctrl+B)"
|
||||
"sidebar.no_match": "没有匹配的连接"
|
||||
"sidebar.search": "搜索连接…"
|
||||
"sidebar.ungrouped": "未分组"
|
||||
"ssl.ca": "CA 证书 (PEM)"
|
||||
"ssl.ca_ph": "CA 证书 PEM 路径(可选)"
|
||||
"ssl.cert": "客户端证书 (PEM)"
|
||||
"ssl.cert_ph": "客户端证书 PEM 路径(可选)"
|
||||
"ssl.key": "客户端密钥 (PEM)"
|
||||
"ssl.key_ph": "客户端私钥 PEM 路径(可选)"
|
||||
"ssl.n_configured": "已配置 {n} 项"
|
||||
"ssl.pick_file": "选择证书 / 密钥文件"
|
||||
"ssl.section": "SSL/TLS"
|
||||
"ssl.skip_verify": "忽略 CA 校验(不验证服务器证书)"
|
||||
"status.connected": "已连接"
|
||||
"status.connecting": "连接中"
|
||||
"status.disconnected": "未连接"
|
||||
"status.error": "错误"
|
||||
"sub.alias": "别名(可选)"
|
||||
"sub.cancel_edit": "取消编辑"
|
||||
"sub.collapse": "收起订阅面板"
|
||||
"sub.color": "订阅颜色"
|
||||
"sub.color_clear": "清除颜色"
|
||||
"sub.color_random": "随机颜色"
|
||||
"sub.disable": "停用订阅"
|
||||
"sub.edit": "编辑订阅"
|
||||
"sub.editing": "正在编辑:{topic}"
|
||||
"sub.empty_hint": "订阅主题后,消息会显示在右侧"
|
||||
"sub.enable": "启用订阅"
|
||||
"sub.expand": "展开订阅面板"
|
||||
"sub.failed": "订阅失败: {e}"
|
||||
"sub.identifier": "订阅标识符"
|
||||
"sub.identifier_field": "订阅标识符"
|
||||
"sub.identifier_invalid": "订阅标识符须为正整数"
|
||||
"sub.mqtt5_options": "MQTT 5 订阅选项"
|
||||
"sub.no_local": "No Local"
|
||||
"sub.rap": "Retain As Published"
|
||||
"sub.retain_handling": "Retain Handling"
|
||||
"sub.rh0": "0 每次发送"
|
||||
"sub.rh1": "1 仅新订阅"
|
||||
"sub.rh2": "2 不发送"
|
||||
"sub.saved_offline": "未连接:订阅已保存"
|
||||
"sub.saved_offline_resub": "未连接:订阅已保存,连接后自动恢复"
|
||||
"sub.topic_placeholder": "订阅主题,支持 # +,多个用逗号/空格分隔"
|
||||
"sub.topic_required": "订阅主题不能为空"
|
||||
"sub.unsubscribe": "取消订阅"
|
||||
"tab.welcome_hint": "从左侧选择一个连接,或新建连接开始调试"
|
||||
"tab.welcome_title": "开始使用 MQTTX"
|
||||
"theme.dark": "深色"
|
||||
"theme.light": "浅色"
|
||||
"theme.system": "跟随系统"
|
||||
"titlebar.aliyun": "阿里云设备"
|
||||
"titlebar.export": "导出连接"
|
||||
"titlebar.import": "导入连接"
|
||||
"titlebar.new_connection": "新建连接"
|
||||
"titlebar.pin": "置顶窗口"
|
||||
"titlebar.pin_failed": "置顶操作失败"
|
||||
"titlebar.resmon": "资源监控"
|
||||
"titlebar.settings": "设置"
|
||||
"titlebar.subtitle": "MQTT 调试客户端"
|
||||
"titlebar.switch_dark": "切换为深色"
|
||||
"titlebar.switch_light": "切换为浅色"
|
||||
"titlebar.unpin": "取消窗口置顶"
|
||||
"titlebar.variables": "全局变量"
|
||||
"tls.ca_invalid_cert": "CA 文件 {p} 中存在无效证书: {e}"
|
||||
"tls.ca_no_certs": "CA 文件 {p} 中没有找到任何证书"
|
||||
"tls.ca_parse_failed": "解析 CA 文件 {p} 失败: {e}"
|
||||
"tls.ca_read_failed": "读取 CA 文件 {p} 失败: {e}"
|
||||
"tls.cert_key_pair_required": "客户端证书与客户端密钥必须同时配置"
|
||||
"tls.client_auth_failed": "加载客户端证书/私钥失败: {e}"
|
||||
"tls.client_cert_no_certs": "客户端证书文件 {p} 中没有找到证书"
|
||||
"tls.client_cert_parse_failed": "解析客户端证书文件 {p} 失败: {e}"
|
||||
"tls.client_cert_read_failed": "读取客户端证书文件 {p} 失败: {e}"
|
||||
"tls.client_key_parse_failed": "解析客户端私钥文件 {p} 失败: {e}"
|
||||
"tls.client_key_read_failed": "读取客户端私钥文件 {p} 失败: {e}"
|
||||
"tls.init_failed": "TLS 初始化失败: {e}"
|
||||
"tls.native_roots_failed": "加载系统根证书失败: {detail}"
|
||||
"update.apply": "立即更新到 v{v}"
|
||||
"update.check": "检查更新"
|
||||
"update.check_hint": "检查 GitHub Releases 上的新版本"
|
||||
"update.checking": "检查中…"
|
||||
"update.checking_long": "正在检查更新…"
|
||||
"update.discard": "放弃此次更新"
|
||||
"update.discarded": "已删除暂存的更新包"
|
||||
"update.downloading": "下载中 {pct}%({done} / {total})"
|
||||
"update.downloading_size": "下载中 {done}"
|
||||
"update.failed": "更新失败:{e}"
|
||||
"update.found": "发现新版本 v{v},下载后自动替换重启"
|
||||
"update.install_failed": "安装失败:{e}"
|
||||
"update.install_later": "下次启动安装"
|
||||
"update.install_now": "立即安装"
|
||||
"update.not_now": "暂不更新"
|
||||
"update.ready": "新版本 v{v} 已下载并通过校验,等待安装"
|
||||
"update.ready_body": "新版本 v{v} 已下载完成(已通过 sha256 校验)。"
|
||||
"update.ready_title": "更新就绪"
|
||||
"update.recheck": "重新检查"
|
||||
"update.staged": "已暂存,下次启动时将自动安装"
|
||||
"update.up_to_date": "已是最新版本"
|
||||
"var.builtin_inline": "内置:{{$ts}}(秒) {{$ts_ms}}(毫秒) {{$uuid}} · 发布时注入"
|
||||
"var.dialog_builtin": "内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}"
|
||||
"var.dialog_hint": "在发布主题、消息负载中使用 {{变量名}} 引用。"
|
||||
"var.no_reference": "主题/负载中没有 {{变量}} 引用"
|
||||
"var.preview": "预览:{tp} | {pl}"
|
||||
"var.reextract": "重新提取占位符"
|
||||
"var.section": "变量({n})"
|
||||
"var.value": "变量值"
|
||||
"will.enabled_hint": "已启用"
|
||||
"will.topic_required": "遗嘱主题不能为空"
|
||||
"will.topic_wildcard": "遗嘱主题不能包含通配符 + 或 #"
|
||||
# MQTTX zh-CN 文案(rust-i18n:文件名即 locale,扁平 key: value)。
|
||||
# key = 语义标识符 ns.name;en/zh-CN 两文件键集合必须一致——单测强制。
|
||||
+45
-11
@@ -13,12 +13,13 @@
|
||||
//! - password: HMAC-SHA256(DeviceSecret, "clientId{ProductKey}.{DeviceName}deviceName{DeviceName}productKey{ProductKey}timestamp{ts}")
|
||||
|
||||
use base64::Engine as _;
|
||||
use hmac::{Hmac, Mac};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha1::Sha1;
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::model::{ConnectionConfig, ProtocolVersion, TransportKind};
|
||||
use crate::ui::i18n;
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
@@ -38,13 +39,15 @@ impl AliyunAuthMode {
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Token => "Group 令牌 (Token)",
|
||||
Self::DeviceCredential => "一机一密",
|
||||
Self::Token => i18n::t("aliyun.auth_token_mode"),
|
||||
Self::DeviceCredential => i18n::t("aliyun.auth_triple_mode"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
// Debug 手写脱敏:AccessKeySecret 与 DeviceSecret 是长期凭据,一次 `{:?}` 格式化
|
||||
// 就会把它们送进 stderr 与 Sentry(panic 钩子会一并上报),因此不能 derive。
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AliyunPreset {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -81,7 +84,7 @@ impl AliyunPreset {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "阿里云设备".into(),
|
||||
name: i18n::t("aliyun.default_preset_name").into(),
|
||||
group: String::new(),
|
||||
instance_id: String::new(),
|
||||
region: String::new(),
|
||||
@@ -97,16 +100,16 @@ impl AliyunPreset {
|
||||
|
||||
/// HMAC-SHA1(secret, client_id) 的 Base64 编码(Token 模式签名)。
|
||||
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}"))?;
|
||||
let mut mac = HmacSha1::new_from_slice(secret.as_bytes())
|
||||
.map_err(|e| i18n::tf("aliyun.hmac_init_failed", &[("e", &e)]))?;
|
||||
mac.update(client_id.as_bytes());
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
/// HMAC-SHA256(secret, msg) 的小写 hex(一机一密签名)。
|
||||
fn hmac_sha256_hex(secret: &str, msg: &str) -> Result<String, String> {
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| format!("HMAC 初始化失败: {e}"))?;
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
||||
.map_err(|e| i18n::tf("aliyun.hmac_init_failed", &[("e", &e)]))?;
|
||||
mac.update(msg.as_bytes());
|
||||
Ok(mac
|
||||
.finalize()
|
||||
@@ -150,7 +153,15 @@ impl AliyunPreset {
|
||||
|| self.access_key_id.is_empty()
|
||||
|| self.access_key_secret.is_empty()
|
||||
{
|
||||
return Err("Group ID、AccessKey ID 与 AccessKey Secret 不能为空".into());
|
||||
return Err(i18n::t("aliyun.token_required_fields").into());
|
||||
}
|
||||
// 空 instance_id 会拼出 ".mqtt.aliyuncs.com" 这样的非法接入点,DNS 报错难懂
|
||||
if self.instance_id.trim().is_empty() {
|
||||
return Err(i18n::t("aliyun.instance_id_required").into());
|
||||
}
|
||||
// 空 DeviceName 会拼出 "group@@@" 这样的非法 clientId,服务端报错难懂
|
||||
if self.device_id.trim().is_empty() {
|
||||
return Err(i18n::t("aliyun.device_id_required").into());
|
||||
}
|
||||
conn.host = self.host();
|
||||
let client_id = format!("{}@@@{}", self.group_id, self.device_id);
|
||||
@@ -162,7 +173,11 @@ impl AliyunPreset {
|
||||
}
|
||||
AliyunAuthMode::DeviceCredential => {
|
||||
if self.product_key.is_empty() || self.device_secret.is_empty() {
|
||||
return Err("一机一密需要 ProductKey 与 DeviceSecret (设备密钥)".into());
|
||||
return Err(i18n::t("aliyun.triple_requires_pk_ds").into());
|
||||
}
|
||||
// 空 DeviceName 会拼出 "pk.|securemode=..." 与 "&pk",服务端报错难懂
|
||||
if self.device_id.trim().is_empty() {
|
||||
return Err(i18n::t("aliyun.triple_requires_device_name").into());
|
||||
}
|
||||
conn.host = self.host();
|
||||
// 物联网平台「一机一密」直连三元组(见模块文档),DeviceName 复用 device_id
|
||||
@@ -189,3 +204,22 @@ impl Default for AliyunPreset {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AliyunPreset {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AliyunPreset")
|
||||
.field("id", &self.id)
|
||||
.field("name", &self.name)
|
||||
.field("group", &self.group)
|
||||
.field("instance_id", &self.instance_id)
|
||||
.field("region", &self.region)
|
||||
.field("auth_mode", &self.auth_mode)
|
||||
.field("access_key_id", &self.access_key_id)
|
||||
.field("access_key_secret", &"<redacted>")
|
||||
.field("group_id", &self.group_id)
|
||||
.field("device_id", &self.device_id)
|
||||
.field("product_key", &self.product_key)
|
||||
.field("device_secret", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
//! MQTTX:数据模型、持久化、MQTT 引擎与 GPUI 界面。
|
||||
|
||||
// 语言包编译期内嵌(key = 语义标识符,en/zh-CN 并排);miss 回退 en
|
||||
rust_i18n::i18n!("locales", fallback = "en");
|
||||
|
||||
/// 把 gpui-kit 组件内置文案(按钮/日历/空态等)并入当前语言包。
|
||||
/// 启动时调用一次;extend! 展开引用本 crate 的 _rust_i18n_backend,
|
||||
/// 必须在 lib 内调用(bin crate 的 crate:: 指向不同根)。
|
||||
pub fn init_i18n() {
|
||||
use gpui_kit::component as gpui_component;
|
||||
rust_i18n::extend!(gpui_component);
|
||||
}
|
||||
|
||||
/// 注册官方捆绑主题预设(38 个)。必须在 `gpui_kit::init` 之后、
|
||||
/// 打开窗口之前调用(bootstrap 阶段):注册触发 ThemeRegistry 的
|
||||
/// 全局观察者回调(Theme::change + 全窗刷新),在窗口已挂满 UI 时
|
||||
/// 连续注册会引发大规模重绘级联。
|
||||
pub fn init_themes(cx: &mut gpui_kit::App) {
|
||||
ui::app::theme::register_bundled_themes(cx);
|
||||
}
|
||||
|
||||
|
||||
pub mod aliyun;
|
||||
pub mod model;
|
||||
pub mod mqtt;
|
||||
pub mod platform;
|
||||
pub mod store;
|
||||
pub mod sysmon;
|
||||
pub mod ui;
|
||||
|
||||
+18
-5
@@ -1,4 +1,8 @@
|
||||
use gpui_kit::component::{Root, TitleBar};
|
||||
// 发布构建不创建控制台窗口(GUI 子系统);debug 构建保留控制台,
|
||||
// 便于直接查看 eprintln / tracing 日志输出。
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use gpui_kit::component::TitleBar;
|
||||
use gpui_kit::*;
|
||||
|
||||
use mqttx_desktop::ui;
|
||||
@@ -19,13 +23,18 @@ fn init_sentry() -> sentry::ClientInitGuard {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// [debug] 栈溢出排查:捕获库内 tracing(registry observer 的 Reload 日志)
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing_subscriber::filter::LevelFilter::INFO)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
// 资源监控的运行时长基准
|
||||
mqttx_desktop::sysmon::init();
|
||||
// OTA 收尾:清理遗留文件;若暂存区有待安装的新版本且用户已同意
|
||||
// (「下次启动安装」),在进入 UI 前完成自替换并重启。
|
||||
update::cleanup_old();
|
||||
if let Some(staged) = update::load_staged() {
|
||||
if update::install_consent_matches(&staged.version) {
|
||||
if update::install_consent_matches(&staged) {
|
||||
match update::install_staged(&staged) {
|
||||
Ok(()) => {
|
||||
// 新进程已拉起,当前进程立即退出
|
||||
@@ -59,7 +68,11 @@ fn main() {
|
||||
// 其余 IconName(MailOpen/SlidersHorizontal 等)运行时会渲染空白
|
||||
.with_assets(gpui_kit::assets::AllAssets)
|
||||
.run(|cx: &mut App| {
|
||||
// gpui-kit 组件内置文案(按钮/日历/空态等)并入当前语言包
|
||||
mqttx_desktop::init_i18n();
|
||||
gpui_kit::init(cx);
|
||||
// 官方捆绑主题预设注册(bootstrap 阶段,窗口创建前)
|
||||
mqttx_desktop::init_themes(cx);
|
||||
|
||||
let options = WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
|
||||
@@ -70,9 +83,9 @@ fn main() {
|
||||
..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))
|
||||
// 0.7 起 gpui_kit::open_window 自动挂载 Root(承载 dialog/sheet/notification 层)
|
||||
gpui_kit::open_window(options, cx, |window, cx| {
|
||||
cx.new(|cx| ui::app::MqttXApp::new(window, cx))
|
||||
})
|
||||
.expect("Failed to open window");
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
//! 连接配置:协议版本、传输方式、遗嘱、TLS 与连接参数。
|
||||
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[derive(Default)]
|
||||
pub enum ProtocolVersion {
|
||||
/// MQTT 3.1.1
|
||||
V311,
|
||||
/// MQTT 5.0
|
||||
#[default]
|
||||
V5,
|
||||
}
|
||||
|
||||
impl ProtocolVersion {
|
||||
pub const ALL: [Self; 2] = [Self::V5, Self::V311];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::V311 => "MQTT 3.1.1",
|
||||
Self::V5 => "MQTT 5.0",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_v5(self) -> bool {
|
||||
matches!(self, Self::V5)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 传输方式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum TransportKind {
|
||||
/// 明文 TCP,默认 1883
|
||||
#[default]
|
||||
Tcp,
|
||||
/// TLS over TCP,默认 8883
|
||||
Tls,
|
||||
/// WebSocket (ws://),默认 8083
|
||||
Ws,
|
||||
/// WebSocket over TLS (wss://),默认 8084
|
||||
Wss,
|
||||
}
|
||||
|
||||
impl TransportKind {
|
||||
pub const ALL: [Self; 4] = [Self::Tcp, Self::Tls, Self::Ws, Self::Wss];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Tcp => "TCP",
|
||||
Self::Tls => "TLS",
|
||||
Self::Ws => "WebSocket",
|
||||
Self::Wss => "WSS",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_port(self) -> u16 {
|
||||
match self {
|
||||
Self::Tcp => 1883,
|
||||
Self::Tls => 8883,
|
||||
Self::Ws => 8083,
|
||||
Self::Wss => 8084,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_tls(self) -> bool {
|
||||
matches!(self, Self::Tls | Self::Wss)
|
||||
}
|
||||
|
||||
pub fn is_websocket(self) -> bool {
|
||||
matches!(self, Self::Ws | Self::Wss)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// MQTT 5 遗嘱消息(3.1.1 下忽略 v5 专属属性)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LastWill {
|
||||
pub topic: String,
|
||||
pub payload: String,
|
||||
pub qos: u8,
|
||||
pub retain: bool,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub response_topic: Option<String>,
|
||||
}
|
||||
|
||||
/// SSL/TLS 自定义配置(CA、双向认证、跳过校验)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SslConfig {
|
||||
/// 自定义 CA 文件路径(PEM),作为系统根证书的补充
|
||||
#[serde(default)]
|
||||
pub ca_file: String,
|
||||
/// 客户端证书(PEM),双向认证用
|
||||
#[serde(default)]
|
||||
pub client_cert_file: String,
|
||||
/// 客户端私钥(PEM),与客户端证书配套
|
||||
#[serde(default)]
|
||||
pub client_key_file: String,
|
||||
/// 跳过服务器证书校验(insecure),仅调试环境使用
|
||||
#[serde(default)]
|
||||
pub ignore_ca: bool,
|
||||
}
|
||||
|
||||
impl SslConfig {
|
||||
/// 是否完全未配置(走 rumqttc 默认 TLS 即可)。
|
||||
pub fn is_default(&self) -> bool {
|
||||
!self.ignore_ca
|
||||
&& self.ca_file.is_empty()
|
||||
&& self.client_cert_file.is_empty()
|
||||
&& self.client_key_file.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// Debug 手写而非 derive:`{:?}` 一次格式化就会把 `password` 送进 stderr 与 Sentry
|
||||
//(panic 钩子会连同载荷一起上报),见文件底部的实现。
|
||||
#[derive(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 auto_connect: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub last_will: Option<LastWill>,
|
||||
|
||||
/// 创建时间(unix 秒),用于排序
|
||||
#[serde(default)]
|
||||
pub created_at: i64,
|
||||
|
||||
/// 连接分组(对标 MQTTX folder)
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
/// 连接超时(秒),覆盖建连与 CONNACK 等待
|
||||
#[serde(default = "default_connection_timeout")]
|
||||
pub connection_timeout_secs: u16,
|
||||
/// 最大重连次数,0 = 无限重连
|
||||
#[serde(default)]
|
||||
pub max_reconnect_times: u32,
|
||||
/// SSL/TLS 自定义配置
|
||||
#[serde(default)]
|
||||
pub ssl: SslConfig,
|
||||
}
|
||||
|
||||
pub(super) fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
pub(super) fn default_keep_alive() -> u16 {
|
||||
60
|
||||
}
|
||||
pub(super) fn default_connection_timeout() -> u16 {
|
||||
10
|
||||
}
|
||||
|
||||
/// 生成随机 Client ID(新建连接与表单「重新生成」共用同一逻辑)。
|
||||
pub fn generate_client_id() -> String {
|
||||
format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8])
|
||||
}
|
||||
|
||||
impl ConnectionConfig {
|
||||
pub fn new() -> Self {
|
||||
let transport = TransportKind::default();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "新建连接".into(),
|
||||
host: "broker.emqx.io".into(),
|
||||
port: transport.default_port(),
|
||||
path: "/mqtt".into(),
|
||||
protocol: ProtocolVersion::V5,
|
||||
transport,
|
||||
client_id: generate_client_id(),
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
clean_start: true,
|
||||
keep_alive: 60,
|
||||
session_expiry_interval: 0,
|
||||
receive_maximum: None,
|
||||
maximum_packet_size: None,
|
||||
topic_alias_maximum: None,
|
||||
auto_resubscribe: true,
|
||||
auto_reconnect: true,
|
||||
auto_connect: false,
|
||||
last_will: None,
|
||||
created_at: chrono::Local::now().timestamp(),
|
||||
group: None,
|
||||
connection_timeout_secs: default_connection_timeout(),
|
||||
max_reconnect_times: 0,
|
||||
ssl: SslConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// broker.emqx.io 等公共服务器的快速预设。
|
||||
pub fn with_public(name: &str, host: &str, port: u16, transport: TransportKind) -> Self {
|
||||
let mut c = Self::new();
|
||||
c.name = name.into();
|
||||
c.host = host.into();
|
||||
c.port = port;
|
||||
c.transport = transport;
|
||||
c
|
||||
}
|
||||
|
||||
pub fn display_address(&self) -> String {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
|
||||
/// 与另一份配置相比,影响 broker 会话建立的关键参数是否变化。
|
||||
/// 这些字段改动只有在重新建立连接后才会生效,用于提示用户重连。
|
||||
/// WS 路径、遗嘱、TLS 配置、keep_alive 随 CONNECT 报文一次性生效;
|
||||
/// MQTT 5 的四个 CONNECT 属性(会话过期/接收上限/最大报文/主题别名)同样如此,
|
||||
/// 全部纳入比较——漏掉的字段会让"重连后生效"提示静默失效。
|
||||
pub fn session_params_changed(&self, other: &ConnectionConfig) -> bool {
|
||||
self.host != other.host
|
||||
|| self.port != other.port
|
||||
|| self.path != other.path
|
||||
|| self.transport != other.transport
|
||||
|| self.protocol != other.protocol
|
||||
|| self.client_id != other.client_id
|
||||
|| self.username != other.username
|
||||
|| self.password != other.password
|
||||
|| self.clean_start != other.clean_start
|
||||
|| self.keep_alive != other.keep_alive
|
||||
|| self.last_will != other.last_will
|
||||
|| self.ssl != other.ssl
|
||||
|| self.session_expiry_interval != other.session_expiry_interval
|
||||
|| self.receive_maximum != other.receive_maximum
|
||||
|| self.maximum_packet_size != other.maximum_packet_size
|
||||
|| self.topic_alias_maximum != other.topic_alias_maximum
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConnectionConfig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 脱敏 Debug:`password` 以 `<redacted>` 输出。
|
||||
/// 该结构在日志/panic 场景下可能被整体格式化(`{:?}`),derive 出的 Debug 会把
|
||||
/// 明文密码带进 stderr 与 Sentry(main.rs 的 panic 钩子会上报),因此必须手写。
|
||||
impl std::fmt::Debug for ConnectionConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ConnectionConfig")
|
||||
.field("id", &self.id)
|
||||
.field("name", &self.name)
|
||||
.field("host", &self.host)
|
||||
.field("port", &self.port)
|
||||
.field("path", &self.path)
|
||||
.field("protocol", &self.protocol)
|
||||
.field("transport", &self.transport)
|
||||
.field("client_id", &self.client_id)
|
||||
.field("username", &self.username)
|
||||
.field("password", &"<redacted>")
|
||||
.field("clean_start", &self.clean_start)
|
||||
.field("keep_alive", &self.keep_alive)
|
||||
.field("auto_resubscribe", &self.auto_resubscribe)
|
||||
.field("auto_reconnect", &self.auto_reconnect)
|
||||
.field("auto_connect", &self.auto_connect)
|
||||
.field("has_last_will", &self.last_will.is_some())
|
||||
.field("group", &self.group)
|
||||
.field("ssl", &"<redacted>")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 订阅 ────────────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,45 @@
|
||||
//! 运行日志:日志级别与日志条目。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LogLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl LogLevel {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Debug => "DEBUG",
|
||||
Self::Info => "INFO",
|
||||
Self::Warn => "WARN",
|
||||
Self::Error => "ERROR",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
"debug" => Self::Debug,
|
||||
"warn" => Self::Warn,
|
||||
"error" => Self::Error,
|
||||
_ => Self::Info,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: i64,
|
||||
pub connection_id: String,
|
||||
pub level: LogLevel,
|
||||
pub event: String,
|
||||
pub message: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
// ─── 连接状态 ────────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,290 @@
|
||||
//! 消息:方向、载荷格式、消息记录与条数/字节双预算的环形缓冲。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Direction {
|
||||
Received,
|
||||
Published,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Received => "dir.received",
|
||||
Self::Published => "dir.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| crate::ui::i18n::tf("encode.base64_failed", &[("e", &e.to_string())]))
|
||||
}
|
||||
Self::Hex => {
|
||||
let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if !cleaned.len().is_multiple_of(2) {
|
||||
return Err(crate::ui::i18n::t("encode.hex_odd").into());
|
||||
}
|
||||
// 必须先做全字节校验:长度检查按字节计数,多字节字符(如中文)
|
||||
// 可凑成偶数字节,直接按字节索引切片会落在字符边界内而 panic。
|
||||
if !cleaned.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(crate::ui::i18n::t("encode.hex_invalid").into());
|
||||
}
|
||||
(0..cleaned.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
u8::from_str_radix(&cleaned[i..i + 2], 16)
|
||||
.map_err(|e| crate::ui::i18n::tf("encode.hex_failed", &[("e", &e.to_string())]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 一条收到/发出的消息记录。payload 以 UTF-8 文本保存(非文本按 lossy 显示)。
|
||||
///
|
||||
/// 内存优化:`connection_id`/`topic` 用 `Arc<str>`——同一连接内主题高度重复,
|
||||
/// 驻留后数千条记录只保留一份字符串。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MqttRecord {
|
||||
pub seq: u64,
|
||||
pub connection_id: Arc<str>,
|
||||
pub direction: Direction,
|
||||
pub topic: Arc<str>,
|
||||
/// Arc 共享:消息列表渲染每帧克隆最多数百条记录,零拷贝
|
||||
pub payload: Arc<str>,
|
||||
pub qos: u8,
|
||||
pub retain: bool,
|
||||
/// unix 毫秒
|
||||
pub timestamp: i64,
|
||||
/// Arc 共享:消息列表每帧克隆数百条记录,Vec<(String,String)> 的深拷贝是
|
||||
/// 每帧堆分配的主要来源,改为 Arc 后 clone 只增加引用计数
|
||||
pub user_properties: Arc<[(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>,
|
||||
/// payload 超过 [`MAX_PAYLOAD_RETAIN`] 被截断
|
||||
pub payload_truncated: bool,
|
||||
/// 非文本负载的原始字节(合法 UTF-8 时为 None)。文本化会失真
|
||||
/// (非法字节被替换为 U+FFFD),详情面板的 Hex/Base64 需要真实报文。
|
||||
pub raw_bytes: Option<Arc<[u8]>>,
|
||||
/// 折叠态预览文本(JSON 美化/压平 + 截断),入环形缓冲时计算一次。
|
||||
/// 渲染热路径每帧对最多数百条可见行直接取用,避免逐帧重复 JSON 解析。
|
||||
pub preview: Arc<str>,
|
||||
}
|
||||
|
||||
impl MqttRecord {
|
||||
/// 该记录保留的主要字节数(文本 + 原始字节),用于内存预算驱逐。
|
||||
/// 其余字段(主题/属性等)相对小一个数量级,不计入。
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
self.payload.len() + self.raw_bytes.as_ref().map_or(0, |b| b.len())
|
||||
}
|
||||
|
||||
/// 入环形缓冲前调用:计算并填充折叠态预览(每条消息仅此一次)。
|
||||
pub fn compute_preview(&mut self) {
|
||||
self.preview = Arc::from(compute_preview(&self.payload));
|
||||
}
|
||||
}
|
||||
|
||||
/// 折叠态预览行数与字符上限:JSON 美化后多行完整展示,超大负载兜底截断
|
||||
const PREVIEW_MAX_LINES: usize = 40;
|
||||
const PREVIEW_MAX_CHARS: usize = 4000;
|
||||
|
||||
/// 计算折叠态预览:JSON 负载按原始数据美化展示(缩进多行),非 JSON
|
||||
/// 压平换行/制表符截 200 字符;含替换字符(U+FFFD)提示切 Hex。
|
||||
pub fn compute_preview(payload: &str) -> String {
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(payload).ok();
|
||||
let mut out = match &parsed {
|
||||
Some(value) => {
|
||||
let pretty = serde_json::to_string_pretty(value).unwrap_or_default();
|
||||
truncate_preview(pretty)
|
||||
}
|
||||
None => {
|
||||
let flat: String = payload
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'\n' | '\r' | '\t' => ' ',
|
||||
_ => c,
|
||||
})
|
||||
.collect();
|
||||
let mut s: String = flat.chars().take(200).collect();
|
||||
if flat.chars().count() > 200 {
|
||||
s.push('…');
|
||||
}
|
||||
s
|
||||
}
|
||||
};
|
||||
if payload.contains('\u{FFFD}') {
|
||||
out.push_str(crate::ui::i18n::t("msg.binary_hint"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 截断预览文本:最多 [`PREVIEW_MAX_LINES`] 行、[`PREVIEW_MAX_CHARS`] 字符。
|
||||
fn truncate_preview(text: String) -> String {
|
||||
let mut lines: Vec<&str> = text.lines().take(PREVIEW_MAX_LINES).collect();
|
||||
let more = text.lines().count() > PREVIEW_MAX_LINES;
|
||||
if more {
|
||||
lines.push("…");
|
||||
}
|
||||
let mut out = lines.join("\n");
|
||||
if out.chars().count() > PREVIEW_MAX_CHARS {
|
||||
out = out.chars().take(PREVIEW_MAX_CHARS).collect::<String>() + "…";
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 单连接的消息环形缓冲:条数上限 + [`MAX_CONNECTION_MESSAGE_BYTES`]
|
||||
/// 字节上限双重驱逐(从最旧开始,均摊 O(1))。
|
||||
pub struct MessageRing {
|
||||
queue: VecDeque<MqttRecord>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl MessageRing {
|
||||
pub fn new(max_count: usize) -> Self {
|
||||
Self {
|
||||
// 预分配按较小值钳制:设置上限十万条时不一次性分配过大
|
||||
queue: VecDeque::with_capacity(max_count.clamp(16, 512)),
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `max_count` 每次传入以便运行时调整设置后即时生效。
|
||||
/// 设置层已校验 ≥100,这里只防 0 值导致缓冲被清空。
|
||||
pub fn push(&mut self, record: MqttRecord, max_count: usize) {
|
||||
let max_count = max_count.max(1);
|
||||
self.bytes += record.retained_bytes();
|
||||
while self.queue.len() >= max_count
|
||||
|| (self.bytes > MAX_CONNECTION_MESSAGE_BYTES && self.queue.len() > 1)
|
||||
{
|
||||
let Some(old) = self.queue.pop_front() else { break };
|
||||
self.bytes -= old.retained_bytes();
|
||||
}
|
||||
self.queue.push_back(record);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.queue.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.queue.is_empty()
|
||||
}
|
||||
|
||||
/// 当前保留的字节数(payload + 原始字节)。
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
self.bytes
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> std::collections::vec_deque::Iter<'_, MqttRecord> {
|
||||
self.queue.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息历史中单条 payload 的保留上限。超限截断,避免个别大报文
|
||||
/// 长期占据历史缓冲(MQTTX 亦有类似限制)。
|
||||
pub const MAX_PAYLOAD_RETAIN: usize = 128 * 1024;
|
||||
|
||||
/// 每连接消息历史的字节预算(payload + 原始字节)。条数上限
|
||||
/// (max_messages)在大报文流下形同虚设(2000 条 × 128KB ≈ 256MB),
|
||||
/// 超出预算后从最旧开始驱逐,保证单连接消息内存有上界。
|
||||
pub const MAX_CONNECTION_MESSAGE_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// 把原始 payload 字节转成用于历史记录的共享文本,超限截断。
|
||||
/// 返回 (文本, 非文本时的原始字节, 是否截断)。截断时回退到 UTF-8 字符边界。
|
||||
///
|
||||
/// 仅当文本化会失真(非合法 UTF-8,字节被替换为 U+FFFD)时才保留原始
|
||||
/// 字节(同样受 [`MAX_PAYLOAD_RETAIN`] 限制);纯文本负载零额外开销。
|
||||
pub fn retained_payload(raw: &[u8]) -> (Arc<str>, Option<Arc<[u8]>>, bool) {
|
||||
let truncated = raw.len() > MAX_PAYLOAD_RETAIN;
|
||||
let text_part = if truncated {
|
||||
let mut end = MAX_PAYLOAD_RETAIN;
|
||||
while end > 0 && (raw[end] & 0xC0) == 0x80 {
|
||||
end -= 1;
|
||||
}
|
||||
&raw[..end]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let raw_bytes = match std::str::from_utf8(text_part) {
|
||||
Ok(_) => None,
|
||||
Err(_) => Some(Arc::from(text_part.to_vec())),
|
||||
};
|
||||
let text: Arc<str> = Arc::from(String::from_utf8_lossy(text_part).as_ref());
|
||||
(text, raw_bytes, truncated)
|
||||
}
|
||||
|
||||
// ─── 发布参数 / 发布预设 ──────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod preview_tests {
|
||||
use super::compute_preview;
|
||||
|
||||
#[test]
|
||||
fn preview_json_is_pretty_and_complete_for_typical_payloads() {
|
||||
let payload = r#"{"type":"heartbeat","ts":1790751279869,"data":{"version":"0.1.0","lon":117.229,"lat":31.8206,"uptime":1716450,"rssi":-52}}"#;
|
||||
let out = compute_preview(payload);
|
||||
// JSON 被美化(多行缩进)且常规负载完整展示、不带任何加工注释
|
||||
assert!(out.contains('\n'), "JSON 应多行展示: {out}");
|
||||
assert!(out.contains("\"rssi\": -52"), "常规负载应完整显示: {out}");
|
||||
assert!(!out.contains("//"), "预览不应附加注释: {out}");
|
||||
assert!(!out.contains('…'), "常规负载不应截断: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_truncates_very_deep_json() {
|
||||
// 50 个元素的数组美化后超过 40 行,应截断并带省略号
|
||||
let items: Vec<String> = (0..50).map(|i| i.to_string()).collect();
|
||||
let payload = format!("{{\"a\":[{}]}}", items.join(","));
|
||||
let deep = compute_preview(&payload);
|
||||
assert!(deep.lines().count() <= 41, "超行应截断: {deep}");
|
||||
assert!(deep.contains('…'), "截断应有省略号: {deep}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_non_json_still_flat() {
|
||||
assert_eq!(compute_preview("hello\nworld"), "hello world");
|
||||
assert_eq!(compute_preview("纯文本"), "纯文本");
|
||||
assert!(compute_preview("bad\ntext\u{FFFD}").contains("非文本"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//! 应用数据模型:按领域拆分的纯数据层(不依赖 GPUI)。
|
||||
//!
|
||||
//! - [`connection`] 连接配置与协议/传输枚举
|
||||
//! - [`subscription`] 订阅与订阅选项
|
||||
//! - [`message`] 消息记录与环形缓冲
|
||||
//! - [`publish`] 发布参数与发布预设
|
||||
//! - [`variable`] 全局变量与模板渲染
|
||||
//! - [`log`] 运行日志
|
||||
//! - [`settings`] 应用设置与主题偏好
|
||||
|
||||
mod connection;
|
||||
mod log;
|
||||
mod message;
|
||||
mod publish;
|
||||
mod settings;
|
||||
mod subscription;
|
||||
mod variable;
|
||||
|
||||
pub use connection::*;
|
||||
pub use log::*;
|
||||
pub use message::*;
|
||||
pub use publish::*;
|
||||
pub use settings::*;
|
||||
pub use subscription::*;
|
||||
pub use variable::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
// 修复回归:Hex 奇数长度必须报错,不能静默截断
|
||||
#[test]
|
||||
fn hex_odd_length_is_rejected() {
|
||||
let err = PayloadFormat::Hex.encode("abc").unwrap_err();
|
||||
assert!(err.contains("偶数"), "应提示偶数长度,实际: {err}");
|
||||
assert!(PayloadFormat::Hex.encode("").is_ok());
|
||||
assert_eq!(
|
||||
PayloadFormat::Hex.encode("68 69").unwrap(),
|
||||
vec![0x68, 0x69],
|
||||
"空白应被忽略"
|
||||
);
|
||||
assert!(PayloadFormat::Hex.encode("zz").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_encode_roundtrip() {
|
||||
use base64::Engine as _;
|
||||
let text = "hello 世界";
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
|
||||
assert_eq!(PayloadFormat::Base64.encode(&encoded).unwrap(), text.as_bytes());
|
||||
assert!(PayloadFormat::Base64.encode("not base64!!!").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_and_json_encode_as_utf8() {
|
||||
assert_eq!(
|
||||
PayloadFormat::Plaintext.encode("中文").unwrap(),
|
||||
"中文".as_bytes()
|
||||
);
|
||||
assert_eq!(
|
||||
PayloadFormat::Json.encode(r#"{"a":1}"#).unwrap(),
|
||||
r#"{"a":1}"#.as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
// 修复回归:raw_bytes 是运行时字段,不得写入持久化 JSON
|
||||
#[test]
|
||||
fn raw_bytes_not_serialized() {
|
||||
let p = PublishParams {
|
||||
topic: "t".into(),
|
||||
payload: "hi".into(),
|
||||
raw_bytes: Some(vec![0x00, 0xff]),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(!json.contains("raw_bytes"), "raw_bytes 不应被序列化: {json}");
|
||||
let back: PublishParams = serde_json::from_str(&json).unwrap();
|
||||
assert!(back.raw_bytes.is_none());
|
||||
// 反序列化时输入中的 raw_bytes 字段应被忽略(skip 双向生效)
|
||||
let mut with_junk = serde_json::to_string(&PublishParams::default()).unwrap();
|
||||
with_junk.pop(); // 去掉结尾 '}'
|
||||
with_junk.push_str(r#", "raw_bytes":[1,2]}"#);
|
||||
let back: PublishParams = serde_json::from_str(&with_junk).unwrap();
|
||||
assert!(back.raw_bytes.is_none(), "#[serde(skip)] 应忽略输入字段");
|
||||
}
|
||||
|
||||
// 修复回归:同一条消息内 $ts 与 $ts_ms 基于同一时刻
|
||||
#[test]
|
||||
fn template_timestamps_share_one_timebase() {
|
||||
let out = render_template("s={{$ts}} ms={{$ts_ms}}", &[]);
|
||||
let (s_part, ms_part) = out.split_once(" ms=").expect("格式 s=.. ms=..");
|
||||
let s: i64 = s_part
|
||||
.strip_prefix("s=")
|
||||
.expect("秒字段")
|
||||
.parse()
|
||||
.unwrap();
|
||||
let ms: i64 = ms_part.parse().expect("毫秒字段");
|
||||
assert_eq!(s, ms / 1000, "秒与毫秒应来自同一时间基准: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_unknown_placeholder_kept_and_custom_var_resolved() {
|
||||
assert_eq!(render_template("{{$unknown}}", &[]), "{{$unknown}}");
|
||||
let vars = vec![GlobalVariable {
|
||||
key: "device".into(),
|
||||
value: "sensor-1".into(),
|
||||
}];
|
||||
assert!(render_template("d={{device}}/{{$uuid}}", &vars).starts_with("d=sensor-1/"));
|
||||
// 多字节字符不被拆坏
|
||||
assert_eq!(render_template("中文{{$no}}", &[]), "中文{{$no}}");
|
||||
}
|
||||
|
||||
// 连接前遗嘱模板注入:只改副本、空遗嘱不 panic、v5 属性一并渲染
|
||||
#[test]
|
||||
fn render_will_templates_injects_and_keeps_no_will_safe() {
|
||||
let vars = vec![GlobalVariable {
|
||||
key: "device".into(),
|
||||
value: "sensor-9".into(),
|
||||
}];
|
||||
// 无遗嘱:直接返回
|
||||
let mut plain = ConnectionConfig::new();
|
||||
render_will_templates(&mut plain, &vars);
|
||||
assert!(plain.last_will.is_none());
|
||||
|
||||
let mut cfg = ConnectionConfig::new();
|
||||
cfg.last_will = Some(LastWill {
|
||||
topic: "alarm/{{device}}".into(),
|
||||
payload: "d={{device}}".into(),
|
||||
qos: 1,
|
||||
retain: false,
|
||||
content_type: Some("text/{{device}}".into()),
|
||||
response_topic: Some("ack/{{device}}".into()),
|
||||
});
|
||||
render_will_templates(&mut cfg, &vars);
|
||||
let w = cfg.last_will.as_ref().unwrap();
|
||||
assert_eq!(w.topic, "alarm/sensor-9");
|
||||
assert_eq!(w.payload, "d=sensor-9");
|
||||
assert_eq!(w.content_type.as_deref(), Some("text/sensor-9"));
|
||||
assert_eq!(w.response_topic.as_deref(), Some("ack/sensor-9"));
|
||||
// 原始模板串不因注入而需要回写:连接配置持久化的是模板原文,
|
||||
// 这里只验证副本注入结果;未知占位符保留
|
||||
let mut raw = ConnectionConfig::new();
|
||||
raw.last_will = Some(LastWill {
|
||||
topic: "t/{{unknown}}".into(),
|
||||
..Default::default()
|
||||
});
|
||||
render_will_templates(&mut raw, &vars);
|
||||
assert_eq!(raw.last_will.as_ref().unwrap().topic, "t/{{unknown}}");
|
||||
}
|
||||
|
||||
// 预设持久化往返:raw_bytes 不落盘,参数完整保留
|
||||
#[test]
|
||||
fn publish_preset_roundtrip_skips_raw_bytes() {
|
||||
let preset = PublishPreset {
|
||||
id: "p1".into(),
|
||||
name: "报警".into(),
|
||||
connection_id: Some("c1".into()),
|
||||
params: PublishParams {
|
||||
topic: "alarm/{{device}}".into(),
|
||||
payload: "6869".into(),
|
||||
payload_format: PayloadFormat::Hex,
|
||||
qos: 1,
|
||||
retain: true,
|
||||
content_type: Some("application/json".into()),
|
||||
user_properties: vec![("k".into(), "v".into())],
|
||||
raw_bytes: Some(vec![0x68, 0x69]),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&preset).unwrap();
|
||||
let back: PublishPreset = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.name, "报警");
|
||||
assert_eq!(back.params.topic, "alarm/{{device}}");
|
||||
assert_eq!(back.params.payload_format, PayloadFormat::Hex);
|
||||
assert_eq!(back.params.qos, 1);
|
||||
assert!(back.params.retain);
|
||||
assert_eq!(back.params.content_type.as_deref(), Some("application/json"));
|
||||
assert_eq!(back.params.user_properties.len(), 1);
|
||||
assert!(back.params.raw_bytes.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_format_equality_and_default() {
|
||||
assert_eq!(PayloadFormat::default(), PayloadFormat::Plaintext);
|
||||
assert_ne!(PayloadFormat::Hex, PayloadFormat::Base64);
|
||||
let json = serde_json::to_string(&PayloadFormat::Hex).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<PayloadFormat>(&json).unwrap(),
|
||||
PayloadFormat::Hex
|
||||
);
|
||||
}
|
||||
|
||||
// 向后兼容:旧版 connections.json 不含 Phase 1 新字段,必须能反序列化且取默认值
|
||||
#[test]
|
||||
fn legacy_connection_json_fills_new_defaults() {
|
||||
let legacy = r#"{
|
||||
"id": "c1",
|
||||
"name": "旧连接",
|
||||
"host": "broker.emqx.io",
|
||||
"port": 1883,
|
||||
"client_id": "legacy_cid",
|
||||
"keep_alive": 30
|
||||
}"#;
|
||||
let c: ConnectionConfig = serde_json::from_str(legacy).expect("旧版 JSON 应能反序列化");
|
||||
assert_eq!(c.name, "旧连接");
|
||||
assert_eq!(c.keep_alive, 30);
|
||||
assert!(c.group.is_none(), "group 默认应为 None");
|
||||
assert_eq!(c.connection_timeout_secs, 10, "连接超时默认 10 秒");
|
||||
assert_eq!(c.max_reconnect_times, 0, "最大重连次数默认 0(无限)");
|
||||
assert!(c.ssl.ca_file.is_empty());
|
||||
assert!(c.ssl.client_cert_file.is_empty());
|
||||
assert!(c.ssl.client_key_file.is_empty());
|
||||
assert!(!c.ssl.ignore_ca, "ignore_ca 默认关闭");
|
||||
}
|
||||
|
||||
// 向后兼容:旧版订阅 JSON 必须默认 enabled=true,其余 v5 选项为关闭/None
|
||||
#[test]
|
||||
fn legacy_subscription_json_defaults_enabled() {
|
||||
let legacy = r#"{"id":"s1","connection_id":"c1","topic":"a/b","qos":1}"#;
|
||||
let s: Subscription = serde_json::from_str(legacy).expect("旧版订阅应能反序列化");
|
||||
assert!(s.enabled, "旧订阅默认启用");
|
||||
assert!(s.alias.is_none());
|
||||
assert!(s.sub_identifier.is_none());
|
||||
assert!(!s.no_local);
|
||||
assert!(!s.retain_as_published);
|
||||
assert_eq!(s.retain_handling, 0);
|
||||
// Subscription::new 同样产出默认启用的订阅
|
||||
assert!(Subscription::new("c1", "t", 0).enabled);
|
||||
}
|
||||
|
||||
// payload 截断:上限内不截断;超出时截断且不切坏 UTF-8 字符
|
||||
#[test]
|
||||
fn retained_payload_caps_and_preserves_utf8() {
|
||||
let small = b"hello";
|
||||
let (p, raw, trunc) = retained_payload(small);
|
||||
assert_eq!(&*p, "hello");
|
||||
assert!(raw.is_none(), "合法 UTF-8 不保留原始字节");
|
||||
assert!(!trunc);
|
||||
|
||||
// 中文每字符 3 字节,让上限落在字符中间(256K 不是 3 的倍数)
|
||||
let big: Vec<u8> = "中".repeat(MAX_PAYLOAD_RETAIN).into_bytes();
|
||||
let (p, _, trunc) = retained_payload(&big);
|
||||
assert!(trunc, "超过上限必须截断");
|
||||
assert!(p.len() <= MAX_PAYLOAD_RETAIN);
|
||||
assert_eq!(p.chars().last(), Some('中'), "不能切坏 UTF-8 字符");
|
||||
assert!(
|
||||
big.starts_with(p.as_bytes()),
|
||||
"截断内容必须是原数据前缀"
|
||||
);
|
||||
|
||||
// 恰好上限不截断
|
||||
let exact = vec![b'a'; MAX_PAYLOAD_RETAIN];
|
||||
let (_, raw, trunc) = retained_payload(&exact);
|
||||
assert!(!trunc);
|
||||
assert!(raw.is_none());
|
||||
}
|
||||
|
||||
// 非文本负载:保留原始字节供 Hex/Base64 详情,文本侧 lossy 展示
|
||||
#[test]
|
||||
fn retained_payload_keeps_raw_bytes_for_binary() {
|
||||
let binary = vec![0x00, 0xff, 0xfe, 0x01];
|
||||
let (p, raw, trunc) = retained_payload(&binary);
|
||||
assert!(!trunc);
|
||||
assert_eq!(&*p, String::from_utf8_lossy(&binary));
|
||||
let raw = raw.expect("非法 UTF-8 必须保留原始字节");
|
||||
assert_eq!(&*raw, &binary);
|
||||
}
|
||||
|
||||
// Hex 编码:多字节字符不得 panic(曾按字节切片落入字符边界内而崩溃)
|
||||
#[test]
|
||||
fn hex_encode_rejects_non_hex_without_panic() {
|
||||
assert!(PayloadFormat::Hex.encode("deadBEEF").is_ok());
|
||||
assert!(PayloadFormat::Hex.encode("de ad be ef").is_ok());
|
||||
assert!(PayloadFormat::Hex.encode("abc").is_err(), "奇数长度报错");
|
||||
assert!(PayloadFormat::Hex.encode("zz").is_err(), "非法字符报错");
|
||||
// 偶数字节长度的多字节字符:修复前此处在字符边界内切片 panic
|
||||
assert!(PayloadFormat::Hex.encode("中文").is_err());
|
||||
assert!(PayloadFormat::Hex.encode("😀").is_err());
|
||||
}
|
||||
|
||||
// 会话参数比较必须覆盖所有随 CONNECT 报文一次性生效的字段
|
||||
#[test]
|
||||
fn session_params_changed_covers_connect_time_fields() {
|
||||
let a = ConnectionConfig::default();
|
||||
let mut b = a.clone();
|
||||
assert!(!a.session_params_changed(&b));
|
||||
|
||||
b.keep_alive = a.keep_alive + 1;
|
||||
assert!(a.session_params_changed(&b), "keep_alive 改动应触发重连提示");
|
||||
b = a.clone();
|
||||
|
||||
b.last_will = Some(LastWill {
|
||||
topic: "w/t".into(),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(a.session_params_changed(&b), "遗嘱改动应触发重连提示");
|
||||
b = a.clone();
|
||||
|
||||
b.ssl.ca_file = "/tmp/ca.pem".into();
|
||||
assert!(a.session_params_changed(&b), "TLS 配置改动应触发重连提示");
|
||||
|
||||
// MQTT 5 的 CONNECT 属性同样随报文一次性生效(此前遗漏,导致
|
||||
// "配置已保存,重连后生效"的提示静默失效)
|
||||
b = a.clone();
|
||||
b.session_expiry_interval = a.session_expiry_interval + 1;
|
||||
assert!(a.session_params_changed(&b), "会话过期间隔改动应触发重连提示");
|
||||
b = a.clone();
|
||||
b.receive_maximum = Some(10);
|
||||
assert!(a.session_params_changed(&b), "接收上限改动应触发重连提示");
|
||||
b = a.clone();
|
||||
b.maximum_packet_size = Some(1024);
|
||||
assert!(a.session_params_changed(&b), "最大报文长度改动应触发重连提示");
|
||||
b = a.clone();
|
||||
b.topic_alias_maximum = Some(5);
|
||||
assert!(a.session_params_changed(&b), "主题别名上限改动应触发重连提示");
|
||||
}
|
||||
|
||||
fn ring_record(seq: u64, payload_len: usize, raw_len: usize) -> MqttRecord {
|
||||
MqttRecord {
|
||||
seq,
|
||||
connection_id: "c".into(),
|
||||
direction: Direction::Received,
|
||||
topic: "t".into(),
|
||||
payload: Arc::from("a".repeat(payload_len)),
|
||||
qos: 0,
|
||||
retain: false,
|
||||
timestamp: 0,
|
||||
user_properties: Default::default(),
|
||||
content_type: None,
|
||||
response_topic: None,
|
||||
correlation_data: None,
|
||||
message_expiry_interval: None,
|
||||
subscription_identifier: None,
|
||||
payload_truncated: false,
|
||||
raw_bytes: if raw_len > 0 {
|
||||
Some(Arc::from(vec![0u8; raw_len]))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
preview: Arc::from(""),
|
||||
}
|
||||
}
|
||||
|
||||
// 消息环形缓冲:条数上限正常驱逐
|
||||
#[test]
|
||||
fn message_ring_enforces_count_limit() {
|
||||
let mut ring = MessageRing::new(4);
|
||||
for i in 0..10 {
|
||||
ring.push(ring_record(i, 64, 0), 4);
|
||||
}
|
||||
assert_eq!(ring.len(), 4);
|
||||
assert_eq!(
|
||||
ring.iter().next().map(|r| r.seq),
|
||||
Some(6),
|
||||
"应从最旧开始驱逐"
|
||||
);
|
||||
assert_eq!(ring.retained_bytes(), 4 * 64);
|
||||
}
|
||||
|
||||
// 消息环形缓冲:大报文流下字节预算优先于条数上限,内存有上界
|
||||
#[test]
|
||||
fn message_ring_enforces_byte_budget() {
|
||||
let mut ring = MessageRing::new(2000);
|
||||
// 每条 256KB(payload 128KB + raw 128KB):2000 条本会到 512MB
|
||||
let per = MAX_PAYLOAD_RETAIN;
|
||||
for i in 0..2000 {
|
||||
ring.push(ring_record(i, per, per), 2000);
|
||||
}
|
||||
assert!(
|
||||
ring.retained_bytes() <= MAX_CONNECTION_MESSAGE_BYTES + 2 * per,
|
||||
"字节数 {} 超出预算 {}",
|
||||
ring.retained_bytes(),
|
||||
MAX_CONNECTION_MESSAGE_BYTES
|
||||
);
|
||||
assert!(
|
||||
ring.len() < 2000,
|
||||
"大报文流不应保留满额条数(实际 {} 条)",
|
||||
ring.len()
|
||||
);
|
||||
assert_eq!(ring.iter().next().map(|r| r.seq), Some((2000 - ring.len()) as u64));
|
||||
}
|
||||
|
||||
// 单条超预算的记录也至少保留最新一条(列表不能被清空)
|
||||
#[test]
|
||||
fn message_ring_never_empties_on_oversized_record() {
|
||||
let mut ring = MessageRing::new(10);
|
||||
ring.push(ring_record(0, MAX_PAYLOAD_RETAIN, MAX_PAYLOAD_RETAIN), 10);
|
||||
assert_eq!(ring.len(), 1);
|
||||
ring.push(ring_record(1, 64, 0), 10);
|
||||
assert_eq!(ring.len(), 2);
|
||||
assert_eq!(ring.iter().next().map(|r| r.seq), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! 发布:发布参数与可保存的发布预设。
|
||||
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PublishParams {
|
||||
pub topic: String,
|
||||
/// 用于界面展示与记录的文本负载。
|
||||
pub payload: String,
|
||||
#[serde(default)]
|
||||
pub payload_format: PayloadFormat,
|
||||
pub qos: u8,
|
||||
pub retain: bool,
|
||||
/// MQTT 5 用户属性
|
||||
#[serde(default)]
|
||||
pub user_properties: Vec<(String, String)>,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 全局变量 ────────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,120 @@
|
||||
//! 应用设置:连接状态枚举、主题偏好与全局设置项。
|
||||
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ConnectionStatus {
|
||||
#[default]
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ConnectionStatus {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Disconnected => "status.disconnected",
|
||||
Self::Connecting => "status.connecting",
|
||||
Self::Connected => "status.connected",
|
||||
Self::Error => "status.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 => "theme.system",
|
||||
Self::Light => "theme.light",
|
||||
Self::Dark => "theme.dark",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 界面语言偏好。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LanguagePref {
|
||||
/// 首次启动按系统语言解析(zh 开头 → 中文,其余 → English)
|
||||
#[default]
|
||||
System,
|
||||
/// 中文
|
||||
Zh,
|
||||
/// English
|
||||
En,
|
||||
}
|
||||
|
||||
impl LanguagePref {
|
||||
pub const ALL: [Self; 3] = [Self::System, Self::Zh, Self::En];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "跟随系统 / System",
|
||||
Self::Zh => "中文",
|
||||
Self::En => "English",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppSettings {
|
||||
#[serde(default)]
|
||||
pub theme: ThemeModePref,
|
||||
/// 界面语言(跟随系统 / 中文 / English)
|
||||
#[serde(default)]
|
||||
pub language: LanguagePref,
|
||||
/// 主题预设名:"linear"(默认,受外观模式控制)或官方捆绑预设名
|
||||
/// (自带明暗,外观模式开关对其无效)
|
||||
#[serde(default = "default_theme_preset")]
|
||||
pub theme_preset: String,
|
||||
/// 每条连接在内存中保留的最大消息条数
|
||||
#[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
|
||||
}
|
||||
|
||||
fn default_theme_preset() -> String {
|
||||
"linear".to_string()
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: ThemeModePref::default(),
|
||||
language: LanguagePref::default(),
|
||||
theme_preset: default_theme_preset(),
|
||||
max_messages: default_max_messages(),
|
||||
show_millis: true,
|
||||
auto_check_update: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 单元测试 ────────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,80 @@
|
||||
//! 订阅:订阅条目与下发引擎的订阅选项。
|
||||
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Subscription {
|
||||
pub id: String,
|
||||
pub connection_id: String,
|
||||
pub topic: String,
|
||||
pub qos: u8,
|
||||
/// 本地备注颜色(与 MQTTX 一致,按订阅给消息着色),0..=359 色相
|
||||
#[serde(default)]
|
||||
pub color: Option<f32>,
|
||||
/// 订阅别名(对标 MQTTX topic alias),仅用于界面展示
|
||||
#[serde(default)]
|
||||
pub alias: Option<String>,
|
||||
/// 是否启用该订阅;禁用后界面置灰、恢复订阅时跳过
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// MQTT 5 订阅标识符
|
||||
#[serde(default)]
|
||||
pub sub_identifier: Option<u32>,
|
||||
/// MQTT 5 No Local (NL)
|
||||
#[serde(default)]
|
||||
pub no_local: bool,
|
||||
/// MQTT 5 Retain As Published (RAP)
|
||||
#[serde(default)]
|
||||
pub retain_as_published: bool,
|
||||
/// MQTT 5 Retain Handling (0/1/2)
|
||||
#[serde(default)]
|
||||
pub retain_handling: u8,
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
pub fn new(connection_id: impl Into<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,
|
||||
alias: None,
|
||||
enabled: true,
|
||||
sub_identifier: None,
|
||||
no_local: false,
|
||||
retain_as_published: false,
|
||||
retain_handling: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MQTT 5 订阅选项(NL/RAP/Retain Handling/订阅标识符);v4 连接会忽略这些选项。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SubscribeOptions {
|
||||
/// 订阅标识符,0 视为未设置
|
||||
#[serde(default)]
|
||||
pub sub_identifier: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub no_local: bool,
|
||||
#[serde(default)]
|
||||
pub retain_as_published: bool,
|
||||
/// 0=每次订阅都发送保留消息,1=仅新订阅发送,2=不发送
|
||||
#[serde(default)]
|
||||
pub retain_handling: u8,
|
||||
}
|
||||
|
||||
impl From<&Subscription> for SubscribeOptions {
|
||||
fn from(sub: &Subscription) -> Self {
|
||||
Self {
|
||||
sub_identifier: sub.sub_identifier,
|
||||
no_local: sub.no_local,
|
||||
retain_as_published: sub.retain_as_published,
|
||||
retain_handling: sub.retain_handling,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! 全局变量:模板占位符渲染(含 {{timestamp}} 时间基一致性)。
|
||||
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GlobalVariable {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// 解析 `{{key}}` 模板:
|
||||
/// - 用户定义的全局变量优先;
|
||||
/// - 内置变量:`{{$ts}}`(unix 秒)、`{{$ts_ms}}`(毫秒)、`{{$uuid}}`;
|
||||
/// - 找不到的占位符保持原样。
|
||||
pub fn render_template(input: &str, vars: &[GlobalVariable]) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let bytes = input.as_bytes();
|
||||
// 同一次渲染内时间基准保持一致,避免一条消息里多个时间变量取值不同。
|
||||
let now_ms = chrono::Local::now().timestamp_millis();
|
||||
let now_s = now_ms / 1000;
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if i + 1 < bytes.len() && bytes[i] == b'{' && bytes[i + 1] == b'{'
|
||||
&& let Some(rel_end) = input[i + 2..].find("}}") {
|
||||
let key = &input[i + 2..i + 2 + rel_end];
|
||||
let resolved = match key.trim() {
|
||||
"$ts" => Some(now_s.to_string()),
|
||||
"$ts_ms" => Some(now_ms.to_string()),
|
||||
"$uuid" => Some(uuid::Uuid::new_v4().to_string()),
|
||||
k => vars
|
||||
.iter()
|
||||
.find(|v| v.key == k)
|
||||
.map(|v| v.value.clone()),
|
||||
};
|
||||
match resolved {
|
||||
Some(v) => out.push_str(&v),
|
||||
None => out.push_str(&input[i..i + 2 + rel_end + 2]),
|
||||
}
|
||||
i = i + 2 + rel_end + 2;
|
||||
continue;
|
||||
}
|
||||
// 按字符推进,避免把多字节 UTF-8 拆开
|
||||
let ch = input[i..].chars().next().unwrap();
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 连接前渲染遗嘱消息中的 `{{变量}}`。
|
||||
/// 只作用于本次连接的配置副本,落盘的连接配置保持模板原文,
|
||||
/// 这样改全局变量后下次重连即生效,`$ts` 等也按连接时刻求值。
|
||||
pub fn render_will_templates(cfg: &mut ConnectionConfig, vars: &[GlobalVariable]) {
|
||||
let Some(will) = cfg.last_will.as_mut() else {
|
||||
return;
|
||||
};
|
||||
will.topic = render_template(&will.topic, vars);
|
||||
will.payload = render_template(&will.payload, vars);
|
||||
if let Some(v) = will.content_type.take() {
|
||||
will.content_type = Some(render_template(&v, vars));
|
||||
}
|
||||
if let Some(v) = will.response_topic.take() {
|
||||
will.response_topic = Some(render_template(&v, vars));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 日志 ────────────────────────────────────────────────────────────────────
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,699 @@
|
||||
//! MQTT 引擎:在独立 tokio runtime 上管理多个连接的生命周期,
|
||||
//! 通过 smol 无界通道把状态/消息/日志事件送回 GPUI 线程。
|
||||
//!
|
||||
//! - [`tls`] TLS/rustls 配置与跳过校验
|
||||
//! - [`v5`] MQTT 5.0 客户端构建与事件循环
|
||||
//! - [`v4`] MQTT 3.1.1 客户端构建与事件循环
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use rumqttc::mqttbytes::v5::PublishProperties as V5PublishProperties;
|
||||
use rumqttc::{
|
||||
AsyncClient as V5AsyncClient, EventLoop as V5EventLoop,
|
||||
PublishOptions as V5PublishOptions, RetainForwardRule, SubscribeFilterInput,
|
||||
SubscribeProperties as V5SubscribeProperties,
|
||||
};
|
||||
use rumqttc_v4 as rv4;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::model::{
|
||||
ConnectionConfig, ConnectionStatus, LogEntry, LogLevel, MqttRecord, PublishParams, SubscribeOptions, Subscription,
|
||||
};
|
||||
use crate::ui::i18n;// ─── 引擎事件 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
mod tls;
|
||||
mod v4;
|
||||
mod v5;
|
||||
|
||||
use tls::*;
|
||||
use v4::*;
|
||||
use v5::*;
|
||||
|
||||
pub enum EngineEvent {
|
||||
/// 连接状态变化
|
||||
Status {
|
||||
connection_id: String,
|
||||
status: ConnectionStatus,
|
||||
error: Option<String>,
|
||||
reason_code: Option<u8>,
|
||||
session_present: Option<bool>,
|
||||
},
|
||||
/// 收到消息
|
||||
Message(MqttRecord),
|
||||
/// 一条本地发出的消息(已成功交给客户端)。payload 为实际发送的字节。
|
||||
/// 属性与接收记录对齐,本地发送记录可完整回显。
|
||||
Published {
|
||||
connection_id: String,
|
||||
topic: String,
|
||||
payload: Vec<u8>,
|
||||
qos: u8,
|
||||
retain: bool,
|
||||
content_type: Option<String>,
|
||||
user_properties: Vec<(String, String)>,
|
||||
response_topic: Option<String>,
|
||||
correlation_data: Option<String>,
|
||||
message_expiry_interval: Option<u32>,
|
||||
},
|
||||
/// 订阅请求结果(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 {
|
||||
/// `None` 表示仍在建立中(占位)。建连完成后由 connect 的任务升级为 Some。
|
||||
client: Option<ClientKind>,
|
||||
cancel_tx: watch::Sender<bool>,
|
||||
connected: Arc<AtomicBool>,
|
||||
/// 连接代数:同一 id 的第几次 connect。事件循环退出清理时只在
|
||||
/// map 中仍是自己这一代时才移除,避免误删重连后的新句柄。
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
pub struct MqttEngine {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
conns: Mutex<HashMap<String, ConnHandle>>,
|
||||
/// 下一次 connect 使用的代数
|
||||
next_generation: AtomicU64,
|
||||
/// 主题/连接 ID 驻留缓存:同一连接内主题高度重复,驻留让消息记录共享字符串
|
||||
topics: Mutex<HashMap<String, Arc<str>>>,
|
||||
tx: EventSender,
|
||||
seq: Arc<AtomicU64>,
|
||||
/// 因 UI 队列满而被丢弃的高频事件数(消息/日志),仅在丢弃时递增
|
||||
dropped: 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()),
|
||||
next_generation: AtomicU64::new(1),
|
||||
topics: Mutex::new(HashMap::new()),
|
||||
tx,
|
||||
seq: Arc::new(AtomicU64::new(1)),
|
||||
dropped: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn next_seq(&self) -> u64 {
|
||||
self.seq.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 投递事件到 UI 线程。
|
||||
///
|
||||
/// 通道是**有界**的(容量由 UI 侧创建时决定)。UI 处理不过来时:
|
||||
/// - 高频事件(收到的消息、日志)直接丢弃并累计计数——它们本身就受消息环形缓冲
|
||||
/// 与日志上限约束,丢掉最多是"少看几条",但内存有了上界;
|
||||
/// - 低频且影响正确性的事件(状态变化、订阅结果、发布回执)改为异步补投,
|
||||
/// 既不阻塞引擎事件循环,也不会丢。
|
||||
///
|
||||
/// 修复前用的是无界通道:到达速率持续超过消费速率时队列无界增长,单条消息最长
|
||||
/// 可携带 128KB payload,内存没有上界。
|
||||
fn emit(&self, event: EngineEvent) {
|
||||
let droppable = matches!(event, EngineEvent::Message(_) | EngineEvent::Log(_));
|
||||
match self.tx.try_send(event) {
|
||||
Ok(()) => {}
|
||||
Err(smol::channel::TrySendError::Full(ev)) => {
|
||||
if droppable {
|
||||
let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
// 每丢弃 1000 条提示一次,避免用户误以为"消息不来了"
|
||||
if dropped.is_multiple_of(1000) {
|
||||
let _ = self.tx.try_send(EngineEvent::Log(LogEntry {
|
||||
timestamp: now_ms(),
|
||||
connection_id: String::new(),
|
||||
level: LogLevel::Warn,
|
||||
event: "events_dropped".into(),
|
||||
message: i18n::tf("engine.events_dropped", &[("n", &dropped)]),
|
||||
details: None,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
let tx = self.tx.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let _ = tx.send(ev).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(smol::channel::TrySendError::Closed(_)) => {}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let connected = Arc::new(AtomicBool::new(false));
|
||||
// 同步占位(client=None):建连期间 close() 也能取消本次连接,
|
||||
// 且 is_connected() 在建连完成前保持 false。
|
||||
self.conns.lock().unwrap().insert(
|
||||
id.clone(),
|
||||
ConnHandle {
|
||||
client: None,
|
||||
cancel_tx,
|
||||
connected: connected.clone(),
|
||||
generation,
|
||||
},
|
||||
);
|
||||
|
||||
self.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Connecting,
|
||||
error: None,
|
||||
reason_code: None,
|
||||
session_present: None,
|
||||
});
|
||||
self.log(
|
||||
&id,
|
||||
LogLevel::Info,
|
||||
"connect",
|
||||
i18n::tf(
|
||||
"engine.connecting",
|
||||
&[
|
||||
("host", &cfg.host),
|
||||
("port", &cfg.port),
|
||||
("protocol", &cfg.protocol.label()),
|
||||
("transport", &cfg.transport.label()),
|
||||
],
|
||||
),
|
||||
None,
|
||||
);
|
||||
|
||||
let engine = Arc::clone(self);
|
||||
self.runtime.spawn(async move {
|
||||
let auto_reconnect = cfg.auto_reconnect;
|
||||
let max_reconnect_times = cfg.max_reconnect_times;
|
||||
// build 含证书/CA 文件读取等阻塞 IO,放 spawn_blocking,
|
||||
// 避免慢盘上卡住 2 线程 runtime 里其他连接的调度
|
||||
let build = tokio::task::spawn_blocking(move || {
|
||||
if cfg.protocol.is_v5() {
|
||||
build_v5(&cfg).map(|(client, el)| {
|
||||
(ClientKind::V5(client), EventLoopKind::V5(Box::new(el)))
|
||||
})
|
||||
} else {
|
||||
build_v4(&cfg).map(|(client, el)| {
|
||||
(ClientKind::V4(client), EventLoopKind::V4(Box::new(el)))
|
||||
})
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let build_error = |engine: &Arc<MqttEngine>, id: &str, generation: u64, e: String| {
|
||||
engine.remove_handle_if(id, generation);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.to_string(),
|
||||
status: ConnectionStatus::Error,
|
||||
error: Some(e.clone()),
|
||||
reason_code: None,
|
||||
session_present: None,
|
||||
});
|
||||
engine.log(
|
||||
id,
|
||||
LogLevel::Error,
|
||||
"connect_error",
|
||||
i18n::tf("engine.connect_failed", &[("e", &e)]),
|
||||
None,
|
||||
);
|
||||
};
|
||||
let (client, eventloop) = match build {
|
||||
Ok(Ok(pair)) => pair,
|
||||
Ok(Err(e)) => {
|
||||
build_error(&engine, &id, generation, e);
|
||||
return;
|
||||
}
|
||||
Err(j) => {
|
||||
build_error(
|
||||
&engine,
|
||||
&id,
|
||||
generation,
|
||||
i18n::tf("engine.internal_task_failed", &[("e", &j)]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 建连期间该连接可能已被 close 或更新的 connect 顶掉:
|
||||
// 仅当占位句柄仍是本代时才升级为可用客户端,否则丢弃本次建连。
|
||||
{
|
||||
let mut map = match engine.conns.lock() {
|
||||
Ok(m) => m,
|
||||
Err(_) => return,
|
||||
};
|
||||
match map.get_mut(&id) {
|
||||
Some(h) if h.generation == generation => h.client = Some(client),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
|
||||
match eventloop {
|
||||
EventLoopKind::V5(el) => {
|
||||
spawn_v5_loop(
|
||||
engine.clone(),
|
||||
id,
|
||||
*el,
|
||||
cancel_rx,
|
||||
connected,
|
||||
generation,
|
||||
auto_reconnect,
|
||||
max_reconnect_times,
|
||||
);
|
||||
}
|
||||
EventLoopKind::V4(el) => {
|
||||
spawn_v4_loop(
|
||||
engine.clone(),
|
||||
id,
|
||||
*el,
|
||||
cancel_rx,
|
||||
connected,
|
||||
generation,
|
||||
auto_reconnect,
|
||||
max_reconnect_times,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 主动断开。`notify` 为 true 时发出 Disconnected 状态(用户操作)。
|
||||
pub fn close(&self, id: &str, user_initiated: bool) {
|
||||
let handle = self.conns.lock().unwrap().remove(id);
|
||||
if let Some(h) = handle {
|
||||
let _ = h.cancel_tx.send(true);
|
||||
let connected = h.connected.clone();
|
||||
// 发送 MQTT DISCONNECT 报文后丢弃客户端(建连未完成时无需发送)
|
||||
self.runtime.spawn(async move {
|
||||
match h.client {
|
||||
Some(ClientKind::V5(c)) => {
|
||||
let _ = c.disconnect().await;
|
||||
}
|
||||
Some(ClientKind::V4(c)) => {
|
||||
let _ = c.disconnect().await;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
connected.store(false, Ordering::SeqCst);
|
||||
});
|
||||
if user_initiated {
|
||||
self.log(
|
||||
id,
|
||||
LogLevel::Info,
|
||||
"disconnect",
|
||||
i18n::t("engine.user_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",
|
||||
i18n::t("engine.publish_no_connection").into(),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let payload = params
|
||||
.raw_bytes
|
||||
.clone()
|
||||
.unwrap_or_else(|| params.payload.as_bytes().to_vec());
|
||||
// 实际发送的字节留给 Published 事件(记录需要真实内容)
|
||||
let sent_payload = payload.clone();
|
||||
// v5 与 v4 两个 crate 各自定义 ClientError,统一归一化为 String。
|
||||
let result: Result<(), String> = match &client {
|
||||
ClientKind::V5(client) => {
|
||||
let props = V5PublishProperties {
|
||||
user_properties: params.user_properties.clone(),
|
||||
content_type: params.content_type.clone(),
|
||||
message_expiry_interval: params.message_expiry_interval,
|
||||
response_topic: params.response_topic.clone(),
|
||||
correlation_data: params
|
||||
.correlation_data
|
||||
.clone()
|
||||
.map(|s| s.into_bytes().into()),
|
||||
..Default::default()
|
||||
};
|
||||
let options = V5PublishOptions::new(v5_qos(params.qos))
|
||||
.retain(params.retain)
|
||||
.properties(props);
|
||||
client
|
||||
.publish(params.topic.clone(), payload, options)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
ClientKind::V4(client) => {
|
||||
let options =
|
||||
rv4::PublishOptions::new(v4_qos(params.qos)).retain(params.retain);
|
||||
client
|
||||
.publish(params.topic.clone(), payload, options)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
engine.log(
|
||||
&connection_id,
|
||||
LogLevel::Info,
|
||||
"publish_sent",
|
||||
i18n::tf(
|
||||
"engine.publish_sent",
|
||||
&[
|
||||
("topic", ¶ms.topic),
|
||||
("qos", ¶ms.qos),
|
||||
("retain", ¶ms.retain),
|
||||
],
|
||||
),
|
||||
None,
|
||||
);
|
||||
engine.emit(EngineEvent::Published {
|
||||
connection_id,
|
||||
topic: params.topic,
|
||||
payload: sent_payload,
|
||||
qos: params.qos,
|
||||
retain: params.retain,
|
||||
content_type: params.content_type,
|
||||
user_properties: params.user_properties,
|
||||
response_topic: params.response_topic,
|
||||
correlation_data: params.correlation_data,
|
||||
message_expiry_interval: params.message_expiry_interval,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
engine.log(
|
||||
&connection_id,
|
||||
LogLevel::Error,
|
||||
"publish_error",
|
||||
i18n::tf("engine.publish_failed", &[("e", &e)]),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 订阅 / 取消订阅 ─────────────────────────────────────────────────────
|
||||
|
||||
/// 旧签名薄封装:不携带 v5 订阅选项(连接视图等现有调用点继续可用)。
|
||||
pub fn subscribe(self: &Arc<Self>, connection_id: String, sub: Subscription) {
|
||||
self.subscribe_with_options(connection_id, sub, SubscribeOptions::default());
|
||||
}
|
||||
|
||||
/// 带 MQTT 5 订阅选项的订阅(sub_identifier / NL / RAP / Retain Handling);
|
||||
/// v4 连接会忽略这些选项。
|
||||
pub fn subscribe_with_options(
|
||||
self: &Arc<Self>,
|
||||
connection_id: String,
|
||||
sub: Subscription,
|
||||
opts: SubscribeOptions,
|
||||
) {
|
||||
let engine = Arc::clone(self);
|
||||
self.runtime.spawn(async move {
|
||||
let Some(client) = engine.client_of(&connection_id) else {
|
||||
engine.emit(EngineEvent::SubscribeResult {
|
||||
connection_id,
|
||||
topic: sub.topic,
|
||||
qos: sub.qos,
|
||||
ok: false,
|
||||
error: Some(i18n::t("engine.connection_not_found").into()),
|
||||
});
|
||||
return;
|
||||
};
|
||||
engine.log(
|
||||
&connection_id,
|
||||
LogLevel::Info,
|
||||
"subscribe",
|
||||
i18n::tf("engine.subscribe_topic", &[("topic", &sub.topic), ("qos", &sub.qos)]),
|
||||
None,
|
||||
);
|
||||
let result = match &client {
|
||||
ClientKind::V5(client) => {
|
||||
// NL/RAP/Retain Handling 是「按主题过滤器」的订阅选项,
|
||||
// 订阅标识符则在订阅报文属性里,分别写入两处。
|
||||
let filter = SubscribeFilterInput::new(sub.topic.clone(), v5_qos(sub.qos))
|
||||
.no_local(opts.no_local)
|
||||
.preserve_retain(opts.retain_as_published)
|
||||
.retain_forward_rule(match opts.retain_handling {
|
||||
1 => RetainForwardRule::OnNewSubscribe,
|
||||
2 => RetainForwardRule::Never,
|
||||
_ => RetainForwardRule::OnEverySubscribe,
|
||||
});
|
||||
let properties = V5SubscribeProperties {
|
||||
// 0 不是合法的订阅标识符,按未设置处理
|
||||
id: opts.sub_identifier.filter(|v| *v > 0).map(|v| v as usize),
|
||||
user_properties: Vec::new(),
|
||||
};
|
||||
match client
|
||||
.subscribe_many_with_properties_tracked(std::iter::once(filter), properties)
|
||||
.await
|
||||
{
|
||||
Ok(notice) => notice.wait_completion_async().await.map_err(|e| {
|
||||
i18n::tf("engine.subscribe_rejected", &[("e", &e)])
|
||||
}),
|
||||
Err(e) => Err(i18n::tf("engine.subscribe_request_failed", &[("e", &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| {
|
||||
i18n::tf("engine.subscribe_rejected", &[("e", &e)])
|
||||
}),
|
||||
Err(e) => Err(i18n::tf("engine.subscribe_request_failed", &[("e", &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",
|
||||
i18n::tf("engine.unsubscribe_ok", &[("topic", &topic)]),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
engine.log(
|
||||
&connection_id,
|
||||
LogLevel::Error,
|
||||
"unsubscribe_error",
|
||||
i18n::tf("engine.unsubscribe_failed", &[("e", &e)]),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 在引擎的 tokio runtime 上调度阻塞任务(OTA 下载等),
|
||||
/// 结果经 smol 通道送回 GPUI 执行器;任务 panic 时接收端以空错误结束。
|
||||
pub fn run_blocking<T: Send + 'static>(
|
||||
&self,
|
||||
f: impl FnOnce() -> T + Send + 'static,
|
||||
) -> smol::channel::Receiver<T> {
|
||||
let (tx, rx) = smol::channel::bounded(1);
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok(value) = tokio::task::spawn_blocking(f).await {
|
||||
let _ = tx.send(value).await;
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// 驻留字符串(主题/连接 ID):同一内容全进程只保留一份 `Arc<str>`。
|
||||
/// 缓存有上限,异常流量下整体清空重建,避免无限增长。
|
||||
pub(crate) fn intern(&self, s: &str) -> Arc<str> {
|
||||
const INTERN_CAP: usize = 10_000;
|
||||
let mut cache = self.topics.lock().unwrap();
|
||||
if let Some(arc) = cache.get(s) {
|
||||
return arc.clone();
|
||||
}
|
||||
if cache.len() >= INTERN_CAP {
|
||||
cache.clear();
|
||||
}
|
||||
let arc: Arc<str> = Arc::from(s);
|
||||
cache.insert(s.to_string(), arc.clone());
|
||||
arc
|
||||
}
|
||||
|
||||
/// 测试连接:使用配置的连接超时(上限 30 秒,防止误填过大值长时间挂起),
|
||||
/// 结果通过 channel 返回。
|
||||
pub fn test_connection(
|
||||
self: &Arc<Self>,
|
||||
cfg: ConnectionConfig,
|
||||
) -> smol::channel::Receiver<Result<(), String>> {
|
||||
let (tx, rx) = smol::channel::bounded(1);
|
||||
let secs = u64::from(cfg.connection_timeout_secs.clamp(1, 30));
|
||||
self.runtime.spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(secs), async {
|
||||
test_handshake(&cfg).await
|
||||
})
|
||||
.await;
|
||||
let reply = match result {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => Err(i18n::tf("engine.test_timeout", &[("secs", &secs)])),
|
||||
};
|
||||
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 client = map.get(id)?.client.as_ref()?;
|
||||
Some(match client {
|
||||
ClientKind::V5(c) => ClientKind::V5(c.clone()),
|
||||
ClientKind::V4(c) => ClientKind::V4(c.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 仅当 map 中该 id 的句柄仍是 `generation` 这一代时才移除。
|
||||
/// 事件循环退出清理必须走这里:无条件按 id 删除会把重连后的
|
||||
/// 新句柄误删,导致连接无法再从 UI 断开。
|
||||
fn remove_handle_if(&self, id: &str, generation: u64) {
|
||||
if let Ok(mut map) = self.conns.lock()
|
||||
&& map.get(id).map(|h| h.generation) == Some(generation)
|
||||
{
|
||||
map.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum EventLoopKind {
|
||||
// 两个事件循环都在 KB 级,装箱让枚举本身只有指针大小
|
||||
V5(Box<V5EventLoop>),
|
||||
V4(Box<rv4::EventLoop>),
|
||||
}
|
||||
|
||||
// ─── 选项构建 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 连接超时秒数:至少 1 秒,避免误填 0 导致立刻超时。
|
||||
fn connection_timeout_secs(cfg: &ConnectionConfig) -> u64 {
|
||||
u64::from(cfg.connection_timeout_secs.max(1))
|
||||
}
|
||||
|
||||
/// 重连退避:指数增长(1s、2s、4s…,封顶 30 秒)并叠加 0~500ms 抖动。
|
||||
///
|
||||
/// 原来固定 2 秒:broker 长时间不可用时会以固定频率持续冲击,多连接还会在同一时刻
|
||||
/// 齐步重连(惊群);抖动让各连接的退避点错开。
|
||||
fn reconnect_delay(attempt: u32) -> Duration {
|
||||
let exp = attempt.saturating_sub(1).min(5);
|
||||
let secs = (1u64 << exp).min(30);
|
||||
let jitter_ms = (uuid::Uuid::new_v4().as_u128() % 500) as u64;
|
||||
Duration::from_millis(secs * 1000 + jitter_ms)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! TLS:由 SslConfig 解析 rustls 配置,含跳过证书校验的 NoVerifier。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rumqttc::TlsConfiguration;
|
||||
use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer};
|
||||
|
||||
use crate::model::{ConnectionConfig, SslConfig};
|
||||
use crate::ui::i18n;// ─── 引擎事件 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
/// SSL 配置非默认时构建自定义 rustls 配置;返回 None 表示沿用 rumqttc 默认 TLS。
|
||||
/// 文件读取/解析失败在此返回 Err,由 connect 的错误通道上报为连接失败事件。
|
||||
pub(super) fn resolve_tls_config(cfg: &ConnectionConfig) -> Result<Option<TlsConfiguration>, String> {
|
||||
if !cfg.transport.is_tls() || cfg.ssl.is_default() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(TlsConfiguration::Rustls(build_rustls_config(&cfg.ssl)?)))
|
||||
}
|
||||
|
||||
/// 按 SslConfig 构造 rustls ClientConfig:
|
||||
/// - `ignore_ca`:跳过服务器证书链/主机名校验;
|
||||
/// - `ca_file`:在系统根证书之外追加自定义 CA;
|
||||
/// - `client_cert_file` + `client_key_file`:双向认证。
|
||||
pub(super) fn build_rustls_config(ssl: &SslConfig) -> Result<Arc<rustls::ClientConfig>, String> {
|
||||
// 只填证书或只填私钥必然握手失败,提前给出明确错误
|
||||
if ssl.client_cert_file.is_empty() != ssl.client_key_file.is_empty() {
|
||||
return Err(i18n::t("tls.cert_key_pair_required").into());
|
||||
}
|
||||
let provider = ensure_crypto_provider();
|
||||
let builder = rustls::ClientConfig::builder();
|
||||
let builder = if ssl.ignore_ca {
|
||||
builder
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoVerifier {
|
||||
provider: provider.clone(),
|
||||
}))
|
||||
} else {
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
if native.certs.is_empty() && ssl.ca_file.is_empty() {
|
||||
let detail = native
|
||||
.errors
|
||||
.iter()
|
||||
.map(|e| e.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Err(i18n::tf("tls.native_roots_failed", &[("detail", &detail)]));
|
||||
}
|
||||
// 个别系统证书加载失败不阻断连接:自定义 CA 与其余根证书仍参与校验
|
||||
for cert in native.certs {
|
||||
let _ = roots.add(cert);
|
||||
}
|
||||
if !ssl.ca_file.is_empty() {
|
||||
let pem = std::fs::read(&ssl.ca_file)
|
||||
.map_err(|e| i18n::tf("tls.ca_read_failed", &[("p", &ssl.ca_file), ("e", &e)]))?;
|
||||
let mut added = 0usize;
|
||||
for cert in CertificateDer::pem_slice_iter(&pem) {
|
||||
let cert = cert.map_err(|e| {
|
||||
i18n::tf("tls.ca_parse_failed", &[("p", &ssl.ca_file), ("e", &e)])
|
||||
})?;
|
||||
roots.add(cert).map_err(|e| {
|
||||
i18n::tf("tls.ca_invalid_cert", &[("p", &ssl.ca_file), ("e", &e)])
|
||||
})?;
|
||||
added += 1;
|
||||
}
|
||||
if added == 0 {
|
||||
return Err(i18n::tf("tls.ca_no_certs", &[("p", &ssl.ca_file)]));
|
||||
}
|
||||
}
|
||||
builder.with_root_certificates(roots)
|
||||
};
|
||||
|
||||
let config = if ssl.client_cert_file.is_empty() {
|
||||
builder.with_no_client_auth()
|
||||
} else {
|
||||
let chain_pem = std::fs::read(&ssl.client_cert_file).map_err(|e| {
|
||||
i18n::tf("tls.client_cert_read_failed", &[("p", &ssl.client_cert_file), ("e", &e)])
|
||||
})?;
|
||||
let key_pem = std::fs::read(&ssl.client_key_file).map_err(|e| {
|
||||
i18n::tf("tls.client_key_read_failed", &[("p", &ssl.client_key_file), ("e", &e)])
|
||||
})?;
|
||||
let certs = CertificateDer::pem_slice_iter(&chain_pem)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| {
|
||||
i18n::tf("tls.client_cert_parse_failed", &[("p", &ssl.client_cert_file), ("e", &e)])
|
||||
})?;
|
||||
if certs.is_empty() {
|
||||
return Err(i18n::tf(
|
||||
"tls.client_cert_no_certs",
|
||||
&[("p", &ssl.client_cert_file)],
|
||||
));
|
||||
}
|
||||
let key = PrivateKeyDer::from_pem_slice(&key_pem).map_err(|e| {
|
||||
i18n::tf("tls.client_key_parse_failed", &[("p", &ssl.client_key_file), ("e", &e)])
|
||||
})?;
|
||||
builder
|
||||
.with_client_auth_cert(certs, key)
|
||||
.map_err(|e| i18n::tf("tls.client_auth_failed", &[("e", &e)]))?
|
||||
};
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
|
||||
/// 取得(必要时安装)进程级 CryptoProvider。
|
||||
/// 工作区同时启用 ring 与 aws-lc-rs 特性时 rustls 无法自动推断默认 provider,
|
||||
/// 不显式安装会让 `ClientConfig::builder()` panic。
|
||||
pub(super) fn ensure_crypto_provider() -> Arc<rustls::crypto::CryptoProvider> {
|
||||
if let Some(provider) = rustls::crypto::CryptoProvider::get_default() {
|
||||
return Arc::clone(provider);
|
||||
}
|
||||
let provider = rustls::crypto::aws_lc_rs::default_provider();
|
||||
// 输掉安装竞争时直接采用已安装的那个
|
||||
match provider.clone().install_default() {
|
||||
Ok(()) => Arc::new(provider),
|
||||
Err(installed) => installed,
|
||||
}
|
||||
}
|
||||
|
||||
/// 「忽略 CA 校验」用:跳过服务器证书链与主机名校验,
|
||||
/// 握手签名仍用 provider 校验(与 rustls 官方示例做法一致)。
|
||||
pub(super) struct NoVerifier {
|
||||
provider: Arc<rustls::crypto::CryptoProvider>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NoVerifier {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("NoVerifier").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for NoVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
self.provider
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
/// 去掉空白字符串,避免表单存了空串也写进报文属性。
|
||||
pub(super) fn non_blank(s: Option<String>) -> Option<String> {
|
||||
s.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! MQTT 3.1.1:MqttOptions/last-will 组装与事件循环泵。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use rumqttc::mqttbytes::v5::{
|
||||
ConnectReturnCode as V5ReturnCode, Packet as V5Packet,
|
||||
};
|
||||
use rumqttc::Event as V5Event;
|
||||
use rumqttc_v4 as rv4;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::model::{
|
||||
ConnectionConfig, ConnectionStatus, Direction, LogLevel, MqttRecord, TransportKind,
|
||||
};// ─── 引擎事件 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
use super::*;
|
||||
use crate::ui::i18n;
|
||||
|
||||
pub(super) fn build_v4(
|
||||
cfg: &ConnectionConfig,
|
||||
) -> Result<(rv4::AsyncClient, rv4::EventLoop), String> {
|
||||
use rv4::{Broker as V4Broker, MqttOptions as V4Options, Transport as V4Transport};
|
||||
|
||||
let custom_tls = resolve_tls_config(cfg)?;
|
||||
let mut opts = match cfg.transport {
|
||||
TransportKind::Tcp | TransportKind::Tls => {
|
||||
V4Options::new(cfg.client_id.clone(), V4Broker::tcp(&cfg.host, cfg.port))
|
||||
}
|
||||
TransportKind::Ws => {
|
||||
let url = format!("ws://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
|
||||
V4Options::new(cfg.client_id.clone(), V4Broker::websocket(url).map_err(|e| format!("{e:?}"))?)
|
||||
}
|
||||
TransportKind::Wss => {
|
||||
let url = format!("wss://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
|
||||
match &custom_tls {
|
||||
Some(tls) => V4Options::websocket_with_tls_config(
|
||||
cfg.client_id.clone(),
|
||||
url,
|
||||
tls.clone(),
|
||||
)
|
||||
.map_err(|e| format!("{e:?}"))?,
|
||||
None => V4Options::try_websocket_with_default_tls(cfg.client_id.clone(), url)
|
||||
.map_err(|e| format!("{e:?}"))?,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if cfg.transport == TransportKind::Tls {
|
||||
match &custom_tls {
|
||||
Some(tls) => {
|
||||
opts.set_transport(V4Transport::tls_with_config(tls.clone()));
|
||||
}
|
||||
None => {
|
||||
opts.set_transport(
|
||||
V4Transport::try_tls_with_default_config()
|
||||
.map_err(|e| i18n::tf("tls.init_failed", &[("e", &format!("{e:?}"))]))?,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
opts.set_keep_alive(cfg.keep_alive);
|
||||
opts.set_clean_session(cfg.clean_start);
|
||||
if !cfg.username.is_empty() {
|
||||
opts.set_credentials(cfg.username.clone(), cfg.password.clone());
|
||||
}
|
||||
if let Some(will) = &cfg.last_will
|
||||
&& !will.topic.is_empty() {
|
||||
// 3.1.1 的遗嘱没有 content_type / response_topic 属性,忽略
|
||||
let lw = rv4::mqttbytes::v4::LastWill::new(
|
||||
will.topic.clone(),
|
||||
will.payload.as_bytes().to_vec(),
|
||||
v4_qos(will.qos),
|
||||
will.retain,
|
||||
);
|
||||
opts.set_last_will(lw);
|
||||
}
|
||||
|
||||
let (client, mut eventloop) = rv4::AsyncClient::builder(opts).build();
|
||||
// v4 的连接超时(含 CONNACK 等待)由 EventLoop 的 NetworkOptions 控制
|
||||
let mut network_options = rv4::NetworkOptions::new();
|
||||
network_options.set_connection_timeout(connection_timeout_secs(cfg));
|
||||
eventloop.set_network_options(network_options);
|
||||
Ok((client, eventloop))
|
||||
}
|
||||
|
||||
pub(super) fn v4_qos(qos: u8) -> rv4::QoS {
|
||||
match qos {
|
||||
1 => rv4::QoS::AtLeastOnce,
|
||||
2 => rv4::QoS::ExactlyOnce,
|
||||
_ => rv4::QoS::AtMostOnce,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 测试连接握手 ────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn test_handshake(cfg_in: &ConnectionConfig) -> Result<(), String> {
|
||||
// 握手测试必须使用独立 client_id,否则会把使用相同 client_id 的现有连接
|
||||
// 从 broker 顶下线(MQTT 同 client_id 单会话规则)。
|
||||
let mut cfg = cfg_in.clone();
|
||||
cfg.client_id = format!("mqttx_test_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
let cfg = &cfg;
|
||||
if cfg.protocol.is_v5() {
|
||||
// 建 client 会读 CA/证书文件(阻塞 IO):与 connect 保持一致放 spawn_blocking,
|
||||
// 避免占住 2 线程引擎 runtime 的 worker
|
||||
let owned = cfg.clone();
|
||||
let (client, mut eventloop) = tokio::task::spawn_blocking(move || build_v5(&owned))
|
||||
.await
|
||||
.map_err(|e| i18n::tf("engine.internal_task_failed", &[("e", &e)]))??;
|
||||
loop {
|
||||
match eventloop.poll().await {
|
||||
Ok(V5Event::Incoming(V5Packet::ConnAck(ack))) => {
|
||||
if ack.code == V5ReturnCode::Success {
|
||||
// 测试完成,主动断开避免在 broker 侧残留会话。
|
||||
// disconnect() 只是把请求入队,需再 poll 才会把
|
||||
// DISCONNECT 真正写上网络。
|
||||
let _ = client.disconnect().await;
|
||||
for _ in 0..3 {
|
||||
if eventloop.poll().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
return Err(i18n::tf(
|
||||
"engine.connack_rejected",
|
||||
&[("code", &format!("{:?}", ack.code))],
|
||||
));
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::Disconnect(d))) => {
|
||||
return Err(i18n::tf(
|
||||
"engine.disconnected_by_server_code",
|
||||
&[("code", &format!("{:?}", d.reason_code))],
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(i18n::tf("engine.connect_failed", &[("e", &e)])),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 同上:v4 分支同样把阻塞的建连放到阻塞线程池
|
||||
let owned = cfg.clone();
|
||||
let (client, mut eventloop) = tokio::task::spawn_blocking(move || build_v4(&owned))
|
||||
.await
|
||||
.map_err(|e| i18n::tf("engine.internal_task_failed", &[("e", &e)]))??;
|
||||
loop {
|
||||
match eventloop.poll().await {
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
|
||||
if ack.code == rv4::ConnectReturnCode::Success {
|
||||
// 同 v5:disconnect() 只入队,需 poll 把报文冲出
|
||||
let _ = client.disconnect().await;
|
||||
for _ in 0..3 {
|
||||
if eventloop.poll().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
return Err(i18n::tf(
|
||||
"engine.connack_rejected",
|
||||
&[("code", &format!("{:?}", ack.code))],
|
||||
));
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
|
||||
return Err(i18n::t("engine.disconnected_by_server_active").into());
|
||||
}
|
||||
Err(e) => return Err(i18n::tf("engine.connect_failed", &[("e", &e)])),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 事件循环 ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn spawn_v4_loop(
|
||||
engine: Arc<MqttEngine>,
|
||||
id: String,
|
||||
mut eventloop: rv4::EventLoop,
|
||||
mut cancel_rx: watch::Receiver<bool>,
|
||||
connected: Arc<AtomicBool>,
|
||||
generation: u64,
|
||||
auto_reconnect: bool,
|
||||
max_reconnect_times: u32,
|
||||
) {
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
let runtime = engine.runtime.handle().clone();
|
||||
runtime.spawn(async move {
|
||||
let mut first_connect = true;
|
||||
// 已执行的重连次数,用于 max_reconnect_times(0=无限)限流
|
||||
let mut reconnect_attempts: u32 = 0;
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = eventloop.poll() => {
|
||||
match event {
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::Publish(p))) => {
|
||||
let (payload, raw_bytes, payload_truncated) =
|
||||
crate::model::retained_payload(&p.payload);
|
||||
let record = MqttRecord {
|
||||
seq: engine.next_seq(),
|
||||
connection_id: engine.intern(&id),
|
||||
direction: Direction::Received,
|
||||
topic: engine.intern(&String::from_utf8_lossy(&p.topic)),
|
||||
payload,
|
||||
qos: match p.qos {
|
||||
rv4::QoS::AtMostOnce => 0,
|
||||
rv4::QoS::AtLeastOnce => 1,
|
||||
rv4::QoS::ExactlyOnce => 2,
|
||||
},
|
||||
retain: p.retain,
|
||||
timestamp: now_ms(),
|
||||
user_properties: Default::default(),
|
||||
content_type: None,
|
||||
response_topic: None,
|
||||
correlation_data: None,
|
||||
message_expiry_interval: None,
|
||||
subscription_identifier: None,
|
||||
payload_truncated,
|
||||
raw_bytes,
|
||||
preview: Arc::from(""),
|
||||
};
|
||||
engine.log(
|
||||
&id, LogLevel::Info, "publish_received",
|
||||
i18n::tf("engine.received_message", &[("topic", &record.topic), ("n", &p.payload.len())]), None);
|
||||
engine.emit(EngineEvent::Message(record));
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::ConnAck(ack))) => {
|
||||
connected.store(ack.code == rv4::ConnectReturnCode::Success, Ordering::SeqCst);
|
||||
if ack.code == rv4::ConnectReturnCode::Success {
|
||||
first_connect = false;
|
||||
// 重连成功后清零计数:max_reconnect_times 限流的是
|
||||
// 「连续失败次数」,不能把历史错误累计进来
|
||||
reconnect_attempts = 0;
|
||||
engine.log(&id, LogLevel::Info, "connack",
|
||||
i18n::tf("engine.connect_ok", &[("sp", &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",
|
||||
i18n::tf("engine.connack_rejected", &[("code", &format!("{:?}", ack.code))]), None);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Error,
|
||||
error: Some(format!("{:?}", ack.code)),
|
||||
// 上报真实的 CONNACK 拒绝码,不再硬编码为 1
|
||||
reason_code: Some(ack.code as u8),
|
||||
session_present: Some(ack.session_present),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::Disconnect)) => {
|
||||
connected.store(false, Ordering::SeqCst);
|
||||
engine.emit(EngineEvent::Status {
|
||||
connection_id: id.clone(),
|
||||
status: ConnectionStatus::Disconnected,
|
||||
error: Some(i18n::t("engine.disconnected_by_server").into()),
|
||||
reason_code: None,
|
||||
session_present: None,
|
||||
});
|
||||
break;
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::SubAck(s))) => {
|
||||
engine.log(&id, LogLevel::Info, "suback",
|
||||
i18n::tf("engine.suback", &[("n", &s.pkid)]), None);
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::UnsubAck(a))) => {
|
||||
engine.log(&id, LogLevel::Info, "unsuback",
|
||||
i18n::tf("engine.unsuback_packet", &[("n", &a.pkid)]), None);
|
||||
}
|
||||
Ok(rv4::Event::Incoming(rv4::Packet::PubAck(a))) => {
|
||||
engine.log(&id, LogLevel::Debug, "puback",
|
||||
i18n::tf("engine.puback", &[("n", &a.pkid)]), None);
|
||||
}
|
||||
Ok(rv4::Event::Outgoing(rv4::Outgoing::Publish(pkid))) => {
|
||||
engine.log(&id, LogLevel::Debug, "publish_outgoing",
|
||||
i18n::tf("engine.publish_outgoing", &[("n", &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",
|
||||
i18n::tf("engine.connection_error", &[("e", &e)]), None);
|
||||
break;
|
||||
}
|
||||
// max_reconnect_times>0 时按次数停止重试;0 表示无限重连
|
||||
if max_reconnect_times > 0 && reconnect_attempts >= max_reconnect_times {
|
||||
engine.log(&id, LogLevel::Error, "reconnect_give_up",
|
||||
i18n::tf("engine.reconnect_give_up", &[("attempts", &reconnect_attempts), ("max", &max_reconnect_times), ("e", &e)]), None);
|
||||
break;
|
||||
}
|
||||
reconnect_attempts += 1;
|
||||
let delay = reconnect_delay(reconnect_attempts);
|
||||
let delay_s = format!("{:.1}", delay.as_secs_f32());
|
||||
engine.log(&id, LogLevel::Warn, "connection_error",
|
||||
i18n::tf("engine.reconnect_retry", &[("delay", &delay_s), ("attempts", &reconnect_attempts), ("e", &e)]), None);
|
||||
// 退避等待也必须可取消:否则 close() 的取消信号要等一整个
|
||||
// 退避周期才可见,期间会对用户已关闭的连接继续重连。
|
||||
tokio::select! {
|
||||
_ = cancel_rx.changed() => break,
|
||||
_ = tokio::time::sleep(delay) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = cancel_rx.changed() => {
|
||||
engine.log(&id, LogLevel::Info, "disconnect_cancelled", i18n::t("engine.cancelled").into(), None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 仅当 map 中仍是本代句柄时才移除,避免误删重连后的新句柄
|
||||
engine.remove_handle_if(&id, generation);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! MQTT 5.0:MqttOptions/last-will/属性组装与事件循环泵。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rumqttc::mqttbytes::v5::{
|
||||
ConnectReturnCode as V5ReturnCode, Packet as V5Packet, Publish as V5Publish,
|
||||
};
|
||||
use rumqttc::{
|
||||
AsyncClient as V5AsyncClient, Broker as V5Broker, ConnectionError as V5Error,
|
||||
Event as V5Event, EventLoop as V5EventLoop, MqttOptions as V5Options, Outgoing as V5Outgoing, QoS as V5QoS, Transport as V5Transport,
|
||||
};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::model::{
|
||||
ConnectionConfig, ConnectionStatus, Direction, LogLevel, MqttRecord, TransportKind,
|
||||
};// ─── 引擎事件 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
use super::*;
|
||||
use crate::ui::i18n;
|
||||
|
||||
pub(super) fn build_v5(
|
||||
cfg: &ConnectionConfig,
|
||||
) -> Result<(V5AsyncClient, V5EventLoop), String> {
|
||||
let custom_tls = resolve_tls_config(cfg)?;
|
||||
let mut opts = match cfg.transport {
|
||||
TransportKind::Tcp | TransportKind::Tls => {
|
||||
V5Options::new(cfg.client_id.clone(), V5Broker::tcp(&cfg.host, cfg.port))
|
||||
}
|
||||
TransportKind::Ws => {
|
||||
let url = format!("ws://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
|
||||
V5Options::new(cfg.client_id.clone(), V5Broker::websocket(url).map_err(|e| format!("{e:?}"))?)
|
||||
}
|
||||
TransportKind::Wss => {
|
||||
let url = format!("wss://{}:{}{}", cfg.host, cfg.port, normalize_path(&cfg.path));
|
||||
match &custom_tls {
|
||||
Some(tls) => V5Options::websocket_with_tls_config(
|
||||
cfg.client_id.clone(),
|
||||
url,
|
||||
tls.clone(),
|
||||
)
|
||||
.map_err(|e| format!("{e:?}"))?,
|
||||
None => V5Options::try_websocket_with_default_tls(cfg.client_id.clone(), url)
|
||||
.map_err(|e| format!("{e:?}"))?,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if cfg.transport == TransportKind::Tls {
|
||||
match &custom_tls {
|
||||
Some(tls) => {
|
||||
opts.set_transport(V5Transport::tls_with_config(tls.clone()));
|
||||
}
|
||||
None => {
|
||||
opts.set_transport(
|
||||
V5Transport::try_tls_with_default_config()
|
||||
.map_err(|e| i18n::tf("tls.init_failed", &[("e", &format!("{e:?}"))]))?,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 连接超时同时覆盖底层建连与 CONNACK 等待
|
||||
let timeout = Duration::from_secs(connection_timeout_secs(cfg));
|
||||
opts.set_connect_timeout(timeout);
|
||||
let mut network_options = rumqttc::NetworkOptions::new();
|
||||
network_options.set_connection_timeout(connection_timeout_secs(cfg));
|
||||
opts.set_network_options(network_options);
|
||||
|
||||
opts.set_keep_alive(cfg.keep_alive);
|
||||
opts.set_clean_start(cfg.clean_start);
|
||||
if cfg.session_expiry_interval > 0 {
|
||||
opts.set_session_expiry_interval(Some(cfg.session_expiry_interval));
|
||||
}
|
||||
if !cfg.username.is_empty() {
|
||||
opts.set_credentials(cfg.username.clone(), cfg.password.clone());
|
||||
}
|
||||
if let Some(v) = cfg.receive_maximum {
|
||||
opts.set_receive_maximum(Some(v));
|
||||
}
|
||||
if let Some(v) = cfg.maximum_packet_size {
|
||||
opts.set_max_packet_size(Some(v));
|
||||
}
|
||||
if let Some(v) = cfg.topic_alias_maximum {
|
||||
opts.set_topic_alias_max(Some(v));
|
||||
}
|
||||
if let Some(will) = &cfg.last_will
|
||||
&& !will.topic.is_empty() {
|
||||
// v5 遗嘱属性:content_type / response_topic(3.1.1 下无此扩展)
|
||||
let properties = if will.content_type.is_some() || will.response_topic.is_some() {
|
||||
Some(rumqttc::mqttbytes::v5::LastWillProperties {
|
||||
delay_interval: None,
|
||||
payload_format_indicator: None,
|
||||
message_expiry_interval: None,
|
||||
content_type: non_blank(will.content_type.clone()),
|
||||
response_topic: non_blank(will.response_topic.clone()),
|
||||
correlation_data: None,
|
||||
user_properties: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let lw = rumqttc::mqttbytes::v5::LastWill::new(
|
||||
will.topic.clone(),
|
||||
will.payload.as_bytes().to_vec(),
|
||||
v5_qos(will.qos),
|
||||
will.retain,
|
||||
properties,
|
||||
);
|
||||
opts.set_last_will(lw);
|
||||
}
|
||||
|
||||
Ok(V5AsyncClient::builder(opts).build())
|
||||
}
|
||||
|
||||
pub(super) fn normalize_path(path: &str) -> String {
|
||||
if path.is_empty() {
|
||||
"/mqtt".to_string()
|
||||
} else if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn v5_qos(qos: u8) -> V5QoS {
|
||||
match qos {
|
||||
1 => V5QoS::AtLeastOnce,
|
||||
2 => V5QoS::ExactlyOnce,
|
||||
_ => V5QoS::AtMostOnce,
|
||||
}
|
||||
}
|
||||
|
||||
fn v5_record(engine: &MqttEngine, seq: u64, conn_id: &str, p: &V5Publish) -> MqttRecord {
|
||||
let props = p.properties.as_ref();
|
||||
let (payload, raw_bytes, payload_truncated) = crate::model::retained_payload(&p.payload);
|
||||
MqttRecord {
|
||||
seq,
|
||||
connection_id: engine.intern(conn_id),
|
||||
direction: Direction::Received,
|
||||
topic: engine.intern(&String::from_utf8_lossy(&p.topic)),
|
||||
payload,
|
||||
qos: v5_qos_u8(p.qos),
|
||||
retain: p.retain,
|
||||
timestamp: now_ms(),
|
||||
user_properties: props
|
||||
.map(|p| Arc::from(p.user_properties.clone()))
|
||||
.unwrap_or_default(),
|
||||
content_type: props.and_then(|p| p.content_type.clone()),
|
||||
response_topic: props.and_then(|p| p.response_topic.clone()),
|
||||
correlation_data: props.and_then(|p| {
|
||||
p.correlation_data
|
||||
.as_ref()
|
||||
.map(|d| correlation_display(d))
|
||||
}),
|
||||
message_expiry_interval: props.and_then(|p| p.message_expiry_interval),
|
||||
subscription_identifier: props
|
||||
.and_then(|p| p.subscription_identifiers.first().map(|v| *v as u32)),
|
||||
payload_truncated,
|
||||
raw_bytes,
|
||||
preview: Arc::from(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// correlation data 的可读表示:优先按 UTF-8 文本展示,非文本回退 hex。
|
||||
/// 发送端把字符串按字节原样发出,两侧展示保持对称(此前收端一律 hex、
|
||||
/// 发端保留原文,同一条数据两侧对不上)。
|
||||
fn correlation_display(data: &[u8]) -> String {
|
||||
match std::str::from_utf8(data) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => data.iter().map(|b| format!("{b:02x}")).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn v5_qos_u8(qos: V5QoS) -> u8 {
|
||||
match qos {
|
||||
V5QoS::AtMostOnce => 0,
|
||||
V5QoS::AtLeastOnce => 1,
|
||||
V5QoS::ExactlyOnce => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn spawn_v5_loop(
|
||||
engine: Arc<MqttEngine>,
|
||||
id: String,
|
||||
mut eventloop: V5EventLoop,
|
||||
mut cancel_rx: watch::Receiver<bool>,
|
||||
connected: Arc<AtomicBool>,
|
||||
generation: u64,
|
||||
auto_reconnect: bool,
|
||||
max_reconnect_times: u32,
|
||||
) {
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
let runtime = engine.runtime.handle().clone();
|
||||
runtime.spawn(async move {
|
||||
let mut first_connect = true;
|
||||
// 已执行的重连次数,用于 max_reconnect_times(0=无限)限流
|
||||
let mut reconnect_attempts: u32 = 0;
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = eventloop.poll() => {
|
||||
match event {
|
||||
Ok(V5Event::Incoming(V5Packet::Publish(p))) => {
|
||||
let topic = String::from_utf8_lossy(&p.topic).to_string();
|
||||
let record = v5_record(&engine, engine.next_seq(), &id, &p);
|
||||
engine.log(
|
||||
&id,
|
||||
LogLevel::Info,
|
||||
"publish_received",
|
||||
i18n::tf(
|
||||
"engine.received_message_qos",
|
||||
&[
|
||||
("topic", &topic),
|
||||
("n", &p.payload.len()),
|
||||
("qos", &v5_qos_u8(p.qos)),
|
||||
],
|
||||
),
|
||||
None,
|
||||
);
|
||||
engine.emit(EngineEvent::Message(record));
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::ConnAck(ack))) => {
|
||||
let code = ack.code as u8;
|
||||
connected.store(ack.code == V5ReturnCode::Success, Ordering::SeqCst);
|
||||
if ack.code == V5ReturnCode::Success {
|
||||
first_connect = false;
|
||||
// 重连成功后清零计数:max_reconnect_times 限流的是
|
||||
// 「连续失败次数」,不能把历史错误累计进来
|
||||
reconnect_attempts = 0;
|
||||
engine.log(
|
||||
&id,
|
||||
LogLevel::Info,
|
||||
"connack",
|
||||
i18n::tf("engine.connect_ok", &[("sp", &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",
|
||||
i18n::tf(
|
||||
"engine.connack_rejected_code",
|
||||
&[("code", &format!("{:?}", ack.code)), ("n", &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",
|
||||
i18n::tf(
|
||||
"engine.disconnect_received",
|
||||
&[
|
||||
("code", &format!("{:?}", d.reason_code)),
|
||||
("n", &(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",
|
||||
i18n::tf("engine.suback_codes", &[("n", &s.pkid), ("codes", &granted.join(", "))]), None);
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::UnsubAck(_))) => {
|
||||
engine.log(&id, LogLevel::Info, "unsuback", i18n::t("engine.unsuback").into(), None);
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::PubAck(a))) => {
|
||||
engine.log(&id, LogLevel::Debug, "puback",
|
||||
i18n::tf("engine.puback_reason", &[("n", &a.pkid), ("reason", &format!("{:?}", a.reason))]), None);
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::PubRec(a))) => {
|
||||
engine.log(&id, LogLevel::Debug, "pubrec",
|
||||
i18n::tf("engine.pubrec", &[("n", &a.pkid)]), None);
|
||||
}
|
||||
Ok(V5Event::Incoming(V5Packet::PubComp(a))) => {
|
||||
engine.log(&id, LogLevel::Debug, "pubcomp",
|
||||
i18n::tf("engine.pubcomp", &[("n", &a.pkid)]), None);
|
||||
}
|
||||
Ok(V5Event::Outgoing(V5Outgoing::Publish(pkid))) => {
|
||||
engine.log(&id, LogLevel::Debug, "publish_outgoing",
|
||||
i18n::tf("engine.publish_outgoing", &[("n", &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",
|
||||
i18n::tf("engine.connection_error", &[("e", &e)]), None);
|
||||
break;
|
||||
}
|
||||
// max_reconnect_times>0 时按次数停止重试;0 表示无限重连
|
||||
if max_reconnect_times > 0 && reconnect_attempts >= max_reconnect_times {
|
||||
engine.log(&id, LogLevel::Error, "reconnect_give_up",
|
||||
i18n::tf("engine.reconnect_give_up", &[("attempts", &reconnect_attempts), ("max", &max_reconnect_times), ("e", &e)]), None);
|
||||
break;
|
||||
}
|
||||
reconnect_attempts += 1;
|
||||
let delay = reconnect_delay(reconnect_attempts);
|
||||
let delay_s = format!("{:.1}", delay.as_secs_f32());
|
||||
engine.log(&id, LogLevel::Warn, "connection_error",
|
||||
i18n::tf("engine.reconnect_retry", &[("delay", &delay_s), ("attempts", &reconnect_attempts), ("e", &e)]), None);
|
||||
// 退避等待也必须可取消:否则 close() 的取消信号要等一整个
|
||||
// 退避周期才可见,期间会对用户已关闭的连接继续重连。
|
||||
tokio::select! {
|
||||
_ = cancel_rx.changed() => break,
|
||||
_ = tokio::time::sleep(delay) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = cancel_rx.changed() => {
|
||||
engine.log(&id, LogLevel::Info, "disconnect_cancelled", i18n::t("engine.cancelled").into(), None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 仅当 map 中仍是本代句柄时才移除,避免误删重连后的新句柄
|
||||
engine.remove_handle_if(&id, generation);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! 平台专属窗口操作。当前仅 Windows:置顶 / 取消置顶。
|
||||
|
||||
/// 窗口置顶 / 取消置顶。返回是否成功(非 Windows 或句柄不可得为 false)。
|
||||
pub fn set_topmost(window: &gpui_kit::Window, topmost: bool) -> bool {
|
||||
// 属性 cfg 分派而非 cfg!() 运行时判断:非 Windows 下 set_topmost_windows
|
||||
// 被 cfg 移除,cfg! 分支仍参与编译会报方法不存在(CI Test job 实测暴露)
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
set_topmost_windows(window, topmost)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
set_topmost_windows(window, topmost)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_topmost_windows(window: &gpui_kit::Window, topmost: bool) -> bool {
|
||||
use raw_window_handle::RawWindowHandle;
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
SetWindowPos, HWND_NOTOPMOST, HWND_TOPMOST, SET_WINDOW_POS_FLAGS,
|
||||
};
|
||||
|
||||
// gpui 的 Window 自带同名方法,HasWindowHandle 必须全路径调用
|
||||
let handle = match raw_window_handle::HasWindowHandle::window_handle(window) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return false,
|
||||
};
|
||||
// WindowHandle::raw 是私有字段;HasWindowHandle 的实现返回自身拷贝,
|
||||
// trait 方法在此类型上拿不到 RawWindowHandle,用 deref 后的 as_raw 途径
|
||||
let raw = handle.as_raw();
|
||||
#[allow(clippy::infallible_destructuring_match)]
|
||||
let RawWindowHandle::Win32(w) = raw else {
|
||||
return false;
|
||||
};
|
||||
let hwnd = HWND(w.hwnd.get() as *mut core::ffi::c_void);
|
||||
let insert = if topmost { HWND_TOPMOST } else { HWND_NOTOPMOST };
|
||||
let flags = SET_WINDOW_POS_FLAGS(SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
||||
// windows 0.62 起 SetWindowPos 的插入位置参数改为 Option<HWND>
|
||||
unsafe { SetWindowPos(hwnd, Some(insert), 0, 0, 0, 0, flags).is_ok() }
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn set_topmost_windows(_window: &gpui_kit::Window, _topmost: bool) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// 仅 Windows 采样路径使用;非 Windows 下该路径被 cfg 移除,常量会成为
|
||||
// dead_code(CI 的 -D warnings 下即错误),按平台放行。
|
||||
#[cfg(target_os = "windows")]
|
||||
const SWP_NOSIZE: u32 = 0x0001;
|
||||
#[cfg(target_os = "windows")]
|
||||
const SWP_NOMOVE: u32 = 0x0002;
|
||||
#[cfg(target_os = "windows")]
|
||||
const SWP_NOACTIVATE: u32 = 0x0010;
|
||||
+35
-10
@@ -72,7 +72,7 @@ impl Storage {
|
||||
|
||||
fn read_json_inner<T: for<'de> serde::Deserialize<'de>>(&self, p: &Path) -> Result<Option<T>> {
|
||||
let mut text = std::fs::read_to_string(p)
|
||||
.with_context(|| format!("读取 {} 失败", p.display()))?;
|
||||
.with_context(|| crate::ui::i18n::tf("io.err_read", &[("p", &p.display().to_string())]))?;
|
||||
// 容忍 Windows 编辑器/PowerShell 写入的 UTF-8 BOM(BOM 只有 1 个字符,逐字符剥离)
|
||||
if let Some(rest) = text.strip_prefix('\u{feff}') {
|
||||
text = rest.to_string();
|
||||
@@ -81,7 +81,7 @@ impl Storage {
|
||||
return Ok(None);
|
||||
}
|
||||
let value =
|
||||
serde_json::from_str(&text).with_context(|| format!("解析 {} 失败", p.display()))?;
|
||||
serde_json::from_str(&text).with_context(|| crate::ui::i18n::tf("io.err_parse_json_path", &[("p", &p.display().to_string())]))?;
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
@@ -207,6 +207,31 @@ impl Storage {
|
||||
elog!("保存设置失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── ui state(标签页会话恢复) ──
|
||||
pub fn load_ui_state(&self) -> UiState {
|
||||
self.read_json("ui-state.json")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn save_ui_state(&self, state: &UiState) {
|
||||
if let Err(e) = self.write_json("ui-state.json", state) {
|
||||
elog!("保存界面状态失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话 UI 状态:打开的标签页与活动标签,用于重启后恢复工作区。
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UiState {
|
||||
#[serde(default)]
|
||||
pub open_tabs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub active_tab: Option<String>,
|
||||
/// 连接侧栏抽屉是否收起(抽屉式折叠,Ctrl+B 切换)
|
||||
#[serde(default)]
|
||||
pub sidebar_collapsed: bool,
|
||||
}
|
||||
|
||||
impl Default for Storage {
|
||||
@@ -220,8 +245,8 @@ impl Default for Storage {
|
||||
/// 导出连接为 JSON 数组(与 connections.json 同构:缩进格式化、UTF-8 无 BOM)。
|
||||
pub fn export_connections_to(path: &Path, conns: &[ConnectionConfig]) -> Result<(), String> {
|
||||
let text = serde_json::to_string_pretty(conns)
|
||||
.map_err(|e| format!("序列化连接失败: {e}"))?;
|
||||
std::fs::write(path, text).map_err(|e| format!("写入 {} 失败: {e}", path.display()))
|
||||
.map_err(|e| crate::ui::i18n::tf("io.err_serialize", &[("e", &e.to_string())]))?;
|
||||
std::fs::write(path, text).map_err(|e| crate::ui::i18n::tf("io.err_write", &[("p", &path.display().to_string()), ("e", &e.to_string())]))
|
||||
}
|
||||
|
||||
/// 从 JSON 文件导入连接。
|
||||
@@ -229,21 +254,21 @@ pub fn export_connections_to(path: &Path, conns: &[ConnectionConfig]) -> Result<
|
||||
/// 容错规则:容忍 UTF-8 BOM;顶层允许是数组,也可能是 `{ "connections": [...] }` 对象;
|
||||
/// 任一项不是合法连接配置、或 `id` 为空白时,返回带序号的错误信息。
|
||||
pub fn import_connections_from(path: &Path) -> Result<Vec<ConnectionConfig>, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("读取 {} 失败: {e}", path.display()))?;
|
||||
let bytes = std::fs::read(path).map_err(|e| crate::ui::i18n::tf("io.err_read", &[("p", &path.display().to_string()), ("e", &e.to_string())]))?;
|
||||
let mut text =
|
||||
String::from_utf8(bytes).map_err(|_| format!("{} 不是有效的 UTF-8 文件", path.display()))?;
|
||||
String::from_utf8(bytes).map_err(|_| crate::ui::i18n::tf("io.err_utf8", &[("p", &path.display().to_string())]))?;
|
||||
if let Some(rest) = text.strip_prefix('\u{feff}') {
|
||||
text = rest.to_string();
|
||||
}
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(text.trim()).map_err(|e| format!("解析 JSON 失败: {e}"))?;
|
||||
serde_json::from_str(text.trim()).map_err(|e| crate::ui::i18n::tf("io.err_parse_json", &[("e", &e.to_string())]))?;
|
||||
let items = match value {
|
||||
serde_json::Value::Array(items) => items,
|
||||
serde_json::Value::Object(mut map) => match map.remove("connections") {
|
||||
Some(serde_json::Value::Array(items)) => items,
|
||||
_ => {
|
||||
return Err(
|
||||
"JSON 对象中缺少 \"connections\" 数组字段".to_string(),
|
||||
crate::ui::i18n::t("io.err_missing_field").to_string(),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -255,11 +280,11 @@ pub fn import_connections_from(path: &Path) -> Result<Vec<ConnectionConfig>, Str
|
||||
Ok(c) => {
|
||||
// 空 id 会在去重/订阅关联时产生悬空引用,导入层直接拒绝
|
||||
if c.id.trim().is_empty() {
|
||||
return Err(format!("第 {} 项的 id 不能为空", i + 1));
|
||||
return Err(crate::ui::i18n::tf("io.err_item_id", &[("n", &(i + 1))]));
|
||||
}
|
||||
conns.push(c);
|
||||
}
|
||||
Err(e) => return Err(format!("第 {} 项不是合法的连接配置: {e}", i + 1)),
|
||||
Err(e) => return Err(crate::ui::i18n::tf("io.err_item_invalid", &[("n", &(i + 1)), ("e", &e.to_string())])),
|
||||
}
|
||||
}
|
||||
Ok(conns)
|
||||
|
||||
+37
-52
@@ -1,7 +1,7 @@
|
||||
//! 进程自身资源采样与窗口置顶辅助。
|
||||
//! 进程自身资源采样(CPU/内存/线程/句柄)。
|
||||
//!
|
||||
//! 仅 Windows 实现数据采集(`windows` crate);其他平台采样返回
|
||||
//! 全零数据、置顶返回 false,调用方按缺失能力展示。
|
||||
//! 仅 Windows 实现数据采集(`windows` crate);其他平台采样返回全零数据,
|
||||
//! 调用方按缺失能力展示。窗口置顶等平台操作见 [`crate::platform`]。
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -14,7 +14,8 @@ pub struct Sample {
|
||||
pub peak_working_set: u64,
|
||||
/// 提交内存(私有字节)
|
||||
pub private_bytes: u64,
|
||||
/// 自上次采样以来的 CPU 占用百分比(按可用核数归一,0~100×核数内)
|
||||
/// 自上次采样以来的 CPU 占用(占整机容量的百分比 0~100,已按核数归一,
|
||||
/// 与任务管理器的"总利用率"同口径)
|
||||
pub cpu_percent: f32,
|
||||
/// 线程数
|
||||
pub threads: u32,
|
||||
@@ -23,9 +24,14 @@ pub struct Sample {
|
||||
}
|
||||
|
||||
/// 周期采样器:内部记录上次 CPU 时间,用于差分计算占用率。
|
||||
// pid/last_cpu/last_percent 仅 Windows 采样路径读写;非 Windows 下该路径被 cfg
|
||||
// 移除,这三个字段会成为 dead_code(CI 的 -D warnings 下即错误),按平台放行。
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub struct Sampler {
|
||||
pid: u32,
|
||||
last_cpu: Option<(u64, u64, Instant)>,
|
||||
/// 上一次成功算出的占用率:采样间隔过近时沿用,避免读数跳回 0
|
||||
last_percent: f32,
|
||||
}
|
||||
|
||||
impl Sampler {
|
||||
@@ -33,15 +39,22 @@ impl Sampler {
|
||||
Self {
|
||||
pid: std::process::id(),
|
||||
last_cpu: None,
|
||||
last_percent: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 采样一次;CPU 占用首次采样为 0(无差分基准)。
|
||||
pub fn sample(&mut self) -> Option<Sample> {
|
||||
if !cfg!(target_os = "windows") {
|
||||
return Some(Sample::default());
|
||||
// 属性 cfg 分派而非 cfg!() 运行时判断:非 Windows 下 sample_windows
|
||||
// 被 cfg 移除,cfg! 分支仍参与编译会报方法不存在(CI Test job 实测暴露)
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.sample_windows()
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Some(Sample::default())
|
||||
}
|
||||
self.sample_windows()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -120,9 +133,16 @@ impl Sampler {
|
||||
if dt > 0.05 {
|
||||
let cpu_secs = ((k - pk) + (u - pu)) as f32 / 1e7;
|
||||
cpu_percent = cpu_secs / dt / cores * 100.;
|
||||
self.last_percent = cpu_percent;
|
||||
// 只有真正算出结果才前移差分基准:否则连续两次快速采样
|
||||
// 会既报 0 又把基准推到"刚刚",用户手点"立即刷新"永远读到 0%
|
||||
self.last_cpu = Some((k, u, now));
|
||||
} else {
|
||||
cpu_percent = self.last_percent;
|
||||
}
|
||||
} else {
|
||||
self.last_cpu = Some((k, u, now));
|
||||
}
|
||||
self.last_cpu = Some((k, u, now));
|
||||
}
|
||||
|
||||
// ── 线程数 / 句柄数 ──
|
||||
@@ -180,49 +200,6 @@ pub fn uptime() -> Duration {
|
||||
START.get().map(|t| t.elapsed()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 窗口置顶 / 取消置顶。返回是否成功(非 Windows 或句柄不可得为 false)。
|
||||
pub fn set_topmost(window: &gpui_kit::Window, topmost: bool) -> bool {
|
||||
if !cfg!(target_os = "windows") {
|
||||
return false;
|
||||
}
|
||||
set_topmost_windows(window, topmost)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_topmost_windows(window: &gpui_kit::Window, topmost: bool) -> bool {
|
||||
use raw_window_handle::RawWindowHandle;
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
SetWindowPos, HWND_NOTOPMOST, HWND_TOPMOST, SET_WINDOW_POS_FLAGS,
|
||||
};
|
||||
|
||||
// gpui 的 Window 自带同名方法,HasWindowHandle 必须全路径调用
|
||||
let handle = match raw_window_handle::HasWindowHandle::window_handle(window) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return false,
|
||||
};
|
||||
// WindowHandle::raw 是私有字段;HasWindowHandle 的实现返回自身拷贝,
|
||||
// trait 方法在此类型上拿不到 RawWindowHandle,用 deref 后的 as_raw 途径
|
||||
let raw = handle.as_raw();
|
||||
#[allow(clippy::infallible_destructuring_match)]
|
||||
let RawWindowHandle::Win32(w) = raw else {
|
||||
return false;
|
||||
};
|
||||
let hwnd = HWND(w.hwnd.get() as *mut core::ffi::c_void);
|
||||
let insert = if topmost { HWND_TOPMOST } else { HWND_NOTOPMOST };
|
||||
let flags = SET_WINDOW_POS_FLAGS(SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
||||
unsafe { SetWindowPos(hwnd, insert, 0, 0, 0, 0, flags).is_ok() }
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn set_topmost_windows(_window: &gpui_kit::Window, _topmost: bool) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
const SWP_NOSIZE: u32 = 0x0001;
|
||||
const SWP_NOMOVE: u32 = 0x0002;
|
||||
const SWP_NOACTIVATE: u32 = 0x0010;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -234,7 +211,15 @@ mod tests {
|
||||
let first = s.sample().expect("采样应成功");
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
let second = s.sample().expect("第二次采样应成功");
|
||||
assert!(second.working_set > 0, "工作集应大于 0");
|
||||
// 采样能力分平台:Windows 采集真实数据,其余平台按契约返回全零占位样本。
|
||||
// 这里用 if cfg! 而不是 #[cfg]:两个分支都参与编译,避免只在某一个平台上
|
||||
// 才发现语法/类型错误。此前无条件断言 > 0,导致 Linux 上 cargo test 必失败,
|
||||
// 而 CI 的 test job 正是跑在 ubuntu-latest 上,v* tag 会直接停在测试阶段。
|
||||
if cfg!(target_os = "windows") {
|
||||
assert!(second.working_set > 0, "工作集应大于 0");
|
||||
} else {
|
||||
assert_eq!(second.working_set, 0, "非 Windows 平台应返回全零占位样本");
|
||||
}
|
||||
let _ = (first, second);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,329 @@
|
||||
//! 用户动作:连接/订阅/预设/变量/设置等状态写操作,与连接表单的打开。
|
||||
|
||||
|
||||
use gpui_kit::component::button::{ Button };
|
||||
use gpui_kit::component::{ h_flex, notification::Notification, };
|
||||
use gpui_kit::{ div, px, App, Context, Window, };
|
||||
|
||||
use crate::aliyun::AliyunPreset;
|
||||
use crate::model::{ AppSettings, ConnectionConfig, ConnectionStatus, GlobalVariable, PublishParams, PublishPreset, Subscription, };
|
||||
use crate::ui::IconName;
|
||||
use crate::ui::{ connection_form::ConnectionForm, connection_view::ConnectionView, };
|
||||
|
||||
use super::*;
|
||||
use crate::ui::i18n;
|
||||
|
||||
impl MqttXApp {
|
||||
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);
|
||||
self.normalize_group_filter();
|
||||
}
|
||||
|
||||
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 = i18n::tf("sidebar.dup_suffix", &[("name", ©.name)]);
|
||||
copy.client_id =
|
||||
format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
self.connections.push(copy);
|
||||
self.storage.save_connections(&self.connections);
|
||||
}
|
||||
|
||||
/// 分组被改名/删除后过滤器可能悬空(列表恒空),在数据变更的 action
|
||||
/// 路径复位到「全部」;render 只读,不做突变。
|
||||
fn normalize_group_filter(&mut self) {
|
||||
if let GroupFilter::Named(name) = &self.group_filter
|
||||
&& !self.connections.iter().any(|c| {
|
||||
c.group.as_deref().map(str::trim) == Some(name.as_str())
|
||||
})
|
||||
{
|
||||
self.group_filter = GroupFilter::All;
|
||||
}
|
||||
}
|
||||
|
||||
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.clear_messages(id);
|
||||
self.statuses.remove(id);
|
||||
self.errors.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);
|
||||
self.normalize_group_filter();
|
||||
self.persist_ui_state();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn toggle_connection(&self, cfg: &ConnectionConfig) {
|
||||
// Connecting 期间 is_connected 恒为 false:防抖,双击/误触不会
|
||||
// 并发两次 connect(会互相顶掉句柄并在 broker 侧形成重连风暴)
|
||||
if self.statuses.get(&cfg.id) == Some(&ConnectionStatus::Connecting) {
|
||||
return;
|
||||
}
|
||||
if self.engine.is_connected(&cfg.id) {
|
||||
self.engine.close(&cfg.id, true);
|
||||
} else {
|
||||
// 遗嘱支持 {{变量}}:对副本注入,保存的配置仍为模板原文
|
||||
let mut cfg = cfg.clone();
|
||||
crate::model::render_will_templates(&mut cfg, &self.variables);
|
||||
self.engine.connect(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开的标签数(资源监控展示用)。
|
||||
pub fn open_tab_count(&self) -> usize {
|
||||
self.open_tabs.len()
|
||||
}
|
||||
|
||||
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());
|
||||
self.persist_ui_state();
|
||||
#[cfg(debug_assertions)]
|
||||
if std::env::var("MQTTX_DEBUG_TABS").as_deref() == Ok("1") {
|
||||
eprintln!("[debug] open_tab {conn_id} views={}", self.views.len());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) 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();
|
||||
}
|
||||
self.persist_ui_state();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// 把当前打开的标签页写入 ui-state.json,重启后恢复工作区。
|
||||
fn persist_ui_state(&self) {
|
||||
self.storage.save_ui_state(&UiState {
|
||||
open_tabs: self.open_tabs.clone(),
|
||||
active_tab: self.active_tab.clone(),
|
||||
sidebar_collapsed: self.sidebar_collapsed,
|
||||
});
|
||||
}
|
||||
|
||||
/// 收起/展开连接侧栏抽屉(Ctrl+B 或 rail/标题栏按钮),状态随 ui-state 持久化。
|
||||
pub fn toggle_sidebar(&mut self, cx: &mut Context<Self>) {
|
||||
self.sidebar_collapsed = !self.sidebar_collapsed;
|
||||
self.persist_ui_state();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Ctrl+Tab / Ctrl+Shift+Tab:按方向在打开的标签页间循环切换。
|
||||
pub fn cycle_tab(&mut self, dir: i32, cx: &mut Context<Self>) {
|
||||
if self.open_tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cur = self
|
||||
.active_tab
|
||||
.as_ref()
|
||||
.and_then(|a| self.open_tabs.iter().position(|t| t == a))
|
||||
.unwrap_or(0);
|
||||
let len = self.open_tabs.len() as i32;
|
||||
let next = ((cur as i32 + dir).rem_euclid(len)) as usize;
|
||||
self.active_tab = Some(self.open_tabs[next].clone());
|
||||
self.persist_ui_state();
|
||||
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, window: &mut Window, cx: &mut App) {
|
||||
// 主题按预设分派:"linear" 走外观模式(跟随系统/浅/深),官方捆绑
|
||||
// 预设整包应用(自带明暗,apply_config 连 mode 一起生效)
|
||||
if settings.theme_preset == crate::ui::app::theme::DEFAULT_PRESET {
|
||||
apply_theme(settings.theme, cx);
|
||||
} else if !crate::ui::app::theme::apply_theme_preset(&settings.theme_preset, cx) {
|
||||
apply_theme(settings.theme, cx);
|
||||
}
|
||||
// 语言即时生效(含跟随系统)
|
||||
crate::ui::i18n::set_lang(match settings.language {
|
||||
crate::model::LanguagePref::Zh => crate::ui::i18n::Lang::Zh,
|
||||
crate::model::LanguagePref::En => crate::ui::i18n::Lang::En,
|
||||
crate::model::LanguagePref::System => crate::ui::i18n::from_system(),
|
||||
});
|
||||
self.settings = settings;
|
||||
self.storage.save_settings(&self.settings);
|
||||
// 预设切换/主题变更后强制整窗重画,避免未损坏区域滞留旧主题像素
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
|
||||
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 mut groups: Vec<String> = self
|
||||
.connections
|
||||
.iter()
|
||||
.filter_map(|c| c.group.as_ref().map(|g| g.trim().to_string()))
|
||||
.filter(|g| !g.is_empty())
|
||||
.collect();
|
||||
groups.sort();
|
||||
groups.dedup();
|
||||
let form = cx.new(|cx| ConnectionForm::new(engine, edit, groups, 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(i18n::t("form.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 { i18n::t("form.testing") } else { i18n::t("form.test") })
|
||||
.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(i18n::t("common.cancel"))
|
||||
.outline()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("form-ok")
|
||||
.label(i18n::t("common.save"))
|
||||
.primary()
|
||||
.on_click(move |_, window, cx| {
|
||||
let built = form_ok.read(cx).build(cx);
|
||||
match built {
|
||||
Ok(cfg) => {
|
||||
// 已连接的连接改了关键参数,重连后才生效,先算好再保存
|
||||
let mut stale = false;
|
||||
app_ok
|
||||
.update(cx, |app, cx| {
|
||||
let connected = app.statuses.get(&cfg.id)
|
||||
== Some(&ConnectionStatus::Connected);
|
||||
if connected
|
||||
&& app
|
||||
.connections
|
||||
.iter()
|
||||
.find(|c| c.id == cfg.id)
|
||||
.is_some_and(|old| {
|
||||
old.session_params_changed(&cfg)
|
||||
})
|
||||
{
|
||||
stale = true;
|
||||
}
|
||||
app.save_connection(cfg);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
window.close_dialog(cx);
|
||||
if stale {
|
||||
window.push_notification(
|
||||
Notification::info(i18n::t("form.saved_reconnect")),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 触发重绘,让字段级红字即时显示(错误已写入表单内部)
|
||||
form_ok.update(cx, |_, cx| cx.notify());
|
||||
window.push_notification(
|
||||
Notification::error(e),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! 引擎事件泵:把 MqttEngine 的状态/消息/日志事件写入应用状态。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui_kit::component::{ notification::Notification, };
|
||||
use gpui_kit::{ Context, Window, };
|
||||
|
||||
use crate::model::{ ConnectionStatus, Direction, LogEntry, LogLevel, MqttRecord, MessageRing, SubscribeOptions, Subscription, retained_payload, };
|
||||
use crate::mqtt::EngineEvent;
|
||||
|
||||
use super::*;
|
||||
use crate::ui::i18n;
|
||||
|
||||
impl MqttXApp {
|
||||
pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
EngineEvent::Status {
|
||||
connection_id,
|
||||
status,
|
||||
error,
|
||||
..
|
||||
} => {
|
||||
// 引擎 close 与事件循环退出之间有窗口,删除连接后其排队
|
||||
// 事件仍会送达:为已删除的连接重建状态/消息属于泄漏,
|
||||
// 一律丢弃(删除时已主动 close)。
|
||||
if !self.connections.iter().any(|c| c.id == connection_id) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
if !self.connections.iter().any(|c| c.id == id.as_ref()) {
|
||||
return;
|
||||
}
|
||||
record.seq = self.next_seq();
|
||||
self.push_message(id.clone(), record);
|
||||
self.notify_message_view(&id, cx);
|
||||
}
|
||||
EngineEvent::Published {
|
||||
connection_id,
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
content_type,
|
||||
user_properties,
|
||||
response_topic,
|
||||
correlation_data,
|
||||
message_expiry_interval,
|
||||
} => {
|
||||
if !self.connections.iter().any(|c| c.id == connection_id) {
|
||||
return;
|
||||
}
|
||||
let (payload, raw_bytes, payload_truncated) = retained_payload(&payload);
|
||||
let record = MqttRecord {
|
||||
seq: self.next_seq(),
|
||||
connection_id: Arc::from(connection_id.as_str()),
|
||||
direction: Direction::Published,
|
||||
topic: Arc::from(topic.as_str()),
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
timestamp: chrono::Local::now().timestamp_millis(),
|
||||
user_properties: Arc::from(user_properties),
|
||||
content_type,
|
||||
response_topic,
|
||||
correlation_data,
|
||||
message_expiry_interval,
|
||||
subscription_identifier: None,
|
||||
payload_truncated,
|
||||
raw_bytes,
|
||||
preview: Arc::from(""),
|
||||
};
|
||||
self.push_message(Arc::from(connection_id.as_str()), record);
|
||||
self.notify_message_view(&connection_id, cx);
|
||||
}
|
||||
EngineEvent::SubscribeResult {
|
||||
connection_id,
|
||||
topic,
|
||||
qos,
|
||||
ok,
|
||||
error,
|
||||
} => {
|
||||
if !self.connections.iter().any(|c| c.id == connection_id) {
|
||||
return;
|
||||
}
|
||||
if ok {
|
||||
// 乐观更新已在发送前入库;这里只在订阅仍存在时同步 QoS,
|
||||
// 避免 SUBACK 前被删除的订阅被此事件用默认字段复活。
|
||||
if let Some(existing) = self.subscriptions.iter_mut().find(|s| {
|
||||
s.connection_id == connection_id && s.topic == topic
|
||||
}) {
|
||||
existing.qos = qos;
|
||||
}
|
||||
} else {
|
||||
if let Some(e) = error {
|
||||
window.push_notification(
|
||||
Notification::error(i18n::tf(
|
||||
"sub.failed",
|
||||
&[("e", &e)],
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
// broker 拒绝订阅:移除乐观插入的订阅行,与引擎状态保持一致
|
||||
// (不发 UNSUBSCRIBE——本就未订阅成功)
|
||||
self.subscriptions
|
||||
.retain(|s| !(s.connection_id == connection_id && s.topic == topic));
|
||||
self.storage.save_subscriptions(&self.subscriptions);
|
||||
}
|
||||
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)
|
||||
.filter(|s| s.enabled)
|
||||
.cloned()
|
||||
.collect();
|
||||
for sub in subs {
|
||||
let opts = SubscribeOptions::from(&sub);
|
||||
self.engine
|
||||
.subscribe_with_options(connection_id.to_string(), sub, opts);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据工具 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// 消息到达后刷新对应工作区;用户按下「暂停」时跳过,
|
||||
/// 避免高频消息流不断把正在阅读的内容顶走(消息仍照常入环形缓冲)。
|
||||
fn notify_message_view(&self, connection_id: &str, cx: &mut Context<Self>) {
|
||||
if let Some(view) = self.views.get(connection_id)
|
||||
&& !view.read(cx).is_paused()
|
||||
{
|
||||
view.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
}
|
||||
|
||||
fn next_seq(&mut self) -> u64 {
|
||||
self.seq += 1;
|
||||
self.seq
|
||||
}
|
||||
|
||||
fn push_message(&mut self, connection_id: Arc<str>, mut record: MqttRecord) {
|
||||
// 折叠态预览在此唯一入口计算一次;渲染热路径逐帧直接取用
|
||||
record.compute_preview();
|
||||
let cap = self.settings.max_messages;
|
||||
self.messages
|
||||
.entry(connection_id)
|
||||
.or_insert_with(|| MessageRing::new(cap))
|
||||
.push(record, cap);
|
||||
}
|
||||
|
||||
/// 清空指定连接的消息(内存缓冲随 drop 释放)。
|
||||
pub fn clear_messages(&mut self, id: &str) {
|
||||
self.messages.remove(id);
|
||||
}
|
||||
|
||||
fn push_log(&mut self, entry: LogEntry) {
|
||||
// 落盘只在事件路径执行(不在 render 热路径),与内存环形缓冲互不影响
|
||||
self.write_log_file(&entry);
|
||||
if self.logs.len() >= MAX_LOGS {
|
||||
self.logs.pop_front();
|
||||
}
|
||||
self.logs.push_back(Arc::new(entry));
|
||||
}
|
||||
|
||||
/// 追加写入按日切分的日志文件 `mqttx-YYYY-MM-DD.log`。
|
||||
/// 文件句柄按日期缓存复用,只有跨日或写失败后才重新打开;
|
||||
/// 失败只在首次 eprintln 一次后保持静默(成功一次即复位,便于恢复后再次提示),
|
||||
/// 且绝不回调 log/push_log(防止递归触发日志)。
|
||||
fn write_log_file(&mut self, entry: &LogEntry) {
|
||||
use std::sync::atomic::{ AtomicBool, Ordering };
|
||||
|
||||
static WRITE_FAILED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
match self.append_log_line(entry) {
|
||||
Ok(()) => WRITE_FAILED.store(false, Ordering::Relaxed),
|
||||
Err(e) => {
|
||||
// 关句柄置空,下次写入自动重开重试
|
||||
self.log_file = None;
|
||||
if !WRITE_FAILED.swap(true, Ordering::Relaxed) {
|
||||
eprintln!("[app] 写入日志文件失败(后续静默): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开(或复用按日缓存的)日志文件并追加一行。
|
||||
/// 文件套 BufWriter:高频消息流下每条日志不再触发多次落盘 syscall;
|
||||
/// 跨日重开/出错关句柄时随 drop 自动 flush。
|
||||
fn append_log_line(&mut self, entry: &LogEntry) -> std::io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
use chrono::TimeZone as _;
|
||||
|
||||
let ts = chrono::Local
|
||||
.timestamp_millis_opt(entry.timestamp)
|
||||
.single()
|
||||
.unwrap_or_else(chrono::Local::now);
|
||||
let day = ts.format("%Y-%m-%d").to_string();
|
||||
let need_reopen = match &self.log_file {
|
||||
Some((cached_day, _)) => *cached_day != day,
|
||||
None => true,
|
||||
};
|
||||
if need_reopen {
|
||||
let dir = self.storage.log_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join(format!("mqttx-{day}.log"));
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
self.log_file = Some((day, std::io::BufWriter::new(file)));
|
||||
}
|
||||
// 连接名查不到时回退为 id,保证行格式恒定
|
||||
let conn = self
|
||||
.connections
|
||||
.iter()
|
||||
.find(|c| c.id == entry.connection_id)
|
||||
.map(|c| c.name.as_str())
|
||||
.unwrap_or(entry.connection_id.as_str());
|
||||
let file = &mut self
|
||||
.log_file
|
||||
.as_mut()
|
||||
.expect("上方已确保日志句柄存在")
|
||||
.1;
|
||||
writeln!(
|
||||
file,
|
||||
"{} [{}] {}/{}: {}",
|
||||
ts.format("%Y-%m-%d %H:%M:%S%.3f"),
|
||||
entry.level.label(),
|
||||
conn,
|
||||
entry.event,
|
||||
entry.message
|
||||
)
|
||||
}
|
||||
|
||||
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 / 生命周期 ──────────────────────────────────────────────
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! 连接配置导入/导出(文件对话框 + JSON 序列化)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui_kit::component::{ notification::Notification, };
|
||||
use gpui_kit::{ Context, Window, };
|
||||
|
||||
|
||||
use super::*;
|
||||
use crate::ui::i18n;
|
||||
|
||||
impl MqttXApp {
|
||||
/// 数据目录(设置对话框展示 / 打开用)。
|
||||
pub fn data_dir(&self) -> PathBuf {
|
||||
self.storage.dir().to_path_buf()
|
||||
}
|
||||
|
||||
/// 日志目录(设置对话框展示 / 打开用)。
|
||||
pub fn log_dir(&self) -> PathBuf {
|
||||
self.storage.log_dir()
|
||||
}
|
||||
|
||||
// ── 连接导入 / 导出 ────────────────────────────────────────────────────
|
||||
|
||||
/// 在后台线程弹出阻塞式原生文件对话框,完成后回主线程执行 `done`。
|
||||
/// 对话框进行中置位,防止重复弹出。
|
||||
fn spawn_file_dialog<R, F, D>(
|
||||
&mut self,
|
||||
work: F,
|
||||
done: D,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) where
|
||||
F: FnOnce() -> R + Send + 'static,
|
||||
R: Send + 'static,
|
||||
D: FnOnce(&mut Self, R, &mut Window, &mut Context<Self>) + 'static,
|
||||
{
|
||||
if self.file_dialog_open {
|
||||
return;
|
||||
}
|
||||
self.file_dialog_open = true;
|
||||
// rfd 的阻塞对话框不可占用 UI 线程:结果经 smol 通道送回异步任务
|
||||
let (tx, rx) = smol::channel::bounded::<R>(1);
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send_blocking(work());
|
||||
});
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
|
||||
let result = rx.recv().await.ok();
|
||||
weak.update_in(cx, |app, window, cx| {
|
||||
app.file_dialog_open = false;
|
||||
if let Some(result) = result {
|
||||
done(app, result, window, cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// 导出全部连接为 JSON(原生保存对话框,默认文件名 connections-export.json)。
|
||||
pub fn export_connections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let dir = self.storage.dir().to_path_buf();
|
||||
self.spawn_file_dialog(
|
||||
move || {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(i18n::t("titlebar.export"))
|
||||
.set_directory(dir)
|
||||
.set_file_name("connections-export.json")
|
||||
.add_filter("JSON", &["json"])
|
||||
.save_file()
|
||||
},
|
||||
|app, path, window, cx| {
|
||||
let Some(path) = path else {
|
||||
return; // 用户取消
|
||||
};
|
||||
let count = app.connections.len();
|
||||
match crate::store::export_connections_to(&path, &app.connections) {
|
||||
Ok(()) => window.push_notification(
|
||||
Notification::success(i18n::tf(
|
||||
"io.exported",
|
||||
&[
|
||||
("n", &count),
|
||||
("path", &path.display()),
|
||||
],
|
||||
)),
|
||||
cx,
|
||||
),
|
||||
Err(e) => window.push_notification(Notification::error(e), cx),
|
||||
}
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// 导入连接:按 id 去重(同 id 跳过),新 id 保留文件原值;读取解析在后台线程完成。
|
||||
pub fn import_connections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let dir = self.storage.dir().to_path_buf();
|
||||
self.spawn_file_dialog(
|
||||
move || {
|
||||
let Some(path) = rfd::FileDialog::new()
|
||||
.set_title(i18n::t("titlebar.import"))
|
||||
.set_directory(dir)
|
||||
.add_filter("JSON", &["json"])
|
||||
.pick_file()
|
||||
else {
|
||||
return Ok(None); // 用户取消
|
||||
};
|
||||
crate::store::import_connections_from(&path).map(Some)
|
||||
},
|
||||
|app, result, window, cx| match result {
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e), cx);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Ok(Some(items)) => {
|
||||
let mut existing: std::collections::HashSet<String> =
|
||||
app.connections.iter().map(|c| c.id.clone()).collect();
|
||||
let mut added = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
for item in items {
|
||||
if existing.contains(&item.id) {
|
||||
skipped += 1;
|
||||
} else {
|
||||
// 记录已入库 id,导入文件内部的重复 id 也只收一条
|
||||
existing.insert(item.id.clone());
|
||||
app.connections.push(item);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
if added > 0 {
|
||||
app.storage.save_connections(&app.connections);
|
||||
}
|
||||
window.push_notification(
|
||||
Notification::success(i18n::tf(
|
||||
"io.imported",
|
||||
&[
|
||||
("n", &added),
|
||||
("n2", &skipped),
|
||||
],
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 对话框 ────────────────────────────────────────────────────────────
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
{
|
||||
"name": "Linear Light",
|
||||
"mode": "light",
|
||||
"font_size": 14,
|
||||
"font.size": 14,
|
||||
"radius": 6,
|
||||
"radius_lg": 10,
|
||||
"radius.lg": 10,
|
||||
"colors": {
|
||||
"accent.background": "#eeeef0",
|
||||
"accent.foreground": "#0d0e10",
|
||||
@@ -211,9 +211,9 @@
|
||||
{
|
||||
"name": "Linear Dark",
|
||||
"mode": "dark",
|
||||
"font_size": 14,
|
||||
"font.size": 14,
|
||||
"radius": 6,
|
||||
"radius_lg": 10,
|
||||
"radius.lg": 10,
|
||||
"colors": {
|
||||
"accent.background": "#1c1d1f",
|
||||
"accent.foreground": "#f7f8f8",
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 主区域:标签页栏与内容视图装配(含空态占位)。
|
||||
|
||||
|
||||
use gpui_kit::component::button::{ Button };
|
||||
use gpui_kit::component::tab::{ Tab, TabBar };
|
||||
use gpui_kit::component::{ v_flex, };
|
||||
use gpui_kit::{ div, px, Context, IntoElement, SharedString, Window, };
|
||||
|
||||
use crate::ui::IconName;
|
||||
|
||||
use super::*;
|
||||
use crate::ui::i18n;
|
||||
|
||||
impl MqttXApp {
|
||||
pub(super) fn render_main(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if self.open_tabs.is_empty() {
|
||||
// 父级 h_flex 交叉轴居中子项,容器必须 h_full 占满高度才不会整体下沉
|
||||
return v_flex()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.min_h(px(0.))
|
||||
.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(i18n::t("tab.welcome_title")))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_center()
|
||||
.child(i18n::t("tab.welcome_hint")),
|
||||
)
|
||||
.child(
|
||||
Button::new("empty-new")
|
||||
.icon(IconName::Plus)
|
||||
.label(i18n::t("titlebar.new_connection"))
|
||||
.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")
|
||||
// 连接名过长时截断省略,避免单个页签占满整行
|
||||
.max_width(px(180.));
|
||||
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();
|
||||
#[cfg(debug_assertions)]
|
||||
if std::env::var("MQTTX_DEBUG_TABS").as_deref() == Ok("1") {
|
||||
eprintln!(
|
||||
"[debug] render_main active={active} views={} tabs={:?} view_found={}",
|
||||
self.views.len(),
|
||||
self.open_tabs,
|
||||
view.is_some()
|
||||
);
|
||||
}
|
||||
// h_full:父级 h_flex 交叉轴居中子项,不占满高度会被整体垂直居中;
|
||||
// 视图根是 size_full,须包在 flex_1 容器里,否则会盖住 TabBar 并溢出。
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.min_h(px(0.))
|
||||
.min_w(px(0.))
|
||||
.child(div().flex_shrink_0().px_2().pt_1().child(bar))
|
||||
.when_some(view, |this, v| {
|
||||
this.child(v_flex().flex_1().min_h(px(0.)).child(v))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
//! 应用外壳:标题栏 + 连接侧边栏 + 多标签连接视图 + 引擎事件泵。
|
||||
//!
|
||||
//! - [`events`] 引擎事件 → 应用状态(连接状态、消息、日志)
|
||||
//! - [`actions`] 用户动作(连接/订阅/预设/设置等状态写操作)
|
||||
//! - [`io`] 连接配置导入导出
|
||||
//! - [`theme`] 主题应用
|
||||
//! - [`titlebar`] / [`sidebar`] / [`main_area`] 渲染分区
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||
use gpui_kit::component::scroll::ScrollableElement as _;
|
||||
use gpui_kit::component::input::InputState;
|
||||
use gpui_kit::component::menu::DropdownMenu as _;
|
||||
use gpui_kit::component::{
|
||||
h_flex, notification::Notification, v_flex, ActiveTheme as _, Selectable as _, Sizable as _,
|
||||
StyledExt as _, Theme, ThemeMode, WindowExt as _,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::{
|
||||
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
KeyBinding, ParentElement as _, Render, StatefulInteractiveElement as _,
|
||||
Styled as _, Window,
|
||||
};
|
||||
|
||||
use crate::aliyun::AliyunPreset;
|
||||
use crate::model::{
|
||||
AppSettings, ConnectionConfig, ConnectionStatus, GlobalVariable, LogEntry, MessageRing, PublishPreset,
|
||||
Subscription, ThemeModePref,
|
||||
};
|
||||
use crate::mqtt::MqttEngine;
|
||||
use crate::store::{Storage, UiState};
|
||||
use crate::update;
|
||||
use crate::ui::connection_view::ConnectionView;
|
||||
use crate::ui::i18n::{self, Lang};
|
||||
|
||||
mod actions;
|
||||
mod events;
|
||||
mod io;
|
||||
mod main_area;
|
||||
mod sidebar;
|
||||
pub(crate) mod theme;
|
||||
mod titlebar;
|
||||
|
||||
use theme::apply_theme;
|
||||
|
||||
const MAX_LOGS: usize = 3000;
|
||||
/// 引擎 → UI 的事件队列容量(有界,见 `MqttEngine::emit` 的丢弃策略)
|
||||
const EVENT_QUEUE_CAPACITY: usize = 1024;
|
||||
|
||||
// 应用级快捷键动作(context=None:任意焦点状态下都匹配)。
|
||||
gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections, OpenResourceMonitor, PublishMessage, ToggleSidebar, CloseActiveTab, NextTab, PrevTab, FocusMessageFilter]);
|
||||
|
||||
/// 侧边栏分组过滤;chips 只做列表过滤,分组重命名/删除通过编辑连接的分组字段完成。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum GroupFilter {
|
||||
All,
|
||||
Ungrouped,
|
||||
Named(String),
|
||||
}
|
||||
|
||||
/// 全局 action 回调只有 `&mut App`:先 defer 出当前分发栈,再取活动窗口回到实体执行,
|
||||
/// 避免在窗口借用期间重入 `update`。
|
||||
fn run_on_active_window(
|
||||
cx: &mut App,
|
||||
weak: gpui_kit::WeakEntity<MqttXApp>,
|
||||
f: impl FnOnce(Entity<MqttXApp>, &mut Window, &mut App) + 'static,
|
||||
) {
|
||||
let Some(handle) = cx.active_window() else {
|
||||
return;
|
||||
};
|
||||
cx.defer(move |cx| {
|
||||
let Some(entity) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let _ = handle.update(cx, move |_, window, cx| f(entity, window, cx));
|
||||
});
|
||||
}
|
||||
|
||||
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>,
|
||||
/// 每连接消息环形缓冲:键为驻留后的连接 ID,条数 + 字节预算双重约束
|
||||
pub messages: HashMap<Arc<str>, MessageRing>,
|
||||
/// Arc 共享:日志面板每帧最多克隆 3000 条,深拷贝会让渲染线程每秒
|
||||
/// 产生数十万次 String 堆分配;改 Arc 后仅增加引用计数
|
||||
pub logs: VecDeque<Arc<LogEntry>>,
|
||||
pub seq: u64,
|
||||
|
||||
// ── 标签与视图 ──
|
||||
open_tabs: Vec<String>,
|
||||
active_tab: Option<String>,
|
||||
views: HashMap<String, Entity<ConnectionView>>,
|
||||
|
||||
search: Entity<InputState>,
|
||||
/// 分组过滤状态(chips 点击切换,与搜索条件叠加)
|
||||
group_filter: GroupFilter,
|
||||
/// 原生文件对话框进行中标记(对话框阻塞后台线程,防止重复弹出)
|
||||
file_dialog_open: bool,
|
||||
/// 按日期缓存的日志文件句柄(日期, 文件),避免每条日志都重新打开文件
|
||||
log_file: Option<(String, std::io::BufWriter<std::fs::File>)>,
|
||||
/// 系统外观变化订阅:设置为「跟随系统」时重应用主题,随实体存活
|
||||
_appearance_obs: gpui_kit::Subscription,
|
||||
/// 窗口置顶状态(标题栏钉子按钮切换)
|
||||
pinned: bool,
|
||||
/// 连接侧栏抽屉收起状态(Ctrl+B / rail 按钮切换,随 ui-state 持久化)
|
||||
sidebar_collapsed: bool,
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
|
||||
// 官方捆绑预设已在 bootstrap(main)注册,这里按保存的选择分派
|
||||
if settings.theme_preset != theme::DEFAULT_PRESET {
|
||||
if !theme::apply_theme_preset(&settings.theme_preset, cx) {
|
||||
apply_theme(settings.theme, cx);
|
||||
}
|
||||
} else {
|
||||
apply_theme(settings.theme, cx);
|
||||
}
|
||||
// 界面语言:设置显式指定优先,跟随系统时按系统 locale 解析
|
||||
i18n::set_lang(match settings.language {
|
||||
crate::model::LanguagePref::Zh => Lang::Zh,
|
||||
crate::model::LanguagePref::En => Lang::En,
|
||||
crate::model::LanguagePref::System => i18n::from_system(),
|
||||
});
|
||||
|
||||
// 有界事件队列:引擎侧对高频事件(消息/日志)做丢弃并计数,保证 UI 消费
|
||||
// 不过来时内存有上界。1024 × 单条最大 128KB ≈ 最坏 128MB,常规消息下几百 KB。
|
||||
let (tx, rx) = smol::channel::bounded(EVENT_QUEUE_CAPACITY);
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// OTA:清理上次自更新遗留文件;开启自动检查则后台静默检查并下载,
|
||||
// 下载完成后弹对话框征得同意再安装(不自动重启)。
|
||||
update::cleanup_old();
|
||||
// 自动连接:勾选「启动时自动连接」的连接逐个拉起(引擎异步建连)
|
||||
for cfg in connections
|
||||
.iter()
|
||||
.filter(|c| c.auto_connect)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
let mut cfg = cfg;
|
||||
crate::model::render_will_templates(&mut cfg, &variables);
|
||||
engine.connect(cfg);
|
||||
}
|
||||
|
||||
// 会话恢复:载入上次打开的标签页(过滤掉已删除的连接)。
|
||||
// 若无可恢复标签且启用了自动连接,则自动打开第一个自动连接的工作区,
|
||||
// 避免「连接已在后台连上、主区却演空态」的不连贯。
|
||||
let ui_state = storage.load_ui_state();
|
||||
let conn_ids: std::collections::HashSet<String> =
|
||||
connections.iter().map(|c| c.id.clone()).collect();
|
||||
let restored_tabs: Vec<String> = ui_state
|
||||
.open_tabs
|
||||
.into_iter()
|
||||
.filter(|id| conn_ids.contains(id))
|
||||
.collect();
|
||||
let restored_active = ui_state
|
||||
.active_tab
|
||||
.filter(|id| restored_tabs.contains(id));
|
||||
let auto_first = connections
|
||||
.iter()
|
||||
.find(|c| c.auto_connect)
|
||||
.map(|c| c.id.clone());
|
||||
let defer_tabs = restored_tabs.clone();
|
||||
let defer_active = restored_active.clone();
|
||||
cx.defer_in(window, move |app, window, cx| {
|
||||
if defer_tabs.is_empty() {
|
||||
if let Some(id) = auto_first {
|
||||
app.open_tab(&id, window, cx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for id in defer_tabs {
|
||||
app.open_tab(&id, window, cx);
|
||||
}
|
||||
if let Some(id) = defer_active {
|
||||
app.active_tab = Some(id);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
if settings.auto_check_update {
|
||||
let rx = engine.run_blocking(update::check_and_download);
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Some(staged))) => {
|
||||
weak.update_in(cx, |_app, window, cx| {
|
||||
window.open_dialog(cx, move |dialog, _, _cx| {
|
||||
let staged = staged.clone();
|
||||
let staged_skip = staged.clone();
|
||||
let staged_consent = staged.clone();
|
||||
dialog
|
||||
.w(px(440.))
|
||||
.title(i18n::t("update.ready_title"))
|
||||
.child(
|
||||
v_flex().gap_2().child(
|
||||
div().text_sm().child(i18n::tf(
|
||||
"update.ready_body",
|
||||
&[("v", &staged.version)],
|
||||
)),
|
||||
),
|
||||
)
|
||||
.footer(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("upd-skip")
|
||||
.label(i18n::t("update.not_now"))
|
||||
.outline()
|
||||
.on_click(move |_, window, cx| {
|
||||
// 拒绝本次更新:删除暂存包,
|
||||
// 下次启动不会被自动安装
|
||||
update::discard_staged(&staged_skip);
|
||||
window.close_dialog(cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("upd-later")
|
||||
.label(i18n::t("update.install_later"))
|
||||
.outline()
|
||||
.on_click(move |_, window, cx| {
|
||||
// 同意标记:启动时据此自动安装;
|
||||
// 用户跳过对话框则不会强制升级
|
||||
update::mark_install_consent(&staged_consent);
|
||||
window.close_dialog(cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("upd-now")
|
||||
.label(i18n::t("update.install_now"))
|
||||
.primary()
|
||||
.on_click(move |_, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
if let Err(e) =
|
||||
update::install_staged(&staged)
|
||||
{
|
||||
window.push_notification(
|
||||
Notification::error(i18n::tf(
|
||||
"update.install_failed",
|
||||
&[("e", &e),
|
||||
])),
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
cx.quit();
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
// 检查/下载失败不打扰用户,但也不能完全无声
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[app] 启动更新检查失败: {e}");
|
||||
sentry::capture_message(
|
||||
&format!("startup update check failed: {e}"),
|
||||
sentry::Level::Warning,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
let search = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("sidebar.search")));
|
||||
|
||||
// 自验后门:MQTTX_OPEN_RESMON=1 启动时直接打开资源监控
|
||||
// (与 MQTTX_VERSION_OVERRIDE 同类的测试入口,供自动化验证 UI)。
|
||||
// defer 到 effects 阶段执行,避免在 MqttXApp 构造借用内嵌套打开
|
||||
if std::env::var("MQTTX_OPEN_RESMON").as_deref() == Ok("1") {
|
||||
cx.defer_in(window, move |_, window, cx| {
|
||||
crate::ui::dialogs::resource_dialog::open(cx.entity(), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
// 「跟随系统」:监听系统外观(深浅色)变化,仅在设置为 System 时重应用主题。
|
||||
// 订阅需随实体存活,dropped 即取消,故存入字段。
|
||||
let appearance_weak = cx.entity().downgrade();
|
||||
let appearance_obs = window.observe_window_appearance(move |window, cx| {
|
||||
let Some(app) = appearance_weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
// 官方捆绑预设自带明暗,系统深浅切换不干预(避免冲掉用户选择)
|
||||
if app.read(cx).settings.theme_preset != theme::DEFAULT_PRESET {
|
||||
return;
|
||||
}
|
||||
if app.read(cx).settings.theme != ThemeModePref::System {
|
||||
return;
|
||||
}
|
||||
let mode = ThemeMode::from(window.appearance());
|
||||
Theme::change(mode, Some(window), cx);
|
||||
// Theme::change 只更新全局令牌,不保证整窗重绘;未损坏区域会
|
||||
// 滞留旧主题像素(实测发布栏长期不收敛),强制重画一次
|
||||
window.refresh();
|
||||
});
|
||||
|
||||
// ── 全局快捷键:绑定 + action 注册 ──
|
||||
// 元素级 on_action 依赖焦点路径,无焦点时分发从树根开始收不到;
|
||||
// 全局 action 在冒泡末端必达,经 defer 取窗口避免在分发栈内重入。
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("ctrl-n", NewConnection, None),
|
||||
KeyBinding::new("ctrl-,", OpenSettings, None),
|
||||
KeyBinding::new("ctrl-shift-e", ExportConnections, None),
|
||||
KeyBinding::new("ctrl-shift-i", ImportConnections, None),
|
||||
KeyBinding::new("ctrl-shift-r", OpenResourceMonitor, None),
|
||||
KeyBinding::new("ctrl-enter", PublishMessage, None),
|
||||
KeyBinding::new("ctrl-b", ToggleSidebar, None),
|
||||
KeyBinding::new("ctrl-w", CloseActiveTab, None),
|
||||
KeyBinding::new("ctrl-tab", NextTab, None),
|
||||
KeyBinding::new("ctrl-shift-tab", PrevTab, None),
|
||||
KeyBinding::new("ctrl-f", FocusMessageFilter, None),
|
||||
]);
|
||||
let weak = cx.entity().downgrade();
|
||||
// Context 上有同名 on_action(绘制期注册),这里必须走 App 的全局注册
|
||||
App::on_action::<NewConnection>(cx, move |_, cx| {
|
||||
let w = weak.clone();
|
||||
run_on_active_window(cx, w, |entity, window, cx| {
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
entity.update(cx, |app, cx| app.open_connection_form(None, window, cx));
|
||||
});
|
||||
});
|
||||
let weak = cx.entity().downgrade();
|
||||
App::on_action::<OpenSettings>(cx, move |_, cx| {
|
||||
let w = weak.clone();
|
||||
run_on_active_window(cx, w, |entity, window, cx| {
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
crate::ui::dialogs::settings_dialog::open(entity, window, cx);
|
||||
});
|
||||
});
|
||||
let weak = cx.entity().downgrade();
|
||||
App::on_action::<OpenResourceMonitor>(cx, move |_, cx| {
|
||||
let w = weak.clone();
|
||||
run_on_active_window(cx, w, |entity, window, cx| {
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
crate::ui::dialogs::resource_dialog::open(entity, window, cx);
|
||||
});
|
||||
});
|
||||
let weak = cx.entity().downgrade();
|
||||
App::on_action::<ExportConnections>(cx, move |_, cx| {
|
||||
let w = weak.clone();
|
||||
run_on_active_window(cx, w, |entity, window, cx| {
|
||||
// 与新建/设置一致:对话框打开时不叠加文件对话框
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
entity.update(cx, |app, cx| app.export_connections(window, cx));
|
||||
});
|
||||
});
|
||||
let weak = cx.entity().downgrade();
|
||||
App::on_action::<ImportConnections>(cx, move |_, cx| {
|
||||
let w = weak.clone();
|
||||
run_on_active_window(cx, w, |entity, window, cx| {
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
entity.update(cx, |app, cx| app.import_connections(window, cx));
|
||||
});
|
||||
});
|
||||
// Ctrl+Enter 全局发送:焦点在负载框、QoS 下拉或任意位置都能发送,
|
||||
// 不再依赖焦点恰好落在某个输入框上(无标签页时静默忽略)
|
||||
let weak = cx.entity().downgrade();
|
||||
App::on_action::<PublishMessage>(cx, move |_, cx| {
|
||||
let w = weak.clone();
|
||||
run_on_active_window(cx, w, |entity, window, cx| {
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
entity.update(cx, |app, cx| {
|
||||
if let Some(tab) = app.active_tab.clone()
|
||||
&& let Some(view) = app.views.get(&tab).cloned()
|
||||
{
|
||||
view.update(cx, |view, cx| view.do_publish(window, cx));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// Ctrl+B 抽屉式收起/展开连接侧栏
|
||||
let weak = cx.entity().downgrade();
|
||||
App::on_action::<ToggleSidebar>(cx, move |_, cx| {
|
||||
let w = weak.clone();
|
||||
run_on_active_window(cx, w, |entity, _, cx| {
|
||||
entity.update(cx, |app, cx| app.toggle_sidebar(cx));
|
||||
});
|
||||
});
|
||||
// Ctrl+W 关闭当前标签页
|
||||
let weak = cx.entity().downgrade();
|
||||
App::on_action::<CloseActiveTab>(cx, move |_, cx| {
|
||||
let w = weak.clone();
|
||||
run_on_active_window(cx, w, |entity, _, cx| {
|
||||
entity.update(cx, |app, cx| {
|
||||
if let Some(tab) = app.active_tab.clone() {
|
||||
app.close_tab(&tab, cx);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab 在打开的标签页间循环切换
|
||||
let weak = cx.entity().downgrade();
|
||||
App::on_action::<NextTab>(cx, move |_, cx| {
|
||||
let w = weak.clone();
|
||||
run_on_active_window(cx, w, |entity, _, cx| {
|
||||
entity.update(cx, |app, cx| app.cycle_tab(1, cx));
|
||||
});
|
||||
});
|
||||
let weak = cx.entity().downgrade();
|
||||
App::on_action::<PrevTab>(cx, move |_, cx| {
|
||||
let w = weak.clone();
|
||||
run_on_active_window(cx, w, |entity, _, cx| {
|
||||
entity.update(cx, |app, cx| app.cycle_tab(-1, cx));
|
||||
});
|
||||
});
|
||||
// Ctrl+F 聚焦当前工作区的消息过滤框
|
||||
let weak = cx.entity().downgrade();
|
||||
App::on_action::<FocusMessageFilter>(cx, move |_, cx| {
|
||||
let w = weak.clone();
|
||||
run_on_active_window(cx, w, |entity, window, cx| {
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
entity.update(cx, |app, cx| {
|
||||
if let Some(tab) = app.active_tab.clone()
|
||||
&& let Some(view) = app.views.get(&tab).cloned()
|
||||
{
|
||||
view.update(cx, |view, cx| view.focus_filter(window, cx));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
// 标签恢复统一走下方 defer 的 open_tab(负责创建视图实体),
|
||||
// 这里不能预填 open_tabs:预填会让 open_tab 误判标签已存在而跳过建视图
|
||||
open_tabs: Vec::new(),
|
||||
active_tab: None,
|
||||
views: HashMap::new(),
|
||||
search,
|
||||
group_filter: GroupFilter::All,
|
||||
file_dialog_open: false,
|
||||
log_file: None,
|
||||
_appearance_obs: appearance_obs,
|
||||
pinned: false,
|
||||
sidebar_collapsed: ui_state.sidebar_collapsed,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 引擎事件 ──────────────────────────────────────────────────────────
|
||||
|
||||
}
|
||||
|
||||
impl Render for MqttXApp {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// 0.7 起 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.))
|
||||
// 连接侧栏抽屉:收起时只剩窄 rail(Ctrl+B 或 rail 按钮拉回)
|
||||
.child(if self.sidebar_collapsed {
|
||||
self.render_sidebar_rail(cx).into_any_element()
|
||||
} else {
|
||||
self.render_sidebar(window, cx).into_any_element()
|
||||
})
|
||||
.child(self.render_main(window, cx)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! 侧边栏:搜索、分组过滤 chips、连接列表与右键菜单。
|
||||
|
||||
|
||||
use gpui_kit::component::button::{ Button, ButtonVariant };
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::input::Input;
|
||||
use gpui_kit::component::menu::{ PopupMenuItem };
|
||||
use gpui_kit::component::{ h_flex, v_flex, };
|
||||
use gpui_kit::{ div, px, Context, IntoElement, SharedString, Window, };
|
||||
|
||||
use crate::model::{ ConnectionConfig, ConnectionStatus, };
|
||||
use crate::ui::IconName;
|
||||
use crate::ui::widgets::status_color;
|
||||
|
||||
use super::*;
|
||||
use crate::ui::i18n;
|
||||
|
||||
impl MqttXApp {
|
||||
/// 侧栏收起后的窄 rail:抽屉把手(拉回侧栏)+ 新建连接入口。
|
||||
pub(super) fn render_sidebar_rail(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.w(px(40.))
|
||||
.h_full()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.pt_2()
|
||||
.gap_1()
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
Button::new("sidebar-rail-expand")
|
||||
.icon(IconName::PanelLeftOpen)
|
||||
.ghost()
|
||||
.small()
|
||||
.tooltip(i18n::t("sidebar.expand"))
|
||||
.accessibility_label(i18n::t("sidebar.expand"))
|
||||
.on_click(cx.listener(|this, _, _, cx| this.toggle_sidebar(cx))),
|
||||
)
|
||||
.child(
|
||||
Button::new("sidebar-rail-add")
|
||||
.icon(IconName::Plus)
|
||||
.ghost()
|
||||
.small()
|
||||
.tooltip(i18n::t("titlebar.new_connection"))
|
||||
.accessibility_label(i18n::t("titlebar.new_connection"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.open_connection_form(None, window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
pub(super) 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();
|
||||
// 分组过滤器复位发生在删除/保存连接的 action 路径(normalize_group_filter),
|
||||
// render 只读——规范禁止渲染期突变状态
|
||||
let filter = self.group_filter.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() {
|
||||
// 分组过滤与搜索叠加(AND)
|
||||
let group = conn.group.as_deref().map(str::trim).unwrap_or("");
|
||||
let match_group = match &filter {
|
||||
GroupFilter::All => true,
|
||||
GroupFilter::Ungrouped => group.is_empty(),
|
||||
GroupFilter::Named(name) => group == name.as_str(),
|
||||
};
|
||||
if !match_group {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
// Error 状态把失败原因直接展示在行内(截断防溢出):
|
||||
// 只有一个红点时用户必须点进 tab 才知道为什么失败
|
||||
let error_line = matches!(status, ConnectionStatus::Error)
|
||||
.then(|| self.errors.get(&conn.id).cloned())
|
||||
.flatten();
|
||||
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),
|
||||
)
|
||||
.when_some(error_line, |v, e| {
|
||||
v.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.child(e),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.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(i18n::t("sidebar.empty")),
|
||||
)
|
||||
} 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(i18n::t("sidebar.no_match")),
|
||||
)
|
||||
} 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)
|
||||
// 深色主题下侧边栏与主区同色,补一条分隔线保持边界可辨
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.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(i18n::t("sidebar.connections")))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(total.to_string()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Button::new("sidebar-collapse")
|
||||
.icon(IconName::PanelLeftClose)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("sidebar.collapse"))
|
||||
.accessibility_label(i18n::t("sidebar.collapse"))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.toggle_sidebar(cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("sidebar-add")
|
||||
.icon(IconName::Plus)
|
||||
.tooltip(i18n::t("titlebar.new_connection"))
|
||||
.accessibility_label(i18n::t("titlebar.new_connection"))
|
||||
.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(self.render_group_chips(cx))
|
||||
.child(body)
|
||||
}
|
||||
|
||||
/// 搜索框下方的分组 chips:全部 / 未分组 / 各分组(带计数徽标),点击切换过滤。
|
||||
fn render_group_chips(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let mut names: Vec<String> = self
|
||||
.connections
|
||||
.iter()
|
||||
.filter_map(|c| c.group.as_deref().map(str::trim))
|
||||
.filter(|g| !g.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
|
||||
let ungrouped = self
|
||||
.connections
|
||||
.iter()
|
||||
.filter(|c| c.group.as_deref().map(str::trim).unwrap_or("").is_empty())
|
||||
.count();
|
||||
|
||||
// 分组多时限制高度并纵向滚动,避免 chips 换行挤占下方连接列表
|
||||
let mut row = h_flex()
|
||||
.gap_1()
|
||||
.flex_wrap()
|
||||
.px_2()
|
||||
.pb_1()
|
||||
.max_h(px(88.))
|
||||
.overflow_y_scrollbar();
|
||||
row = row.child(self.render_group_chip(
|
||||
"chip-all",
|
||||
format!("{} {}", i18n::t("common.all"), self.connections.len()),
|
||||
GroupFilter::All,
|
||||
cx,
|
||||
));
|
||||
row = row.child(self.render_group_chip(
|
||||
"chip-ungrouped",
|
||||
format!("{} {ungrouped}", i18n::t("sidebar.ungrouped")),
|
||||
GroupFilter::Ungrouped,
|
||||
cx,
|
||||
));
|
||||
for name in names {
|
||||
let count = self
|
||||
.connections
|
||||
.iter()
|
||||
.filter(|c| c.group.as_deref().map(str::trim) == Some(name.as_str()))
|
||||
.count();
|
||||
row = row.child(self.render_group_chip(
|
||||
SharedString::from(format!("chip-g-{name}")),
|
||||
format!("{name} {count}"),
|
||||
GroupFilter::Named(name),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
fn render_group_chip(
|
||||
&self,
|
||||
id: impl Into<gpui_kit::ElementId>,
|
||||
label: String,
|
||||
filter: GroupFilter,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let selected = match (&self.group_filter, &filter) {
|
||||
(GroupFilter::All, GroupFilter::All)
|
||||
| (GroupFilter::Ungrouped, GroupFilter::Ungrouped) => true,
|
||||
(GroupFilter::Named(a), GroupFilter::Named(b)) => a == b,
|
||||
_ => false,
|
||||
};
|
||||
div()
|
||||
.id(id)
|
||||
.px_2()
|
||||
.py_0p5()
|
||||
.rounded_md()
|
||||
.text_xs()
|
||||
.when(selected, |d| d.bg(cx.theme().secondary).font_semibold())
|
||||
.when(!selected, |d| {
|
||||
d.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.hover(|d| d.bg(cx.theme().secondary))
|
||||
})
|
||||
.cursor_pointer()
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
// listener 是 Fn(可多次调用),只能克隆而不能移出捕获的 filter
|
||||
this.group_filter = filter.clone();
|
||||
cx.notify();
|
||||
}))
|
||||
.child(label)
|
||||
}
|
||||
|
||||
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 { i18n::t("sidebar.disconnect") } else { i18n::t("sidebar.connect") })
|
||||
.accessibility_label(if connected { i18n::t("sidebar.disconnect") } else { i18n::t("sidebar.connect") })
|
||||
.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(i18n::t("common.edit")).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(i18n::t("sidebar.duplicate")).on_click(move |_, _, cx| {
|
||||
w2.update(cx, |app, cx| {
|
||||
app.duplicate_connection(&e2);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}))
|
||||
.separator()
|
||||
.item(PopupMenuItem::new(i18n::t("common.delete")).on_click(move |_, window, cx| {
|
||||
let Some(app) = w3.upgrade() else {
|
||||
return;
|
||||
};
|
||||
// 二次确认:删除会级联清理订阅并断开连接,不可撤销
|
||||
let (name, sub_count) = {
|
||||
let a = app.read(cx);
|
||||
let name = a
|
||||
.connections
|
||||
.iter()
|
||||
.find(|c| c.id == e3)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_else(|| e3.clone());
|
||||
let n = a.subscriptions.iter().filter(|s| s.connection_id == e3).count();
|
||||
(name, n)
|
||||
};
|
||||
let confirm_id = e3.clone();
|
||||
window.open_alert_dialog(cx, move |alert, _, _| {
|
||||
// 构建闭包是 Fn(可重复调用),克隆后再移入按钮回调
|
||||
let app = app.clone();
|
||||
let confirm_id = confirm_id.clone();
|
||||
alert
|
||||
.title(i18n::t("sidebar.delete"))
|
||||
.description(SharedString::from(i18n::tf(
|
||||
"sidebar.delete_confirm",
|
||||
&[("name", &name), ("sub_count", &sub_count)],
|
||||
)))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.ok_text(i18n::t("common.delete"))
|
||||
.ok_variant(ButtonVariant::Danger)
|
||||
.show_cancel(true),
|
||||
)
|
||||
.on_ok(move |_, _, cx| {
|
||||
app.update(cx, |app, cx| app.delete_connection(&confirm_id, cx));
|
||||
true
|
||||
})
|
||||
});
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! 主题:Linear 自定义主题 + 官方捆绑预设(38 个)的注册与应用。
|
||||
//!
|
||||
//! 两套来源:
|
||||
//! - **Linear**(`linear-theme.json`,浅/深一对):默认预设,受「跟随系统 /
|
||||
//! 浅色 / 深色」外观模式控制;
|
||||
//! - **官方捆绑**(`themes/*.json`,36 个变体 + Default Light/Dark):
|
||||
//! 编译期内嵌、启动时注册进 ThemeRegistry,选择后整包应用(自带 mode)。
|
||||
//!
|
||||
//! 预设与外观模式是互斥维度:选定官方预设时由该预设决定全部配色与明暗,
|
||||
//! 外观模式开关仅对 Linear 生效(系统深浅变化监听同样跳过官方预设,
|
||||
//! 避免冲掉用户选择)。
|
||||
|
||||
|
||||
use gpui_kit::component::{
|
||||
Theme, ThemeMode, ThemeRegistry,
|
||||
};
|
||||
use gpui_kit::App;
|
||||
|
||||
use crate::model::ThemeModePref;
|
||||
|
||||
/// 默认预设:Linear 风格主题(内嵌 JSON,模式与 gpui-component 默认主题同构)。
|
||||
const LINEAR_THEME: &str = include_str!("linear-theme.json");
|
||||
|
||||
/// 官方捆绑预设(gpui-kit themes/,21 文件 36 变体 + Default Light/Dark)。
|
||||
/// 编译期内嵌:单二进制发布不依赖运行时 themes 目录。
|
||||
const BUNDLED_THEME_FILES: &[&str] = &[
|
||||
include_str!("../../../themes/adventure.json"),
|
||||
include_str!("../../../themes/alduin.json"),
|
||||
include_str!("../../../themes/asciinema.json"),
|
||||
include_str!("../../../themes/aurora.json"),
|
||||
include_str!("../../../themes/ayu.json"),
|
||||
include_str!("../../../themes/catppuccin.json"),
|
||||
include_str!("../../../themes/everforest.json"),
|
||||
include_str!("../../../themes/fahrenheit.json"),
|
||||
include_str!("../../../themes/flexoki.json"),
|
||||
include_str!("../../../themes/gruvbox.json"),
|
||||
include_str!("../../../themes/harper.json"),
|
||||
include_str!("../../../themes/hybrid.json"),
|
||||
include_str!("../../../themes/jellybeans.json"),
|
||||
include_str!("../../../themes/kibble.json"),
|
||||
include_str!("../../../themes/macos-classic.json"),
|
||||
include_str!("../../../themes/mellifluous.json"),
|
||||
include_str!("../../../themes/molokai.json"),
|
||||
include_str!("../../../themes/solarized.json"),
|
||||
include_str!("../../../themes/spaceduck.json"),
|
||||
include_str!("../../../themes/tokyonight.json"),
|
||||
include_str!("../../../themes/twilight.json"),
|
||||
];
|
||||
|
||||
/// 默认预设名(settings.theme_preset 的初始值)。
|
||||
pub const DEFAULT_PRESET: &str = "linear";
|
||||
/// Linear 在预设列表中的展示名。
|
||||
pub const LINEAR_PRESET_LABEL: &str = "Linear";
|
||||
|
||||
/// 应用主题偏好(Linear 预设专用路径)。
|
||||
///
|
||||
/// 「跟随系统」读取 gpui 的窗口外观;系统深浅色变化由
|
||||
/// `MqttXApp::new` 里注册的 `Window::observe_window_appearance` 监听并重应用本函数。
|
||||
///
|
||||
/// 流程:先 `change` 确保 Theme 全局已建立(注册表 observer 依赖它)→ 注册
|
||||
/// Linear Light/Dark(重名跳过,幂等)→ 把当前明暗对指向 Linear → 再 `change`
|
||||
/// 应用其颜色/字号/圆角。
|
||||
pub fn apply_theme(pref: ThemeModePref, cx: &mut App) {
|
||||
let mode = match pref {
|
||||
ThemeModePref::Light => ThemeMode::Light,
|
||||
ThemeModePref::Dark => ThemeMode::Dark,
|
||||
ThemeModePref::System => ThemeMode::from(cx.window_appearance()),
|
||||
};
|
||||
Theme::change(mode, None, cx);
|
||||
if let Err(e) = ThemeRegistry::global_mut(cx).load_themes_from_str(LINEAR_THEME) {
|
||||
eprintln!("加载 Linear 主题失败,回退默认主题: {e}");
|
||||
}
|
||||
{
|
||||
let reg = ThemeRegistry::global(cx);
|
||||
let light = reg.themes().get("Linear Light").cloned();
|
||||
let dark = reg.themes().get("Linear Dark").cloned();
|
||||
let theme = Theme::global_mut(cx);
|
||||
if let Some(l) = light {
|
||||
theme.light_theme = l;
|
||||
}
|
||||
if let Some(d) = dark {
|
||||
theme.dark_theme = d;
|
||||
}
|
||||
}
|
||||
Theme::change(mode, None, cx);
|
||||
}
|
||||
|
||||
/// 注册全部官方捆绑预设(幂等,重名由 ThemeRegistry 覆盖/跳过)。
|
||||
/// 启动时调用一次。
|
||||
pub fn register_bundled_themes(cx: &mut App) {
|
||||
for json in BUNDLED_THEME_FILES {
|
||||
if let Err(e) = ThemeRegistry::global_mut(cx).load_themes_from_str(json) {
|
||||
eprintln!("加载捆绑主题预设失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用官方捆绑预设(settings.theme_preset ≠ "linear" 时的路径)。
|
||||
/// 整包应用:apply_config 连同预设自带的 mode 一起生效,
|
||||
/// 经 Theme::update 走官方 reconcile + sync + 全窗刷新。
|
||||
/// 名字不存在(理论不可达,测试拦截)时返回 false 回退 Linear。
|
||||
pub fn apply_theme_preset(name: &str, cx: &mut App) -> bool {
|
||||
let config = ThemeRegistry::global(cx).themes().get(name).cloned();
|
||||
let Some(config) = config else {
|
||||
return false;
|
||||
};
|
||||
Theme::update(cx, |theme| theme.apply_config(&config));
|
||||
true
|
||||
}
|
||||
|
||||
/// 预设展示名单:Linear 在首,其余按名字排序(与 ThemeRegistry 一致)。
|
||||
pub fn theme_preset_names(cx: &App) -> Vec<String> {
|
||||
let mut names: Vec<String> = ThemeRegistry::global(cx)
|
||||
.themes()
|
||||
.keys()
|
||||
.map(|k| k.to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
let mut out = vec![LINEAR_PRESET_LABEL.to_string()];
|
||||
out.extend(names);
|
||||
out
|
||||
}
|
||||
|
||||
/// 展示名 → 存储名(Linear 之外的官方预设直接用注册名)。
|
||||
pub fn preset_storage_name(label: &str) -> String {
|
||||
if label == LINEAR_PRESET_LABEL {
|
||||
DEFAULT_PRESET.to_string()
|
||||
} else {
|
||||
label.to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 标题栏:窗口控制、钉子置顶、主题切换与全局动作菜单。
|
||||
|
||||
|
||||
use gpui_kit::component::button::{ Button };
|
||||
use gpui_kit::component::menu::{ PopupMenuItem };
|
||||
use gpui_kit::component::{ h_flex, notification::Notification, ThemeMode, TitleBar, };
|
||||
use gpui_kit::{ div, px, Context, IntoElement, Window, };
|
||||
|
||||
use crate::model::ThemeModePref;
|
||||
use crate::ui::IconName;
|
||||
|
||||
use super::*;
|
||||
use gpui_kit::Action as _;
|
||||
use crate::ui::i18n;
|
||||
|
||||
impl MqttXApp {
|
||||
pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let app_weak = cx.entity().downgrade();
|
||||
// 非 Windows 平台 `set_topmost` 恒为 false,渲染一个必然报错的按钮只会让人困惑
|
||||
let pin_button = if cfg!(target_os = "windows") {
|
||||
Some(
|
||||
Button::new("pin-window")
|
||||
.icon(if self.pinned {
|
||||
IconName::PinOff
|
||||
} else {
|
||||
IconName::Pin
|
||||
})
|
||||
.ghost()
|
||||
.small()
|
||||
.tooltip(if self.pinned {
|
||||
i18n::t("titlebar.unpin")
|
||||
} else {
|
||||
i18n::t("titlebar.pin")
|
||||
})
|
||||
.accessibility_label(if self.pinned {
|
||||
i18n::t("titlebar.unpin")
|
||||
} else {
|
||||
i18n::t("titlebar.pin")
|
||||
})
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let next = !this.pinned;
|
||||
if crate::platform::set_topmost(window, next) {
|
||||
this.pinned = next;
|
||||
cx.notify();
|
||||
} else {
|
||||
window.push_notification(
|
||||
Notification::error(i18n::t("titlebar.pin_failed")),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
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(i18n::t("titlebar.subtitle")),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.pr_2()
|
||||
.items_center()
|
||||
// 整条标题栏被 gpui-component 标记为 WindowControlArea::Drag
|
||||
// (WM_NCHITTEST 返回 HTCAPTION),点击会变成拖动窗口。
|
||||
// 按钮簇必须遮挡鼠标:hit_test 收集到 BlockMouse hitbox
|
||||
// 即截断,父级 Drag 区不再命中,按钮才能收到点击。
|
||||
.occlude()
|
||||
.child(
|
||||
Button::new("new-connection")
|
||||
.icon(IconName::Plus)
|
||||
.label(i18n::t("titlebar.new_connection"))
|
||||
.primary()
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.open_connection_form(None, window, cx);
|
||||
})),
|
||||
)
|
||||
.children(pin_button)
|
||||
.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();
|
||||
menu.item(
|
||||
PopupMenuItem::new(i18n::t("titlebar.aliyun"))
|
||||
.on_click(move |_, window, cx| {
|
||||
if let Some(entity) = w1.upgrade() {
|
||||
crate::ui::dialogs::aliyun_dialog::open(entity, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.item(PopupMenuItem::new(i18n::t("titlebar.variables")).on_click(
|
||||
move |_, window, cx| {
|
||||
if let Some(entity) = w2.upgrade() {
|
||||
crate::ui::dialogs::variables_dialog::open(entity, window, cx);
|
||||
}
|
||||
},
|
||||
))
|
||||
.item(PopupMenuItem::new(i18n::t("titlebar.resmon")).on_click(
|
||||
move |_, window, cx| {
|
||||
window.dispatch_action(
|
||||
OpenResourceMonitor.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
},
|
||||
))
|
||||
.separator()
|
||||
.item(PopupMenuItem::new(i18n::t("titlebar.export")).on_click(
|
||||
move |_, window, cx| {
|
||||
window.dispatch_action(ExportConnections.boxed_clone(), cx);
|
||||
},
|
||||
))
|
||||
.item(PopupMenuItem::new(i18n::t("titlebar.import")).on_click(
|
||||
move |_, window, cx| {
|
||||
window.dispatch_action(ImportConnections.boxed_clone(), cx);
|
||||
},
|
||||
))
|
||||
.separator()
|
||||
.item(PopupMenuItem::new(i18n::t("titlebar.settings")).on_click(
|
||||
move |_, window, cx| {
|
||||
window.dispatch_action(OpenSettings.boxed_clone(), 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 {
|
||||
i18n::t("titlebar.switch_light")
|
||||
} else {
|
||||
i18n::t("titlebar.switch_dark")
|
||||
})
|
||||
.accessibility_label(if dark {
|
||||
i18n::t("titlebar.switch_light")
|
||||
} else {
|
||||
i18n::t("titlebar.switch_dark")
|
||||
})
|
||||
.on_click(cx.listener(|this, _, window, 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, window, cx);
|
||||
cx.notify();
|
||||
}))
|
||||
}
|
||||
}
|
||||
+244
-106
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
|
||||
use gpui_kit::component::scroll::ScrollableElement as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||
use gpui_kit::component::input::{Input, InputState};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||
use gpui_kit::component::select::{Select, SelectEvent, SelectState};
|
||||
use gpui_kit::component::switch::Switch;
|
||||
use gpui_kit::component::{
|
||||
@@ -15,6 +15,7 @@ use gpui_kit::component::{
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use crate::ui::IconName;
|
||||
use crate::ui::i18n;
|
||||
use gpui_kit::{
|
||||
div, percentage, px, App, AppContext as _, Context, Entity, InteractiveElement as _,
|
||||
IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _,
|
||||
@@ -25,11 +26,11 @@ use crate::model::{ConnectionConfig, LastWill, ProtocolVersion, SslConfig, Trans
|
||||
use crate::mqtt::MqttEngine;
|
||||
use crate::ui::widgets::{field, field_ex, 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"];
|
||||
fn protocol_labels() -> [&'static 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"]; // 语言无关
|
||||
/// 分组下拉的首项:选择它表示不归属任何分组。
|
||||
const NO_GROUP: &str = "无分组";
|
||||
fn no_group_label() -> &'static str { i18n::t("form.no_group") }
|
||||
|
||||
pub struct ConnectionForm {
|
||||
engine: Arc<MqttEngine>,
|
||||
@@ -142,28 +143,28 @@ impl ConnectionForm {
|
||||
chrono::Local::now().timestamp()
|
||||
});
|
||||
|
||||
let name = input(&c.name, "连接名称", window, cx);
|
||||
let name = input(&c.name, i18n::t("form.name_ph"), window, cx);
|
||||
let host = input(&c.host, "broker.example.com", window, cx);
|
||||
let port = input(&c.port.to_string(), "端口", window, cx);
|
||||
let port = input(&c.port.to_string(), i18n::t(i18n::t("form.port")), 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 client_id = input(&c.client_id, i18n::t("form.client_id"), window, cx);
|
||||
let username = input(&c.username, i18n::t("form.username_ph"), window, cx);
|
||||
let password = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("密码(可选)")
|
||||
.placeholder(i18n::t("form.password_ph"))
|
||||
.default_value(&c.password)
|
||||
.masked(true)
|
||||
});
|
||||
let keep_alive = input(&c.keep_alive.to_string(), "60", window, cx);
|
||||
let conn_timeout = input(
|
||||
&c.connection_timeout_secs.to_string(),
|
||||
"秒(默认 10)",
|
||||
i18n::t("form.timeout_ph"),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let max_reconnect = input(
|
||||
&c.max_reconnect_times.to_string(),
|
||||
"0=无限重连",
|
||||
i18n::t("form.max_reconnects_ph"),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -173,44 +174,44 @@ impl ConnectionForm {
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
"秒(0=会话随连接结束)",
|
||||
i18n::t("form.session_expiry_ph"),
|
||||
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 receive_max = optional_input(c.receive_maximum.map(u32::from), i18n::t("form.receive_max_ph"), window, cx);
|
||||
let max_packet = optional_input(c.maximum_packet_size, i18n::t("form.max_packet"), window, cx);
|
||||
let topic_alias = optional_input(c.topic_alias_maximum.map(u32::from), i18n::t("form.topic_alias_max"), window, cx);
|
||||
|
||||
let ssl_ca = input(&c.ssl.ca_file, "CA 证书 PEM 路径(可选)", window, cx);
|
||||
let ssl_client_cert = input(&c.ssl.client_cert_file, "客户端证书 PEM 路径(可选)", window, cx);
|
||||
let ssl_client_key = input(&c.ssl.client_key_file, "客户端私钥 PEM 路径(可选)", window, cx);
|
||||
let ssl_ca = input(&c.ssl.ca_file, i18n::t("ssl.ca_ph"), window, cx);
|
||||
let ssl_client_cert = input(&c.ssl.client_cert_file, i18n::t("ssl.cert_ph"), window, cx);
|
||||
let ssl_client_key = input(&c.ssl.client_key_file, i18n::t("ssl.key_ph"), window, cx);
|
||||
|
||||
let group_new = input("", "新分组名(可选)", window, cx);
|
||||
let group_new = input("", i18n::t("form.new_group_hint"), window, cx);
|
||||
|
||||
let (will_topic, will_payload, will_enabled, will_retain, will_qos_idx, will_content_type, will_response_topic) =
|
||||
match &c.last_will {
|
||||
Some(w) => (
|
||||
input(&w.topic, "遗嘱主题", window, cx),
|
||||
input(&w.payload, "遗嘱消息", window, cx),
|
||||
input(&w.topic, i18n::t("form.will_topic"), window, cx),
|
||||
input(&w.payload, i18n::t("form.will_payload_ph"), window, cx),
|
||||
true,
|
||||
w.retain,
|
||||
w.qos as usize,
|
||||
input(w.content_type.as_deref().unwrap_or(""), "内容类型(可选)", window, cx),
|
||||
input(w.response_topic.as_deref().unwrap_or(""), "响应主题(可选)", window, cx),
|
||||
input(w.content_type.as_deref().unwrap_or(""), i18n::t("form.content_type_ph"), window, cx),
|
||||
input(w.response_topic.as_deref().unwrap_or(""), i18n::t("form.response_topic_ph"), window, cx),
|
||||
),
|
||||
None => (
|
||||
input("", "遗嘱主题", window, cx),
|
||||
input("", "遗嘱消息", window, cx),
|
||||
input("", i18n::t("form.will_topic"), window, cx),
|
||||
input("", i18n::t("form.will_payload_ph"), window, cx),
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
input("", "内容类型(可选)", window, cx),
|
||||
input("", "响应主题(可选)", window, cx),
|
||||
input("", i18n::t("form.content_type_ph"), window, cx),
|
||||
input("", i18n::t("form.response_topic_ph"), window, cx),
|
||||
),
|
||||
};
|
||||
|
||||
let protocol = make_select(
|
||||
&PROTOCOLS,
|
||||
&protocol_labels(),
|
||||
if c.protocol.is_v5() { 0 } else { 1 },
|
||||
window,
|
||||
cx,
|
||||
@@ -222,7 +223,7 @@ impl ConnectionForm {
|
||||
let transport = make_select(&TRANSPORTS, transport_idx, window, cx);
|
||||
let will_qos = make_select(&QOS, will_qos_idx, window, cx);
|
||||
// 分组下拉:首项固定为「无分组」,其后是已有分组
|
||||
let mut group_labels = vec![NO_GROUP];
|
||||
let mut group_labels = vec![no_group_label()];
|
||||
group_labels.extend(groups.iter().map(|s| s.as_str()));
|
||||
let group_idx = c
|
||||
.group
|
||||
@@ -240,7 +241,9 @@ impl ConnectionForm {
|
||||
// 切换传输方式时,把端口同步成该方式的默认端口,并刷新 WS 路径的按需显示。
|
||||
// gpui 列表点击任意项(含当前项)都会发 Confirm,若不比对索引,
|
||||
// 打开下拉点一下当前传输就会把自定义端口静默改回默认值。
|
||||
let last_transport = std::cell::Cell::new(transport_idx);
|
||||
// 记录"上一个传输方式"而非索引:端口只在仍是上一个方式的默认端口时才跟随,
|
||||
// 与全部默认端口比较会把用户恰好手输的 8084/8883 等值误判为默认值而覆盖
|
||||
let last_transport = std::cell::Cell::new(TransportKind::ALL[transport_idx]);
|
||||
let port_for_sub = port.clone();
|
||||
let sub_transport = cx.subscribe_in(
|
||||
&transport,
|
||||
@@ -250,17 +253,15 @@ impl ConnectionForm {
|
||||
&& let Some(idx) = state.read(cx).selected_value().copied()
|
||||
&& let Some(kind) = TransportKind::ALL.get(idx).copied()
|
||||
{
|
||||
// 索引未变化:视为重新点选当前项,不覆盖用户输入的端口
|
||||
if last_transport.get() == idx {
|
||||
// 未变化:视为重新点选当前项,不覆盖用户输入的端口
|
||||
if last_transport.get() == kind {
|
||||
return;
|
||||
}
|
||||
// 端口只在仍是某个默认端口(而非用户自定义值)时才跟随切换:
|
||||
// 否则 WSS:9443 → TCP → WSS 的往返会把 9443 静默丢失
|
||||
let current = port_for_sub.read(cx).value().trim().parse::<u16>().ok();
|
||||
let is_default = matches!(current, Some(p) if
|
||||
TransportKind::ALL.iter().any(|k| k.default_port() == p));
|
||||
last_transport.set(idx);
|
||||
if is_default {
|
||||
// WSS:9443 → TCP → WSS 的往返不会丢失 9443
|
||||
let is_prev_default = port_for_sub.read(cx).value().trim().parse::<u16>().ok()
|
||||
== Some(last_transport.get().default_port());
|
||||
last_transport.set(kind);
|
||||
if is_prev_default {
|
||||
port_for_sub.update(cx, |p, cx| {
|
||||
p.set_value(kind.default_port().to_string(), window, cx);
|
||||
});
|
||||
@@ -290,6 +291,42 @@ impl ConnectionForm {
|
||||
|| c.maximum_packet_size.is_some()
|
||||
|| c.topic_alias_maximum.is_some();
|
||||
|
||||
// 任一被校验字段被编辑时清空字段错误:错误只在保存/测试时登记,若不随输入
|
||||
// 清除,用户改好值后红字仍然挂着,无从判断当前输入是否已合法
|
||||
let err_watch: [Entity<InputState>; 16] = [
|
||||
name.clone(),
|
||||
host.clone(),
|
||||
port.clone(),
|
||||
path.clone(),
|
||||
client_id.clone(),
|
||||
keep_alive.clone(),
|
||||
conn_timeout.clone(),
|
||||
max_reconnect.clone(),
|
||||
session_expiry.clone(),
|
||||
receive_max.clone(),
|
||||
max_packet.clone(),
|
||||
topic_alias.clone(),
|
||||
ssl_ca.clone(),
|
||||
ssl_client_cert.clone(),
|
||||
ssl_client_key.clone(),
|
||||
will_topic.clone(),
|
||||
];
|
||||
let mut err_subs = Vec::with_capacity(err_watch.len());
|
||||
for w in err_watch {
|
||||
err_subs.push(cx.subscribe_in(
|
||||
&w,
|
||||
window,
|
||||
move |this, _, ev: &InputEvent, _, cx| {
|
||||
if matches!(ev, InputEvent::Change)
|
||||
&& !this.field_errors.borrow().is_empty()
|
||||
{
|
||||
this.field_errors.borrow_mut().clear();
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Self {
|
||||
engine,
|
||||
editing_id,
|
||||
@@ -334,7 +371,11 @@ impl ConnectionForm {
|
||||
show_ssl: ssl_configured,
|
||||
field_errors: RefCell::default(),
|
||||
testing: false,
|
||||
_subs: vec![sub_transport, sub_protocol],
|
||||
_subs: {
|
||||
let mut subs = vec![sub_transport, sub_protocol];
|
||||
subs.extend(err_subs);
|
||||
subs
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,20 +399,39 @@ impl ConnectionForm {
|
||||
r.map_err(|e| self.fail(key, e))
|
||||
}
|
||||
|
||||
fn parse_u16(s: &str, label: &str) -> Result<u16, String> {
|
||||
fn parse_u16(s: &str, label: &'static str) -> Result<u16, String> {
|
||||
s.trim()
|
||||
.parse::<u16>()
|
||||
.map_err(|_| format!("{label} 需要是 0~65535 的整数"))
|
||||
.map_err(|_| format!("{} {}", i18n::t(label), i18n::t("form.err_range")))
|
||||
}
|
||||
|
||||
fn parse_opt_u32(s: &str, label: &str) -> Result<Option<u32>, String> {
|
||||
/// 端口:1~65535。0 不是可连接端口,放行只会在 CONNECT 阶段报出难以理解的错误。
|
||||
fn parse_port(s: &str) -> Result<u16, String> {
|
||||
match s.trim().parse::<u16>() {
|
||||
Ok(0) => Err(i18n::t("form.port_invalid").to_string()),
|
||||
Ok(p) => Ok(p),
|
||||
Err(_) => Err(i18n::t("form.port_invalid").to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 同 `parse_opt_u32`,但 0 视为非法:MQTT 5 规范把 Receive Maximum /
|
||||
/// Maximum Packet Size 为 0 定义为 Protocol Error,broker 会直接断开连接,
|
||||
/// 且该值会被持久化,表现为"每次连接都失败"。
|
||||
fn parse_opt_positive_u32(s: &str, label: &'static str) -> Result<Option<u32>, String> {
|
||||
match Self::parse_opt_u32(s, label)? {
|
||||
Some(0) => Err(format!("{} {}", i18n::t(label), i18n::t("form.err_positive"))),
|
||||
other => Ok(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_opt_u32(s: &str, label: &'static 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} 需要是非负整数"))
|
||||
.map_err(|_| format!("{} {}", i18n::t(label), i18n::t("form.err_uint")))
|
||||
}
|
||||
|
||||
pub fn build(&self, cx: &App) -> Result<ConnectionConfig, String> {
|
||||
@@ -391,28 +451,28 @@ impl ConnectionForm {
|
||||
let mut name = self.val(&self.name, cx);
|
||||
if name.trim().is_empty() {
|
||||
if !for_test {
|
||||
return Err(self.fail("name", "连接名称不能为空"));
|
||||
return Err(self.fail("name", i18n::t("form.name_required")));
|
||||
}
|
||||
name = "测试连接".into();
|
||||
name = i18n::t("form.test").into();
|
||||
}
|
||||
// 判空已按 trim,保存也存 trim 后的值,避免首尾空白入库
|
||||
let name = name.trim().to_string();
|
||||
let host = self.val(&self.host, cx);
|
||||
if host.trim().is_empty() {
|
||||
return Err(self.fail("host", "主机地址不能为空"));
|
||||
return Err(self.fail("host", i18n::t("form.host_required")));
|
||||
}
|
||||
let host = host.trim().to_string();
|
||||
let port = self.fail_as("port", Self::parse_u16(&self.val(&self.port, cx), "端口"))?;
|
||||
let port = self.fail_as("port", Self::parse_port(&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(self.fail("client_id", "Client ID 不能为空"));
|
||||
return Err(self.fail("client_id", i18n::t("form.client_id_required")));
|
||||
}
|
||||
client_id = format!("mqttx-test-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
}
|
||||
let client_id = client_id.trim().to_string();
|
||||
let keep_alive =
|
||||
self.fail_as("keep_alive", Self::parse_u16(&self.val(&self.keep_alive, cx), "Keep Alive"))?;
|
||||
self.fail_as("keep_alive", Self::parse_u16(&self.val(&self.keep_alive, cx), i18n::t("form.keep_alive_short")))?;
|
||||
// 空串视为 0(会话随连接结束);非空必须是合法非负整数,不再静默吞掉 -1/abc
|
||||
let session_expiry = {
|
||||
let raw = self.val(&self.session_expiry, cx);
|
||||
@@ -423,23 +483,23 @@ impl ConnectionForm {
|
||||
t.parse::<u32>().map_err(|_| {
|
||||
self.fail(
|
||||
"session_expiry",
|
||||
"会话过期间隔需要是非负整数(0=会话随连接结束)",
|
||||
i18n::t("form.session_expiry_invalid"),
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
let receive_maximum = self.fail_as("receive_max", (|| -> Result<_, String> {
|
||||
Ok(Self::parse_opt_u32(&self.val(&self.receive_max, cx), "接收上限")?
|
||||
.map(|v| u16::try_from(v).map_err(|_| "接收上限不能超过 65535".to_string()))
|
||||
Ok(Self::parse_opt_positive_u32(&self.val(&self.receive_max, cx), i18n::t("form.receive_max_short"))?
|
||||
.map(|v| u16::try_from(v).map_err(|_| i18n::t("form.receive_max_invalid").to_string()))
|
||||
.transpose())
|
||||
})())??;
|
||||
let maximum_packet_size = self.fail_as(
|
||||
"max_packet",
|
||||
Self::parse_opt_u32(&self.val(&self.max_packet, cx), "最大报文长度"),
|
||||
Self::parse_opt_positive_u32(&self.val(&self.max_packet, cx), i18n::t("form.max_packet")),
|
||||
)?;
|
||||
let topic_alias_maximum = self.fail_as("topic_alias", (|| -> Result<_, String> {
|
||||
Ok(Self::parse_opt_u32(&self.val(&self.topic_alias, cx), "主题别名")?
|
||||
.map(|v| u16::try_from(v).map_err(|_| "主题别名上限不能超过 65535".to_string()))
|
||||
Ok(Self::parse_opt_positive_u32(&self.val(&self.topic_alias, cx), i18n::t("form.topic_alias"))?
|
||||
.map(|v| u16::try_from(v).map_err(|_| i18n::t("form.topic_alias_invalid").to_string()))
|
||||
.transpose())
|
||||
})())??;
|
||||
|
||||
@@ -452,7 +512,7 @@ impl ConnectionForm {
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.filter(|v| *v >= 1)
|
||||
.ok_or_else(|| self.fail("conn_timeout", "连接超时需要是 1~65535 的整数(秒)"))?
|
||||
.ok_or_else(|| self.fail("conn_timeout", i18n::t("form.conn_timeout_invalid")))?
|
||||
};
|
||||
let max_reconnect_raw = self.val(&self.max_reconnect, cx);
|
||||
let max_reconnect_times = if max_reconnect_raw.trim().is_empty() {
|
||||
@@ -461,7 +521,7 @@ impl ConnectionForm {
|
||||
max_reconnect_raw
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.map_err(|_| self.fail("max_reconnect", "最大重连次数需要是非负整数(0=无限)"))?
|
||||
.map_err(|_| self.fail("max_reconnect", i18n::t("form.max_reconnects_invalid")))?
|
||||
};
|
||||
|
||||
// 分组:新分组名非空时优先,否则取下拉所选(首项=无分组)
|
||||
@@ -499,7 +559,7 @@ impl ConnectionForm {
|
||||
// WS/WSS 依赖 path 建立升级请求,缺失时连接必然失败,保存前拦截
|
||||
let path = self.val(&self.path, cx).trim().to_string();
|
||||
if transport.is_websocket() && path.is_empty() {
|
||||
return Err(self.fail("path", "WS/WSS 传输下 WS 路径不能为空"));
|
||||
return Err(self.fail("path", i18n::t("form.ws_path_required")));
|
||||
}
|
||||
|
||||
let ssl = SslConfig {
|
||||
@@ -512,7 +572,7 @@ impl ConnectionForm {
|
||||
if transport.is_tls()
|
||||
&& ssl.client_cert_file.is_empty() != ssl.client_key_file.is_empty()
|
||||
{
|
||||
return Err(self.fail("ssl_client_cert", "客户端证书与客户端密钥必须同时填写"));
|
||||
return Err(self.fail("ssl_client_cert", i18n::t("form.cert_pair_required")));
|
||||
}
|
||||
// 证书文件不存在时握手才报错太晚,保存前给出字段级提示
|
||||
if transport.is_tls() {
|
||||
@@ -522,15 +582,23 @@ impl ConnectionForm {
|
||||
("ssl_client_key", "客户端私钥", &ssl.client_key_file),
|
||||
] {
|
||||
if !p.is_empty() && !std::path::Path::new(p).exists() {
|
||||
return Err(self.fail(key, format!("{label}路径不存在:{p}")));
|
||||
return Err(self.fail(key, i18n::tf("form.path_missing", &[("label", &i18n::t(label)), ("p", &p)])));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let last_will = if self.will_enabled {
|
||||
let topic = self.val(&self.will_topic, cx);
|
||||
if topic.trim().is_empty() {
|
||||
return Err(self.fail("will_topic", "遗嘱主题不能为空"));
|
||||
// 与其它字段一致:trim 后入库
|
||||
let topic = self.val(&self.will_topic, cx).trim().to_string();
|
||||
if topic.is_empty() {
|
||||
return Err(self.fail("will_topic", i18n::t("will.topic_required")));
|
||||
}
|
||||
// 通配符只用于订阅过滤器;出现在 PUBLISH 主题上会被 broker 直接拒绝
|
||||
if topic.contains('+') || topic.contains('#') {
|
||||
return Err(self.fail(
|
||||
"will_topic",
|
||||
i18n::t("will.topic_wildcard"),
|
||||
));
|
||||
}
|
||||
let opt_str = |v: String| {
|
||||
let t = v.trim().to_string();
|
||||
@@ -600,7 +668,7 @@ impl ConnectionForm {
|
||||
form.testing = false;
|
||||
match result {
|
||||
Ok(()) => window.push_notification(
|
||||
Notification::success("连接测试成功"),
|
||||
Notification::success(i18n::t("form.test_ok")),
|
||||
cx,
|
||||
),
|
||||
Err(e) => window.push_notification(Notification::error(format!(
|
||||
@@ -614,6 +682,66 @@ impl ConnectionForm {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// 证书路径字段 + 「浏览…」按钮:路径仍可手输,按钮负责选文件。
|
||||
fn cert_path_row(
|
||||
&self,
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
target: Entity<InputState>,
|
||||
err: Option<SharedString>,
|
||||
danger: gpui_kit::Hsla,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.child(field_ex(label, Input::new(&target), false, err, danger)),
|
||||
)
|
||||
.child(
|
||||
Button::new(id)
|
||||
.label(i18n::t("common.browse"))
|
||||
.outline()
|
||||
.small()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.browse_cert_path(target.clone(), window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// 弹原生文件对话框并把所选路径回填到输入框。rfd 的阻塞式对话框
|
||||
/// 不可占用 UI 线程,与连接导入/导出同模式:后台线程 + smol 通道回传。
|
||||
fn browse_cert_path(
|
||||
&self,
|
||||
target: Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let (tx, rx) = smol::channel::bounded::<Option<std::path::PathBuf>>(1);
|
||||
std::thread::spawn(move || {
|
||||
let picked = rfd::FileDialog::new()
|
||||
.set_title(i18n::t("ssl.pick_file"))
|
||||
.add_filter("证书 / 密钥 (PEM)", &["pem", "crt", "cer", "key", "der"])
|
||||
.pick_file();
|
||||
let _ = tx.send_blocking(picked);
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Some(path)) = rx.recv().await {
|
||||
let text = path.display().to_string();
|
||||
this.update_in(cx, |_form, window, cx| {
|
||||
target.update(cx, |s, cx| s.set_value(text, window, cx));
|
||||
// 回填的路径可能本就带字段错误提示,触发重绘即时更新
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn switch_row(
|
||||
&self,
|
||||
id: &'static str,
|
||||
@@ -750,38 +878,40 @@ impl Render for ConnectionForm {
|
||||
.iter()
|
||||
.filter(|on| **on)
|
||||
.count();
|
||||
let ssl_hint = (ssl_count > 0).then(|| SharedString::from(format!("已配置 {ssl_count} 项")));
|
||||
let ssl_hint = (ssl_count > 0).then(|| SharedString::from(i18n::tf("ssl.n_configured", &[("n", &ssl_count)])));
|
||||
let mqtt5_hint =
|
||||
(mqtt5_count > 0).then(|| SharedString::from(format!("已配置 {mqtt5_count} 项")));
|
||||
(mqtt5_count > 0).then(|| SharedString::from(i18n::tf("ssl.n_configured", &[("n", &mqtt5_count)])));
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.max_h(px(560.))
|
||||
.overflow_y_scrollbar()
|
||||
.pr_1()
|
||||
// ── 基本信息(始终展开) ──
|
||||
.child(section("基本信息", border, card_bg,
|
||||
.child(section(i18n::t("form.basics"), border, card_bg,
|
||||
v_flex().gap_2().child(
|
||||
// 主机与端口是一对,放同一行;名称与协议/传输同行
|
||||
h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("名称", Input::new(&self.name), true, err("name"), danger)))
|
||||
.child(div().w(px(120.)).child(field_ex("端口", Input::new(&self.port), true, err("port"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.host"), Input::new(&self.host), true, err("host"), danger)))
|
||||
.child(div().w(px(120.)).child(field_ex(i18n::t(i18n::t("form.port")), Input::new(&self.port), true, err("port"), danger)))
|
||||
)
|
||||
.child(h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("主机", Input::new(&self.host), true, err("host"), danger)))
|
||||
.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(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.name"), Input::new(&self.name), true, err("name"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.protocol"), Select::new(&self.protocol))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.transport"), Select::new(&self.transport))))
|
||||
)
|
||||
.child(h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("分组", Select::new(&self.group_select))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("新建分组(优先于左侧选择)", Input::new(&self.group_new))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.group"), Select::new(&self.group_select))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.new_group"), Input::new(&self.group_new))))
|
||||
)
|
||||
.child(h_flex().gap_2().w_full().items_end()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("Client ID", Input::new(&self.client_id), true, err("client_id"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.client_id"), Input::new(&self.client_id), true, err("client_id"), danger)))
|
||||
// 一键换一个随机 Client ID,与 ConnectionConfig::new 的生成逻辑一致
|
||||
.child(
|
||||
Button::new("cid-regen")
|
||||
.icon(IconName::RefreshCw)
|
||||
.ghost()
|
||||
.tooltip("重新生成 Client ID")
|
||||
.tooltip(i18n::t("form.regen_client_id"))
|
||||
.accessibility_label(i18n::t("form.regen_client_id"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let cid = crate::model::generate_client_id();
|
||||
this.client_id
|
||||
@@ -791,40 +921,48 @@ impl Render for ConnectionForm {
|
||||
)
|
||||
// WS 路径仅对 WebSocket/WSS 有意义,按需显示;该传输下必填
|
||||
.when(is_ws, |w| w.child(
|
||||
div().flex_1().min_w(px(0.)).child(field_ex("WS 路径", Input::new(&self.path), true, err("path"), danger))
|
||||
div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.ws_path"), Input::new(&self.path), true, err("path"), danger))
|
||||
))
|
||||
)
|
||||
))
|
||||
// ── 认证与心跳 ──
|
||||
.child(self.collapsible_section(
|
||||
"sec-auth", "认证与心跳", None, false, self.show_auth,
|
||||
"sec-auth", i18n::t("form.auth_section"), 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_ex("Keep Alive (秒)", Input::new(&self.keep_alive), true, err("keep_alive"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.username"), Input::new(&self.username))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.password"), Input::new(&self.password).mask_toggle())))
|
||||
.child(div().w(px(110.)).child(field_ex(i18n::t("form.keep_alive"), Input::new(&self.keep_alive), true, err("keep_alive"), danger)))
|
||||
)
|
||||
.child(h_flex().gap_2().w_full()
|
||||
.child(div().w(px(150.)).child(field_ex("连接超时 (秒)", Input::new(&self.conn_timeout), false, err("conn_timeout"), danger)))
|
||||
.child(div().w(px(170.)).child(field_ex("最大重连次数 (0=无限)", Input::new(&self.max_reconnect), false, err("max_reconnect"), danger)))
|
||||
.child(div().w(px(150.)).child(field_ex(i18n::t("form.conn_timeout"), Input::new(&self.conn_timeout), false, err("conn_timeout"), danger)))
|
||||
.child(div().w(px(170.)).child(field_ex(i18n::t("form.max_reconnects"), Input::new(&self.max_reconnect), false, err("max_reconnect"), danger)))
|
||||
)
|
||||
.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))
|
||||
.child(self.switch_row("auto_connect", "启动时自动连接", self.auto_connect, cx)),
|
||||
.child(self.switch_row("clean_start", i18n::t("form.clean_start"), self.clean_start, cx))
|
||||
.child(self.switch_row("auto_reconnect", i18n::t("form.auto_reconnect"), self.auto_reconnect, cx))
|
||||
.child(self.switch_row("auto_resubscribe", i18n::t("form.auto_resub"), self.auto_resubscribe, cx))
|
||||
.child(self.switch_row("auto_connect", i18n::t("form.auto_connect"), self.auto_connect, cx)),
|
||||
cx,
|
||||
))
|
||||
// ── SSL/TLS(仅 TLS/WSS 显示) ──
|
||||
.when(is_tls, |w| w.child(self.collapsible_section(
|
||||
"sec-ssl", "SSL/TLS", ssl_hint, true, self.show_ssl,
|
||||
"sec-ssl", i18n::t("ssl.section"), ssl_hint, true, self.show_ssl,
|
||||
|f| f.show_ssl = !f.show_ssl, border, card_bg,
|
||||
v_flex().gap_2()
|
||||
// Phase 2 或后续接原生文件对话框;GPUI 暂无内置文件选择器,先用路径文本输入
|
||||
.child(field_ex("CA 证书 (PEM)", Input::new(&self.ssl_ca), false, err("ssl_ca"), danger))
|
||||
.child(field_ex("客户端证书 (PEM)", Input::new(&self.ssl_client_cert), false, err("ssl_client_cert"), danger))
|
||||
.child(field_ex("客户端密钥 (PEM)", Input::new(&self.ssl_client_key), false, err("ssl_client_key"), danger))
|
||||
.child(self.switch_row("ssl_ignore_ca", "忽略 CA 校验(不验证服务器证书)", self.ssl_ignore_ca, cx)),
|
||||
.child(self.cert_path_row(
|
||||
"ssl-ca-browse", i18n::t("ssl.ca"), self.ssl_ca.clone(),
|
||||
err("ssl_ca"), danger, cx,
|
||||
))
|
||||
.child(self.cert_path_row(
|
||||
"ssl-cert-browse", i18n::t("ssl.cert"), self.ssl_client_cert.clone(),
|
||||
err("ssl_client_cert"), danger, cx,
|
||||
))
|
||||
.child(self.cert_path_row(
|
||||
"ssl-key-browse", i18n::t("ssl.key"), self.ssl_client_key.clone(),
|
||||
err("ssl_client_key"), danger, cx,
|
||||
))
|
||||
.child(self.switch_row("ssl_ignore_ca", i18n::t("ssl.skip_verify"), self.ssl_ignore_ca, cx)),
|
||||
cx,
|
||||
)))
|
||||
// ── MQTT 5 高级属性(仅 v5 协议显示) ──
|
||||
@@ -832,29 +970,29 @@ impl Render for ConnectionForm {
|
||||
"sec-mqtt5", "MQTT 5 属性", mqtt5_hint, true, 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_ex("会话过期间隔(秒)", Input::new(&self.session_expiry), false, err("session_expiry"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("接收最大值", Input::new(&self.receive_max), false, err("receive_max"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("最大报文长度", Input::new(&self.max_packet), false, err("max_packet"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("主题别名上限", Input::new(&self.topic_alias), false, err("topic_alias"), danger))),
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.session_expiry"), Input::new(&self.session_expiry), false, err("session_expiry"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.receive_max"), Input::new(&self.receive_max), false, err("receive_max"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.max_packet"), Input::new(&self.max_packet), false, err("max_packet"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.topic_alias_max"), Input::new(&self.topic_alias), false, err("topic_alias"), danger))),
|
||||
cx,
|
||||
)))
|
||||
// ── 遗嘱 ──
|
||||
.child(self.collapsible_section(
|
||||
"sec-will", "遗嘱消息 (Last Will)",
|
||||
self.will_enabled.then(|| "已启用".into()), true, self.show_will,
|
||||
"sec-will", i18n::t("form.will_section"),
|
||||
self.will_enabled.then(|| i18n::t("will.enabled_hint").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))
|
||||
.child(self.switch_row("will_enabled", i18n::t("form.will_enable"), 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_ex("遗嘱主题", Input::new(&self.will_topic), true, err("will_topic"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex(i18n::t("form.will_topic"), Input::new(&self.will_topic), true, err("will_topic"), danger)))
|
||||
.child(div().w(px(130.)).child(field("QoS", Select::new(&self.will_qos))))
|
||||
)
|
||||
.child(field("遗嘱内容", Input::new(&self.will_payload)))
|
||||
.child(field(i18n::t("form.will_payload"), Input::new(&self.will_payload)))
|
||||
// v5 遗嘱属性;3.1.1 连接会忽略这两项
|
||||
.child(h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("内容类型 (Content Type)", Input::new(&self.will_content_type))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("响应主题 (Response Topic)", Input::new(&self.will_response_topic))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.will_content_type"), Input::new(&self.will_content_type))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field(i18n::t("form.will_response_topic"), Input::new(&self.will_response_topic))))
|
||||
)
|
||||
.child(self.switch_row("will_retain", "Retain", self.will_retain, cx))
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,838 @@
|
||||
//! 消息区:顶栏过滤/工具条、消息流渲染、单条消息行与日志视图。
|
||||
|
||||
|
||||
use gpui_kit::component::button::{ Button, ButtonGroup, ButtonVariant };
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::input::Input;
|
||||
use gpui_kit::component::{ h_flex, notification::Notification, v_flex, };
|
||||
use crate::ui::IconName;
|
||||
use gpui_kit::{ div, px, ClipboardItem, Context, IntoElement, SharedString, };
|
||||
|
||||
use crate::model::{ render_will_templates, ConnectionStatus, Direction, LogLevel, MqttRecord, Subscription, MAX_PAYLOAD_RETAIN, };
|
||||
use crate::ui::widgets::format_time;
|
||||
|
||||
use super::*;
|
||||
use crate::ui::i18n;
|
||||
|
||||
impl ConnectionView {
|
||||
pub(super) fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let id = self.conn_id.clone();
|
||||
let (name, status, is_v5, error) = self
|
||||
.with_app(cx, |app| {
|
||||
let cfg = app.connections.iter().find(|c| c.id == id);
|
||||
(
|
||||
cfg.map(|c| c.name.clone()).unwrap_or_else(|| id.clone()),
|
||||
app.statuses.get(&id).copied().unwrap_or(ConnectionStatus::Disconnected),
|
||||
cfg.map(|c| c.protocol.is_v5()).unwrap_or(true),
|
||||
app.errors.get(&id).cloned(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((id.clone(), ConnectionStatus::Disconnected, true, None));
|
||||
let connected = matches!(status, ConnectionStatus::Connected);
|
||||
let connecting = matches!(status, ConnectionStatus::Connecting);
|
||||
let _ = is_v5;
|
||||
|
||||
let status_text = i18n::t(status.label());
|
||||
let status_tone = match status {
|
||||
ConnectionStatus::Connected => cx.theme().success,
|
||||
ConnectionStatus::Connecting => cx.theme().warning,
|
||||
ConnectionStatus::Error => cx.theme().danger,
|
||||
ConnectionStatus::Disconnected => cx.theme().muted_foreground,
|
||||
};
|
||||
// Error 状态把失败原因展示出来,避免只有「错误」二字无从排查
|
||||
let error_hint = matches!(status, ConnectionStatus::Error)
|
||||
.then(|| error.clone())
|
||||
.flatten();
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.h_12()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(div().size(px(8.)).rounded_full().bg(status_tone))
|
||||
.child(div().text_sm().font_semibold().child(name))
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded_md()
|
||||
.bg(status_tone.alpha(0.12))
|
||||
.child(div().size(px(5.)).rounded_full().bg(status_tone))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(status_tone)
|
||||
.child(status_text),
|
||||
),
|
||||
)
|
||||
// Error 副文本展示失败原因(截断避免挤掉按钮)
|
||||
.when_some(error_hint.clone(), |h, e| {
|
||||
h.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.child(e),
|
||||
)
|
||||
})
|
||||
.when(error_hint.is_none(), |h| h.child(div().flex_1()))
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("conn-toggle-{}", self.conn_id)))
|
||||
.icon(if connected {
|
||||
IconName::Square
|
||||
} else {
|
||||
IconName::PlugZap
|
||||
})
|
||||
.label(if connected {
|
||||
i18n::t("sidebar.disconnect")
|
||||
} else if connecting {
|
||||
i18n::t("status.connecting")
|
||||
} else {
|
||||
i18n::t("sidebar.connect")
|
||||
})
|
||||
.when(connected, |b| b.danger().ghost())
|
||||
.when(!connected, |b| b.primary().ghost())
|
||||
// Connecting 期间禁用,避免重复点击重启连接流程
|
||||
.disabled(connecting)
|
||||
.small()
|
||||
.when_some(error_hint.clone(), |b, e| b.tooltip(e))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
// Connecting 期间(按钮禁用之外再兜一层)不响应,防止重复 connect 重启流程
|
||||
let connecting = this
|
||||
.with_app(cx, |app| {
|
||||
app.statuses.get(&this.conn_id)
|
||||
== Some(&ConnectionStatus::Connecting)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if connecting {
|
||||
return;
|
||||
}
|
||||
let cfg = this.config(cx);
|
||||
if let Some(mut cfg) = cfg {
|
||||
if this.engine.is_connected(&cfg.id) {
|
||||
this.engine.close(&cfg.id, true);
|
||||
} else {
|
||||
// 遗嘱 {{变量}} 在每次连接时按当前全局变量求值
|
||||
let vars = this
|
||||
.with_app(cx, |app| app.variables.clone())
|
||||
.unwrap_or_default();
|
||||
render_will_templates(&mut cfg, &vars);
|
||||
this.engine.connect(cfg);
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_message_row(
|
||||
&self,
|
||||
record: &MqttRecord,
|
||||
sub_color: Option<f32>,
|
||||
show_millis: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let seq = record.seq;
|
||||
let expanded = self.expanded == Some(seq);
|
||||
let received = matches!(record.direction, Direction::Received);
|
||||
let accent = if received {
|
||||
cx.theme().primary
|
||||
} else {
|
||||
cx.theme().success
|
||||
};
|
||||
let hover_bg = cx.theme().muted;
|
||||
let border = cx.theme().border;
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let mono = cx.theme().mono_font_family.clone();
|
||||
let direction_label = if received { i18n::t("dir.received") } else { i18n::t("dir.published") };
|
||||
|
||||
let header = h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.size(px(18.))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(accent.alpha(0.14))
|
||||
.child(
|
||||
gpui_kit::component::Icon::new(if received {
|
||||
IconName::ArrowDown
|
||||
} else {
|
||||
IconName::ArrowUp
|
||||
})
|
||||
.size_3()
|
||||
.text_color(accent),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.font_family(mono.clone())
|
||||
// 匹配到订阅色时主题文字着色,否则保持默认前景色
|
||||
.when_some(sub_color, |t, hue| t.text_color(hue_color(hue)))
|
||||
.child(record.topic.to_string()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.font_family(mono.clone())
|
||||
.child(direction_label),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.px_1()
|
||||
.rounded_sm()
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(muted)
|
||||
.font_family(mono.clone())
|
||||
.child(format!("Q{}", record.qos)),
|
||||
)
|
||||
.when(record.retain, |t| {
|
||||
t.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.px_1()
|
||||
.rounded_sm()
|
||||
.bg(cx.theme().warning.alpha(0.15))
|
||||
.text_color(cx.theme().warning)
|
||||
.child("retain"),
|
||||
)
|
||||
})
|
||||
.when(record.payload_truncated, |t| {
|
||||
t.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.px_1()
|
||||
.rounded_sm()
|
||||
.bg(cx.theme().warning.alpha(0.15))
|
||||
.text_color(cx.theme().warning)
|
||||
.child(i18n::tf("msg.truncated", &[("n", &(MAX_PAYLOAD_RETAIN / 1024))])),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.font_family(mono.clone())
|
||||
.child(format_time(record.timestamp, show_millis)),
|
||||
);
|
||||
|
||||
let mut body = v_flex()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.child(header)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().foreground)
|
||||
.pt_0p5()
|
||||
.font_family(mono.clone())
|
||||
// 预览在入环形缓冲时已算好(JSON 美化/压平+截断),此处零解析
|
||||
.child(SharedString::from(record.preview.clone())),
|
||||
);
|
||||
|
||||
if expanded {
|
||||
let fmt = self.detail_format;
|
||||
let detail_text = format_payload_detail(&record.payload, record.raw_bytes.as_deref(), fmt);
|
||||
let mut details = v_flex().gap_1p5().pt_1();
|
||||
|
||||
// 格式切换 + 复制按钮;stop_propagation 避免点击时又收起整行
|
||||
let fmt_group = ButtonGroup::new(SharedString::from(format!("msg-fmt-{}", seq)))
|
||||
.compact()
|
||||
.children(
|
||||
[DetailFormat::Auto, DetailFormat::Text, DetailFormat::Hex, DetailFormat::Base64]
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
Button::new(SharedString::from(format!(
|
||||
"msg-fmt-btn-{}-{}",
|
||||
seq,
|
||||
f.index()
|
||||
)))
|
||||
.label(i18n::t(f.label()))
|
||||
.small()
|
||||
.selected(f == fmt)
|
||||
}),
|
||||
)
|
||||
.on_click(cx.listener(|this, ixs: &Vec<usize>, _, cx| {
|
||||
cx.stop_propagation();
|
||||
if let Some(&i) = ixs.first() {
|
||||
this.detail_format = DetailFormat::from_index(i);
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
let copy_topic = record.topic.clone();
|
||||
let copy_payload = record.payload.clone();
|
||||
let copy_detail = details_copy_text(record, show_millis);
|
||||
let detail_toolbar = h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(fmt_group)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("msg-copy-topic-{}", seq)))
|
||||
.icon(IconName::Copy)
|
||||
.label(i18n::t("msg.topic"))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("msg.copy_topic"))
|
||||
.accessibility_label(i18n::t("msg.copy_topic"))
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(copy_topic.to_string()));
|
||||
window.push_notification(Notification::success(i18n::t("msg.topic_copied")), cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("msg-copy-payload-{}", seq)))
|
||||
.icon(IconName::Copy)
|
||||
.label(i18n::t("msg.payload"))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("msg.copy_payload"))
|
||||
.accessibility_label(i18n::t("msg.copy_payload"))
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(copy_payload.to_string()));
|
||||
window.push_notification(Notification::success(i18n::t("msg.payload_copied")), cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("msg-copy-detail-{}", seq)))
|
||||
.icon(IconName::Copy)
|
||||
.label(i18n::t("msg.details"))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("msg.copy_details"))
|
||||
.accessibility_label(i18n::t("msg.copy_details"))
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(copy_detail.clone()));
|
||||
window.push_notification(Notification::success(i18n::t("msg.details_copied")), cx);
|
||||
}),
|
||||
);
|
||||
details = details.child(detail_toolbar).child(
|
||||
div()
|
||||
.font_family(mono.clone())
|
||||
.text_xs()
|
||||
.p_2()
|
||||
.rounded_md()
|
||||
.bg(cx.theme().background)
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.child(detail_text),
|
||||
);
|
||||
|
||||
if !record.user_properties.is_empty() {
|
||||
let props: String = record
|
||||
.user_properties
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k} = {v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
details = details
|
||||
.child(div().text_xs().font_semibold().child(i18n::t("preset.user_props")))
|
||||
.child(
|
||||
div()
|
||||
.font_family(mono.clone())
|
||||
.text_xs()
|
||||
.child(props),
|
||||
);
|
||||
}
|
||||
for (label, value) in [
|
||||
("Content-Type", record.content_type.clone()),
|
||||
("Response Topic", record.response_topic.clone()),
|
||||
("Correlation Data", record.correlation_data.clone()),
|
||||
(
|
||||
i18n::t("msg.expiry"),
|
||||
record
|
||||
.message_expiry_interval
|
||||
.map(|v| i18n::tf("msg.seconds", &[("n", &v)])),
|
||||
),
|
||||
(
|
||||
"订阅标识符",
|
||||
record.subscription_identifier.map(|v| v.to_string()),
|
||||
),
|
||||
] {
|
||||
if let Some(v) = value {
|
||||
details = details.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(div().text_color(muted).child(format!("{label}:")))
|
||||
.child(div().font_family(mono.clone()).child(v)),
|
||||
);
|
||||
}
|
||||
}
|
||||
body = body.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("msg-detail-{}", seq)))
|
||||
.mt_1()
|
||||
.p_2()
|
||||
.rounded_lg()
|
||||
.bg(cx.theme().muted)
|
||||
// 详情区点击不冒泡到行折叠,保证文本可选中
|
||||
.on_click(|_, _, cx| {
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.child(details),
|
||||
);
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.id(SharedString::from(format!(
|
||||
"msg-{}-{}",
|
||||
record.connection_id, record.seq
|
||||
)))
|
||||
.w_full()
|
||||
.border_b_1()
|
||||
.border_color(border)
|
||||
.hover(move |this| this.bg(hover_bg))
|
||||
.cursor_pointer()
|
||||
// 左侧色条:有订阅色时条件上色(无色不设 bg,天然透明保持行内对齐);
|
||||
// 上下留 2px 间隙,避免相邻行的色条连成一整条竖线
|
||||
.child(
|
||||
div()
|
||||
.w(px(3.))
|
||||
.self_stretch()
|
||||
.my_0p5()
|
||||
.rounded_full()
|
||||
.when_some(sub_color, |bar, hue| bar.bg(hue_color(hue))),
|
||||
)
|
||||
.child(body)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
if this.expanded == Some(seq) {
|
||||
this.expanded = None;
|
||||
} else {
|
||||
// 切换到另一条消息时格式回到 Auto,单条内折叠/展开仍记忆选择
|
||||
if this.detail_owner != Some(seq) {
|
||||
this.detail_format = DetailFormat::Auto;
|
||||
this.detail_owner = Some(seq);
|
||||
}
|
||||
this.expanded = Some(seq);
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn render_messages(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let query = self.filter.read(cx).value().to_lowercase();
|
||||
let conn = self.conn_id.clone();
|
||||
let dir = self.msg_dir;
|
||||
let sub_filter = self.sub_filter.clone();
|
||||
// 引用过滤 + 计数命中总数,仅克隆前 MAX_RENDERED_MESSAGES 条,避免全量拷贝
|
||||
let (records, subs, matched_total, total): (
|
||||
Vec<MqttRecord>,
|
||||
Vec<Subscription>,
|
||||
usize,
|
||||
usize,
|
||||
) = self
|
||||
.with_app(cx, |app| {
|
||||
let subs: Vec<Subscription> = app
|
||||
.subscriptions
|
||||
.iter()
|
||||
.filter(|s| s.connection_id == conn)
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut matched = 0usize;
|
||||
let mut records: Vec<MqttRecord> = Vec::new();
|
||||
let total = app.messages.get(conn.as_str()).map_or(0, |m| m.len());
|
||||
if let Some(m) = app.messages.get(conn.as_str()) {
|
||||
for r in m.iter().rev() {
|
||||
// 方向 / 搜索 / 订阅过滤叠加,过滤后再截断保证最新消息优先
|
||||
let dir_ok = match dir {
|
||||
DirFilter::All => true,
|
||||
DirFilter::Received => r.direction == Direction::Received,
|
||||
DirFilter::Published => r.direction == Direction::Published,
|
||||
};
|
||||
let query_ok = query.is_empty()
|
||||
|| contains_ignore_case(&r.topic, &query)
|
||||
|| contains_ignore_case(&r.payload, &query);
|
||||
let sub_ok = sub_filter
|
||||
.as_deref()
|
||||
.is_none_or(|f| topic_matches(f, &r.topic));
|
||||
if dir_ok && query_ok && sub_ok {
|
||||
matched += 1;
|
||||
if records.len() < MAX_RENDERED_MESSAGES {
|
||||
records.push(r.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(records, subs, matched, total)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut body = v_flex().flex_1().overflow_y_scrollbar();
|
||||
if records.is_empty() {
|
||||
let filtering = !query.is_empty()
|
||||
|| sub_filter.is_some()
|
||||
|| dir != DirFilter::All;
|
||||
body = body.child(
|
||||
v_flex()
|
||||
.h_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_3()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.size(px(56.))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().muted)
|
||||
.child(
|
||||
gpui_kit::component::Icon::new(IconName::MailOpen)
|
||||
.size_6()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(if filtering {
|
||||
i18n::t("msg.no_match")
|
||||
} else {
|
||||
i18n::t("msg.empty")
|
||||
}),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 最新消息在最上方(与 MQTTX 一致,避免长列表需要手动滚底)
|
||||
let show_millis = self
|
||||
.with_app(cx, |app| app.settings.show_millis)
|
||||
.unwrap_or(true);
|
||||
for r in &records {
|
||||
let color = pick_subscription_color(&subs, &r.topic);
|
||||
body = body.child(self.render_message_row(r, color, show_millis, cx));
|
||||
}
|
||||
}
|
||||
|
||||
// 方向过滤 seg
|
||||
let dir_group = ButtonGroup::new(SharedString::from(format!(
|
||||
"msg-dir-{}",
|
||||
self.conn_id
|
||||
)))
|
||||
.compact()
|
||||
.children([DirFilter::All, DirFilter::Received, DirFilter::Published].into_iter().map(
|
||||
|d| {
|
||||
Button::new(SharedString::from(format!(
|
||||
"msg-dir-btn-{}-{}",
|
||||
self.conn_id,
|
||||
d.index()
|
||||
)))
|
||||
.label(i18n::t(d.label()))
|
||||
.small()
|
||||
.selected(d == dir)
|
||||
},
|
||||
))
|
||||
.on_click(cx.listener(|this, ixs: &Vec<usize>, _, cx| {
|
||||
if let Some(&i) = ixs.first() {
|
||||
this.msg_dir = DirFilter::from_index(i);
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// 订阅过滤 chip(点击订阅项激活)
|
||||
let filter_chip = self.sub_filter.clone().map(|topic| {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded_md()
|
||||
.bg(cx.theme().secondary)
|
||||
.max_w(px(180.))
|
||||
.child(
|
||||
gpui_kit::component::Icon::new(IconName::ListFilter)
|
||||
.xsmall()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.child(topic),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!(
|
||||
"msg-filter-clear-{}",
|
||||
self.conn_id
|
||||
)))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("msg.clear_sub_filter"))
|
||||
.accessibility_label(i18n::t("msg.clear_sub_filter"))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.sub_filter = None;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
});
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.h_full()
|
||||
.bg(cx.theme().background)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_11()
|
||||
.px_3()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(dir_group)
|
||||
.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("msg-filter-wrap-{}", self.conn_id)))
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
// Esc 清空过滤词:高频调试时比全选删除顺手
|
||||
.on_key_down(cx.listener(
|
||||
|this, ev: &gpui_kit::KeyDownEvent, window, cx| {
|
||||
if ev.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
this.filter
|
||||
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
))
|
||||
.child(Input::new(&self.filter).small().prefix(
|
||||
gpui_kit::component::Icon::new(IconName::Search).small(),
|
||||
)),
|
||||
)
|
||||
.children(filter_chip)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.px_1p5()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
// 计数显示过滤命中总数,不受渲染截断影响
|
||||
.child(i18n::tf("msg.count", &[("n", &matched_total)])),
|
||||
)
|
||||
.when(matched_total > records.len(), |h| {
|
||||
h.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.px_1p5()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(i18n::tf(
|
||||
"msg.latest_only",
|
||||
&[("n", &MAX_RENDERED_MESSAGES)],
|
||||
)),
|
||||
)
|
||||
})
|
||||
.child({
|
||||
// 暂停滚动:高频消息流下锁定当前画面以便阅读/展开,
|
||||
// 消息仍照常入环形缓冲,恢复后直接显示最新
|
||||
let paused = self.paused;
|
||||
Button::new(SharedString::from(format!(
|
||||
"msg-pause-{}",
|
||||
self.conn_id
|
||||
)))
|
||||
.icon(if paused {
|
||||
IconName::Play
|
||||
} else {
|
||||
IconName::Pause
|
||||
})
|
||||
.label(if paused { i18n::t("common.resume") } else { i18n::t("common.pause") })
|
||||
.ghost()
|
||||
.small()
|
||||
.when(paused, |b| b.selected(true))
|
||||
.tooltip(if paused {
|
||||
i18n::t("msg.resume_tooltip")
|
||||
} else {
|
||||
i18n::t("msg.pause_tooltip")
|
||||
})
|
||||
.accessibility_label(if paused {
|
||||
i18n::t("common.resume")
|
||||
} else {
|
||||
i18n::t("common.pause")
|
||||
})
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.paused = !this.paused;
|
||||
cx.notify();
|
||||
}))
|
||||
})
|
||||
.child({
|
||||
let weak = cx.weak_entity();
|
||||
let clear_id = self.conn_id.clone();
|
||||
// 清空针对连接的全部消息(非过滤结果):
|
||||
// 禁用与否依据总数,过滤命中 0 条不应禁用
|
||||
let empty = total == 0;
|
||||
Button::new(SharedString::from(format!("msg-clear-{}", self.conn_id)))
|
||||
.icon(IconName::Eraser)
|
||||
.label(i18n::t("common.clear"))
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(empty)
|
||||
.tooltip(i18n::t("msg.clear_tooltip"))
|
||||
.accessibility_label(i18n::t("msg.clear_tooltip"))
|
||||
.on_click(move |_, window, cx| {
|
||||
// 清空不可撤销,先弹二次确认
|
||||
let weak = weak.clone();
|
||||
let clear_id = clear_id.clone();
|
||||
window.open_alert_dialog(cx, move |alert, _, _| {
|
||||
let weak = weak.clone();
|
||||
let clear_id = clear_id.clone();
|
||||
alert
|
||||
.title(i18n::t("msg.clear_title"))
|
||||
.description(i18n::t("msg.clear_confirm"))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.show_cancel(true)
|
||||
.cancel_text(i18n::t("common.cancel"))
|
||||
.ok_text(i18n::t("common.clear"))
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.on_ok(move |_, _, cx| {
|
||||
weak.update(cx, |view, cx| {
|
||||
view.expanded = None;
|
||||
view.detail_owner = None;
|
||||
view.detail_format = DetailFormat::Auto;
|
||||
if let Some(app) = view.app.upgrade() {
|
||||
app.update(cx, |app, cx| {
|
||||
app.clear_messages(&clear_id);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
true
|
||||
})
|
||||
});
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(body)
|
||||
}
|
||||
|
||||
pub(super) fn render_logs(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let conn = self.conn_id.clone();
|
||||
let show_millis = self
|
||||
.with_app(cx, |app| app.settings.show_millis)
|
||||
.unwrap_or(true);
|
||||
let logs: Vec<_> = self
|
||||
.with_app(cx, |app| {
|
||||
app.logs
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|l| l.connection_id == conn)
|
||||
// 与 app.rs 的 MAX_LOGS=3000 对齐,避免渲染截断早于存储上限
|
||||
.take(MAX_RENDERED_LOGS)
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut body = v_flex().flex_1().overflow_y_scrollbar().p_2().gap_0p5();
|
||||
if logs.is_empty() {
|
||||
body = body.child(
|
||||
v_flex()
|
||||
.p_4()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
gpui_kit::component::Icon::new(IconName::ScrollText)
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(i18n::t("msg.no_logs")),
|
||||
),
|
||||
);
|
||||
}
|
||||
let log_hover = cx.theme().muted;
|
||||
for l in logs {
|
||||
let color = match l.level {
|
||||
LogLevel::Error => cx.theme().danger,
|
||||
LogLevel::Warn => cx.theme().warning,
|
||||
LogLevel::Debug => cx.theme().muted_foreground,
|
||||
LogLevel::Info => cx.theme().foreground,
|
||||
};
|
||||
body = body.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.hover(move |this| this.bg(log_hover))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.child(format_time(l.timestamp, show_millis)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.w_10()
|
||||
.text_color(color)
|
||||
.child(l.level.label()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.child(format!("[{}] {}", l.event, l.message)),
|
||||
),
|
||||
);
|
||||
}
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.h_full()
|
||||
.bg(cx.theme().background)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_11()
|
||||
.px_3()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.font_family(cx.theme().mono_font_family.clone())
|
||||
.child(i18n::t("msg.log_title")),
|
||||
),
|
||||
)
|
||||
.child(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! 单个连接的工作区:订阅列表 + 消息流 + 发布面板 + 日志。
|
||||
//!
|
||||
//! - [`subscribe`] 订阅表单/列表的操作与渲染
|
||||
//! - [`messages`] 顶栏过滤、消息流与日志渲染
|
||||
//! - [`publish`] 发布表单/预设的操作与渲染
|
||||
//! - [`vars`] 模板变量面板
|
||||
//! - [`util`] topic 匹配、格式化等纯函数
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui_kit::component::button::ButtonVariants as _;
|
||||
use gpui_kit::component::scroll::ScrollableElement as _;
|
||||
use gpui_kit::component::input::{InputState, TextareaState};
|
||||
use gpui_kit::component::menu::DropdownMenu as _;
|
||||
use gpui_kit::component::select::SelectState;
|
||||
use gpui_kit::component::tab::{Tab, TabBar};
|
||||
use gpui_kit::component::{
|
||||
h_flex, v_flex, ActiveTheme as _, Disableable as _,
|
||||
Selectable as _, Sizable as _, StyledExt as _, WindowExt as _,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use gpui_kit::{
|
||||
px, App, AppContext as _, Context, Entity,
|
||||
InteractiveElement as _, IntoElement, ParentElement as _, Render,
|
||||
StatefulInteractiveElement as _, Styled as _, Subscription as InputSubscription, Window,
|
||||
};
|
||||
|
||||
use crate::model::ConnectionConfig;
|
||||
use crate::ui::i18n;
|
||||
use crate::mqtt::MqttEngine;
|
||||
use crate::ui::app::MqttXApp;
|
||||
use crate::ui::widgets::{make_select, KvEditor, OptionDelegate};
|
||||
|
||||
mod messages;
|
||||
mod publish;
|
||||
mod subscribe;
|
||||
mod util;
|
||||
mod vars;
|
||||
use util::*;
|
||||
|
||||
const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"];
|
||||
const PAYLOAD_FORMATS: [&str; 4] = ["Plaintext", "JSON", "Base64", "Hex"];
|
||||
/// Retain Handling 0/1/2 的下拉文案
|
||||
fn retain_handling_labels() -> [&'static str; 3] {
|
||||
[i18n::t("sub.rh0"), i18n::t("sub.rh1"), i18n::t("sub.rh2")]
|
||||
}
|
||||
const MAX_RENDERED_MESSAGES: usize = 300;
|
||||
/// 日志渲染上限,与 app.rs 的 MAX_LOGS 保持一致
|
||||
const MAX_RENDERED_LOGS: usize = 3000;
|
||||
/// 订阅色板预设:色相(度)+ 名称
|
||||
const PRESET_HUES: [(f32, &str); 10] = [
|
||||
(0., "color.red"),
|
||||
(30., "color.orange"),
|
||||
(60., "color.yellow"),
|
||||
(120., "color.green"),
|
||||
(160., "color.cyan"),
|
||||
(200., "color.blue"),
|
||||
(240., "color.indigo"),
|
||||
(270., "color.purple"),
|
||||
(300., "color.magenta"),
|
||||
(330., "color.rose"),
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Panel {
|
||||
Messages,
|
||||
Logs,
|
||||
}
|
||||
|
||||
/// 消息流方向过滤(与搜索、订阅过滤叠加生效)
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum DirFilter {
|
||||
All,
|
||||
Received,
|
||||
Published,
|
||||
}
|
||||
|
||||
impl DirFilter {
|
||||
/// 返回 i18n 语义 key;Hex/Base64 为语言中性字面量。
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "common.all",
|
||||
Self::Received => "dir.received",
|
||||
Self::Published => "dir.published",
|
||||
}
|
||||
}
|
||||
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Self::All => 0,
|
||||
Self::Received => 1,
|
||||
Self::Published => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_index(i: usize) -> Self {
|
||||
match i {
|
||||
1 => Self::Received,
|
||||
2 => Self::Published,
|
||||
_ => Self::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 展开消息详情里的 payload 展示格式。任一时刻只展开一条消息,
|
||||
/// 视图级单值即可视作「逐消息」选择。
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum DetailFormat {
|
||||
Auto,
|
||||
Text,
|
||||
Hex,
|
||||
Base64,
|
||||
}
|
||||
|
||||
impl DetailFormat {
|
||||
/// 返回 i18n 语义 key;Hex/Base64 为语言中性字面量。
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "common.auto",
|
||||
Self::Text => "common.text",
|
||||
Self::Hex => "Hex",
|
||||
Self::Base64 => "Base64",
|
||||
}
|
||||
}
|
||||
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Auto => 0,
|
||||
Self::Text => 1,
|
||||
Self::Hex => 2,
|
||||
Self::Base64 => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_index(i: usize) -> Self {
|
||||
match i {
|
||||
1 => Self::Text,
|
||||
2 => Self::Hex,
|
||||
3 => Self::Base64,
|
||||
_ => Self::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConnectionView {
|
||||
conn_id: String,
|
||||
app: gpui_kit::WeakEntity<MqttXApp>,
|
||||
engine: Arc<MqttEngine>,
|
||||
|
||||
// ── 订阅 ──
|
||||
sub_topic: Entity<InputState>,
|
||||
sub_qos: Entity<SelectState<OptionDelegate>>,
|
||||
sub_alias: Entity<InputState>,
|
||||
// v5 高级订阅选项
|
||||
sub_identifier: Entity<InputState>,
|
||||
sub_no_local: bool,
|
||||
sub_rap: bool,
|
||||
sub_retain_handling: Entity<SelectState<OptionDelegate>>,
|
||||
sub_show_advanced: bool,
|
||||
/// 编辑中的旧主题:提交时用旧主题移除原订阅,避免改名过程丢数据
|
||||
editing: Option<String>,
|
||||
/// 点击订阅项激活的消息过滤主题(再点一次取消)
|
||||
sub_filter: Option<String>,
|
||||
|
||||
// 发布
|
||||
pub_topic: Entity<InputState>,
|
||||
payload: Entity<TextareaState>,
|
||||
pub_qos: Entity<SelectState<OptionDelegate>>,
|
||||
payload_format: Entity<SelectState<OptionDelegate>>,
|
||||
retain: bool,
|
||||
content_type: Entity<InputState>,
|
||||
user_props: Entity<KvEditor>,
|
||||
msg_expiry: Entity<InputState>,
|
||||
response_topic: Entity<InputState>,
|
||||
correlation_data: Entity<InputState>,
|
||||
show_props: bool,
|
||||
|
||||
// 过滤与面板
|
||||
filter: Entity<InputState>,
|
||||
panel: Panel,
|
||||
expanded: Option<u64>,
|
||||
/// 当前 detail_format 归属的消息:切换到另一条消息时据此重置格式,
|
||||
/// 同一条内折叠/展开则保留用户选择
|
||||
detail_owner: Option<u64>,
|
||||
msg_dir: DirFilter,
|
||||
detail_format: DetailFormat,
|
||||
/// 消息流暂停滚动:消息照常入环形缓冲,仅不再触发本视图重绘
|
||||
paused: bool,
|
||||
/// 订阅面板抽屉收起状态(会话内有效)
|
||||
subs_collapsed: bool,
|
||||
/// 底部发布栏抽屉收起状态(会话内有效)
|
||||
publish_collapsed: bool,
|
||||
|
||||
// 预设
|
||||
preset_name: Entity<InputState>,
|
||||
|
||||
// 发布面板内联变量绑定
|
||||
/// 「变量」小节展开状态(仅在提取到占位符时渲染整节)
|
||||
show_vars: bool,
|
||||
/// 提取到的 `{{key}}` → 值输入框与变更订阅,按提取顺序排列
|
||||
var_rows: Vec<(String, Entity<InputState>, InputSubscription)>,
|
||||
}
|
||||
|
||||
impl ConnectionView {
|
||||
pub fn new(
|
||||
conn_id: String,
|
||||
app: gpui_kit::WeakEntity<MqttXApp>,
|
||||
engine: std::sync::Arc<MqttEngine>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
|
||||
let sub_topic = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder(i18n::t("sub.topic_placeholder"))
|
||||
});
|
||||
let sub_qos = make_select(&QOS, 0, window, cx);
|
||||
let sub_alias = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("sub.alias")));
|
||||
let sub_identifier = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("sub.identifier")));
|
||||
let sub_retain_handling = make_select(&retain_handling_labels(), 0, window, cx);
|
||||
let pub_topic = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.topic_placeholder")));
|
||||
let payload = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.placeholder(i18n::t("pub.payload_placeholder"))
|
||||
});
|
||||
let pub_qos = make_select(&QOS, 0, window, cx);
|
||||
let payload_format = make_select(&PAYLOAD_FORMATS, 0, window, cx);
|
||||
let content_type = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.content_type_ph")));
|
||||
let msg_expiry = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.seconds_hint")));
|
||||
let response_topic =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.response_topic_ph")));
|
||||
let correlation_data =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.correlation_ph")));
|
||||
let filter = cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("msg.filter_placeholder")));
|
||||
let user_props = cx.new(|cx| KvEditor::new("pub", &[], window, cx));
|
||||
let preset_name =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(i18n::t("pub.preset_name_hint")));
|
||||
|
||||
Self {
|
||||
conn_id,
|
||||
app,
|
||||
engine,
|
||||
sub_topic,
|
||||
sub_qos,
|
||||
sub_alias,
|
||||
sub_identifier,
|
||||
sub_no_local: false,
|
||||
sub_rap: false,
|
||||
sub_retain_handling,
|
||||
sub_show_advanced: false,
|
||||
editing: None,
|
||||
sub_filter: None,
|
||||
pub_topic,
|
||||
payload,
|
||||
pub_qos,
|
||||
payload_format,
|
||||
retain: false,
|
||||
content_type,
|
||||
user_props,
|
||||
msg_expiry,
|
||||
response_topic,
|
||||
correlation_data,
|
||||
show_props: false,
|
||||
filter,
|
||||
panel: Panel::Messages,
|
||||
expanded: None,
|
||||
detail_owner: None,
|
||||
msg_dir: DirFilter::All,
|
||||
detail_format: DetailFormat::Auto,
|
||||
paused: false,
|
||||
subs_collapsed: false,
|
||||
publish_collapsed: false,
|
||||
preset_name,
|
||||
show_vars: true,
|
||||
var_rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_app<R>(&self, cx: &App, f: impl FnOnce(&MqttXApp) -> R) -> Option<R> {
|
||||
self.app.upgrade().map(|a| f(a.read(cx)))
|
||||
}
|
||||
|
||||
fn config(&self, cx: &App) -> Option<ConnectionConfig> {
|
||||
let id = self.conn_id.clone();
|
||||
self.with_app(cx, |app| {
|
||||
app.connections.iter().find(|c| c.id == id).cloned()
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn is_v5(&self, cx: &App) -> bool {
|
||||
self.config(cx).map(|c| c.protocol.is_v5()).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// 消息流是否处于暂停滚动状态(引擎事件泵据此决定是否刷新本视图)。
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.paused
|
||||
}
|
||||
|
||||
/// Ctrl+F:把焦点移到消息过滤框(键盘直达过滤入口)。
|
||||
pub fn focus_filter(&self, window: &mut Window, cx: &mut App) {
|
||||
let handle = gpui_kit::component::input::AnyInputState::from(self.filter.clone())
|
||||
.focus_handle(cx);
|
||||
window.focus(&handle, cx);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
impl Render for ConnectionView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let panel = self.panel;
|
||||
v_flex()
|
||||
.size_full()
|
||||
.min_w(px(0.))
|
||||
.child(self.render_top_bar(cx))
|
||||
.child(self.render_subscribe_bar(cx))
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.min_h(px(0.))
|
||||
// 订阅面板抽屉:收起时只剩窄 rail
|
||||
.child(if self.subs_collapsed {
|
||||
self.render_subs_rail(cx).into_any_element()
|
||||
} else {
|
||||
self.render_subscriptions(cx).into_any_element()
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.h_full()
|
||||
.child(
|
||||
TabBar::new("workspace-tabs")
|
||||
.segmented()
|
||||
.selected_index(if panel == Panel::Messages { 0 } else { 1 })
|
||||
.child(
|
||||
Tab::new()
|
||||
.label(i18n::t("connview.messages"))
|
||||
.selected(panel == Panel::Messages)
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.panel = Panel::Messages;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Tab::new()
|
||||
.label(i18n::t("connview.logs"))
|
||||
.selected(panel == Panel::Logs)
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.panel = Panel::Logs;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(match panel {
|
||||
Panel::Messages => self.render_messages(cx).into_any_element(),
|
||||
Panel::Logs => self.render_logs(cx).into_any_element(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(if self.publish_collapsed {
|
||||
self.render_publish_rail(cx).into_any_element()
|
||||
} else {
|
||||
self.render_publish_bar(cx).into_any_element()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
//! 发布:参数收集/发送、发布预设菜单与保存对话框。
|
||||
|
||||
|
||||
use gpui_kit::component::button::{ Button, ButtonVariant };
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::input::{ Input, InputState, Textarea };
|
||||
use gpui_kit::component::menu::{ PopupMenuItem };
|
||||
use gpui_kit::component::select::Select;
|
||||
use gpui_kit::component::switch::Switch;
|
||||
use gpui_kit::component::{ h_flex, notification::Notification, v_flex, };
|
||||
use crate::ui::IconName;
|
||||
use gpui_kit::{ div, px, App, Context, Entity, IntoElement, SharedString, Window, };
|
||||
use gpui_kit::component::IndexPath;
|
||||
|
||||
use crate::model::{ render_template, PayloadFormat, PublishParams, };
|
||||
use crate::ui::widgets::{ field, KvEditor };
|
||||
|
||||
use super::*;
|
||||
use crate::ui::i18n;
|
||||
|
||||
impl ConnectionView {
|
||||
/// 发布栏收起后的细条:抽屉把手。
|
||||
pub(super) fn render_publish_rail(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(34.))
|
||||
.px_3()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.flex_shrink_0()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().secondary)
|
||||
.child(
|
||||
Button::new("pub-rail-expand")
|
||||
.icon(IconName::PanelBottomOpen)
|
||||
.ghost()
|
||||
.small()
|
||||
.tooltip(i18n::t("pub.expand"))
|
||||
.accessibility_label(i18n::t("pub.expand"))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.publish_collapsed = false;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(i18n::t("pub.collapsed_hint")),
|
||||
)
|
||||
}
|
||||
|
||||
/// 发布当前面板参数。入口有三:发送按钮、发布主题框内 Enter、
|
||||
/// 全局 Ctrl+Enter action(app/mod.rs 注册)。
|
||||
pub(crate) fn do_publish(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// 未连接时消息发不出去,先拦截给出与订阅路径一致的提示
|
||||
if !self.engine.is_connected(&self.conn_id) {
|
||||
window.push_notification(Notification::warning(i18n::t("pub.offline_not_sent")), cx);
|
||||
return;
|
||||
}
|
||||
let mut params = self.collect_publish_params(cx);
|
||||
if params.topic.trim().is_empty() {
|
||||
window.push_notification(Notification::warning(i18n::t("pub.topic_required")), cx);
|
||||
return;
|
||||
}
|
||||
// 过期秒数输入非法时 collect 会静默得到 None,这里显式拦截避免误发
|
||||
let expiry_raw = self.msg_expiry.read(cx).value().to_string();
|
||||
if !expiry_raw.trim().is_empty() && params.message_expiry_interval.is_none() {
|
||||
window.push_notification(Notification::warning(i18n::t("pub.expiry_invalid")), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let vars = self.with_app(cx, |app| app.variables.clone()).unwrap_or_default();
|
||||
params.topic = render_template(¶ms.topic, &vars);
|
||||
params.payload = render_template(¶ms.payload, &vars);
|
||||
// {{topic}} 等变量渲染后可能变空串,渲染完成后再校验一次
|
||||
if params.topic.trim().is_empty() {
|
||||
window.push_notification(Notification::warning(i18n::t("pub.topic_required")), cx);
|
||||
return;
|
||||
}
|
||||
// {{变量}} 同样作用于用户属性的 key/value
|
||||
params.user_properties = params
|
||||
.user_properties
|
||||
.into_iter()
|
||||
.map(|(k, v)| (render_template(&k, &vars), render_template(&v, &vars)))
|
||||
.collect();
|
||||
// v5 文本属性(Content-Type / Response Topic / Correlation Data)一并注入
|
||||
params.content_type = params.content_type.map(|v| render_template(&v, &vars));
|
||||
params.response_topic = params.response_topic.map(|v| render_template(&v, &vars));
|
||||
params.correlation_data = params.correlation_data.map(|v| render_template(&v, &vars));
|
||||
|
||||
if let PayloadFormat::Json = params.payload_format
|
||||
&& let Err(e) = serde_json::from_str::<serde_json::Value>(¶ms.payload) {
|
||||
window.push_notification(
|
||||
Notification::warning(i18n::tf("pub.json_invalid", &[("e", &e)])),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
match params.payload_format.encode(¶ms.payload) {
|
||||
Ok(bytes) => params.raw_bytes = Some(bytes),
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::warning(e), cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// v3.1.1 忽略 v5 属性,避免残留输入带进发布报文
|
||||
if !self.is_v5(cx) {
|
||||
params.user_properties.clear();
|
||||
params.content_type = None;
|
||||
params.message_expiry_interval = None;
|
||||
params.response_topic = None;
|
||||
params.correlation_data = None;
|
||||
}
|
||||
// 发出的消息若会被当前过滤条件隐藏(方向/订阅/搜索),发布记录在
|
||||
// 消息流里看不到,用户容易误以为没发出去,此时给一条显式反馈
|
||||
let query = self.filter.read(cx).value().to_lowercase();
|
||||
let hidden = self.msg_dir == DirFilter::Received
|
||||
|| self
|
||||
.sub_filter
|
||||
.as_deref()
|
||||
.map(|f| !topic_matches(f, ¶ms.topic))
|
||||
.unwrap_or(false)
|
||||
|| (!query.is_empty()
|
||||
&& !contains_ignore_case(¶ms.topic, &query)
|
||||
&& !contains_ignore_case(¶ms.payload, &query));
|
||||
self.engine.publish(self.conn_id.clone(), params);
|
||||
if hidden {
|
||||
window.push_notification(
|
||||
Notification::success(i18n::t("pub.sent_hidden")),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取当前发布面板上的参数(模板渲染前的原始值);保存预设与发布共用,
|
||||
/// 须覆盖全部 v5 属性与用户属性。
|
||||
fn collect_publish_params(&self, cx: &App) -> PublishParams {
|
||||
let qos = self.pub_qos.read(cx).selected_value().copied().unwrap_or(0) as u8;
|
||||
let format = PayloadFormat::ALL
|
||||
[self.payload_format.read(cx).selected_value().copied().unwrap_or(0)];
|
||||
let opt = |s: String| {
|
||||
let t = s.trim();
|
||||
(!t.is_empty()).then(|| t.to_string())
|
||||
};
|
||||
PublishParams {
|
||||
topic: self.pub_topic.read(cx).value().to_string(),
|
||||
payload: self.payload.read(cx).value().to_string(),
|
||||
payload_format: format,
|
||||
qos,
|
||||
retain: self.retain,
|
||||
user_properties: self.user_props.read(cx).pairs(cx),
|
||||
content_type: opt(self.content_type.read(cx).value().to_string()),
|
||||
message_expiry_interval: {
|
||||
let t = self.msg_expiry.read(cx).value().to_string();
|
||||
let t = t.trim();
|
||||
if t.is_empty() { None } else { t.parse::<u32>().ok() }
|
||||
},
|
||||
response_topic: opt(self.response_topic.read(cx).value().to_string()),
|
||||
correlation_data: opt(self.correlation_data.read(cx).value().to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 把预设应用到发布面板控件。
|
||||
fn apply_publish_preset(
|
||||
&mut self,
|
||||
params: &PublishParams,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.pub_topic
|
||||
.update(cx, |s, cx| s.set_value(params.topic.clone(), window, cx));
|
||||
self.payload
|
||||
.update(cx, |s, cx| s.set_value(params.payload.clone(), window, cx));
|
||||
// 预设未指定 Content-Type 时清空旧值,避免上一次输入残留
|
||||
let ct = params.content_type.clone().unwrap_or_default();
|
||||
self.content_type
|
||||
.update(cx, |s, cx| s.set_value(ct, window, cx));
|
||||
let expiry = params
|
||||
.message_expiry_interval
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default();
|
||||
self.msg_expiry
|
||||
.update(cx, |s, cx| s.set_value(expiry, window, cx));
|
||||
let rt = params.response_topic.clone().unwrap_or_default();
|
||||
self.response_topic
|
||||
.update(cx, |s, cx| s.set_value(rt, window, cx));
|
||||
let cd = params.correlation_data.clone().unwrap_or_default();
|
||||
self.correlation_data
|
||||
.update(cx, |s, cx| s.set_value(cd, window, cx));
|
||||
// KvEditor 不支持运行时整体替换,直接重建实体回填用户属性
|
||||
self.user_props = cx.new(|cx| KvEditor::new("pub", ¶ms.user_properties, window, cx));
|
||||
let qos_idx = params.qos.min(2) as usize;
|
||||
self.pub_qos.update(cx, |s, cx| {
|
||||
s.set_selected_index(Some(IndexPath::new(qos_idx)), window, cx)
|
||||
});
|
||||
let fmt_idx = PayloadFormat::ALL
|
||||
.iter()
|
||||
.position(|f| *f == params.payload_format)
|
||||
.unwrap_or(0);
|
||||
self.payload_format.update(cx, |s, cx| {
|
||||
s.set_selected_index(Some(IndexPath::new(fmt_idx)), window, cx)
|
||||
});
|
||||
self.retain = params.retain;
|
||||
// 同步模板变量面板:预设替换了模板,旧占位符行必须随之清理,
|
||||
// 否则残留行仍会写回全局变量
|
||||
self.sync_var_rows(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn render_publish_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let pub_topic = self.pub_topic.clone();
|
||||
let payload = self.payload.clone();
|
||||
let pub_qos = self.pub_qos.clone();
|
||||
let payload_format = self.payload_format.clone();
|
||||
let content_type = self.content_type.clone();
|
||||
let msg_expiry = self.msg_expiry.clone();
|
||||
let response_topic = self.response_topic.clone();
|
||||
let correlation_data = self.correlation_data.clone();
|
||||
let user_props = self.user_props.clone();
|
||||
let retain = self.retain;
|
||||
let show_props = self.show_props;
|
||||
let show_vars = self.show_vars;
|
||||
let is_v5 = self.is_v5(cx);
|
||||
|
||||
// 发布预设
|
||||
let presets = self.with_app(cx, |app| app.presets.clone()).unwrap_or_default();
|
||||
let connected = self.engine.is_connected(&self.conn_id);
|
||||
// 当前提取到的占位符数量(仅用于提示,行构建在 sync_var_rows 完成)
|
||||
let var_count = extract_placeholder_keys(
|
||||
&self.pub_topic.read(cx).value(),
|
||||
&self.payload.read(cx).value(),
|
||||
)
|
||||
.len();
|
||||
|
||||
let mut card = v_flex()
|
||||
.w_full()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().secondary)
|
||||
.px_3()
|
||||
.py_2()
|
||||
.gap_2();
|
||||
|
||||
card = card.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
// 主题框内 Enter 直接发送(Ctrl+Enter 走全局 action,任意焦点可用)
|
||||
.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("pub-topic-wrap-{}", self.conn_id)))
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.on_key_down(cx.listener(
|
||||
|this, ev: &gpui_kit::KeyDownEvent, window, cx| {
|
||||
if ev.keystroke.key == "enter"
|
||||
&& !ev.keystroke.modifiers.control
|
||||
{
|
||||
cx.stop_propagation();
|
||||
this.do_publish(window, cx);
|
||||
}
|
||||
},
|
||||
))
|
||||
.child(Input::new(&pub_topic).small()),
|
||||
)
|
||||
.child(div().w(px(92.)).child(Select::new(&pub_qos).small()))
|
||||
.child(div().w(px(124.)).child(Select::new(&payload_format).small()))
|
||||
.child(self.render_preset_menu(presets, cx))
|
||||
.child({
|
||||
// 变量注入开关:展开/收起内联绑定面板
|
||||
let count = var_count;
|
||||
Button::new("vars-toggle")
|
||||
.icon(IconName::Variable)
|
||||
.ghost()
|
||||
.small()
|
||||
.when(show_vars, |b| b.selected(true))
|
||||
.tooltip(if count > 0 {
|
||||
i18n::tf(
|
||||
"pub.vars_count",
|
||||
&[("n", &count)],
|
||||
)
|
||||
} else {
|
||||
i18n::t("pub.vars").to_string()
|
||||
})
|
||||
.accessibility_label(i18n::t("pub.vars").to_string())
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
if this.show_vars {
|
||||
this.show_vars = false;
|
||||
cx.notify();
|
||||
} else {
|
||||
this.sync_var_rows(window, cx);
|
||||
}
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(Switch::new("retain").checked(retain).on_change(
|
||||
cx.listener(|this, v, _, cx| {
|
||||
this.retain = *v;
|
||||
cx.notify();
|
||||
}),
|
||||
))
|
||||
.child(div().text_xs().child("Retain")),
|
||||
)
|
||||
.when(is_v5, |h| {
|
||||
h.child(
|
||||
Button::new("toggle-props")
|
||||
.icon(IconName::SlidersHorizontal)
|
||||
.ghost()
|
||||
.small()
|
||||
.when(show_props, |b| b.selected(true))
|
||||
.tooltip(i18n::t("pub.mqtt5_props"))
|
||||
.accessibility_label(i18n::t("pub.mqtt5_props"))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.show_props = !this.show_props;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("publish-{}", self.conn_id)))
|
||||
.icon(IconName::SendHorizontal)
|
||||
.label(i18n::t("common.send"))
|
||||
.primary()
|
||||
.small()
|
||||
// 未连接时禁用,Ctrl+Enter 路径由 do_publish 内的前置检查兜底
|
||||
.disabled(!connected)
|
||||
.when(!connected, |b| b.tooltip(i18n::t("pub.send_disabled")))
|
||||
.on_click(cx.listener(|this, _, window, cx| this.do_publish(window, cx))),
|
||||
)
|
||||
// 发布栏抽屉把手:收起整个底部面板
|
||||
.child(
|
||||
Button::new("pub-collapse")
|
||||
.icon(IconName::PanelBottom)
|
||||
.ghost()
|
||||
.small()
|
||||
.tooltip(i18n::t("pub.collapse"))
|
||||
.accessibility_label(i18n::t("pub.collapse"))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.publish_collapsed = true;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
card = card.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("payload-wrap-{}", self.conn_id)))
|
||||
.h(px(110.))
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().background)
|
||||
.p_1()
|
||||
// Ctrl+Enter 发送由全局 action 兜底(焦点在本框内也能触发)
|
||||
.child(Textarea::new(&payload)),
|
||||
);
|
||||
|
||||
// 内联变量绑定面板:仅在展开且已提取到占位符时渲染
|
||||
if show_vars && !self.var_rows.is_empty() {
|
||||
card = card.child(self.render_vars_panel(cx));
|
||||
}
|
||||
|
||||
if show_props && is_v5 {
|
||||
card = card.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
div().w(px(220.)).child(field(
|
||||
"Content-Type",
|
||||
Input::new(&content_type).small(),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div().w(px(120.)).child(field(
|
||||
"消息过期(秒)",
|
||||
Input::new(&msg_expiry).small(),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"Response Topic",
|
||||
Input::new(&response_topic).small(),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"Correlation Data",
|
||||
Input::new(&correlation_data).small(),
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(user_props.clone()),
|
||||
);
|
||||
}
|
||||
card
|
||||
}
|
||||
|
||||
/// 发布预设下拉:应用已有预设、保存当前、删除预设。
|
||||
fn render_preset_menu( &self,
|
||||
presets: Vec<crate::model::PublishPreset>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let weak_view = cx.weak_entity();
|
||||
let preset_name = self.preset_name.clone();
|
||||
let app_weak = self.app.clone();
|
||||
let has = !presets.is_empty();
|
||||
Button::new(SharedString::from(format!("preset-menu-{}", self.conn_id)))
|
||||
.icon(IconName::Bookmark)
|
||||
.ghost()
|
||||
.small()
|
||||
.tooltip(i18n::t("pub.preset_menu"))
|
||||
.accessibility_label(i18n::t("pub.preset_menu"))
|
||||
.dropdown_menu(move |mut menu, window, cx| {
|
||||
// 应用预设
|
||||
for p in &presets {
|
||||
let weak = weak_view.clone();
|
||||
let params = p.params.clone();
|
||||
menu = menu.item(
|
||||
PopupMenuItem::new(SharedString::from(p.name.clone())).on_click(
|
||||
move |_ev, window, cx| {
|
||||
if let Some(view) = weak.upgrade() {
|
||||
view.update(cx, |view, cx| {
|
||||
view.apply_publish_preset(¶ms, window, cx);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if has {
|
||||
menu = menu.separator();
|
||||
}
|
||||
// 保存当前
|
||||
let weak_save = weak_view.clone();
|
||||
let name_input = preset_name.clone();
|
||||
menu = menu.item(PopupMenuItem::new(i18n::t("pub.save_preset")).on_click(
|
||||
move |_ev, window, cx| {
|
||||
if let Some(view) = weak_save.upgrade() {
|
||||
view.update(cx, |view, cx| {
|
||||
view.open_save_preset_dialog(window, cx, name_input.clone());
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
// 打开预设管理对话框(新建 / 编辑 / 删除)
|
||||
let manage_app = app_weak.clone();
|
||||
menu = menu.item(PopupMenuItem::new(i18n::t("pub.manage_presets")).on_click(
|
||||
move |_ev, window, cx| {
|
||||
if let Some(app) = manage_app.upgrade() {
|
||||
crate::ui::dialogs::presets_dialog::open(app, window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
// 删除子菜单
|
||||
if has {
|
||||
let weak_del = weak_view.clone();
|
||||
let del_presets = presets.clone();
|
||||
menu = menu.submenu(
|
||||
"删除预设",
|
||||
window,
|
||||
cx,
|
||||
move |submenu, _window, _cx| {
|
||||
let mut submenu = submenu;
|
||||
for p in &del_presets {
|
||||
let weak2 = weak_del.clone();
|
||||
let id = p.id.clone();
|
||||
let name = p.name.clone();
|
||||
let label = i18n::tf("pub.delete_item", &[("name", &p.name)]);
|
||||
submenu = submenu.item(
|
||||
PopupMenuItem::new(SharedString::from(label)).on_click(
|
||||
move |_ev, window, cx| {
|
||||
// 预设删除不可撤销,先弹二次确认
|
||||
let weak = weak2.clone();
|
||||
let pid = id.clone();
|
||||
let pname = name.clone();
|
||||
window.open_alert_dialog(cx, move |alert, _, _| {
|
||||
let weak = weak.clone();
|
||||
let pid = pid.clone();
|
||||
alert
|
||||
.title(i18n::t("pub.delete_title"))
|
||||
.description(i18n::tf(
|
||||
"pub.delete_confirm",
|
||||
&[("name", &pname)],
|
||||
))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.show_cancel(true)
|
||||
.cancel_text(i18n::t("common.cancel"))
|
||||
.ok_text(i18n::t("common.delete"))
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.on_ok(move |_, _, cx| {
|
||||
weak.update(cx, |view, cx| {
|
||||
if let Some(app) = view.app.upgrade() {
|
||||
app.update(cx, |app, cx| {
|
||||
app.delete_publish_preset(&pid);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
true
|
||||
})
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
submenu
|
||||
},
|
||||
);
|
||||
}
|
||||
menu
|
||||
})
|
||||
}
|
||||
|
||||
/// 打开"保存为预设"命名对话框,保存当前发布参数。
|
||||
fn open_save_preset_dialog(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
name_input: Entity<InputState>,
|
||||
) {
|
||||
// 预填默认名
|
||||
let topic = self.pub_topic.read(cx).value().to_string();
|
||||
let default_name = if topic.is_empty() {
|
||||
"新预设".to_string()
|
||||
} else {
|
||||
topic.chars().take(20).collect()
|
||||
};
|
||||
name_input.update(cx, |s, cx| s.set_value(default_name, window, cx));
|
||||
|
||||
let weak = cx.weak_entity();
|
||||
let name_for_ok = name_input.clone();
|
||||
window.open_dialog(cx, move |dialog, _, _| {
|
||||
dialog
|
||||
.w(px(420.))
|
||||
.title(i18n::t("pub.save_title"))
|
||||
.child(v_flex().gap_3().child(
|
||||
crate::ui::widgets::field(i18n::t("aliyun.preset_name"), Input::new(&name_for_ok)),
|
||||
))
|
||||
.footer(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("preset-save-cancel")
|
||||
.label(i18n::t("common.cancel"))
|
||||
.outline()
|
||||
.on_click(move |_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("preset-save-ok")
|
||||
.label(i18n::t("common.save"))
|
||||
.primary()
|
||||
.on_click({
|
||||
let name_input = name_for_ok.clone();
|
||||
let weak = weak.clone();
|
||||
move |_ev, window, cx| {
|
||||
let name = name_input
|
||||
.read(cx)
|
||||
.value()
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() {
|
||||
window.push_notification(
|
||||
Notification::warning(i18n::t("pub.preset_name_required")),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 复用 do_publish 的过期秒数校验:输入了但解析不出即拒绝
|
||||
let params = weak
|
||||
.read_with(cx, |view, cx| {
|
||||
let expiry_raw = view
|
||||
.msg_expiry
|
||||
.read(cx)
|
||||
.value()
|
||||
.to_string();
|
||||
(
|
||||
view.collect_publish_params(cx),
|
||||
expiry_raw,
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
if let (Some(view), Some((params, expiry_raw))) =
|
||||
(weak.upgrade(), params)
|
||||
{
|
||||
if !expiry_raw.trim().is_empty()
|
||||
&& params.message_expiry_interval.is_none()
|
||||
{
|
||||
window.push_notification(
|
||||
Notification::warning(i18n::t("pub.expiry_invalid")),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(app) = view.read(cx).app.upgrade() {
|
||||
// 同名即覆盖,提示语区分以明确发生了什么
|
||||
let existed = app
|
||||
.read(cx)
|
||||
.presets
|
||||
.iter()
|
||||
.any(|p| p.name == name);
|
||||
app.update(cx, |app, cx| {
|
||||
app.upsert_publish_preset(name.clone(), params);
|
||||
cx.notify();
|
||||
});
|
||||
let msg = if existed {
|
||||
i18n::tf("pub.preset_overwritten", &[("name", &name)])
|
||||
} else {
|
||||
i18n::t("pub.preset_saved").to_string()
|
||||
};
|
||||
window.push_notification(
|
||||
Notification::success(msg),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
//! 订阅:表单操作、订阅列表渲染与主题配色。
|
||||
|
||||
|
||||
use gpui_kit::component::button::{ Button };
|
||||
use gpui_kit::component::input::Input;
|
||||
use gpui_kit::component::menu::{ PopupMenuItem };
|
||||
use gpui_kit::component::select::Select;
|
||||
use gpui_kit::component::switch::Switch;
|
||||
use gpui_kit::component::{ h_flex, notification::Notification, v_flex, };
|
||||
use crate::ui::IconName;
|
||||
use gpui_kit::{ div, px, Context, IntoElement, SharedString, Window, };
|
||||
use gpui_kit::component::IndexPath;
|
||||
|
||||
use crate::model::{ SubscribeOptions, Subscription, };
|
||||
use crate::ui::widgets::field;
|
||||
|
||||
use super::*;
|
||||
use crate::ui::i18n;
|
||||
|
||||
impl ConnectionView {
|
||||
/// 订阅面板收起后的窄 rail:抽屉把手。
|
||||
pub(super) fn render_subs_rail(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.w(px(32.))
|
||||
.h_full()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.pt_2()
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
Button::new("subs-rail-expand")
|
||||
.icon(IconName::PanelLeftOpen)
|
||||
.ghost()
|
||||
.small()
|
||||
.tooltip(i18n::t("sub.expand"))
|
||||
.accessibility_label(i18n::t("sub.expand"))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.subs_collapsed = false;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// 订阅按 (连接, 主题) 唯一:先摘掉旧条目再走 `add_subscription`,
|
||||
/// 借它入列 + `save_subscriptions` 落盘的逻辑完成 upsert(storage 字段是私有的)。
|
||||
/// 落盘顺序以 add 时为准,入列后移回原位以保持界面顺序稳定。
|
||||
fn upsert_subscription(&self, sub: Subscription, cx: &mut Context<Self>) {
|
||||
let Some(app_entity) = self.app.upgrade() else {
|
||||
return;
|
||||
};
|
||||
app_entity.update(cx, |app, cx| {
|
||||
let slot = app
|
||||
.subscriptions
|
||||
.iter()
|
||||
.position(|s| s.connection_id == sub.connection_id && s.topic == sub.topic);
|
||||
app.subscriptions.retain(|s| {
|
||||
!(s.connection_id == sub.connection_id && s.topic == sub.topic)
|
||||
});
|
||||
let new_id = sub.id.clone();
|
||||
app.add_subscription(sub);
|
||||
if let Some(pos) = slot
|
||||
&& let Some(from) = app.subscriptions.iter().position(|s| s.id == new_id)
|
||||
{
|
||||
let moved = app.subscriptions.remove(from);
|
||||
app.subscriptions.insert(pos.min(app.subscriptions.len()), moved);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_subscription_color(&mut self, sub_id: &str, hue: Option<f32>, cx: &mut Context<Self>) {
|
||||
let sub = self
|
||||
.with_app(cx, |app| {
|
||||
app.subscriptions
|
||||
.iter()
|
||||
.find(|s| s.id == sub_id)
|
||||
.cloned()
|
||||
})
|
||||
.flatten();
|
||||
if let Some(mut sub) = sub {
|
||||
sub.color = hue;
|
||||
self.upsert_subscription(sub, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// 启停订阅:置灰/恢复行显示,并在已连接时对引擎退订/重订。
|
||||
fn toggle_subscription_enabled(&mut self, sub_id: &str, cx: &mut Context<Self>) {
|
||||
let Some(mut sub) = self
|
||||
.with_app(cx, |app| {
|
||||
app.subscriptions
|
||||
.iter()
|
||||
.find(|s| s.id == sub_id)
|
||||
.cloned()
|
||||
})
|
||||
.flatten()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
sub.enabled = !sub.enabled;
|
||||
let enabled = sub.enabled;
|
||||
let topic = sub.topic.clone();
|
||||
// 停用的订阅不再参与消息过滤,指向它的过滤一并清掉
|
||||
if !enabled && self.sub_filter.as_deref() == Some(topic.as_str()) {
|
||||
self.sub_filter = None;
|
||||
}
|
||||
let conn = self.conn_id.clone();
|
||||
self.upsert_subscription(sub.clone(), cx);
|
||||
if self.engine.is_connected(&conn) {
|
||||
if enabled {
|
||||
self.engine
|
||||
.subscribe_with_options(conn, sub.clone(), SubscribeOptions::from(&sub));
|
||||
} else {
|
||||
self.engine.unsubscribe(conn, topic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填输入区进入编辑态;旧订阅保留到提交时才替换,中途放弃不丢数据。
|
||||
fn start_edit_subscription(&mut self, sub: &Subscription, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.editing = Some(sub.topic.clone());
|
||||
self.sub_topic
|
||||
.update(cx, |s, cx| s.set_value(sub.topic.clone(), window, cx));
|
||||
let qos_idx = (sub.qos.min(2)) as usize;
|
||||
self.sub_qos.update(cx, |s, cx| {
|
||||
s.set_selected_index(Some(IndexPath::new(qos_idx)), window, cx)
|
||||
});
|
||||
let alias = sub.alias.clone().unwrap_or_default();
|
||||
self.sub_alias
|
||||
.update(cx, |s, cx| s.set_value(alias, window, cx));
|
||||
let ident = sub
|
||||
.sub_identifier
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default();
|
||||
self.sub_identifier
|
||||
.update(cx, |s, cx| s.set_value(ident, window, cx));
|
||||
self.sub_no_local = sub.no_local;
|
||||
self.sub_rap = sub.retain_as_published;
|
||||
let rh_idx = sub.retain_handling.min(2) as usize;
|
||||
self.sub_retain_handling.update(cx, |s, cx| {
|
||||
s.set_selected_index(Some(IndexPath::new(rh_idx)), window, cx)
|
||||
});
|
||||
self.sub_show_advanced = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// 重置订阅表单(主题/别名/高级字段):新增提交与取消编辑共用,
|
||||
/// 避免高级选项残留到下一次订阅。
|
||||
fn reset_subscribe_form(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.sub_topic
|
||||
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||
self.sub_alias
|
||||
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||
self.sub_identifier
|
||||
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||
self.sub_no_local = false;
|
||||
self.sub_rap = false;
|
||||
self.sub_retain_handling.update(cx, |s, cx| {
|
||||
s.set_selected_index(Some(IndexPath::new(0)), window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
// ── 订阅 / 发布动作 ─────────────────────────────────────────────────────
|
||||
|
||||
fn do_subscribe(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let raw = self.sub_topic.read(cx).value().to_string();
|
||||
let topics = split_topic_list(&raw);
|
||||
if topics.is_empty() {
|
||||
window.push_notification(Notification::warning(i18n::t("sub.topic_required")), cx);
|
||||
return;
|
||||
}
|
||||
let qos = self.sub_qos.read(cx).selected_value().copied().unwrap_or(0) as u8;
|
||||
let alias_raw = self.sub_alias.read(cx).value().to_string();
|
||||
let alias = {
|
||||
let t = alias_raw.trim();
|
||||
(!t.is_empty()).then(|| t.to_string())
|
||||
};
|
||||
let is_v5 = self.is_v5(cx);
|
||||
// v5 选项只对 v5 连接生效,v4 下不读取(避免无效输入阻断订阅)
|
||||
let mut sub_identifier = None;
|
||||
if is_v5 {
|
||||
let ident = self.sub_identifier.read(cx).value().to_string();
|
||||
let t = ident.trim();
|
||||
if !t.is_empty() {
|
||||
match t.parse::<u32>() {
|
||||
Ok(v) if v > 0 => sub_identifier = Some(v),
|
||||
_ => {
|
||||
window.push_notification(
|
||||
Notification::warning(i18n::t("sub.identifier_invalid")),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let no_local = self.sub_no_local;
|
||||
let rap = self.sub_rap;
|
||||
let retain_handling = self
|
||||
.sub_retain_handling
|
||||
.read(cx)
|
||||
.selected_value()
|
||||
.copied()
|
||||
.unwrap_or(0) as u8;
|
||||
|
||||
let connected = self.engine.is_connected(&self.conn_id);
|
||||
let conn = self.conn_id.clone();
|
||||
|
||||
// 记录编辑来源:编辑态提交后以目标订阅回填,保住编辑上下文
|
||||
let was_editing = self.editing.clone();
|
||||
// 改名编辑时旧记录随后会被删除,先取出供新主题承接 id/color/enabled
|
||||
let editing_old = was_editing.as_ref().and_then(|old_topic| {
|
||||
self.with_app(cx, |app| {
|
||||
app.subscriptions
|
||||
.iter()
|
||||
.find(|s| s.connection_id == conn && s.topic == *old_topic)
|
||||
.cloned()
|
||||
})
|
||||
.flatten()
|
||||
});
|
||||
|
||||
// 编辑提交:旧主题不在新列表里时先移除(含引擎退订),新主题由下方 upsert 覆盖
|
||||
if let Some(old_topic) = self.editing.take()
|
||||
&& !topics.contains(&old_topic)
|
||||
{
|
||||
if self.sub_filter.as_deref() == Some(old_topic.as_str()) {
|
||||
self.sub_filter = None;
|
||||
}
|
||||
if let Some(app_entity) = self.app.upgrade() {
|
||||
app_entity.update(cx, |app, cx| {
|
||||
app.remove_subscription(&conn, &old_topic);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for topic in &topics {
|
||||
// 按 (连接, 主题) 查旧记录:继承 id/color/enabled,避免重复提交把备注色清掉、
|
||||
// 把停用中的订阅静默重启
|
||||
let existing = self
|
||||
.with_app(cx, |app| {
|
||||
app.subscriptions
|
||||
.iter()
|
||||
.find(|s| s.connection_id == conn && s.topic == *topic)
|
||||
.cloned()
|
||||
})
|
||||
.flatten();
|
||||
let sub = match existing {
|
||||
Some(mut old) => {
|
||||
old.qos = qos;
|
||||
old.alias = alias.clone();
|
||||
if is_v5 {
|
||||
old.sub_identifier = sub_identifier;
|
||||
old.no_local = no_local;
|
||||
old.retain_as_published = rap;
|
||||
old.retain_handling = retain_handling;
|
||||
}
|
||||
old
|
||||
}
|
||||
None => {
|
||||
// 改名编辑的单主题提交:新主题承接旧记录身份与备注,避免改名丢数据
|
||||
if topics.len() == 1 && let Some(mut old) = editing_old.clone() {
|
||||
old.topic = topic.clone();
|
||||
old.qos = qos;
|
||||
old.alias = alias.clone();
|
||||
if is_v5 {
|
||||
old.sub_identifier = sub_identifier;
|
||||
old.no_local = no_local;
|
||||
old.retain_as_published = rap;
|
||||
old.retain_handling = retain_handling;
|
||||
}
|
||||
old
|
||||
} else {
|
||||
let mut sub = Subscription::new(conn.clone(), topic.clone(), qos);
|
||||
sub.alias = alias.clone();
|
||||
if is_v5 {
|
||||
sub.sub_identifier = sub_identifier;
|
||||
sub.no_local = no_local;
|
||||
sub.retain_as_published = rap;
|
||||
sub.retain_handling = retain_handling;
|
||||
}
|
||||
sub
|
||||
}
|
||||
}
|
||||
};
|
||||
// 继承到的停用订阅保持退订状态,不因重新提交被拉起
|
||||
let keep_disabled = !sub.enabled;
|
||||
self.upsert_subscription(sub.clone(), cx);
|
||||
if connected && !keep_disabled {
|
||||
self.engine.subscribe_with_options(
|
||||
conn.clone(),
|
||||
sub.clone(),
|
||||
SubscribeOptions::from(&sub),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !connected {
|
||||
let hint = if self
|
||||
.config(cx)
|
||||
.map(|c| c.auto_resubscribe)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
i18n::t("sub.saved_offline_resub")
|
||||
} else {
|
||||
i18n::t("sub.saved_offline")
|
||||
};
|
||||
window.push_notification(Notification::warning(hint), cx);
|
||||
}
|
||||
|
||||
// 编辑态:以刚保存的目标订阅回填表单继续保留上下文;
|
||||
// 新增态:清空主题/别名并重置高级字段,避免残留到下一次订阅
|
||||
if was_editing.is_some() && topics.len() == 1 {
|
||||
let target = topics[0].clone();
|
||||
let saved = self
|
||||
.with_app(cx, |app| {
|
||||
app.subscriptions
|
||||
.iter()
|
||||
.find(|s| s.connection_id == conn && s.topic == target)
|
||||
.cloned()
|
||||
})
|
||||
.flatten();
|
||||
if let Some(saved) = saved {
|
||||
self.start_edit_subscription(&saved, window, cx);
|
||||
} else {
|
||||
self.editing = None;
|
||||
self.reset_subscribe_form(window, cx);
|
||||
}
|
||||
} else {
|
||||
self.editing = None;
|
||||
self.reset_subscribe_form(window, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let sub_topic = self.sub_topic.clone();
|
||||
let sub_qos = self.sub_qos.clone();
|
||||
let sub_alias = self.sub_alias.clone();
|
||||
let is_v5 = self.is_v5(cx);
|
||||
let editing_topic = self.editing.clone();
|
||||
let editing = editing_topic.is_some();
|
||||
let show_advanced = self.sub_show_advanced && is_v5;
|
||||
|
||||
let mut bar = v_flex()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().secondary);
|
||||
bar = bar.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.h_12()
|
||||
.items_center()
|
||||
// 编辑态显式提示目标主题,避免被误当成新增提交
|
||||
.when_some(editing_topic.clone(), |h, topic| {
|
||||
h.child(
|
||||
div()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded_md()
|
||||
.bg(cx.theme().warning.alpha(0.15))
|
||||
.text_xs()
|
||||
.text_color(cx.theme().warning)
|
||||
.child(i18n::tf("sub.editing", &[("topic", &topic)])),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("sub-topic-wrap-{}", self.conn_id)))
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
// Enter 直接提交订阅;Esc 退出编辑态或清空输入,
|
||||
// 调试时不必为每次订阅伸手点按钮
|
||||
.on_key_down(cx.listener(
|
||||
|this, ev: &gpui_kit::KeyDownEvent, window, cx| {
|
||||
if ev.keystroke.key == "enter"
|
||||
&& !ev.keystroke.modifiers.control
|
||||
&& !ev.keystroke.modifiers.alt
|
||||
{
|
||||
cx.stop_propagation();
|
||||
this.do_subscribe(window, cx);
|
||||
} else if ev.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
if this.editing.is_some() {
|
||||
this.editing = None;
|
||||
this.reset_subscribe_form(window, cx);
|
||||
} else {
|
||||
this.sub_topic
|
||||
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
))
|
||||
.child(Input::new(&sub_topic).small()),
|
||||
)
|
||||
.child(div().w(px(96.)).child(Select::new(&sub_qos).small()))
|
||||
.child(div().w(px(112.)).child(Input::new(&sub_alias).small()))
|
||||
.when(is_v5, |h| {
|
||||
h.child(
|
||||
Button::new(SharedString::from(format!("sub-adv-{}", self.conn_id)))
|
||||
.icon(IconName::SlidersHorizontal)
|
||||
.label(i18n::t("common.advanced"))
|
||||
.ghost()
|
||||
.small()
|
||||
.when(show_advanced, |b| b.selected(true))
|
||||
.tooltip(i18n::t("sub.mqtt5_options"))
|
||||
.accessibility_label(i18n::t("sub.mqtt5_options"))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.sub_show_advanced = !this.sub_show_advanced;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("subscribe-{}", self.conn_id)))
|
||||
.icon(if editing { IconName::Check } else { IconName::Plus })
|
||||
.label(if editing { i18n::t("common.update") } else { i18n::t("common.subscribe") })
|
||||
// 编辑用 warning 色与新增的 primary 区分
|
||||
.when(editing, |b| b.warning())
|
||||
.when(!editing, |b| b.primary())
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _, window, cx| this.do_subscribe(window, cx))),
|
||||
)
|
||||
.when(editing, |h| {
|
||||
h.child(
|
||||
Button::new(SharedString::from(format!("sub-edit-cancel-{}", self.conn_id)))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.small()
|
||||
.tooltip(i18n::t("sub.cancel_edit"))
|
||||
.accessibility_label(i18n::t("sub.cancel_edit"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.editing = None;
|
||||
// 清空回填到输入框的主题/别名/高级字段,回到新增态
|
||||
this.reset_subscribe_form(window, cx);
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}),
|
||||
);
|
||||
|
||||
if show_advanced {
|
||||
let sub_identifier = self.sub_identifier.clone();
|
||||
let sub_no_local = self.sub_no_local;
|
||||
let sub_rap = self.sub_rap;
|
||||
let sub_retain_handling = self.sub_retain_handling.clone();
|
||||
bar = bar.child(
|
||||
h_flex()
|
||||
.gap_4()
|
||||
.px_3()
|
||||
.pb_2()
|
||||
.items_start()
|
||||
.child(
|
||||
div()
|
||||
.w(px(150.))
|
||||
.child(field(i18n::t("sub.identifier_field"), Input::new(&sub_identifier).small())),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.pt_4()
|
||||
.child(Switch::new("sub-no-local").checked(sub_no_local).on_change(
|
||||
cx.listener(|this, v, _, cx| {
|
||||
this.sub_no_local = *v;
|
||||
cx.notify();
|
||||
}),
|
||||
))
|
||||
.child(div().text_xs().child(i18n::t("sub.no_local"))),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.pt_4()
|
||||
.child(Switch::new("sub-rap").checked(sub_rap).on_change(
|
||||
cx.listener(|this, v, _, cx| {
|
||||
this.sub_rap = *v;
|
||||
cx.notify();
|
||||
}),
|
||||
))
|
||||
.child(div().text_xs().child(i18n::t("sub.rap"))),
|
||||
)
|
||||
.child(
|
||||
div().w(px(150.)).child(field(
|
||||
i18n::t("sub.retain_handling"),
|
||||
Select::new(&sub_retain_handling).small(),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
bar
|
||||
}
|
||||
|
||||
pub(super) fn render_subscriptions(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let hover_bg = cx.theme().muted;
|
||||
let muted_fg = cx.theme().muted_foreground;
|
||||
let conn = self.conn_id.clone();
|
||||
let subs: Vec<Subscription> = self
|
||||
.with_app(cx, |app| {
|
||||
app.subscriptions
|
||||
.iter()
|
||||
.filter(|s| s.connection_id == conn)
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let active_filter = self.sub_filter.clone();
|
||||
|
||||
let mut list = v_flex().gap_0p5();
|
||||
if subs.is_empty() {
|
||||
list = list.child(
|
||||
v_flex()
|
||||
.p_3()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
gpui_kit::component::Icon::new(IconName::Hash)
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_center()
|
||||
.child(i18n::t("sub.empty_hint")),
|
||||
),
|
||||
);
|
||||
}
|
||||
for sub in subs {
|
||||
let enabled = sub.enabled;
|
||||
let is_active = enabled && active_filter.as_deref() == Some(sub.topic.as_str());
|
||||
let topic = sub.topic.clone();
|
||||
let sub_id = sub.id.clone();
|
||||
let alias = sub.alias.clone().filter(|a| !a.trim().is_empty());
|
||||
let primary = alias.clone().unwrap_or_else(|| topic.clone());
|
||||
|
||||
// 颜色按钮:已设色显示色点,未设显示灰色「+」
|
||||
let color_btn = {
|
||||
let weak_menu = cx.weak_entity();
|
||||
let sid = sub_id.clone();
|
||||
let trigger_id = SharedString::from(format!("sub-color-{}", sub_id));
|
||||
let trigger = if let Some(hue) = sub.color {
|
||||
Button::new(trigger_id)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("sub.color"))
|
||||
.accessibility_label(i18n::t("sub.color"))
|
||||
.child(
|
||||
div()
|
||||
.size(px(11.))
|
||||
.rounded_full()
|
||||
.bg(hue_color(hue))
|
||||
.border_1()
|
||||
.border_color(cx.theme().border),
|
||||
)
|
||||
} else {
|
||||
Button::new(trigger_id)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("sub.color"))
|
||||
.accessibility_label(i18n::t("sub.color"))
|
||||
.child(
|
||||
div()
|
||||
.size(px(11.))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(muted_fg)
|
||||
.text_xs()
|
||||
.child("+"),
|
||||
)
|
||||
};
|
||||
// 色点描边用主题底色(双主题下都是「与背景的缝隙」语义);
|
||||
// 闭包签名拿不到带主题的 cx,提前在 render 取值捕获
|
||||
let swatch_border = cx.theme().background;
|
||||
trigger.dropdown_menu(move |mut menu, _, _| {
|
||||
for (hue, name) in PRESET_HUES {
|
||||
let weak = weak_menu.clone();
|
||||
let sid = sid.clone();
|
||||
let swatch = hue_color(hue);
|
||||
// 菜单只显示颜色名,角度数值对用户没有意义
|
||||
let label = SharedString::from(crate::ui::i18n::t(name));
|
||||
menu = menu.item(
|
||||
PopupMenuItem::element(move |_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.size(px(10.))
|
||||
.rounded_full()
|
||||
.bg(swatch)
|
||||
.border_1()
|
||||
.border_color(swatch_border),
|
||||
)
|
||||
.child(div().text_sm().child(label.clone()))
|
||||
})
|
||||
.on_click(move |_, _, cx| {
|
||||
weak
|
||||
.update(cx, |view, cx| {
|
||||
view.set_subscription_color(&sid, Some(hue), cx);
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
);
|
||||
}
|
||||
menu = menu.separator();
|
||||
let weak_rand = weak_menu.clone();
|
||||
let sid_rand = sid.clone();
|
||||
menu = menu.item(PopupMenuItem::new(i18n::t("sub.color_random")).on_click(move |_, _, cx| {
|
||||
let hue = (uuid::Uuid::new_v4().as_u128() % 360) as f32;
|
||||
weak_rand
|
||||
.update(cx, |view, cx| {
|
||||
view.set_subscription_color(&sid_rand, Some(hue), cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
let weak_clear = weak_menu.clone();
|
||||
let sid_clear = sid.clone();
|
||||
menu = menu.item(PopupMenuItem::new(i18n::t("sub.color_clear")).on_click(move |_, _, cx| {
|
||||
weak_clear
|
||||
.update(cx, |view, cx| {
|
||||
view.set_subscription_color(&sid_clear, None, cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
menu
|
||||
})
|
||||
};
|
||||
|
||||
let eye_sid = sub_id.clone();
|
||||
let eye_btn = Button::new(SharedString::from(format!("sub-eye-{}", sub_id)))
|
||||
.icon(if enabled { IconName::Eye } else { IconName::EyeOff })
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(if enabled { i18n::t("sub.disable") } else { i18n::t("sub.enable") })
|
||||
.accessibility_label(if enabled { i18n::t("sub.disable") } else { i18n::t("sub.enable") })
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_subscription_enabled(&eye_sid, cx);
|
||||
}));
|
||||
|
||||
let edit_sid = sub_id.clone();
|
||||
let edit_btn = Button::new(SharedString::from(format!("sub-edit-{}", sub_id)))
|
||||
.icon(IconName::Pencil)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("sub.edit"))
|
||||
.accessibility_label(i18n::t("sub.edit"))
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
let sub = this
|
||||
.with_app(cx, |app| {
|
||||
app.subscriptions
|
||||
.iter()
|
||||
.find(|s| s.id == edit_sid)
|
||||
.cloned()
|
||||
})
|
||||
.flatten();
|
||||
if let Some(sub) = sub {
|
||||
this.start_edit_subscription(&sub, window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
let del_cid = self.conn_id.clone();
|
||||
let del_topic = topic.clone();
|
||||
let del_btn = Button::new(SharedString::from(format!("sub-del-{}", sub_id)))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("sub.unsubscribe"))
|
||||
.accessibility_label(i18n::t("sub.unsubscribe"))
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
// 若删除的是当前过滤主题,一并清掉消息过滤
|
||||
if this.sub_filter.as_deref() == Some(del_topic.as_str()) {
|
||||
this.sub_filter = None;
|
||||
}
|
||||
if let Some(app) = this.app.upgrade() {
|
||||
app.update(cx, |app, cx| {
|
||||
app.remove_subscription(&del_cid, &del_topic);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
// 只有删除的是正在编辑的主题才退出编辑态,编辑别的不受影响
|
||||
if this.editing.as_deref() == Some(del_topic.as_str()) {
|
||||
this.editing = None;
|
||||
}
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
// 点击区(非按钮):切换按该主题过滤消息流;禁用的订阅不参与
|
||||
let filter_topic = topic.clone();
|
||||
let click_zone = h_flex()
|
||||
.id(SharedString::from(format!("sub-click-{}", sub_id)))
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.when(enabled, |z| {
|
||||
z.cursor_pointer().on_click(cx.listener(
|
||||
move |this, _, _, cx| {
|
||||
this.sub_filter = if this.sub_filter.as_deref()
|
||||
== Some(filter_topic.as_str())
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(filter_topic.clone())
|
||||
};
|
||||
cx.notify();
|
||||
},
|
||||
))
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.when(!enabled, |t| t.text_color(muted_fg))
|
||||
.child(primary),
|
||||
)
|
||||
.when_some(alias, |v, _| {
|
||||
v.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted_fg)
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.child(topic.clone()),
|
||||
)
|
||||
}),
|
||||
);
|
||||
|
||||
list = list.child(
|
||||
h_flex()
|
||||
.id(SharedString::from(format!("sub-{}-{}", self.conn_id, sub.id)))
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.rounded_md()
|
||||
.when(is_active, |t| t.bg(cx.theme().secondary))
|
||||
.when(!is_active, |t| t.hover(move |this| this.bg(hover_bg)))
|
||||
.when(!enabled, |t| t.opacity(0.55))
|
||||
.child(color_btn)
|
||||
.child(click_zone)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.px_1()
|
||||
.rounded_sm()
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(muted_fg)
|
||||
.child(format!("Q{}", sub.qos)),
|
||||
)
|
||||
.child(eye_btn)
|
||||
.child(edit_btn)
|
||||
.child(del_btn),
|
||||
);
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.w(px(248.))
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
.bg(cx.theme().sidebar)
|
||||
.text_color(cx.theme().sidebar_foreground)
|
||||
// 深色主题下订阅面板与消息区同色,补一条分隔线保持边界可辨
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_11()
|
||||
.px_3()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(div().text_sm().font_semibold().child(i18n::t("connview.subscriptions")))
|
||||
.child(
|
||||
Button::new("subs-collapse")
|
||||
.icon(IconName::PanelLeftClose)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("sub.collapse"))
|
||||
.accessibility_label(i18n::t("sub.collapse"))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.subs_collapsed = true;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1().overflow_y_scrollbar().px_2().child(list))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! 纯函数工具:topic 匹配、占位符提取、载荷预览/复制文本、订阅配色。
|
||||
|
||||
|
||||
use gpui_kit::{ hsla, Hsla, };
|
||||
|
||||
use crate::model::{ MqttRecord, Subscription, };
|
||||
use crate::ui::widgets::format_time;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 色相(度)→ gpui Hsla(h 分量为 0..=1 的整圆比例)。
|
||||
/// 固定 L=0.52 时亮色相(黄绿)在浅色主题下过亮、暗色相(蓝靛)在深色主题下过暗,
|
||||
/// 按色相段微调明度,保证两端主题下都可辨识。
|
||||
pub(super) fn hue_color(hue: f32) -> Hsla {
|
||||
let h = hue.rem_euclid(360.0);
|
||||
let l = if (30.0..=90.0).contains(&h) {
|
||||
// 黄/黄绿本身明度感高,压低一点防刺眼
|
||||
0.44
|
||||
} else if (200.0..=280.0).contains(&h) {
|
||||
// 蓝/靛本身明度感低,抬高一点防发黑
|
||||
0.60
|
||||
} else {
|
||||
0.52
|
||||
};
|
||||
hsla(h / 360.0, 0.72, l, 1.0)
|
||||
}
|
||||
|
||||
/// 逗号 / 空白 / 换行分隔的多主题输入 → 去重后的主题列表(批量订阅)
|
||||
pub(super) fn split_topic_list(raw: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for part in raw.split(|c: char| c == ',' || c.is_whitespace()) {
|
||||
let t = part.trim();
|
||||
if !t.is_empty() && !out.iter().any(|x| x == t) {
|
||||
out.push(t.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 从发布主题与负载中提取 `{{key}}` 占位符的 key 列表:
|
||||
/// - key 两侧空白去除(trim);
|
||||
/// - `$` 开头的内置变量(`$ts` / `$ts_ms` / `$uuid`)不提取;
|
||||
/// - 重复 key 去重,保持首次出现顺序(主题在前、负载在后)。
|
||||
pub(super) fn extract_placeholder_keys(topic: &str, payload: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for text in [topic, payload] {
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + 1 < bytes.len() {
|
||||
if bytes[i] == b'{' && bytes[i + 1] == b'{' && let Some(rel_end) = text[i + 2..].find("}}") {
|
||||
let key = text[i + 2..i + 2 + rel_end].trim();
|
||||
if !key.is_empty() && !key.starts_with('$') && !out.iter().any(|k| k == key) {
|
||||
out.push(key.to_string());
|
||||
}
|
||||
i = i + 2 + rel_end + 2;
|
||||
continue;
|
||||
}
|
||||
// 按字符推进,避免把多字节 UTF-8 拆开
|
||||
let ch = text[i..].chars().next().unwrap();
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// MQTT 主题过滤器匹配:`+` 匹配单层,`#` 匹配本层及以下(可匹配父级本身)。
|
||||
pub(super) fn topic_matches(filter: &str, topic: &str) -> bool {
|
||||
let f: Vec<&str> = filter.split('/').collect();
|
||||
let t: Vec<&str> = topic.split('/').collect();
|
||||
for (i, part) in f.iter().enumerate() {
|
||||
if *part == "#" {
|
||||
// "a/#" 需要匹配 "a" 自身:剩余层级数 >= 过滤器已消费层数
|
||||
return i <= t.len();
|
||||
}
|
||||
if i >= t.len() {
|
||||
return false;
|
||||
}
|
||||
if *part != "+" && *part != t[i] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
f.len() == t.len()
|
||||
}
|
||||
|
||||
/// 挑选消息着色用的订阅色:先看精确匹配,再看通配匹配;只考虑启用且已设色的订阅。
|
||||
pub(super) fn pick_subscription_color(subs: &[Subscription], topic: &str) -> Option<f32> {
|
||||
let has_wild = |t: &str| t.contains('+') || t.contains('#');
|
||||
for s in subs.iter().filter(|s| s.enabled) {
|
||||
if s.topic == topic && !has_wild(&s.topic) && s.color.is_some() {
|
||||
return s.color;
|
||||
}
|
||||
}
|
||||
for s in subs.iter().filter(|s| s.enabled) {
|
||||
if has_wild(&s.topic) && topic_matches(&s.topic, topic) && s.color.is_some() {
|
||||
return s.color;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 展开详情里的 payload 渲染:自动 = 合法 JSON 美化,否则原文。
|
||||
/// Hex/Base64 优先使用入库保留的原始字节——非文本负载的 lossy 文本
|
||||
/// 已把非法字节替换为 U+FFFD,按文本字节展示会与线上报文不符。
|
||||
pub(super) fn format_payload_detail(payload: &str, raw: Option<&[u8]>, format: DetailFormat) -> String {
|
||||
match format {
|
||||
DetailFormat::Auto => serde_json::from_str::<serde_json::Value>(payload)
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_string_pretty(&v).ok())
|
||||
.unwrap_or_else(|| payload.to_string()),
|
||||
DetailFormat::Text => payload.to_string(),
|
||||
DetailFormat::Hex => raw
|
||||
.unwrap_or(payload.as_bytes())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
DetailFormat::Base64 => {
|
||||
use base64::Engine as _;
|
||||
base64::engine::general_purpose::STANDARD.encode(raw.unwrap_or(payload.as_bytes()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「复制详情」的可读文本:元数据 + v5 属性 + 用户属性 + 负载。
|
||||
pub(super) fn details_copy_text(record: &MqttRecord, show_millis: bool) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("Topic: {}\n", record.topic));
|
||||
out.push_str(&format!("Direction: {}\n", crate::ui::i18n::t(record.direction.label())));
|
||||
out.push_str(&format!("QoS: {}\n", record.qos));
|
||||
out.push_str(&format!("Retain: {}\n", record.retain));
|
||||
out.push_str(&format!(
|
||||
"Time: {}\n",
|
||||
format_time(record.timestamp, show_millis)
|
||||
));
|
||||
if let Some(v) = record.content_type.as_deref() {
|
||||
out.push_str(&format!("Content-Type: {v}\n"));
|
||||
}
|
||||
if let Some(v) = record.response_topic.as_deref() {
|
||||
out.push_str(&format!("Response Topic: {v}\n"));
|
||||
}
|
||||
if let Some(v) = record.correlation_data.as_deref() {
|
||||
out.push_str(&format!("Correlation Data: {v}\n"));
|
||||
}
|
||||
if let Some(v) = record.message_expiry_interval {
|
||||
out.push_str(&format!("Message Expiry Interval: {v}\n"));
|
||||
}
|
||||
if let Some(v) = record.subscription_identifier {
|
||||
out.push_str(&format!("Subscription Identifier: {v}\n"));
|
||||
}
|
||||
if !record.user_properties.is_empty() {
|
||||
out.push_str("User Properties:\n");
|
||||
for (k, v) in record.user_properties.iter() {
|
||||
out.push_str(&format!(" {k} = {v}\n"));
|
||||
}
|
||||
}
|
||||
out.push_str(&format!("Payload:\n{}", record.payload));
|
||||
out
|
||||
}
|
||||
|
||||
/// 大小写不敏感子串匹配。`needle` 已由调用方 to_lowercase 一次;
|
||||
/// 先做零拷贝的字面匹配,未命中且原文含大写时才回退整串小写,
|
||||
/// 避免渲染热路径对全部记录做 to_lowercase 拷贝。
|
||||
pub(super) fn contains_ignore_case(hay: &str, needle: &str) -> bool {
|
||||
if needle.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if hay.contains(needle) {
|
||||
return true;
|
||||
}
|
||||
// 纯 ASCII 快速路径:双方均为 ASCII 且原文无 ASCII 大写时,
|
||||
// 小写化恒等,可直接判否。不能用 is_uppercase 判断——个别
|
||||
// 小写字符(如 U+1E9B)的小写映射并不等于自身。
|
||||
if needle.is_ascii()
|
||||
&& hay.is_ascii()
|
||||
&& !hay.bytes().any(|b| b.is_ascii_uppercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
hay.to_lowercase().contains(needle)
|
||||
}
|
||||
|
||||
// ─── 单元测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_placeholder_keys;
|
||||
|
||||
// 修复回归:提取须 trim、排除内置变量、去重且保持首次出现顺序
|
||||
#[test]
|
||||
fn extract_keys_trims_excludes_builtin_and_dedups() {
|
||||
let keys = extract_placeholder_keys(
|
||||
"sensor/{{ device }}/state",
|
||||
"{\"v\":{{value}},\"t\":{{ $ts }},\"u\":{{uuid}}}",
|
||||
);
|
||||
assert_eq!(keys, vec!["device", "value", "uuid"], "实际: {keys:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_keys_empty_and_multibyte_safe() {
|
||||
assert!(extract_placeholder_keys("", "").is_empty());
|
||||
assert!(extract_placeholder_keys("no placeholders here", "纯文本").is_empty());
|
||||
// 未闭合的 {{ 不提取、不越界
|
||||
assert!(extract_placeholder_keys("a{{b", "c}}d").is_empty());
|
||||
let keys = extract_placeholder_keys("温度{{名称}}", "");
|
||||
assert_eq!(keys, vec!["名称"]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! 模板变量面板:扫描 topic/payload 占位符并管理输入行。
|
||||
|
||||
|
||||
use gpui_kit::component::button::{ Button };
|
||||
use gpui_kit::component::input::{ Input, InputEvent, InputState };
|
||||
use gpui_kit::component::{ h_flex, notification::Notification, v_flex, };
|
||||
use crate::ui::IconName;
|
||||
use gpui_kit::{ div, px, Context, IntoElement, Window, };
|
||||
|
||||
use crate::model::{ render_template, GlobalVariable, };
|
||||
|
||||
use super::*;
|
||||
use crate::ui::i18n;
|
||||
|
||||
impl ConnectionView {
|
||||
/// 输入框创建需要 Window,因此只能在按钮回调里调用(无法在 render 中同步)。
|
||||
pub(super) fn sync_var_rows(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let topic = self.pub_topic.read(cx).value().to_string();
|
||||
let payload = self.payload.read(cx).value().to_string();
|
||||
let keys = extract_placeholder_keys(&topic, &payload);
|
||||
if keys.is_empty() {
|
||||
self.show_vars = false;
|
||||
window.push_notification(
|
||||
Notification::warning(i18n::t("var.no_reference")),
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
let vars = self.with_app(cx, |a| a.variables.clone()).unwrap_or_default();
|
||||
// 移除已不存在的 key(保留仍存在的行,避免丢未保存的输入)
|
||||
self.var_rows.retain(|(k, _, _)| keys.contains(k));
|
||||
for key in keys {
|
||||
if self.var_rows.iter().any(|(k, ..)| k == &key) {
|
||||
continue;
|
||||
}
|
||||
let initial = vars
|
||||
.iter()
|
||||
.find(|v| v.key == key)
|
||||
.map(|v| v.value.clone())
|
||||
.unwrap_or_default();
|
||||
let input = cx.new(|cx| {
|
||||
let mut s = InputState::new(window, cx).placeholder(i18n::t("var.value"));
|
||||
// set_value 不会触发 InputEvent::Change,不会误写回
|
||||
if !initial.is_empty() {
|
||||
s.set_value(initial.as_str(), window, cx);
|
||||
}
|
||||
s
|
||||
});
|
||||
// 编辑即写回全局变量(含持久化),预览与发布随之生效
|
||||
let key_for_sub = key.clone();
|
||||
let sub = cx.subscribe(&input, move |this, entity, ev: &InputEvent, cx| {
|
||||
if !matches!(ev, InputEvent::Change) {
|
||||
return;
|
||||
}
|
||||
let val = entity.read(cx).value().to_string();
|
||||
let key = key_for_sub.clone();
|
||||
let Some(app) = this.app.upgrade() else {
|
||||
return;
|
||||
};
|
||||
app.update(cx, |a, cx| {
|
||||
match a.variables.iter_mut().find(|v| v.key == key) {
|
||||
Some(v) => {
|
||||
if v.value == val {
|
||||
return;
|
||||
}
|
||||
v.value = val;
|
||||
}
|
||||
None => a.variables.push(GlobalVariable { key, value: val }),
|
||||
}
|
||||
let snapshot = a.variables.clone();
|
||||
a.save_variables(snapshot);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
self.var_rows.push((key, input, sub));
|
||||
}
|
||||
self.show_vars = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// 变量绑定面板:逐 key 编辑 + 实时渲染预览。
|
||||
pub(super) fn render_vars_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let vars = self.with_app(cx, |a| a.variables.clone()).unwrap_or_default();
|
||||
let topic_raw = self.pub_topic.read(cx).value().to_string();
|
||||
let payload_raw = self.payload.read(cx).value().to_string();
|
||||
let topic_rendered = render_template(&topic_raw, &vars);
|
||||
let payload_rendered = render_template(&payload_raw, &vars);
|
||||
let payload_preview: String = payload_rendered.chars().take(80).collect();
|
||||
let muted = cx.theme().muted_foreground;
|
||||
|
||||
let mut rows = v_flex().gap_1();
|
||||
for (key, input, _) in &self.var_rows {
|
||||
rows = rows.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.w(px(160.))
|
||||
.min_w(px(0.))
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child(format!("{{{{{key}}}}}")),
|
||||
)
|
||||
.child(div().flex_1().min_w(px(0.)).child(Input::new(input).small())),
|
||||
);
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.child(i18n::tf(
|
||||
"var.section",
|
||||
&[("n", &self.var_rows.len())],
|
||||
)),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
Button::new("vars-refresh")
|
||||
.icon(IconName::RefreshCw)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("var.reextract"))
|
||||
.accessibility_label(i18n::t("var.reextract"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.sync_var_rows(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("vars-collapse")
|
||||
.icon(IconName::ChevronUp)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(i18n::t("common.collapse"))
|
||||
.accessibility_label(i18n::t("common.collapse"))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.show_vars = false;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(rows)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child(i18n::t("var.builtin_inline")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child(i18n::tf(
|
||||
"var.preview",
|
||||
&[("tp", &topic_rendered), ("pl", &payload_preview)],
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,8 +18,11 @@ use crate::aliyun::{AliyunAuthMode, AliyunPreset};
|
||||
use crate::model::{render_will_templates, ConnectionConfig};
|
||||
use crate::ui::app::MqttXApp;
|
||||
use crate::ui::widgets::{field, make_select, OptionDelegate};
|
||||
use crate::ui::i18n;
|
||||
|
||||
const AUTH_MODES: [&str; 2] = ["Group 令牌 (Token)", "一机一密"];
|
||||
fn auth_mode_labels() -> [&'static str; 2] {
|
||||
[i18n::t("aliyun.auth_token_mode"), i18n::t("aliyun.auth_triple_mode")]
|
||||
}
|
||||
|
||||
struct AliyunDialog {
|
||||
app: Entity<MqttXApp>,
|
||||
@@ -52,16 +55,19 @@ macro_rules! text_field {
|
||||
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);
|
||||
// 表单已用 first 预填:selected 也指向它,否则"打开即保存"会用相同的
|
||||
// 内容 push 出一条重复预设(selected=None 走新建分支、另生成 uuid)
|
||||
let selected = (!presets.is_empty()).then_some(0);
|
||||
Self {
|
||||
app,
|
||||
presets,
|
||||
selected: None,
|
||||
name: text_field!(cx, window, &first.name, "预设名称"),
|
||||
group: text_field!(cx, window, &first.group, "分组(可选)"),
|
||||
selected,
|
||||
name: text_field!(cx, window, &first.name, i18n::t("aliyun.preset_name")),
|
||||
group: text_field!(cx, window, &first.group, i18n::t("aliyun.group")),
|
||||
instance_id: text_field!(cx, window, &first.instance_id, "post-cn-xxxx"),
|
||||
region: text_field!(cx, window, &first.region, "如 cn-shanghai(可留空)"),
|
||||
region: text_field!(cx, window, &first.region, i18n::t("aliyun.region_ph")),
|
||||
auth_mode: make_select(
|
||||
&AUTH_MODES,
|
||||
&auth_mode_labels(),
|
||||
match first.auth_mode {
|
||||
AliyunAuthMode::Token => 0,
|
||||
AliyunAuthMode::DeviceCredential => 1,
|
||||
@@ -77,12 +83,12 @@ impl AliyunDialog {
|
||||
.masked(true)
|
||||
}),
|
||||
group_id: text_field!(cx, window, &first.group_id, "GID-xxx"),
|
||||
device_id: text_field!(cx, window, &first.device_id, "设备 ID"),
|
||||
product_key: text_field!(cx, window, &first.product_key, "一机一密:ProductKey"),
|
||||
device_id: text_field!(cx, window, &first.device_id, i18n::t("aliyun.device_id")),
|
||||
product_key: text_field!(cx, window, &first.product_key, i18n::t("aliyun.product_key_ph")),
|
||||
device_secret: cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.default_value(&first.device_secret)
|
||||
.placeholder("一机一密:DeviceSecret")
|
||||
.placeholder(i18n::t("aliyun.device_secret_ph"))
|
||||
.masked(true)
|
||||
}),
|
||||
}
|
||||
@@ -182,7 +188,7 @@ impl AliyunDialog {
|
||||
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);
|
||||
window.push_notification(Notification::warning(i18n::t("pub.preset_name_required")), cx);
|
||||
return;
|
||||
}
|
||||
match self.selected {
|
||||
@@ -194,7 +200,7 @@ impl AliyunDialog {
|
||||
a.save_aliyun(saved);
|
||||
cx.notify();
|
||||
});
|
||||
window.push_notification(Notification::success("预设已保存"), cx);
|
||||
window.push_notification(Notification::success(i18n::t("pub.preset_saved")), cx);
|
||||
}
|
||||
|
||||
fn connect(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -213,7 +219,7 @@ impl AliyunDialog {
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(format!("无法生成连接: {e}")), cx);
|
||||
window.push_notification(Notification::error(i18n::tf("aliyun.build_failed", &[("e", &e)])), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,7 +235,7 @@ impl Render for AliyunDialog {
|
||||
v_flex()
|
||||
.w(px(160.))
|
||||
.gap_1()
|
||||
.child(div().text_xs().font_semibold().child("已保存预设"))
|
||||
.child(div().text_xs().font_semibold().child(i18n::t("aliyun.saved_presets")))
|
||||
.children(
|
||||
self.presets
|
||||
.iter()
|
||||
@@ -253,7 +259,7 @@ impl Render for AliyunDialog {
|
||||
.child(
|
||||
Button::new("aliyun-new")
|
||||
.icon(IconName::Plus)
|
||||
.label("新建")
|
||||
.label(i18n::t("common.new"))
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
@@ -267,36 +273,36 @@ impl Render for AliyunDialog {
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.gap_2()
|
||||
.child(field("预设名称", Input::new(&self.name)))
|
||||
.child(field(i18n::t("aliyun.preset_name"), Input::new(&self.name)))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"实例 ID",
|
||||
i18n::t("aliyun.instance_id"),
|
||||
Input::new(&self.instance_id),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"地域",
|
||||
i18n::t("aliyun.region"),
|
||||
Input::new(&self.region),
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(field("鉴权方式", Select::new(&self.auth_mode)))
|
||||
.child(field(i18n::t("aliyun.auth_mode"), Select::new(&self.auth_mode)))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"AccessKey ID",
|
||||
i18n::t("aliyun.access_key_id"),
|
||||
Input::new(&self.access_key_id),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"AccessKey Secret",
|
||||
i18n::t("aliyun.access_key_secret"),
|
||||
Input::new(&self.access_key_secret).mask_toggle(),
|
||||
)),
|
||||
),
|
||||
@@ -306,13 +312,13 @@ impl Render for AliyunDialog {
|
||||
.gap_2()
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"Group ID",
|
||||
i18n::t("aliyun.group_id"),
|
||||
Input::new(&self.group_id),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"设备 ID",
|
||||
i18n::t("aliyun.device_id"),
|
||||
Input::new(&self.device_id),
|
||||
)),
|
||||
),
|
||||
@@ -322,13 +328,13 @@ impl Render for AliyunDialog {
|
||||
.gap_2()
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"ProductKey(一机一密)",
|
||||
i18n::t("aliyun.product_key"),
|
||||
Input::new(&self.product_key),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div().flex_1().min_w(px(0.)).child(field(
|
||||
"DeviceSecret(一机一密)",
|
||||
i18n::t("aliyun.device_secret"),
|
||||
Input::new(&self.device_secret).mask_toggle(),
|
||||
)),
|
||||
),
|
||||
@@ -337,7 +343,7 @@ impl Render for AliyunDialog {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Token 模式(云消息队列 MQTT 版):接入点 {实例ID}.mqtt.aliyuncs.com,clientId={GroupId}@@@{设备ID},密码为 HMAC-SHA1 签名;一机一密(物联网平台):接入点 {ProductKey}.iot-as-mqtt.{地域}.aliyuncs.com,按官方三元组规范签名"),
|
||||
.child(i18n::t("aliyun.help")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -345,7 +351,7 @@ impl Render for AliyunDialog {
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("aliyun-save")
|
||||
.label("保存预设")
|
||||
.label(i18n::t("aliyun.save_preset"))
|
||||
.outline()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.save_preset(window, cx)
|
||||
@@ -353,7 +359,7 @@ impl Render for AliyunDialog {
|
||||
)
|
||||
.child(
|
||||
Button::new("aliyun-connect")
|
||||
.label("生成并连接")
|
||||
.label(i18n::t("aliyun.generate_connect"))
|
||||
.primary()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.connect(window, cx)
|
||||
@@ -365,12 +371,17 @@ impl Render for AliyunDialog {
|
||||
}
|
||||
|
||||
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
// 对话框是栈式叠加:标题栏菜单、快捷键、行内按钮等多个入口都会调 open,
|
||||
// 不加守卫会压入第二个模态,同 id 的控件在两层之间串台
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
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 设备")
|
||||
.title(i18n::t("aliyun.title"))
|
||||
.child(dialog_view.clone())
|
||||
.footer(gpui_kit::div())
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
//! 模态对话框集合:设置、资源监控、发布预设、阿里云预设、全局变量。
|
||||
|
||||
pub mod aliyun_dialog;
|
||||
pub mod presets_dialog;
|
||||
pub mod resource_dialog;
|
||||
pub mod settings_dialog;
|
||||
pub mod variables_dialog;
|
||||
@@ -12,6 +12,7 @@ use gpui_kit::component::{
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use crate::ui::IconName;
|
||||
use crate::ui::i18n;
|
||||
use gpui_kit::{
|
||||
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window,
|
||||
@@ -80,17 +81,17 @@ impl PresetsDialog {
|
||||
let name_value = first.as_ref().map(|p| p.name.clone()).unwrap_or_default();
|
||||
let name = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("预设名称,如 温度上报")
|
||||
.placeholder(i18n::t("pub.preset_name_hint"))
|
||||
.default_value(name_value)
|
||||
});
|
||||
let topic = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("发布主题,支持 {{变量名}}")
|
||||
.placeholder(i18n::t("pub.topic_preset_ph"))
|
||||
.default_value(params.topic.clone())
|
||||
});
|
||||
let payload = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.placeholder("消息负载,支持 {{变量名}}")
|
||||
.placeholder(i18n::t("pub.payload_preset_ph"))
|
||||
.default_value(params.payload.clone())
|
||||
});
|
||||
let qos = make_select(&QOS, params.qos.min(2) as usize, window, cx);
|
||||
@@ -101,11 +102,11 @@ impl PresetsDialog {
|
||||
let payload_format = make_select(&PAYLOAD_FORMATS, fmt_idx, window, cx);
|
||||
let content_type = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Content-Type(可选)")
|
||||
.placeholder(i18n::t("pub.content_type_ph"))
|
||||
.default_value(params.content_type.clone().unwrap_or_default())
|
||||
});
|
||||
let msg_expiry = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder("秒,如 60").default_value(
|
||||
InputState::new(window, cx).placeholder(i18n::t("pub.seconds_hint")).default_value(
|
||||
params
|
||||
.message_expiry_interval
|
||||
.map(|v| v.to_string())
|
||||
@@ -114,12 +115,12 @@ impl PresetsDialog {
|
||||
});
|
||||
let response_topic = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Response Topic(可选)")
|
||||
.placeholder(i18n::t("pub.response_topic_ph"))
|
||||
.default_value(params.response_topic.clone().unwrap_or_default())
|
||||
});
|
||||
let correlation_data = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Correlation Data(可选)")
|
||||
.placeholder(i18n::t("pub.correlation_ph"))
|
||||
.default_value(params.correlation_data.clone().unwrap_or_default())
|
||||
});
|
||||
let user_props =
|
||||
@@ -267,10 +268,10 @@ impl PresetsDialog {
|
||||
|
||||
/// 「新建」:创建默认名称预设(重名自动加序号)并立即落盘、进入编辑。
|
||||
fn create_new(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let mut name = "新预设".to_string();
|
||||
let mut name = i18n::t("preset.default_name").to_string();
|
||||
let mut n = 2;
|
||||
while self.presets.iter().any(|p| p.name == name) {
|
||||
name = format!("新预设 {n}");
|
||||
name = i18n::tf("preset.default_name_n", &[("n", &n)]);
|
||||
n += 1;
|
||||
}
|
||||
self.presets.push(PublishPreset::new(name, PublishParams::default()));
|
||||
@@ -282,12 +283,12 @@ impl PresetsDialog {
|
||||
/// 「保存」:name 校验 + 冲突检查通过后按 id 更新,再落盘。
|
||||
fn save(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(idx) = self.selected else {
|
||||
window.push_notification(Notification::warning("请先选择或新建预设"), cx);
|
||||
window.push_notification(Notification::warning(i18n::t("preset.pick_first")), cx);
|
||||
return;
|
||||
};
|
||||
let name = self.name.read(cx).value().trim().to_string();
|
||||
if name.is_empty() {
|
||||
window.push_notification(Notification::warning("请填写预设名称"), cx);
|
||||
window.push_notification(Notification::warning(i18n::t("pub.preset_name_required")), cx);
|
||||
return;
|
||||
}
|
||||
// 与其他预设(不含自身)重名 → 报错且不落盘
|
||||
@@ -298,7 +299,10 @@ impl PresetsDialog {
|
||||
.any(|p| p.id != own_id && p.name == name)
|
||||
{
|
||||
window.push_notification(
|
||||
Notification::error(format!("预设名称「{name}」已存在")),
|
||||
Notification::error(i18n::tf(
|
||||
"preset.name_exists",
|
||||
&[("name", &name)],
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
@@ -308,7 +312,7 @@ impl PresetsDialog {
|
||||
let params = self.collect(cx);
|
||||
if !expiry_raw.trim().is_empty() && params.message_expiry_interval.is_none() {
|
||||
window.push_notification(
|
||||
Notification::warning("消息过期须为非负整数(秒)"),
|
||||
Notification::warning(i18n::t("pub.expiry_invalid")),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
@@ -319,13 +323,16 @@ impl PresetsDialog {
|
||||
p.params = params;
|
||||
}
|
||||
self.persist(cx);
|
||||
window.push_notification(Notification::success(format!("预设「{name}」已保存")), cx);
|
||||
window.push_notification(Notification::success(i18n::tf(
|
||||
"preset.saved",
|
||||
&[("name", &name)],
|
||||
)), cx);
|
||||
}
|
||||
|
||||
/// 「删除」:`open_alert_dialog` 二次确认后从列表移除并落盘。
|
||||
fn ask_delete(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(idx) = self.selected else {
|
||||
window.push_notification(Notification::warning("请先选择要删除的预设"), cx);
|
||||
window.push_notification(Notification::warning(i18n::t("preset.pick_delete_first")), cx);
|
||||
return;
|
||||
};
|
||||
let pid = self.presets[idx].id.clone();
|
||||
@@ -336,13 +343,16 @@ impl PresetsDialog {
|
||||
let weak = weak.clone();
|
||||
let pid = pid.clone();
|
||||
alert
|
||||
.title("删除预设")
|
||||
.description(format!("确定删除预设「{pname}」吗?此操作不可撤销。"))
|
||||
.title(i18n::t("pub.delete_title"))
|
||||
.description(i18n::tf(
|
||||
"pub.delete_confirm",
|
||||
&[("name", &pname)],
|
||||
))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.show_cancel(true)
|
||||
.cancel_text("取消")
|
||||
.ok_text("删除")
|
||||
.cancel_text(i18n::t("common.cancel"))
|
||||
.ok_text(i18n::t("common.delete"))
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.on_ok(move |_, window, cx| {
|
||||
@@ -388,7 +398,7 @@ impl Render for PresetsDialog {
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.text_center()
|
||||
.child("暂无发布预设,点击上方「新建」创建"),
|
||||
.child(i18n::t("preset.empty")),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -450,10 +460,10 @@ impl Render for PresetsDialog {
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.gap_2()
|
||||
.child(field("预设名称", Input::new(&self.name)))
|
||||
.child(field("发布主题", Input::new(&self.topic)))
|
||||
.child(field(i18n::t("aliyun.preset_name"), Input::new(&self.name)))
|
||||
.child(field(i18n::t("pub.topic_placeholder"), Input::new(&self.topic)))
|
||||
.child(field(
|
||||
"Payload(原文保存,发送时渲染变量)",
|
||||
i18n::t("preset.payload_field"),
|
||||
Textarea::new(&self.payload).h(px(96.)),
|
||||
))
|
||||
.child(
|
||||
@@ -484,13 +494,13 @@ impl Render for PresetsDialog {
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.child(field(
|
||||
"Payload 格式",
|
||||
i18n::t("preset.payload_format"),
|
||||
Select::new(&self.payload_format).small(),
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(div().h(px(1.)).bg(cx.theme().border).w_full())
|
||||
.child(div().text_xs().font_semibold().child("MQTT 5 属性"))
|
||||
.child(div().text_xs().font_semibold().child(i18n::t("pub.mqtt5_props")))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -502,7 +512,7 @@ impl Render for PresetsDialog {
|
||||
)
|
||||
.child(
|
||||
div().w(px(120.)).child(field(
|
||||
"消息过期(秒)",
|
||||
i18n::t("pub.expiry_field"),
|
||||
Input::new(&self.msg_expiry).small(),
|
||||
)),
|
||||
),
|
||||
@@ -523,7 +533,7 @@ impl Render for PresetsDialog {
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(field("用户属性", self.user_props.clone()));
|
||||
.child(field(i18n::t("preset.user_props"), self.user_props.clone()));
|
||||
|
||||
h_flex()
|
||||
.gap_3()
|
||||
@@ -538,11 +548,11 @@ impl Render for PresetsDialog {
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(div().text_sm().font_semibold().child("发布预设"))
|
||||
.child(div().text_sm().font_semibold().child(i18n::t("preset.section")))
|
||||
.child(
|
||||
Button::new("preset-new")
|
||||
.icon(IconName::Plus)
|
||||
.label("新建")
|
||||
.label(i18n::t("common.new"))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
@@ -558,6 +568,11 @@ impl Render for PresetsDialog {
|
||||
}
|
||||
|
||||
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
// 对话框是栈式叠加:标题栏菜单、快捷键、行内按钮等多个入口都会调 open,
|
||||
// 不加守卫会压入第二个模态,同 id 的控件在两层之间串台
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
let presets = app.read(cx).presets.clone();
|
||||
let dialog_view: Entity<PresetsDialog> =
|
||||
cx.new(|cx| PresetsDialog::new(app, presets, window, cx));
|
||||
@@ -569,7 +584,7 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
let has_selection = dialog_view.read(cx).selected.is_some();
|
||||
dialog
|
||||
.w(px(760.))
|
||||
.title("管理发布预设")
|
||||
.title(i18n::t("preset.manage_title"))
|
||||
.child(body)
|
||||
.footer(
|
||||
h_flex()
|
||||
@@ -578,7 +593,7 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
.child(
|
||||
Button::new("preset-delete")
|
||||
.icon(IconName::Trash)
|
||||
.label("删除")
|
||||
.label(i18n::t("common.delete"))
|
||||
.danger()
|
||||
.outline()
|
||||
.disabled(!has_selection)
|
||||
@@ -591,13 +606,13 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("preset-close")
|
||||
.label("关闭")
|
||||
.label(i18n::t("common.close"))
|
||||
.outline()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("preset-save")
|
||||
.label("保存")
|
||||
.label(i18n::t("common.save"))
|
||||
.primary()
|
||||
.disabled(!has_selection)
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -19,6 +19,7 @@ use gpui_kit::{
|
||||
};
|
||||
|
||||
use crate::model::MAX_CONNECTION_MESSAGE_BYTES;
|
||||
use crate::ui::i18n;
|
||||
use crate::sysmon::{Sample, Sampler};
|
||||
use crate::ui::app::MqttXApp;
|
||||
use crate::ui::IconName;
|
||||
@@ -49,7 +50,7 @@ pub struct ResourceMonitorDialog {
|
||||
}
|
||||
|
||||
impl ResourceMonitorDialog {
|
||||
fn new(app: Entity<MqttXApp>, cx: &mut Context<Self>) -> Self {
|
||||
fn new(app: Entity<MqttXApp>, _cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
app,
|
||||
sampler: Sampler::new(),
|
||||
@@ -89,7 +90,7 @@ impl ResourceMonitorDialog {
|
||||
.get(&c.id)
|
||||
.copied()
|
||||
.map(|s| s.label())
|
||||
.unwrap_or("未连接"),
|
||||
.unwrap_or(i18n::t("status.disconnected")),
|
||||
connected: app.engine.is_connected(&c.id),
|
||||
messages,
|
||||
bytes,
|
||||
@@ -246,7 +247,7 @@ impl ResourceMonitorDialog {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child(format!("{} 条消息", c.messages)),
|
||||
.child(i18n::tf("resmon.n_msgs", &[("n", &c.messages)])),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -262,11 +263,16 @@ impl ResourceMonitorDialog {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child(format!(
|
||||
"预算占用 {:.0}%({} / {})",
|
||||
pct,
|
||||
Self::fmt_bytes(c.bytes as u64),
|
||||
Self::fmt_bytes(MAX_CONNECTION_MESSAGE_BYTES as u64)
|
||||
.child(i18n::tf(
|
||||
"resmon.budget",
|
||||
&[
|
||||
("p", &format!("{:.0}", pct)),
|
||||
("used", &Self::fmt_bytes(c.bytes as u64)),
|
||||
(
|
||||
"total",
|
||||
&Self::fmt_bytes(MAX_CONNECTION_MESSAGE_BYTES as u64),
|
||||
),
|
||||
]
|
||||
)),
|
||||
),
|
||||
),
|
||||
@@ -309,7 +315,7 @@ impl Render for ResourceMonitorDialog {
|
||||
cx.theme().primary,
|
||||
cx.theme().primary_foreground,
|
||||
))
|
||||
.child(div().text_base().font_semibold().child("资源监控"))
|
||||
.child(div().text_base().font_semibold().child(i18n::t("titlebar.resmon")))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
@@ -318,7 +324,7 @@ impl Render for ResourceMonitorDialog {
|
||||
.rounded_full()
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(muted)
|
||||
.child(format!("运行 {uptime_text}")),
|
||||
.child(i18n::tf("resmon.uptime", &[("t", &uptime_text)])),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -333,7 +339,7 @@ impl Render for ResourceMonitorDialog {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child("自动刷新"),
|
||||
.child(i18n::t("resmon.auto_refresh")),
|
||||
)
|
||||
.child(
|
||||
Switch::new("resmon-auto")
|
||||
@@ -347,7 +353,7 @@ impl Render for ResourceMonitorDialog {
|
||||
.child(
|
||||
Button::new("resmon-refresh")
|
||||
.icon(IconName::RefreshCw)
|
||||
.label("立即刷新")
|
||||
.label(i18n::t("resmon.refresh_now"))
|
||||
.outline()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
@@ -366,36 +372,39 @@ impl Render for ResourceMonitorDialog {
|
||||
.child(self.tile(
|
||||
"tile-cpu",
|
||||
IconName::Cpu,
|
||||
"CPU 占用",
|
||||
i18n::t("resmon.cpu"),
|
||||
format!("{cpu:.1}%"),
|
||||
Some("按可用核数归一".into()),
|
||||
Some(i18n::t("resmon.cpu_hint").into()),
|
||||
Some(cpu),
|
||||
cx,
|
||||
))
|
||||
.child(self.tile(
|
||||
"tile-ws",
|
||||
IconName::MemoryStick,
|
||||
"工作集内存",
|
||||
i18n::t("resmon.working_set"),
|
||||
Self::fmt_bytes(ws),
|
||||
Some(format!("峰值 {}", Self::fmt_bytes(s.peak_working_set))),
|
||||
Some(i18n::tf(
|
||||
"resmon.peak",
|
||||
&[("v", &Self::fmt_bytes(s.peak_working_set))],
|
||||
)),
|
||||
Some(ws as f32 / peak as f32 * 100.),
|
||||
cx,
|
||||
))
|
||||
.child(self.tile(
|
||||
"tile-private",
|
||||
IconName::HardDrive,
|
||||
"提交内存",
|
||||
i18n::t("resmon.committed"),
|
||||
Self::fmt_bytes(s.private_bytes),
|
||||
Some("进程独占的物理页承诺".into()),
|
||||
Some(i18n::t("resmon.committed_hint").into()),
|
||||
None,
|
||||
cx,
|
||||
))
|
||||
.child(self.tile(
|
||||
"tile-threads",
|
||||
IconName::Layers,
|
||||
"线程 · 句柄",
|
||||
i18n::t("resmon.threads_handles"),
|
||||
format!("{} / {}", s.threads, s.handles),
|
||||
Some("tokio 引擎 + GPUI 渲染".into()),
|
||||
Some(i18n::t("resmon.threads_hint").into()),
|
||||
None,
|
||||
cx,
|
||||
));
|
||||
@@ -412,7 +421,7 @@ impl Render for ResourceMonitorDialog {
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(Icon::new(IconName::Info).size_4().text_color(muted))
|
||||
.child(div().text_sm().text_color(muted).child("暂无连接")),
|
||||
.child(div().text_sm().text_color(muted).child(i18n::t("resmon.no_conn"))),
|
||||
);
|
||||
}
|
||||
for (i, c) in self.conns.iter().enumerate() {
|
||||
@@ -427,19 +436,21 @@ impl Render for ResourceMonitorDialog {
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(self.section_header(IconName::Database, "消息与连接", cx))
|
||||
.child(self.section_header(IconName::Database, i18n::t("resmon.section"), cx))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child(format!(
|
||||
"{} 个连接(已连接 {})· 打开标签 {} · 内存消息 {} 条(每连接上限 {} 条)· 日志 {} 条",
|
||||
self.conns.len(),
|
||||
connected_count,
|
||||
self.tabs,
|
||||
self.total_messages,
|
||||
self.app.read(cx).settings.max_messages,
|
||||
self.logs
|
||||
.child(i18n::tf(
|
||||
"resmon.stats",
|
||||
&[
|
||||
("n", &self.conns.len()),
|
||||
("m", &connected_count),
|
||||
("t", &self.tabs),
|
||||
("msgs", &self.total_messages),
|
||||
("cap", &self.app.read(cx).settings.max_messages),
|
||||
("logs", &self.logs),
|
||||
]
|
||||
)),
|
||||
)
|
||||
.child(conns_block),
|
||||
@@ -454,14 +465,14 @@ impl Render for ResourceMonitorDialog {
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.child(if self.auto_refresh {
|
||||
format!(
|
||||
"版本 v{} · 每 1.5 秒自动刷新",
|
||||
crate::update::current_version()
|
||||
i18n::tf(
|
||||
"resmon.version_live",
|
||||
&[("v", &crate::update::current_version())],
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"版本 v{} · 自动刷新已暂停",
|
||||
crate::update::current_version()
|
||||
i18n::tf(
|
||||
"resmon.version_paused",
|
||||
&[("v", &crate::update::current_version())],
|
||||
)
|
||||
}),
|
||||
),
|
||||
@@ -470,6 +481,11 @@ impl Render for ResourceMonitorDialog {
|
||||
}
|
||||
|
||||
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
// 对话框是栈式叠加:标题栏菜单、快捷键、行内按钮等多个入口都会调 open,
|
||||
// 不加守卫会压入第二个模态,同 id 的控件在两层之间串台
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
let dialog_view = cx.new(|cx| ResourceMonitorDialog::new(app, cx));
|
||||
// 用弱引用刷新:对话框关闭、实体释放后 update 返回 Err,循环退出。
|
||||
// 强引用的 update 在实体释放后会 panic。
|
||||
@@ -497,7 +513,7 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |dialog, _, _| {
|
||||
dialog
|
||||
.w(px(640.))
|
||||
.title("资源监控")
|
||||
.title(i18n::t("titlebar.resmon"))
|
||||
.child(dialog_view.clone())
|
||||
.footer(
|
||||
h_flex()
|
||||
@@ -505,7 +521,7 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("res-close")
|
||||
.label("关闭")
|
||||
.label(i18n::t("common.close"))
|
||||
.outline()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
),
|
||||
@@ -17,21 +17,27 @@ use gpui_kit::{
|
||||
Styled as _, Window,
|
||||
};
|
||||
|
||||
use crate::model::{AppSettings, ThemeModePref, MAX_CONNECTION_MESSAGE_BYTES};
|
||||
use crate::model::{AppSettings, LanguagePref, ThemeModePref, MAX_CONNECTION_MESSAGE_BYTES};
|
||||
use crate::mqtt::MqttEngine;
|
||||
use crate::ui::app::MqttXApp;
|
||||
use crate::ui::widgets::{field, make_select, OptionDelegate};
|
||||
use crate::ui::IconName;
|
||||
use crate::ui::i18n;
|
||||
use crate::update::{self, UpdateInfo};
|
||||
|
||||
const THEMES: [&str; 3] = ["跟随系统(暂按浅色)", "浅色", "深色"];
|
||||
fn theme_labels() -> [&'static str; 3] {
|
||||
[i18n::t("theme.system"), i18n::t("theme.light"), i18n::t("theme.dark")]
|
||||
}
|
||||
fn language_labels() -> [&'static str; 3] {
|
||||
["跟随系统 / System", "中文", "English"]
|
||||
}
|
||||
|
||||
/// 消息缓存条数的合法范围:太少不够回看,太多占内存
|
||||
const MAX_MESSAGES_MIN: usize = 100;
|
||||
const MAX_MESSAGES_MAX: usize = 100_000;
|
||||
|
||||
const SITE_URL: &str = "https://mqttx.app";
|
||||
const REPO_URL: &str = "https://gitea.heavenlybook.cn/JoeAllen/mqttx";
|
||||
const REPO_URL: &str = "https://github.com/LiJoeAllen/mqttx";
|
||||
|
||||
/// 更新检查/安装在设置对话框内的展示状态。
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -49,6 +55,11 @@ enum UpdateUi {
|
||||
struct SettingsDialog {
|
||||
engine: Arc<MqttEngine>,
|
||||
theme: Entity<SelectState<OptionDelegate>>,
|
||||
/// 官方捆绑预设选择("Linear" + 38 个注册名),与外观模式互斥
|
||||
theme_preset: Entity<SelectState<OptionDelegate>>,
|
||||
/// 预设下拉的选项名(SelectState 只回索引,展示名由此表查)
|
||||
preset_names: Vec<String>,
|
||||
language: Entity<SelectState<OptionDelegate>>,
|
||||
max_messages: Entity<InputState>,
|
||||
show_millis: bool,
|
||||
auto_check_update: bool,
|
||||
@@ -61,6 +72,7 @@ impl SettingsDialog {
|
||||
fn new(
|
||||
engine: Arc<MqttEngine>,
|
||||
initial: AppSettings,
|
||||
preset_names: Vec<String>,
|
||||
data_dir: PathBuf,
|
||||
log_dir: PathBuf,
|
||||
window: &mut Window,
|
||||
@@ -71,9 +83,28 @@ impl SettingsDialog {
|
||||
ThemeModePref::Light => 1,
|
||||
ThemeModePref::Dark => 2,
|
||||
};
|
||||
// 预设下拉选项:Linear 在首,其余为 ThemeRegistry 全部注册名(open() 传入)
|
||||
let preset_idx = preset_names
|
||||
.iter()
|
||||
.position(|n| {
|
||||
n == &initial.theme_preset
|
||||
|| (initial.theme_preset == crate::ui::app::theme::DEFAULT_PRESET
|
||||
&& n == crate::ui::app::theme::LINEAR_PRESET_LABEL)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let preset_labels: Vec<&str> = preset_names.iter().map(String::as_str).collect();
|
||||
let preset_select = make_select(&preset_labels, preset_idx, window, cx);
|
||||
let lang_idx = match initial.language {
|
||||
LanguagePref::System => 0,
|
||||
LanguagePref::Zh => 1,
|
||||
LanguagePref::En => 2,
|
||||
};
|
||||
Self {
|
||||
engine,
|
||||
theme: make_select(&THEMES, theme_idx, window, cx),
|
||||
preset_names,
|
||||
theme: make_select(&theme_labels(), theme_idx, window, cx),
|
||||
theme_preset: preset_select,
|
||||
language: make_select(&language_labels(), lang_idx, window, cx),
|
||||
max_messages: cx.new(|cx| {
|
||||
InputState::new(window, cx).default_value(initial.max_messages.to_string())
|
||||
}),
|
||||
@@ -166,32 +197,32 @@ impl SettingsDialog {
|
||||
match &self.update {
|
||||
UpdateUi::Idle => row(
|
||||
Button::new("check-update")
|
||||
.label("检查更新")
|
||||
.label(i18n::t("update.check"))
|
||||
.outline()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx)))
|
||||
.into_any_element(),
|
||||
"检查 Gitea Release 上的新版本".into_any_element(),
|
||||
i18n::t("update.check_hint").into_any_element(),
|
||||
false,
|
||||
),
|
||||
UpdateUi::Checking => row(
|
||||
Button::new("check-update")
|
||||
.label("检查中…")
|
||||
.label(i18n::t("update.checking"))
|
||||
.outline()
|
||||
.xsmall()
|
||||
.loading(true)
|
||||
.into_any_element(),
|
||||
"正在检查更新…".into_any_element(),
|
||||
i18n::t("update.checking_long").into_any_element(),
|
||||
false,
|
||||
),
|
||||
UpdateUi::UpToDate => row(
|
||||
Button::new("check-update")
|
||||
.label("重新检查")
|
||||
.label(i18n::t("update.recheck"))
|
||||
.outline()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx)))
|
||||
.into_any_element(),
|
||||
"已是最新版本".into_any_element(),
|
||||
i18n::t("update.up_to_date").into_any_element(),
|
||||
false,
|
||||
),
|
||||
UpdateUi::Available(info) => {
|
||||
@@ -199,14 +230,21 @@ impl SettingsDialog {
|
||||
let info_for_click = info.clone();
|
||||
row(
|
||||
Button::new("apply-update")
|
||||
.label(format!("立即更新到 v{}", ver))
|
||||
.label(i18n::tf(
|
||||
"update.apply",
|
||||
&[("v", &ver)],
|
||||
))
|
||||
.primary()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.run_update(&info_for_click, window, cx)
|
||||
}))
|
||||
.into_any_element(),
|
||||
format!("发现新版本 v{},下载后自动替换重启", ver).into_any_element(),
|
||||
i18n::tf(
|
||||
"update.found",
|
||||
&[("v", &ver)],
|
||||
)
|
||||
.into_any_element(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -219,9 +257,9 @@ impl SettingsDialog {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().accent)
|
||||
.child(format!(
|
||||
"新版本 v{} 已下载并通过校验,等待安装",
|
||||
ver
|
||||
.child(i18n::tf(
|
||||
"update.ready",
|
||||
&[("v", &ver)],
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
@@ -229,7 +267,7 @@ impl SettingsDialog {
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Button::new("upd-now")
|
||||
.label("立即安装")
|
||||
.label(i18n::t("update.install_now"))
|
||||
.primary()
|
||||
.xsmall()
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -247,19 +285,20 @@ impl SettingsDialog {
|
||||
)
|
||||
.child(
|
||||
Button::new("upd-later")
|
||||
.label("下次启动安装")
|
||||
.label(i18n::t("update.install_later"))
|
||||
.outline()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
// 写入同意标记:下次启动据此自动安装
|
||||
if let UpdateUi::ReadyToInstall(staged) = &this.update {
|
||||
update::mark_install_consent(&staged.version);
|
||||
// 同意标记绑定版本 + 暂存文件哈希
|
||||
update::mark_install_consent(staged);
|
||||
}
|
||||
this.update = UpdateUi::Idle;
|
||||
window.push_notification(
|
||||
Notification::info(
|
||||
"已暂存,下次启动时将自动安装",
|
||||
),
|
||||
Notification::info(i18n::t(
|
||||
"update.staged",
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
@@ -267,7 +306,7 @@ impl SettingsDialog {
|
||||
)
|
||||
.child(
|
||||
Button::new("upd-discard")
|
||||
.label("放弃此次更新")
|
||||
.label(i18n::t("update.discard"))
|
||||
.outline()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
@@ -276,7 +315,7 @@ impl SettingsDialog {
|
||||
}
|
||||
this.update = UpdateUi::Idle;
|
||||
window.push_notification(
|
||||
Notification::info("已删除暂存的更新包"),
|
||||
Notification::info(i18n::t("update.discarded")),
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
@@ -291,14 +330,19 @@ impl SettingsDialog {
|
||||
0.
|
||||
};
|
||||
let status = if *total > 0 {
|
||||
format!(
|
||||
"下载中 {:.0}%({} / {})",
|
||||
pct * 100.,
|
||||
Self::fmt_mb(*downloaded),
|
||||
Self::fmt_mb(*total)
|
||||
i18n::tf(
|
||||
"update.downloading",
|
||||
&[
|
||||
("pct", &format!("{:.0}", pct * 100.)),
|
||||
("done", &Self::fmt_mb(*downloaded)),
|
||||
("total", &Self::fmt_mb(*total)),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
format!("下载中 {}", Self::fmt_mb(*downloaded))
|
||||
i18n::tf(
|
||||
"update.downloading_size",
|
||||
&[("done", &Self::fmt_mb(*downloaded))],
|
||||
)
|
||||
};
|
||||
v_flex()
|
||||
.gap_1()
|
||||
@@ -315,12 +359,12 @@ impl SettingsDialog {
|
||||
}
|
||||
UpdateUi::Failed(e) => row(
|
||||
Button::new("check-update")
|
||||
.label("重试")
|
||||
.label(i18n::t("common.retry"))
|
||||
.outline()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| this.run_check(window, cx)))
|
||||
.into_any_element(),
|
||||
format!("更新失败:{e}").into_any_element(),
|
||||
i18n::tf("update.failed", &[("e", &e)]).into_any_element(),
|
||||
false,
|
||||
),
|
||||
}
|
||||
@@ -333,6 +377,21 @@ impl SettingsDialog {
|
||||
Some(2) => ThemeModePref::Dark,
|
||||
_ => ThemeModePref::System,
|
||||
};
|
||||
let language = match self.language.read(cx).selected_value() {
|
||||
Some(1) => LanguagePref::Zh,
|
||||
Some(2) => LanguagePref::En,
|
||||
_ => LanguagePref::System,
|
||||
};
|
||||
// 展示名 → 存储名(Linear ↔ "linear",官方预设用注册名)。
|
||||
// SelectState 只回索引,展示名从构造时的选项表查
|
||||
let theme_preset = self
|
||||
.theme_preset
|
||||
.read(cx)
|
||||
.selected_value()
|
||||
.copied()
|
||||
.and_then(|idx| self.preset_names.get(idx))
|
||||
.map(|l| crate::ui::app::theme::preset_storage_name(l))
|
||||
.unwrap_or_else(|| crate::ui::app::theme::DEFAULT_PRESET.to_string());
|
||||
// 显式校验范围而非静默钳制:越界直接报错,不落盘
|
||||
let max_messages = self
|
||||
.max_messages
|
||||
@@ -351,6 +410,8 @@ impl SettingsDialog {
|
||||
// 四个字段全部显式写出,避免 ..Default::default() 把开关项重置为默认值
|
||||
Ok(AppSettings {
|
||||
theme,
|
||||
language,
|
||||
theme_preset,
|
||||
max_messages,
|
||||
show_millis: self.show_millis,
|
||||
auto_check_update: self.auto_check_update,
|
||||
@@ -369,7 +430,7 @@ fn open_in_file_manager(path: &Path, window: &mut Window, cx: &mut App) {
|
||||
|
||||
cmd.arg(path);
|
||||
if let Err(e) = cmd.spawn() {
|
||||
window.push_notification(Notification::error(format!("打开目录失败: {e}")), cx);
|
||||
window.push_notification(Notification::error(i18n::tf("settings.open_dir_failed", &[("e", &e)])), cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,24 +444,31 @@ impl Render for SettingsDialog {
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.w(px(500.))
|
||||
.child(field("主题", Select::new(&self.theme)))
|
||||
.child(field(
|
||||
"每条连接内存中保留的消息条数(100~100000)",
|
||||
i18n::t("settings.theme_preset"),
|
||||
Select::new(&self.theme_preset),
|
||||
))
|
||||
.child(field(i18n::t("settings.theme"), Select::new(&self.theme)))
|
||||
.child(field(i18n::t("settings.language"), Select::new(&self.language)))
|
||||
.child(field(
|
||||
i18n::t("settings.max_messages"),
|
||||
Input::new(&self.max_messages),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!(
|
||||
"另有每连接 {}MB 的字节预算兜底:大报文流会优先按字节从最旧开始驱逐",
|
||||
MAX_CONNECTION_MESSAGE_BYTES / (1024 * 1024)
|
||||
.child(i18n::tf(
|
||||
"settings.byte_budget",
|
||||
&[
|
||||
("n", &format!("{}", MAX_CONNECTION_MESSAGE_BYTES / (1024 * 1024))),
|
||||
],
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(div().text_sm().child("时间戳显示毫秒"))
|
||||
.child(div().text_sm().child(i18n::t("settings.show_millis")))
|
||||
.child(
|
||||
Switch::new("show-millis")
|
||||
.checked(self.show_millis)
|
||||
@@ -413,7 +481,7 @@ impl Render for SettingsDialog {
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(div().text_sm().child("自动检查更新(启动时)"))
|
||||
.child(div().text_sm().child(i18n::t("settings.auto_update")))
|
||||
.child(
|
||||
Switch::new("auto-check-update")
|
||||
.checked(self.auto_check_update)
|
||||
@@ -425,7 +493,7 @@ impl Render for SettingsDialog {
|
||||
)
|
||||
.child(div().h(px(1.)).bg(cx.theme().border).w_full())
|
||||
.child(field(
|
||||
"数据目录",
|
||||
i18n::t("settings.data_dir"),
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
@@ -444,7 +512,7 @@ impl Render for SettingsDialog {
|
||||
.child(
|
||||
Button::new("open-data-dir")
|
||||
.icon(IconName::FolderOpen)
|
||||
.label("打开目录")
|
||||
.label(i18n::t("settings.open_dir"))
|
||||
.outline()
|
||||
.xsmall()
|
||||
.on_click(move |_, window, cx| {
|
||||
@@ -453,7 +521,7 @@ impl Render for SettingsDialog {
|
||||
),
|
||||
))
|
||||
.child(field(
|
||||
"日志目录",
|
||||
i18n::t("settings.log_dir"),
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
@@ -471,14 +539,14 @@ impl Render for SettingsDialog {
|
||||
.child(
|
||||
Button::new("open-log-dir")
|
||||
.icon(IconName::FolderOpen)
|
||||
.label("打开日志目录")
|
||||
.label(i18n::t("settings.open_log_dir"))
|
||||
.outline()
|
||||
.xsmall()
|
||||
.on_click(move |_, window, cx| {
|
||||
// 日志目录可能尚未创建(从未产生过日志)
|
||||
if let Err(e) = std::fs::create_dir_all(&log_dir_for_open) {
|
||||
window.push_notification(
|
||||
Notification::error(format!("创建日志目录失败: {e}")),
|
||||
Notification::error(i18n::tf("settings.mkdir_failed", &[("e", &e)])),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
@@ -492,25 +560,27 @@ impl Render for SettingsDialog {
|
||||
v_flex().gap_1().child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(div().text_sm().font_semibold().child("关于"))
|
||||
.child(div().text_sm().font_semibold().child(i18n::t("settings.about")))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!(
|
||||
"版本 {}(更新源 {})",
|
||||
update::current_version(),
|
||||
update::PKG_REPO
|
||||
.child(i18n::tf(
|
||||
"settings.version_line",
|
||||
&[
|
||||
("v", &update::current_version()),
|
||||
("src", &update::UPDATE_SOURCE),
|
||||
],
|
||||
))
|
||||
.child("Apache-2.0 许可证"),
|
||||
.child(i18n::t("settings.license")),
|
||||
)
|
||||
.child(self.render_update_row(window, cx))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("对标 MQTTX 的 GPUI 跨平台 MQTT 调试客户端"),
|
||||
.child(i18n::t("settings.tagline")),
|
||||
)
|
||||
.child(
|
||||
h_flex().gap_1().child(
|
||||
@@ -522,7 +592,7 @@ impl Render for SettingsDialog {
|
||||
.on_click(|_, _, cx| cx.open_url(SITE_URL)),
|
||||
).child(
|
||||
Button::new("about-repo")
|
||||
.label("gitea.heavenlybook.cn/JoeAllen/mqttx")
|
||||
.label("github.com/LiJoeAllen/mqttx")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip(REPO_URL)
|
||||
@@ -535,17 +605,23 @@ impl Render for SettingsDialog {
|
||||
}
|
||||
|
||||
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
let (engine, initial, data_dir, log_dir) = {
|
||||
// 对话框是栈式叠加:标题栏菜单、快捷键、行内按钮等多个入口都会调 open,
|
||||
// 不加守卫会压入第二个模态,同 id 的控件在两层之间串台
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
let (engine, initial, preset_names, data_dir, log_dir) = {
|
||||
let state = app.read(cx);
|
||||
(
|
||||
state.engine.clone(),
|
||||
state.settings.clone(),
|
||||
crate::ui::app::theme::theme_preset_names(cx),
|
||||
state.data_dir(),
|
||||
state.log_dir(),
|
||||
)
|
||||
};
|
||||
let dialog_view: Entity<SettingsDialog> = cx.new(|cx| {
|
||||
SettingsDialog::new(engine, initial, data_dir, log_dir, window, cx)
|
||||
SettingsDialog::new(engine, initial, preset_names, data_dir, log_dir, window, cx)
|
||||
});
|
||||
let app_save = app.clone();
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
@@ -554,7 +630,7 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
let app_for_save = app_save.clone();
|
||||
dialog
|
||||
.w(px(560.))
|
||||
.title("设置")
|
||||
.title(i18n::t("titlebar.settings"))
|
||||
.child(body)
|
||||
.footer(
|
||||
h_flex()
|
||||
@@ -563,20 +639,21 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("settings-cancel")
|
||||
.label("取消")
|
||||
.label(i18n::t("common.cancel"))
|
||||
.outline()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("settings-ok")
|
||||
.label("保存")
|
||||
.label(i18n::t("common.save"))
|
||||
.primary()
|
||||
.on_click(move |_, window, cx| {
|
||||
let result = for_collect.read(cx).collect(cx);
|
||||
match result {
|
||||
Ok(settings) => {
|
||||
// save_settings 内部会 apply_theme + 整窗 refresh
|
||||
app_for_save.update(cx, |a, cx| {
|
||||
a.save_settings(settings, cx);
|
||||
a.save_settings(settings, window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
window.close_dialog(cx);
|
||||
@@ -8,9 +8,15 @@ use gpui_kit::{
|
||||
|
||||
use crate::model::GlobalVariable;
|
||||
use crate::ui::app::MqttXApp;
|
||||
use crate::ui::i18n;
|
||||
use crate::ui::widgets::KvEditor;
|
||||
|
||||
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
// 对话框是栈式叠加:标题栏菜单、快捷键、行内按钮等多个入口都会调 open,
|
||||
// 不加守卫会压入第二个模态,同 id 的控件在两层之间串台
|
||||
if window.has_active_dialog(cx) {
|
||||
return;
|
||||
}
|
||||
let pairs: Vec<(String, String)> = app
|
||||
.read(cx)
|
||||
.variables
|
||||
@@ -27,7 +33,7 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
let app_ok = app.clone();
|
||||
dialog
|
||||
.w(px(560.))
|
||||
.title("全局变量")
|
||||
.title(i18n::t("titlebar.variables"))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
@@ -35,8 +41,8 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.gap_1()
|
||||
.child("在发布主题、消息负载中使用 {{变量名}} 引用。")
|
||||
.child("内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}"),
|
||||
.child(i18n::t("var.dialog_hint"))
|
||||
.child(i18n::t("var.dialog_builtin")),
|
||||
)
|
||||
.child(editor_body),
|
||||
)
|
||||
@@ -47,13 +53,13 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("vars-cancel")
|
||||
.label("取消")
|
||||
.label(i18n::t("common.cancel"))
|
||||
.outline()
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
)
|
||||
.child(
|
||||
Button::new("vars-ok")
|
||||
.label("保存")
|
||||
.label(i18n::t("common.save"))
|
||||
.primary()
|
||||
.on_click(move |_, window, cx| {
|
||||
let vars = for_ok
|
||||
@@ -0,0 +1,324 @@
|
||||
//! i18n 门面:语义键(`ns.key`)+ rust-i18n(gpui-kit 官方方案)。
|
||||
//!
|
||||
//! # 架构
|
||||
//! - 语言包在 `locales/`,**按语言一分文件**(`en.yml` / `zh-CN.yml`,
|
||||
//! rust-i18n 以文件名 stem 识别 locale,扁平 `"ns.key": "文案"` 格式),
|
||||
//! 由 crate 根的 `rust_i18n::i18n!("locales", fallback = "en")` 编译期内嵌;
|
||||
//! gpui-kit 组件内置文案经 lib.rs 的 `init_i18n()`(`rust_i18n::extend!`)
|
||||
//! 随语言切换。
|
||||
//! - 调用点写 `t("common.save")` / `tf("msg.count", &[("n", &count)])`,
|
||||
//! key 是**稳定标识符**,与任何自然语言解耦——改文案措辞不动代码,
|
||||
//! 也不存在「一词多义撞 key」。
|
||||
//! - [`t`]/[`tf`] 直接查 [`crate::_rust_i18n_translate`](rust-i18n 生成、
|
||||
//! 接受运行时 key 的公开函数),不经 `t!` 宏——`label()` 这类运行时
|
||||
//! 值同样可查。
|
||||
//!
|
||||
//! # 规则(单元测试强制,违反即测试失败)
|
||||
//! 1. 新增文案:调用点 `t("ns.key")`,`locales/en.yml` 与 `locales/zh-CN.yml`
|
||||
//! 各加一行(两文件键集合必须一致)。
|
||||
//! 2. 源码每个 `t("…")` / `tf("…")` 字面量必须在语言包中存在。
|
||||
//! 3. 模型 `label()` 返回的 key 必须有**非自映射**的 en 词条。
|
||||
//! 4. 同一 key 的 zh-CN 与 en 占位符 `{name}` 集合必须一致
|
||||
//! (`tf` 按名替换,两侧不一致即静默漏参)。
|
||||
//!
|
||||
//! # 已知限制
|
||||
//! 语言切换对**已打开页签里的下拉框**(QoS / Retain Handling 等)不即时生效——
|
||||
//! 这些 SelectState 在构造时固化了标签列表;重开页签即刷新。其余界面即时生效
|
||||
//! (save_settings 内 `window.refresh()` 兜底整窗重绘)。
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Lang {
|
||||
Zh,
|
||||
En,
|
||||
}
|
||||
|
||||
const LOCALE_ZH: &str = "zh-CN";
|
||||
const LOCALE_EN: &str = "en";
|
||||
|
||||
/// 0 = 中文,1 = English。默认中文。
|
||||
static LANG: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// 切换语言:同时更新 rust-i18n 全局 locale(gpui-kit 组件内置文案随之切换)。
|
||||
/// 已渲染界面的刷新由调用方负责(settings 保存路径带整窗 refresh)。
|
||||
pub fn set_lang(lang: Lang) {
|
||||
LANG.store(lang as u8, Ordering::Relaxed);
|
||||
gpui_kit::component::set_locale(match lang {
|
||||
Lang::Zh => LOCALE_ZH,
|
||||
Lang::En => LOCALE_EN,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn lang() -> Lang {
|
||||
if LANG.load(Ordering::Relaxed) == 1 {
|
||||
Lang::En
|
||||
} else {
|
||||
Lang::Zh
|
||||
}
|
||||
}
|
||||
|
||||
/// 按系统语言解析初始语言(AppSettings::System 时使用)。
|
||||
pub fn from_system() -> Lang {
|
||||
match sys_locale::get_locale().unwrap_or_default().to_lowercase() {
|
||||
l if l.starts_with("zh") => Lang::Zh,
|
||||
_ => Lang::En,
|
||||
}
|
||||
}
|
||||
|
||||
/// 查指定 locale 的词条。
|
||||
fn translate<'k>(locale: &str, key: &'k str) -> Cow<'k, str> {
|
||||
crate::_rust_i18n_translate(locale, key)
|
||||
}
|
||||
|
||||
/// 取当前语言的界面文案。miss 时 rust-i18n 原样返回 key(测试会拦截,
|
||||
/// 运行期裸 key 只是最后的安全网)。
|
||||
pub fn t(key: &'static str) -> &'static str {
|
||||
let locale = match lang() {
|
||||
Lang::Zh => LOCALE_ZH,
|
||||
Lang::En => LOCALE_EN,
|
||||
};
|
||||
match translate(locale, key) {
|
||||
// 无参查表只返回 Borrowed(静态表值或原 key);Owned 视为异常回退 key
|
||||
Cow::Borrowed(s) => s,
|
||||
Cow::Owned(_) => key,
|
||||
}
|
||||
}
|
||||
|
||||
/// 带参数的界面文案:语言包内占位符写作 `{name}`(非 rust-i18n 的 `%{name}`,
|
||||
/// 由本函数按名替换),参数值只需 `Display`。
|
||||
pub fn tf(key: &'static str, args: &[(&str, &dyn std::fmt::Display)]) -> String {
|
||||
let mut out = t(key).to_string();
|
||||
for (name, value) in args {
|
||||
out = out.replace(&format!("{{{name}}}"), &value.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ─── 单元测试:语言包完整性由测试强制,而不是靠人工纪律 ──────────────────────
|
||||
|
||||
/// 提取 `{name}` 形式的占位符(ASCII 标识符;`{{x}}` 双括号、`{$builtin}`
|
||||
/// 与中文花括号说明文不算),排序后比较集合。
|
||||
#[cfg(test)]
|
||||
fn placeholders(s: &str) -> Vec<String> {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'{' {
|
||||
let before_ok = i == 0 || b[i - 1] != b'{';
|
||||
let mut j = i + 1;
|
||||
while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'_') {
|
||||
j += 1;
|
||||
}
|
||||
let after_ok = j < b.len() && b[j] == b'}' && (j + 1 == b.len() || b[j + 1] != b'}');
|
||||
let name = &s[i + 1..j];
|
||||
let name_ok = name
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
|
||||
if before_ok && after_ok && name_ok {
|
||||
out.push(name.to_string());
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// 解析 `locales/{locale}.yml`(规整格式:每行 `"key": "value"`,# 注释)。
|
||||
/// 格式意外时 panic——语言包是构建产物,格式受控。
|
||||
#[cfg(test)]
|
||||
fn load_locale_yml(locale: &str) -> std::collections::BTreeMap<String, String> {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("locales")
|
||||
.join(format!("{locale}.yml"));
|
||||
let text =
|
||||
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{} 应存在: {e}", path.display()));
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let rest = line.strip_prefix('"').unwrap_or_else(|| panic!("行缺开引号: {line:?}"));
|
||||
let (key, value) = rest
|
||||
.split_once("\": ")
|
||||
.unwrap_or_else(|| panic!("行缺 `\": ` 分隔: {line:?}"));
|
||||
let unquote_value = |v: &str| {
|
||||
let inner = v
|
||||
.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.unwrap_or_else(|| panic!("值缺引号: {v:?}"));
|
||||
inner.replace("\\\"", "\"").replace("\\\\", "\\")
|
||||
};
|
||||
map.insert(key.to_string(), unquote_value(value));
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 两个语言文件键集合必须一致;每个 key 的 en/zh-CN 非空且
|
||||
/// 占位符两侧集合一致。
|
||||
#[test]
|
||||
fn yml_complete_and_placeholder_parity() {
|
||||
let en = load_locale_yml("en");
|
||||
let zh = load_locale_yml("zh-CN");
|
||||
assert!(!en.is_empty(), "en 语言包不能为空");
|
||||
let en_only: Vec<_> = en.keys().filter(|k| !zh.contains_key(*k)).collect();
|
||||
let zh_only: Vec<_> = zh.keys().filter(|k| !en.contains_key(*k)).collect();
|
||||
assert!(
|
||||
en_only.is_empty() && zh_only.is_empty(),
|
||||
"键集合不一致: en 独有 {en_only:?}, zh 独有 {zh_only:?}"
|
||||
);
|
||||
for (k, en_v) in &en {
|
||||
let zh_v = &zh[k];
|
||||
assert!(!en_v.trim().is_empty(), "en 为空: {k:?}");
|
||||
assert!(!zh_v.trim().is_empty(), "zh-CN 为空: {k:?}");
|
||||
assert_eq!(
|
||||
placeholders(zh_v),
|
||||
placeholders(en_v),
|
||||
"占位符两侧不一致: {k:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫描 src/ 全部源码里的 `t("…")` / `tf("…")` 字面量,
|
||||
/// 逐一断言语言包有词条——漏翻是测试失败,不再是静默回退。
|
||||
/// `(` 与 `"` 之间允许空白/换行(覆盖多行调用写法)。
|
||||
#[test]
|
||||
fn call_sites_covered_by_yml() {
|
||||
let map = load_locale_yml("en");
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut files = Vec::new();
|
||||
collect_rs(&root, &mut files);
|
||||
assert!(!files.is_empty(), "应扫描到源码文件");
|
||||
let mut missing = Vec::new();
|
||||
for f in files {
|
||||
if f.ends_with("i18n.rs") {
|
||||
continue;
|
||||
}
|
||||
let src = std::fs::read_to_string(&f).unwrap();
|
||||
for key in extract_t_literals(&src) {
|
||||
if !map.contains_key(&key) {
|
||||
missing.push(format!("{}: {key:?}", f.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"以下 t()/tf() 字面量没有语言包词条:\n{}",
|
||||
missing.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// 模型 `label()` 返回的 key 必须有真实(非自映射)的 en 词条。
|
||||
#[test]
|
||||
fn model_labels_have_real_translations() {
|
||||
let labels = [
|
||||
crate::model::ConnectionStatus::Disconnected.label(),
|
||||
crate::model::ConnectionStatus::Connecting.label(),
|
||||
crate::model::ConnectionStatus::Connected.label(),
|
||||
crate::model::ConnectionStatus::Error.label(),
|
||||
crate::model::Direction::Received.label(),
|
||||
crate::model::Direction::Published.label(),
|
||||
crate::model::ThemeModePref::System.label(),
|
||||
crate::model::ThemeModePref::Light.label(),
|
||||
crate::model::ThemeModePref::Dark.label(),
|
||||
];
|
||||
let missing: Vec<&str> = labels
|
||||
.iter()
|
||||
.filter(|l| translate(LOCALE_EN, l) == Cow::Borrowed(**l))
|
||||
.copied()
|
||||
.collect();
|
||||
assert!(missing.is_empty(), "模型 label 缺 en 词条: {missing:?}");
|
||||
}
|
||||
|
||||
fn collect_rs(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
collect_rs(&p, out);
|
||||
} else if p.extension().is_some_and(|x| x == "rs") {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 手写扫描(避免引入 regex 依赖):找 `::t(` / `::tf(` 后的字面量。
|
||||
/// `(` 与 `"` 之间允许空白/换行——覆盖多行调用的写法;`::t(` 针不会
|
||||
/// 命中 `::tf(`(f 不是空白,也到不了引号分支)。
|
||||
fn extract_t_literals(src: &str) -> Vec<String> {
|
||||
let b = src.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
for needle in [b"::tf(".as_slice(), b"::t(".as_slice()] {
|
||||
let mut i = 0;
|
||||
while let Some(rel) = find(b, i, needle) {
|
||||
let mut start = rel + needle.len();
|
||||
while start < b.len() && b[start].is_ascii_whitespace() {
|
||||
start += 1;
|
||||
}
|
||||
if start < b.len() && b[start] == b'"'
|
||||
&& let Some(lit) = read_string(b, start).0
|
||||
{
|
||||
out.push(lit);
|
||||
}
|
||||
i = rel + needle.len();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
|
||||
hay[from..]
|
||||
.windows(needle.len())
|
||||
.position(|w| w == needle)
|
||||
.map(|p| p + from)
|
||||
}
|
||||
|
||||
/// 从 `start`(首字节应为 `"`)读一个 Rust 字符串字面量,
|
||||
/// 处理转义,返回 (解码内容, 结束位置)。
|
||||
fn read_string(b: &[u8], start: usize) -> (Option<String>, usize) {
|
||||
if start >= b.len() || b[start] != b'"' {
|
||||
return (None, start + 1);
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut i = start + 1;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'\\' => {
|
||||
i += 1;
|
||||
if i < b.len() {
|
||||
match b[i] {
|
||||
b'"' => out.push('"'),
|
||||
b'\\' => out.push('\\'),
|
||||
other => {
|
||||
out.push('\\');
|
||||
out.push(other as char);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'"' => return (Some(out), i + 1),
|
||||
_ => {
|
||||
let mut end = i + 1;
|
||||
while end < b.len() && (b[end] & 0xC0) == 0x80 {
|
||||
end += 1;
|
||||
}
|
||||
out.push_str(&String::from_utf8_lossy(&b[i..end]));
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, i)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
//! GPUI 界面层。
|
||||
//! GPUI 界面层:主窗口(app)、连接工作区(connection_view)、
|
||||
//! 连接表单、对话框集合与共享小组件。
|
||||
|
||||
pub mod aliyun_dialog;
|
||||
pub mod app;
|
||||
pub mod connection_form;
|
||||
pub mod connection_view;
|
||||
pub mod dialogs;
|
||||
pub mod i18n;
|
||||
pub mod logo;
|
||||
pub mod presets_dialog;
|
||||
pub mod resource_dialog;
|
||||
pub mod settings_dialog;
|
||||
pub mod variables_dialog;
|
||||
pub mod widgets;
|
||||
|
||||
/// 统一使用全量 Lucide 图标(gpui-kit-assets 已随应用加载)。
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
//! 跨界面复用的小组件:枚举下拉、键值对编辑器、状态指示、字段行等。
|
||||
|
||||
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 {
|
||||
field_ex(label, control, false, None, Hsla::default())
|
||||
}
|
||||
|
||||
/// 字段行变体:`required` 时标签后追加红色 `*`;
|
||||
/// `error` 为 Some 时在控件下方渲染红字提示(`danger` 为主题错误色)。
|
||||
pub fn field_ex(
|
||||
label: impl Into<SharedString>,
|
||||
control: impl IntoElement,
|
||||
required: bool,
|
||||
error: Option<SharedString>,
|
||||
danger: Hsla,
|
||||
) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.min_w(px(0.))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.items_baseline()
|
||||
.child(div().text_xs().child(label.into()))
|
||||
.children(required.then(|| div().text_xs().text_color(danger).child("*"))),
|
||||
)
|
||||
.child(control)
|
||||
.children(error.map(|e| div().text_xs().text_color(danger).child(e)))
|
||||
}
|
||||
|
||||
// ─── 状态颜色 ────────────────────────────────────────────────────────────────
|
||||
|
||||
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)> {
|
||||
// key 统一 trim 并去重(保留最后一条):否则带首尾空白的 key
|
||||
// 永远无法被 {{key}} 模板引用,重复 key 也只有一条能生效。
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for (k, v) in self
|
||||
.rows
|
||||
.iter()
|
||||
.map(|(k, v)| (k.read(cx).value().to_string(), v.read(cx).value().to_string()))
|
||||
{
|
||||
let k = k.trim().to_string();
|
||||
if k.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match out.iter_mut().find(|(ek, _)| *ek == k) {
|
||||
Some(slot) => slot.1 = v,
|
||||
None => out.push((k, v)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
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,68 @@
|
||||
//! 基础部件:字段行布局、连接状态指示、时间格式化。
|
||||
|
||||
use gpui_kit::component::{h_flex, v_flex, ActiveTheme as _};
|
||||
use gpui_kit::{div, px, App, Hsla, IntoElement, ParentElement as _, SharedString, Styled as _};
|
||||
|
||||
use crate::model::ConnectionStatus;
|
||||
|
||||
/// 小号标签 + 控件的垂直字段容器(可选字段,无校验态)。
|
||||
pub fn field(label: impl Into<SharedString>, control: impl IntoElement) -> impl IntoElement {
|
||||
field_ex(label, control, false, None, Hsla::default())
|
||||
}
|
||||
|
||||
/// 字段行变体:`required` 时标签后追加红色 `*`;
|
||||
/// `error` 为 Some 时在控件下方渲染红字提示(`danger` 为主题错误色)。
|
||||
pub fn field_ex(
|
||||
label: impl Into<SharedString>,
|
||||
control: impl IntoElement,
|
||||
required: bool,
|
||||
error: Option<SharedString>,
|
||||
danger: Hsla,
|
||||
) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.min_w(px(0.))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.items_baseline()
|
||||
.child(div().text_xs().child(label.into()))
|
||||
.children(required.then(|| div().text_xs().text_color(danger).child("*"))),
|
||||
)
|
||||
.child(control)
|
||||
.children(error.map(|e| div().text_xs().text_color(danger).child(e)))
|
||||
}
|
||||
|
||||
// ─── 状态颜色 ────────────────────────────────────────────────────────────────
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// ─── 键值对编辑器 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! key/value 列表编辑器(MQTT 5 用户属性等)。
|
||||
|
||||
use gpui_kit::component::button::{ Button, ButtonVariants as _ };
|
||||
use gpui_kit::component::input::{ Input, InputState };
|
||||
use gpui_kit::component::{ h_flex, v_flex, Disableable as _, Sizable as _ };
|
||||
use crate::ui::IconName;
|
||||
use gpui_kit::{ App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div, px };
|
||||
|
||||
|
||||
/// 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)> {
|
||||
// key 统一 trim 并去重(保留最后一条):否则带首尾空白的 key
|
||||
// 永远无法被 {{key}} 模板引用,重复 key 也只有一条能生效。
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for (k, v) in self
|
||||
.rows
|
||||
.iter()
|
||||
.map(|(k, v)| (k.read(cx).value().to_string(), v.read(cx).value().to_string()))
|
||||
{
|
||||
let k = k.trim().to_string();
|
||||
if k.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match out.iter_mut().find(|(ek, _)| *ek == k) {
|
||||
Some(slot) => slot.1 = v,
|
||||
None => out.push((k, v)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
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(crate::ui::i18n::t("kv.add"))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.push(None, window, cx);
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 时间格式化 ──────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,9 @@
|
||||
//! 跨界面复用的小组件:枚举下拉、键值对编辑器、字段行、状态指示等。
|
||||
|
||||
mod common;
|
||||
mod kv_editor;
|
||||
mod select;
|
||||
|
||||
pub use common::*;
|
||||
pub use kv_editor::*;
|
||||
pub use select::*;
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 可搜索的枚举下拉:选项模型、委托与统一外观的 Select 包装。
|
||||
|
||||
use gpui_kit::component::searchable_list::{ SearchableListDelegate, SearchableListItem };
|
||||
use gpui_kit::component::select::{ Select, SelectState };
|
||||
use gpui_kit::component::IndexPath;
|
||||
use gpui_kit::{ Entity, SharedString, Window };
|
||||
|
||||
#[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 {
|
||||
eprintln!("[SEL] items_count={}", self.items.len());
|
||||
self.items.len()
|
||||
}
|
||||
fn item(&self, ix: IndexPath) -> Option<&Self::Item> {
|
||||
eprintln!("[SEL] item row={}", ix.row);
|
||||
self.items.get(ix.row)
|
||||
}
|
||||
fn position<V>(&self, value: &V) -> Option<IndexPath>
|
||||
where
|
||||
Self::Item: SearchableListItem<Value = V>,
|
||||
V: PartialEq,
|
||||
{
|
||||
// 框架契约:按值反查索引,set_selected_value 与过滤视图都依赖它。
|
||||
// 返回 None 会导致下拉显示空、保存写默认值,这里必须如实实现。
|
||||
self.items
|
||||
.iter()
|
||||
.position(|i| i.value() == value)
|
||||
.map(IndexPath::new)
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建字符串枚举下拉状态(在任意实体上下文里调用)。
|
||||
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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 字段行 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/// 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)
|
||||
}
|
||||
@@ -1,658 +0,0 @@
|
||||
//! OTA 自更新:以 Gitea Release 为更新源。
|
||||
//!
|
||||
//! 流程:`check_latest`(比较最新 Release 与当前版本)→ `download_and_verify`
|
||||
//! (下载到 exe 同目录临时文件并校验 sha256)→ `install_and_restart`
|
||||
//! (改名自替换后拉起新进程)。全程 HTTPS + sha256 校验。
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::Digest as _;
|
||||
|
||||
/// 更新源(自建 Gitea,仓库公开、附件可直接下载,无需认证)。
|
||||
pub const GITEA_URL: &str = "https://gitea.heavenlybook.cn";
|
||||
pub const PKG_OWNER: &str = "JoeAllen";
|
||||
pub const PKG_REPO: &str = "mqttx";
|
||||
|
||||
const HTTP_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// 发现的可用更新。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateInfo {
|
||||
/// 新版本号(不含 v 前缀),如 "1.0.1"
|
||||
pub version: String,
|
||||
/// Release 说明(Markdown 原文)
|
||||
pub notes: String,
|
||||
/// 附件名(判断 7z 压缩包 / 裸二进制)
|
||||
pub asset_name: String,
|
||||
/// 平台对应产物下载地址
|
||||
pub asset_url: String,
|
||||
/// sha256 校验文件地址(若 Release 提供了 `.sha256` 侧车)
|
||||
pub sha256_url: Option<String>,
|
||||
/// 产物字节数(API 提供,用于下载进度)
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// 当前版本。测试可用环境变量 `MQTTX_VERSION_OVERRIDE` 伪装旧版本,
|
||||
/// 验证"发现新版本"路径。
|
||||
pub fn current_version() -> &'static str {
|
||||
static VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
VERSION.get_or_init(|| {
|
||||
std::env::var("MQTTX_VERSION_OVERRIDE")
|
||||
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// 构建目标的平台三元组,需与 release.yml / publish 脚本的产物命名一致。
|
||||
pub fn platform_triple() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"x86_64-pc-windows-msvc"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"x86_64-apple-darwin"
|
||||
} else {
|
||||
"x86_64-unknown-linux-gnu"
|
||||
}
|
||||
}
|
||||
|
||||
/// 某版本解压后二进制的暂存名(与 CI 的命名约定 `mqttx-v<ver>-<triple>-mqttx[.exe]` 一致)。
|
||||
pub fn binary_name_for(version: &str) -> String {
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
format!("mqttx-v{}-{}-mqttx{}", version, platform_triple(), ext)
|
||||
}
|
||||
|
||||
/// 某版本的 7z 压缩附件名(打包端与此约定一致;内部为 [`binary_name_for`])。
|
||||
pub fn asset_name_for(version: &str) -> String {
|
||||
format!("{}.7z", binary_name_for(version))
|
||||
}
|
||||
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
// 整体上限:防止慢速滴流的下载无限占用阻塞线程
|
||||
.timeout(std::time::Duration::from_secs(10 * 60))
|
||||
.timeout_connect(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||
.timeout_read(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||
.build()
|
||||
}
|
||||
|
||||
fn http_get_json(url: &str) -> Result<serde_json::Value, String> {
|
||||
let resp = agent()
|
||||
.get(url)
|
||||
.call()
|
||||
.map_err(|e| format!("请求失败: {e}"))?;
|
||||
resp.into_json::<serde_json::Value>()
|
||||
.map_err(|e| format!("解析响应失败: {e}"))
|
||||
}
|
||||
|
||||
/// 去掉 v 前缀与预发布后缀(`-` 及之后),返回数字主体。
|
||||
fn release_part(v: &str) -> &str {
|
||||
let v = v.trim().trim_start_matches(['v', 'V']);
|
||||
match v.find('-') {
|
||||
Some(i) => &v[..i],
|
||||
None => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从版本字符串提取数字段(容忍 v 前缀;预发布后缀(`-` 之后)不参与比较)。
|
||||
pub fn parse_version(v: &str) -> Vec<u32> {
|
||||
release_part(v)
|
||||
.split('.')
|
||||
.filter_map(|s| s.parse::<u32>().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// a 是否比 b 新(按数字段逐位比较,缺失段视为 0)。
|
||||
/// 预发布版本(含 `-` 后缀)不会比同数字段的正式版新(semver 语义)。
|
||||
pub fn is_newer(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (parse_version(a), parse_version(b));
|
||||
let len = a.len().max(b.len());
|
||||
for i in 0..len {
|
||||
let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
|
||||
if x != y {
|
||||
return x > y;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 从 Release JSON 里挑选当前平台的产物与 sha256 侧车。
|
||||
/// 优先 7z 压缩包(体积小、下载快);Release 未提供 7z 时回退裸二进制
|
||||
/// (兼容旧发布方式)。
|
||||
pub fn pick_assets(rel: &serde_json::Value) -> Option<(String, String, u64, Option<String>)> {
|
||||
let assets = rel.get("assets")?.as_array()?;
|
||||
let want_ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
let triple = platform_triple();
|
||||
|
||||
// (后缀过滤器, 说明):第一轮 7z,第二轮裸二进制
|
||||
for suffix in [".7z", want_ext] {
|
||||
for a in assets {
|
||||
let Some(name) = a.get("name").and_then(|n| n.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !name.contains(triple)
|
||||
|| !name.ends_with(suffix)
|
||||
|| name.ends_with(".sha256")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 7z 轮要排除裸二进制(windows 裸 exe 不以 .7z 结尾,天然互斥)
|
||||
if suffix == ".7z" && !name.ends_with(&format!("{want_ext}.7z")) && !want_ext.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(url) = a.get("browser_download_url").and_then(|u| u.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let url = url.to_string();
|
||||
let size = a.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
|
||||
// 侧车命名:<asset>.sha256
|
||||
let sha = assets
|
||||
.iter()
|
||||
.find(|b| {
|
||||
b.get("name").and_then(|n| n.as_str()) == Some(&format!("{name}.sha256"))
|
||||
})
|
||||
.and_then(|b| b.get("browser_download_url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(|s| s.to_string());
|
||||
return Some((name.to_string(), url, size, sha));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 检查更新。返回 Ok(None) 表示已是最新。
|
||||
pub fn check_latest() -> Result<Option<UpdateInfo>, String> {
|
||||
let base = format!("{GITEA_URL}/api/v1/repos/{PKG_OWNER}/{PKG_REPO}/releases");
|
||||
// 优先 latest 端点;旧版 Gitea 没有时回退到列表取第一个非草稿/预发布
|
||||
let rel = match http_get_json(&format!("{base}/latest")) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
let list = http_get_json(&format!("{base}?limit=5"))?;
|
||||
let first = list
|
||||
.as_array()
|
||||
.and_then(|l| l.iter().find(|r| {
|
||||
r.get("draft").and_then(|d| d.as_bool()) == Some(false)
|
||||
&& r.get("prerelease").and_then(|d| d.as_bool()) == Some(false)
|
||||
}))
|
||||
.ok_or_else(|| "没有可用 Release".to_string())?;
|
||||
first.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let tag = rel
|
||||
.get("tag_name")
|
||||
.and_then(|t| t.as_str())
|
||||
.ok_or_else(|| "Release 缺少 tag_name".to_string())?;
|
||||
if !is_newer(tag, current_version()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((name, url, size, sha)) = pick_assets(&rel) else {
|
||||
return Err(format!("Release {tag} 没有当前平台({})的产物", platform_triple()));
|
||||
};
|
||||
Ok(Some(UpdateInfo {
|
||||
version: tag.trim_start_matches('v').to_string(),
|
||||
notes: rel
|
||||
.get("body")
|
||||
.and_then(|b| b.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
asset_name: name,
|
||||
asset_url: url,
|
||||
sha256_url: sha,
|
||||
size,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 已下载并校验、等待安装的更新包(存放在数据目录的暂存区)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StagedUpdate {
|
||||
pub version: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// 暂存目录:数据目录下的 `updates/`,重启不丢失。
|
||||
fn stage_dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("MQTTX-GPUI")
|
||||
.join("updates");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("创建暂存目录失败: {e}"))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// 检查更新并静默下载到暂存区(供启动自动检查使用)。
|
||||
/// 返回 Ok(None) 表示已是最新;错误仅记录、不打扰用户。
|
||||
pub fn check_and_download() -> Result<Option<StagedUpdate>, String> {
|
||||
let Some(info) = check_latest()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// 已暂存同版本则不重复下载
|
||||
if let Some(staged) = load_staged()
|
||||
&& staged.version == info.version
|
||||
{
|
||||
return Ok(Some(staged));
|
||||
}
|
||||
download_and_stage(&info, &|_, _| {}).map(Some)
|
||||
}
|
||||
|
||||
/// 下载并校验到暂存区,返回待安装的更新。
|
||||
///
|
||||
/// 附件为 7z 压缩包:下载 → 校验 → 解压出二进制暂存(压缩包随即删除)。
|
||||
/// 回退路径(裸二进制附件)直接校验暂存。
|
||||
/// `on_progress(downloaded, total)` 在阻塞线程上回调,total 为 0 表示未知。
|
||||
pub fn download_and_stage(
|
||||
info: &UpdateInfo,
|
||||
on_progress: &dyn Fn(u64, u64),
|
||||
) -> Result<StagedUpdate, String> {
|
||||
let dir = stage_dir()?;
|
||||
let binary_name = binary_name_for(&info.version);
|
||||
let is_7z = info.asset_name.ends_with(".7z");
|
||||
let final_name = if is_7z {
|
||||
asset_name_for(&info.version)
|
||||
} else {
|
||||
binary_name.clone()
|
||||
};
|
||||
// tmp 名加入毫秒时间戳:同 pid 并发下载不共用文件
|
||||
let tmp = dir.join(format!(
|
||||
"{}.{}.{}.tmp",
|
||||
final_name,
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
let dst = dir.join(&final_name);
|
||||
|
||||
if let Err(e) = download_to(info, &tmp, on_progress) {
|
||||
// 失败路径统一清理临时文件
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// 清掉其他版本的残留暂存文件,避免堆积与误装旧版
|
||||
let staged_path = if is_7z {
|
||||
// 解压到暂存目录,取出二进制后删掉压缩包
|
||||
if let Err(e) = sevenz_rust::decompress_file(&tmp, &dir) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(format!("解压更新包失败: {e}"));
|
||||
}
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let extracted = dir.join(&binary_name);
|
||||
if !extracted.exists() {
|
||||
return Err(format!(
|
||||
"更新包内缺少 {},压缩包内容异常",
|
||||
binary_name
|
||||
));
|
||||
}
|
||||
// 确认解压产物后再清理其他版本,最后返回二进制路径
|
||||
remove_other_staged(&dir, &extracted);
|
||||
extracted
|
||||
} else {
|
||||
remove_other_staged(&dir, &dst);
|
||||
std::fs::rename(&tmp, &dst).map_err(|e| format!("暂存失败: {e}"))?;
|
||||
dst
|
||||
};
|
||||
Ok(StagedUpdate {
|
||||
version: info.version.clone(),
|
||||
path: staged_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn download_to(info: &UpdateInfo, tmp: &std::path::Path, on_progress: &dyn Fn(u64, u64)) -> Result<(), String> {
|
||||
let resp = agent()
|
||||
.get(&info.asset_url)
|
||||
.call()
|
||||
.map_err(|e| format!("下载失败: {e}"))?;
|
||||
let mut reader = resp.into_reader();
|
||||
let total = info.size;
|
||||
let mut file = std::fs::File::create(tmp).map_err(|e| format!("创建临时文件失败: {e}"))?;
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
loop {
|
||||
let n = reader.read(&mut buf).map_err(|e| format!("下载中断: {e}"))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
std::io::Write::write_all(&mut file, &buf[..n])
|
||||
.map_err(|e| format!("写入临时文件失败: {e}"))?;
|
||||
hasher.update(&buf[..n]);
|
||||
downloaded += n as u64;
|
||||
on_progress(downloaded, total);
|
||||
}
|
||||
std::io::Write::flush(&mut file).ok();
|
||||
drop(file);
|
||||
|
||||
// 传输完整性:API 提供了大小却没下满/超量,视为损坏(read 返回 0
|
||||
// 无法区分干净结束与连接提前断开,必须与预期大小对账)
|
||||
if total > 0 && downloaded != total {
|
||||
return Err(format!(
|
||||
"下载不完整:期望 {total} 字节,实际 {downloaded} 字节"
|
||||
));
|
||||
}
|
||||
|
||||
// sha256 校验
|
||||
if let Some(expect) = fetch_expected_sha256(info)? {
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if !actual.eq_ignore_ascii_case(&expect) {
|
||||
return Err(format!(
|
||||
"校验失败:期望 sha256 {expect},实际 {actual}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除暂存目录中除 `keep` 外的其他版本暂存文件。
|
||||
fn remove_other_staged(dir: &std::path::Path, keep: &std::path::Path) {
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p == *keep {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with("mqttx-v")
|
||||
&& name.contains(platform_triple())
|
||||
&& !name.ends_with(".tmp")
|
||||
&& !name.ends_with(".sha256")
|
||||
&& !name.ends_with(".7z")
|
||||
{
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 `.sha256` 侧车内容,返回其中的哈希值。没有侧车时返回 None。
|
||||
fn fetch_expected_sha256(info: &UpdateInfo) -> Result<Option<String>, String> {
|
||||
let Some(url) = &info.sha256_url else {
|
||||
return Ok(None);
|
||||
};
|
||||
let text = agent()
|
||||
.get(url)
|
||||
.call()
|
||||
.map_err(|e| format!("获取校验文件失败: {e}"))?
|
||||
.into_string()
|
||||
.map_err(|e| format!("读取校验文件失败: {e}"))?;
|
||||
// 常见格式:<hex> <filename> 或纯 hex
|
||||
let hex = text
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
Ok(Some(hex))
|
||||
} else {
|
||||
Err("校验文件格式异常".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// 从暂存文件名解析版本号(需匹配当前平台的命名约定;`.tmp` 不算)。
|
||||
/// 预发布后缀原样保留。
|
||||
pub fn parse_staged_name(name: &str) -> Option<String> {
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
let suffix = format!("-{}-mqttx{ext}", platform_triple());
|
||||
if name.contains(".tmp") {
|
||||
return None;
|
||||
}
|
||||
name.strip_prefix("mqttx-v")?
|
||||
.strip_suffix(&suffix)
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// 扫描暂存区,返回待安装的更新(文件存在且比当前版本新才算)。
|
||||
/// 多个版本并存时取最新版本(readdir 顺序不保证有序)。
|
||||
pub fn load_staged() -> Option<StagedUpdate> {
|
||||
let dir = stage_dir().ok()?;
|
||||
let mut best: Option<StagedUpdate> = None;
|
||||
for e in std::fs::read_dir(&dir).ok()?.flatten() {
|
||||
if !e.path().is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy().to_string();
|
||||
let Some(version) = parse_staged_name(&name) else {
|
||||
continue;
|
||||
};
|
||||
if !is_newer(&version, current_version()) {
|
||||
continue;
|
||||
}
|
||||
if best.as_ref().is_none_or(|b| is_newer(&version, &b.version)) {
|
||||
best = Some(StagedUpdate {
|
||||
version,
|
||||
path: e.path(),
|
||||
});
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// 安装暂存的更新:替换自身并拉起新进程(旧文件改名 `.old`,
|
||||
/// 新进程启动时由 [`cleanup_old`] 清理)。成功后调用方应退出当前进程。
|
||||
pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> {
|
||||
let cur = std::env::current_exe().map_err(|e| format!("无法定位自身: {e}"))?;
|
||||
let mut old_name = cur.clone().into_os_string();
|
||||
old_name.push(".old");
|
||||
let old = PathBuf::from(old_name);
|
||||
// 旧残留先清掉,避免改名失败
|
||||
let _ = std::fs::remove_file(&old);
|
||||
std::fs::rename(&cur, &old).map_err(|e| format!("替换失败(可能无写入权限): {e}"))?;
|
||||
if let Err(e) = std::fs::rename(&staged.path, &cur) {
|
||||
// 回滚,尽量保住当前可执行文件
|
||||
let rollback = std::fs::rename(&old, &cur);
|
||||
return Err(format!(
|
||||
"替换失败: {e};{}",
|
||||
rollback_result(rollback, &old)
|
||||
));
|
||||
}
|
||||
if let Err(e) = std::process::Command::new(&cur).spawn() {
|
||||
// 新文件无法启动(常见:杀毒软件锁定刚落地的文件):恢复旧版本
|
||||
let _ = std::fs::remove_file(&cur);
|
||||
let rollback = std::fs::rename(&old, &cur);
|
||||
return Err(format!(
|
||||
"启动新版本失败: {e};{}",
|
||||
rollback_result(rollback, &old)
|
||||
));
|
||||
}
|
||||
clear_install_consent();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 描述回滚结果;回滚失败时提示 `.old` 备份仍保留在原处。
|
||||
fn rollback_result(rollback: std::io::Result<()>, old: &std::path::Path) -> String {
|
||||
match rollback {
|
||||
Ok(()) => "已恢复原版本".to_string(),
|
||||
Err(re) => format!(
|
||||
"回滚失败({re}),原版本备份保留在 {}",
|
||||
old.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装同意标记:仅当用户在更新对话框明确选择「下次启动安装」后才写入,
|
||||
/// 启动时据此自动安装——用户未选择的更新不会被强制替换。
|
||||
fn consent_path() -> Result<PathBuf, String> {
|
||||
stage_dir().map(|d| d.join("install-consent.txt"))
|
||||
}
|
||||
|
||||
/// 记录对指定版本的安装同意。
|
||||
pub fn mark_install_consent(version: &str) {
|
||||
if let Ok(p) = consent_path() {
|
||||
let _ = std::fs::write(p, version);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除安装同意标记。
|
||||
pub fn clear_install_consent() {
|
||||
if let Ok(p) = consent_path() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// 同意标记是否与暂存的版本匹配(版本不匹配视为未同意)。
|
||||
pub fn install_consent_matches(staged_version: &str) -> bool {
|
||||
consent_path()
|
||||
.ok()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.map(|v| v.trim() == staged_version)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 用户拒绝本次更新:删除暂存包与同意标记。
|
||||
pub fn discard_staged(staged: &StagedUpdate) {
|
||||
let _ = std::fs::remove_file(&staged.path);
|
||||
clear_install_consent();
|
||||
}
|
||||
|
||||
/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。
|
||||
pub fn cleanup_old() {
|
||||
if let Ok(cur) = std::env::current_exe()
|
||||
&& let Some(dir) = cur.parent()
|
||||
&& let Ok(entries) = std::fs::read_dir(dir)
|
||||
{
|
||||
for e in entries.flatten() {
|
||||
// Windows 文件系统不区分大小写,统一按小写匹配
|
||||
let name = e.file_name();
|
||||
let lower = name.to_string_lossy().to_lowercase();
|
||||
if (lower.ends_with(".old") && lower.starts_with("mqttx"))
|
||||
|| lower.ends_with(".update.tmp")
|
||||
{
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
// 暂存目录里中断的 .tmp。跳过最近修改的文件,避免误删
|
||||
// 并行实例正在写入的临时文件。
|
||||
if let Ok(dir) = stage_dir()
|
||||
&& let Ok(entries) = std::fs::read_dir(&dir)
|
||||
{
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name();
|
||||
if !name.to_string_lossy().ends_with(".tmp") {
|
||||
continue;
|
||||
}
|
||||
let recent = std::fs::metadata(e.path())
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.elapsed().ok())
|
||||
.is_some_and(|age| age < std::time::Duration::from_secs(10 * 60));
|
||||
if recent {
|
||||
continue;
|
||||
}
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 单元测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_compare() {
|
||||
assert!(is_newer("1.0.1", "1.0.0"));
|
||||
assert!(is_newer("v1.1.0", "1.0.9"));
|
||||
assert!(is_newer("2.0", "1.9.9"));
|
||||
// 预发布版本不比同数字段的正式版新(semver 语义)
|
||||
assert!(!is_newer("1.0.0-beta", "1.0.0"));
|
||||
assert!(!is_newer("1.0.0-beta.2", "1.0.0"));
|
||||
assert!(!is_newer("1.0.0", "1.0.0"));
|
||||
assert!(!is_newer("0.9", "1.0.0"));
|
||||
// 更高版本号的预发布仍是更新(1.0.1-beta > 1.0.0)
|
||||
assert!(is_newer("1.0.1-beta", "1.0.0"));
|
||||
assert_eq!(parse_version("v1.2.3-beta"), vec![1, 2, 3]);
|
||||
assert_eq!(parse_version("v1.2.3"), vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn staged_name_parsing_keeps_prerelease_suffix() {
|
||||
let triple = platform_triple();
|
||||
assert_eq!(
|
||||
parse_staged_name(&format!("mqttx-v1.0.3-{triple}-mqttx.exe")),
|
||||
Some("1.0.3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_staged_name(&format!("mqttx-v1.0.3-beta.1-{triple}-mqttx.exe")),
|
||||
Some("1.0.3-beta.1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_staged_name(&format!("mqttx-v1.0.3-{triple}-mqttx.exe.1234.56.tmp")),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_staged_name("其他文件.exe"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_naming_matches_ci_convention() {
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
// 附件为 7z 压缩包;解压出的二进制保留原命名
|
||||
assert_eq!(
|
||||
asset_name_for("1.0.1"),
|
||||
format!("mqttx-v1.0.1-{triple}-mqttx{ext}.7z", triple = platform_triple())
|
||||
);
|
||||
assert_eq!(
|
||||
binary_name_for("1.0.1"),
|
||||
format!("mqttx-v1.0.1-{triple}-mqttx{ext}", triple = platform_triple())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_assets_prefers_7z_over_raw_binary() {
|
||||
let rel: serde_json::Value = serde_json::json!({
|
||||
"tag_name": "v1.0.1",
|
||||
"assets": [
|
||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe", "size": 21000000,
|
||||
"browser_download_url": "https://x/win.exe"},
|
||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65,
|
||||
"browser_download_url": "https://x/win.exe.sha256"},
|
||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.7z", "size": 9000000,
|
||||
"browser_download_url": "https://x/win.7z"},
|
||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.7z.sha256", "size": 65,
|
||||
"browser_download_url": "https://x/win.7z.sha256"}
|
||||
]
|
||||
});
|
||||
let (name, url, size, sha) = pick_assets(&rel).expect("应选中 7z");
|
||||
assert!(name.ends_with(".7z"), "应优先 7z 压缩包,实际 {name}");
|
||||
assert_eq!(url, "https://x/win.7z");
|
||||
assert_eq!(size, 9000000);
|
||||
assert_eq!(sha.as_deref(), Some("https://x/win.7z.sha256"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_assets_falls_back_to_raw_binary() {
|
||||
// 旧发布方式只有裸二进制:仍可被选中
|
||||
let rel: serde_json::Value = serde_json::json!({
|
||||
"tag_name": "v1.0.1",
|
||||
"assets": [
|
||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe", "size": 19132928,
|
||||
"browser_download_url": "https://x/win"},
|
||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65,
|
||||
"browser_download_url": "https://x/win.sha256"}
|
||||
]
|
||||
});
|
||||
let (name, url, _size, sha) = pick_assets(&rel).expect("应回退裸二进制");
|
||||
assert!(name.ends_with(".exe"));
|
||||
assert_eq!(url, "https://x/win");
|
||||
assert_eq!(sha.as_deref(), Some("https://x/win.sha256"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_assets_skips_sidecars_and_other_platforms() {
|
||||
let rel: serde_json::Value = serde_json::json!({
|
||||
"tag_name": "v1.0.1",
|
||||
"assets": [
|
||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65,
|
||||
"browser_download_url": "https://x/side"},
|
||||
]
|
||||
});
|
||||
if cfg!(windows) {
|
||||
// 只有侧车、没有主体 → 不应选中侧车
|
||||
assert!(pick_assets(&rel).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! 更新检查:访问 GitHub Releases,比较版本并挑选本平台资产。
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) fn agent() -> ureq::Agent {
|
||||
ureq::Agent::new_with_config(
|
||||
ureq::Agent::config_builder()
|
||||
// GitHub API 要求请求携带 User-Agent(缺省 ureq/x.y 也能过,
|
||||
// 但标识应用更符合其规范,也便于服务端区分流量)
|
||||
.user_agent("mqttx-ota")
|
||||
// 整体上限:防止慢速滴流的下载无限占用阻塞线程
|
||||
.timeout_global(Some(std::time::Duration::from_secs(10 * 60)))
|
||||
.timeout_connect(Some(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)))
|
||||
.timeout_recv_response(Some(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn http_get_json(url: &str) -> Result<serde_json::Value, String> {
|
||||
let mut body = agent()
|
||||
.get(url)
|
||||
.call()
|
||||
.map_err(|e| format!("请求失败: {e}"))?
|
||||
.into_body();
|
||||
body.read_json::<serde_json::Value>()
|
||||
.map_err(|e| format!("解析响应失败: {e}"))
|
||||
}
|
||||
|
||||
|
||||
pub fn pick_assets(rel: &serde_json::Value) -> Option<(String, String, u64, Option<String>)> {
|
||||
let assets = rel.get("assets")?.as_array()?;
|
||||
let triple = platform_triple();
|
||||
// 产物命名约定:`mqttx-v<ver>-<triple>-mqttx[.exe]`(压缩包再加 `.7z`)。
|
||||
// 必须按固定后缀精确匹配:非 Windows 下扩展名为空串时 `ends_with("")` 恒真,
|
||||
// 任何含三元组的附件(.tar.gz/.deb/.txt)都会被当成可执行文件下载并替换自身。
|
||||
let bin_name = if cfg!(windows) { "mqttx.exe" } else { "mqttx" };
|
||||
|
||||
// 第一轮 7z 压缩包,第二轮裸二进制
|
||||
for suffix in [".7z", ""] {
|
||||
for a in assets {
|
||||
let Some(name) = a.get("name").and_then(|n| n.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !name.contains(triple.as_str())
|
||||
|| !name.ends_with(&format!("{bin_name}{suffix}"))
|
||||
|| name.ends_with(".sha256")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(url) = a.get("browser_download_url").and_then(|u| u.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let url = url.to_string();
|
||||
let size = a.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
|
||||
// 侧车命名:<asset>.sha256
|
||||
let sha = assets
|
||||
.iter()
|
||||
.find(|b| {
|
||||
b.get("name").and_then(|n| n.as_str()) == Some(&format!("{name}.sha256"))
|
||||
})
|
||||
.and_then(|b| b.get("browser_download_url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.map(|s| s.to_string());
|
||||
return Some((name.to_string(), url, size, sha));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 检查更新。返回 Ok(None) 表示已是最新。
|
||||
pub fn check_latest() -> Result<Option<UpdateInfo>, String> {
|
||||
// API 主路径信息最全(notes/资产清单/大小),但 api.github.com 未认证配额
|
||||
// 仅 60 次/小时/IP,共享出口 IP(VPN/办公 NAT)经常打满 —— 打满或不可达时
|
||||
// 退到 releases 页面重定向发现(无配额),完整性校验不受影响。
|
||||
match check_latest_via_api() {
|
||||
Ok(v) => Ok(v),
|
||||
Err(api_err) => match check_latest_via_redirect() {
|
||||
Ok(v) => Ok(v),
|
||||
Err(_) => Err(api_err),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn check_latest_via_api() -> Result<Option<UpdateInfo>, String> {
|
||||
let base = RELEASES_API;
|
||||
// 优先 latest 端点(GitHub 恒可用);回退到列表取第一个非草稿/预发布,作容错
|
||||
let rel = match http_get_json(&format!("{base}/latest")) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
let list = http_get_json(&format!("{base}?per_page=5"))?;
|
||||
let first = list
|
||||
.as_array()
|
||||
.and_then(|l| l.iter().find(|r| {
|
||||
r.get("draft").and_then(|d| d.as_bool()) == Some(false)
|
||||
&& r.get("prerelease").and_then(|d| d.as_bool()) == Some(false)
|
||||
}))
|
||||
.ok_or_else(|| "没有可用 Release".to_string())?;
|
||||
first.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let tag = rel
|
||||
.get("tag_name")
|
||||
.and_then(|t| t.as_str())
|
||||
.ok_or_else(|| "Release 缺少 tag_name".to_string())?;
|
||||
if !is_newer(tag, current_version()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((name, url, size, sha)) = pick_assets(&rel) else {
|
||||
return Err(format!("Release {tag} 没有当前平台({})的产物", platform_triple()));
|
||||
};
|
||||
Ok(Some(UpdateInfo {
|
||||
version: tag.trim_start_matches('v').to_string(),
|
||||
notes: rel
|
||||
.get("body")
|
||||
.and_then(|b| b.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
asset_name: name,
|
||||
asset_url: url,
|
||||
sha256_url: sha,
|
||||
size,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 兜底:跟踪 `releases/latest` 的 302,从最终 URL 提取最新 tag。
|
||||
///
|
||||
/// github.com 页面不受 API 配额限制。代价是拿不到 Release notes 与资产清单:
|
||||
/// notes 置空、大小置 0(进度条显示未知大小,大小对账自动跳过),
|
||||
/// 下载 URL 与 `.sha256` 侧车按命名约定构造,sha256 校验仍然强制执行。
|
||||
fn check_latest_via_redirect() -> Result<Option<UpdateInfo>, String> {
|
||||
use ureq::ResponseExt as _;
|
||||
let resp = agent()
|
||||
.get(&format!("{RELEASES_PAGE}/latest"))
|
||||
.call()
|
||||
.map_err(|e| format!("页面请求失败: {e}"))?;
|
||||
let uri = resp.get_uri().to_string();
|
||||
let Some(tag) = tag_from_releases_url(&uri) else {
|
||||
return Err(format!("页面 URL 异常: {uri}"));
|
||||
};
|
||||
if !is_newer(tag, current_version()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let version = tag.trim_start_matches('v');
|
||||
let triple = platform_triple();
|
||||
let bin = if cfg!(windows) { "mqttx.exe" } else { "mqttx" };
|
||||
// 与 publish 脚本/工作流的产物命名约定一致(pick_assets 同款匹配规则)
|
||||
let asset_base = format!("mqttx-v{version}-{triple}-{bin}");
|
||||
let asset = format!("{asset_base}.7z");
|
||||
Ok(Some(UpdateInfo {
|
||||
version: version.to_string(),
|
||||
notes: String::new(),
|
||||
asset_name: asset.clone(),
|
||||
asset_url: format!("{RELEASES_PAGE}/download/{tag}/{asset}"),
|
||||
sha256_url: Some(format!("{RELEASES_PAGE}/download/{tag}/{asset}.sha256")),
|
||||
size: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 从 Releases 页面 URL 提取 tag(如 `.../releases/tag/v1.0.1` → `v1.0.1`)。
|
||||
fn tag_from_releases_url(uri: &str) -> Option<&str> {
|
||||
let tag = uri.split("/releases/tag/").nth(1)?;
|
||||
let tag = tag.trim_end_matches('/');
|
||||
// 防御:tag 不含路径段,且必须以 v 开头(约定)
|
||||
(tag.starts_with('v') && !tag.contains('/')).then_some(tag)
|
||||
}
|
||||
|
||||
/// 自动检查的节流窗口:24h 内启动不再重复请求 GitHub(官方 auto-update
|
||||
/// 指南建议缓存上次检查时间)。手动「检查更新」不受此限制。
|
||||
const AUTO_CHECK_INTERVAL_SECS: i64 = 24 * 60 * 60;
|
||||
|
||||
fn stamp_path() -> std::path::PathBuf {
|
||||
crate::store::Storage::new().dir().join(".last-update-check")
|
||||
}
|
||||
|
||||
fn last_check_recent() -> bool {
|
||||
let Ok(meta) = std::fs::metadata(stamp_path()) else { return false };
|
||||
let Ok(modified) = meta.modified() else { return false };
|
||||
modified
|
||||
.elapsed()
|
||||
.map(|d| d.as_secs() < AUTO_CHECK_INTERVAL_SECS as u64)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn touch_check_stamp() {
|
||||
let _ = std::fs::write(stamp_path(), chrono::Local::now().to_rfc3339());
|
||||
}
|
||||
|
||||
/// 返回 Ok(None) 表示已是最新;错误仅记录、不打扰用户。
|
||||
/// 走 24h 节流:距上次自动检查不足一天时直接跳过(返回 None)。
|
||||
pub fn check_and_download() -> Result<Option<StagedUpdate>, String> {
|
||||
if last_check_recent() {
|
||||
return Ok(None);
|
||||
}
|
||||
touch_check_stamp();
|
||||
let Some(info) = check_latest()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// 已暂存同版本则不重复下载
|
||||
if let Some(staged) = load_staged()
|
||||
&& staged.version == info.version
|
||||
{
|
||||
return Ok(Some(staged));
|
||||
}
|
||||
download_and_stage(&info, &|_, _| {}).map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tag_extraction_from_releases_url() {
|
||||
// /releases/latest 302 后的最终 URL 形态
|
||||
assert_eq!(
|
||||
tag_from_releases_url("https://github.com/LiJoeAllen/mqttx/releases/tag/v1.0.1"),
|
||||
Some("v1.0.1")
|
||||
);
|
||||
assert_eq!(
|
||||
tag_from_releases_url("https://github.com/LiJoeAllen/mqttx/releases/tag/v1.2.3-rc.1/"),
|
||||
Some("v1.2.3-rc.1")
|
||||
);
|
||||
// 非 tag 页面 / 异常形态一律拒绝
|
||||
assert_eq!(
|
||||
tag_from_releases_url("https://github.com/LiJoeAllen/mqttx/releases"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
tag_from_releases_url("https://github.com/LiJoeAllen/mqttx/releases/tag/1.0.0"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
tag_from_releases_url("https://github.com/LiJoeAllen/mqttx/releases/tag/v1.0.0/extra"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//! 更新执行:下载暂存(7z 优先)→ sha256 校验 → 重启自替换安装,以及同意标记与旧档清理。
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
||||
use super::check::agent;
|
||||
use super::*;
|
||||
|
||||
/// 暂存目录:数据目录下的 `updates/`,重启不丢失。
|
||||
fn stage_dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("MQTTX-GPUI")
|
||||
.join("updates");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("创建暂存目录失败: {e}"))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
|
||||
/// 下载并校验到暂存区,返回待安装的更新。
|
||||
///
|
||||
/// 附件为 7z 压缩包:下载 → 校验 → 解压出二进制暂存(压缩包随即删除)。
|
||||
/// 回退路径(裸二进制附件)直接校验暂存。
|
||||
/// `on_progress(downloaded, total)` 在阻塞线程上回调,total 为 0 表示未知。
|
||||
pub fn download_and_stage(
|
||||
info: &UpdateInfo,
|
||||
on_progress: &dyn Fn(u64, u64),
|
||||
) -> Result<StagedUpdate, String> {
|
||||
let dir = stage_dir()?;
|
||||
let binary_name = binary_name_for(&info.version);
|
||||
let is_7z = info.asset_name.ends_with(".7z");
|
||||
let final_name = if is_7z {
|
||||
asset_name_for(&info.version)
|
||||
} else {
|
||||
binary_name.clone()
|
||||
};
|
||||
// tmp 名加入毫秒时间戳:同 pid 并发下载不共用文件
|
||||
let tmp = dir.join(format!(
|
||||
"{}.{}.{}.tmp",
|
||||
final_name,
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
let dst = dir.join(&final_name);
|
||||
|
||||
if let Err(e) = download_to(info, &tmp, on_progress) {
|
||||
// 失败路径统一清理临时文件
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// 清掉其他版本的残留暂存文件,避免堆积与误装旧版
|
||||
let staged_path = if is_7z {
|
||||
// 安全前置校验:sevenz-rust 0.6.1 的 decompress_file 直接把 entry.name()
|
||||
// 拼到目标目录(de_funcs.rs: dest.join(entry.name())),不校验 ".."、
|
||||
// 绝对路径与盘符,还会为条目创建父目录,恶意归档足以越界写文件。
|
||||
// 打包端约定归档内只有 binary_name 一个条目,因此这里要求完全一致。
|
||||
if let Err(e) = verify_archive_entries(&tmp, &binary_name) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
// 解压到暂存目录,取出二进制后删掉压缩包
|
||||
if let Err(e) = sevenz_rust::decompress_file(&tmp, &dir) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(format!("解压更新包失败: {e}"));
|
||||
}
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let extracted = dir.join(&binary_name);
|
||||
if !extracted.exists() {
|
||||
return Err(format!(
|
||||
"更新包内缺少 {},压缩包内容异常",
|
||||
binary_name
|
||||
));
|
||||
}
|
||||
// 确认解压产物后再清理其他版本,最后返回二进制路径
|
||||
remove_other_staged(&dir, &extracted);
|
||||
extracted
|
||||
} else {
|
||||
remove_other_staged(&dir, &dst);
|
||||
std::fs::rename(&tmp, &dst).map_err(|e| format!("暂存失败: {e}"))?;
|
||||
dst
|
||||
};
|
||||
// 非 Windows 平台补可执行位:File::create 落盘默认 0644,rename 保留权限,
|
||||
// 直接 spawn 会 EACCES —— 这正是 Linux/macOS 自更新必定失败的原因。
|
||||
if let Err(e) = make_executable(&staged_path) {
|
||||
let _ = std::fs::remove_file(&staged_path);
|
||||
return Err(e);
|
||||
}
|
||||
// 记录最终二进制的哈希:安装同意标记与安装前复核都绑定它
|
||||
let sha256 = file_sha256(&staged_path)?;
|
||||
Ok(StagedUpdate {
|
||||
version: info.version.clone(),
|
||||
path: staged_path,
|
||||
sha256,
|
||||
})
|
||||
}
|
||||
|
||||
/// 计算文件的 sha256(小写 hex)。
|
||||
fn file_sha256(path: &Path) -> Result<String, String> {
|
||||
use sha2::Digest as _;
|
||||
let mut file =
|
||||
std::fs::File::open(path).map_err(|e| format!("打开 {} 失败: {e}", path.display()))?;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = file
|
||||
.read(&mut buf)
|
||||
.map_err(|e| format!("读取 {} 失败: {e}", path.display()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
// digest 0.11 的 Output 不再实现 LowerHex,手动转 hex
|
||||
Ok(hasher.finalize().iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
/// 校验 7z 归档的条目清单(只读归档头,不解压数据)。
|
||||
///
|
||||
/// 依赖 sevenz-rust 0.6.1 的 `decompress_file` 会把 `entry.name()` 直接拼到目标目录
|
||||
/// (`de_funcs.rs: dest.join(entry.name())`),既不校验 `..`、绝对路径与盘符,还会为
|
||||
/// 条目自动创建父目录——带恶意条目的压缩包足以写出暂存目录之外(如启动目录),
|
||||
/// 随后被 `install_staged` 执行。这里在解压**之前**按归档头校验条目:必须恰好一个,
|
||||
/// 且名字与约定文件名完全一致,任何偏差都拒绝(fail-closed)。
|
||||
fn verify_archive_entries(archive: &Path, expected: &str) -> Result<(), String> {
|
||||
let file = std::fs::File::open(archive).map_err(|e| format!("打开更新包失败: {e}"))?;
|
||||
let len = file
|
||||
.metadata()
|
||||
.map_err(|e| format!("读取更新包大小失败: {e}"))?
|
||||
.len();
|
||||
let reader = sevenz_rust::SevenZReader::new(file, len, sevenz_rust::Password::empty())
|
||||
.map_err(|e| format!("解析更新包失败: {e}"))?;
|
||||
let files = &reader.archive().files;
|
||||
if files.len() != 1 {
|
||||
return Err(format!(
|
||||
"更新包应恰好包含 1 个条目 '{expected}',实际 {} 个",
|
||||
files.len()
|
||||
));
|
||||
}
|
||||
let entry = &files[0];
|
||||
let name = entry.name();
|
||||
if entry.is_directory() || name != expected || name.contains('/') || name.contains('\\') {
|
||||
return Err(format!(
|
||||
"更新包内容异常:条目 '{name}' 不符合约定(期望仅 '{expected}')"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unix 下补可执行位(Windows 无此概念,空实现)。
|
||||
#[cfg(unix)]
|
||||
fn make_executable(path: &Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let mode = std::fs::metadata(path)
|
||||
.map_err(|e| format!("读取文件权限失败: {e}"))?
|
||||
.permissions()
|
||||
.mode();
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode | 0o755))
|
||||
.map_err(|e| format!("设置可执行权限失败: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn make_executable(_path: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把新版本文件放到目标位置。
|
||||
///
|
||||
/// 暂存在数据目录(`%APPDATA%` / `~/.local/share`),可执行文件在安装目录,二者可能
|
||||
/// 不同卷:Windows 的 `MoveFile` 与 Unix 的 `rename` 跨卷都会失败,因此失败时回退为
|
||||
/// 「复制 + 刷盘 + 删除源文件」,而不是直接报"可能无写入权限"。
|
||||
fn place_file(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
match std::fs::rename(src, dst) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => {
|
||||
let mut input = std::fs::File::open(src)?;
|
||||
let mut output = std::fs::File::create(dst)?;
|
||||
std::io::copy(&mut input, &mut output)?;
|
||||
output.sync_all()?;
|
||||
drop(output);
|
||||
let _ = std::fs::remove_file(src);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装互斥锁:暂存目录下的 `install.lock`。
|
||||
///
|
||||
/// 用 `create_new` 原子抢占;超过 10 分钟的锁视为上次异常退出(`exit` 不跑 Drop)
|
||||
/// 留下的陈旧锁,可被接管。两个实例同时安装会互删暂存包、争抢同一个 `.old`。
|
||||
struct InstallLock {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl InstallLock {
|
||||
fn acquire() -> Result<Self, String> {
|
||||
let path = stage_dir()?.join("install.lock");
|
||||
let stale_after = std::time::Duration::from_secs(10 * 60);
|
||||
for attempt in 0..2 {
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(mut f) => {
|
||||
use std::io::Write as _;
|
||||
let _ = write!(f, "{}", std::process::id());
|
||||
return Ok(Self { path });
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
let is_stale = std::fs::metadata(&path)
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.elapsed().ok())
|
||||
.is_some_and(|age| age > stale_after);
|
||||
if is_stale && attempt == 0 {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
continue;
|
||||
}
|
||||
return Err("另一个实例正在安装更新,已跳过本次安装".into());
|
||||
}
|
||||
Err(e) => return Err(format!("创建安装锁失败: {e}")),
|
||||
}
|
||||
}
|
||||
Err("创建安装锁失败".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InstallLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn download_to(info: &UpdateInfo, tmp: &std::path::Path, on_progress: &dyn Fn(u64, u64)) -> Result<(), String> {
|
||||
let resp = agent()
|
||||
.get(&info.asset_url)
|
||||
.call()
|
||||
.map_err(|e| format!("下载失败: {e}"))?;
|
||||
let mut reader = resp.into_body().into_reader();
|
||||
let total = info.size;
|
||||
let mut file = std::fs::File::create(tmp).map_err(|e| format!("创建临时文件失败: {e}"))?;
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
loop {
|
||||
let n = reader.read(&mut buf).map_err(|e| format!("下载中断: {e}"))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
std::io::Write::write_all(&mut file, &buf[..n])
|
||||
.map_err(|e| format!("写入临时文件失败: {e}"))?;
|
||||
hasher.update(&buf[..n]);
|
||||
downloaded += n as u64;
|
||||
on_progress(downloaded, total);
|
||||
}
|
||||
std::io::Write::flush(&mut file).ok();
|
||||
drop(file);
|
||||
|
||||
// 传输完整性:API 提供了大小却没下满/超量,视为损坏(read 返回 0
|
||||
// 无法区分干净结束与连接提前断开,必须与预期大小对账)
|
||||
if total > 0 && downloaded != total {
|
||||
return Err(format!(
|
||||
"下载不完整:期望 {total} 字节,实际 {downloaded} 字节"
|
||||
));
|
||||
}
|
||||
|
||||
// sha256 校验:缺侧车不再静默跳过——一次漏传就会让所有客户端在"零完整性校验"
|
||||
// 状态下安装,而 UI 仍宣称"已通过 sha256 校验"。发布脚本两侧都会生成侧车,
|
||||
// 因此这里 fail-closed 不会误伤正常发布。
|
||||
let expect = fetch_expected_sha256(info)?.ok_or_else(|| {
|
||||
"Release 未提供 .sha256 校验文件,无法验证完整性,已拒绝安装".to_string()
|
||||
})?;
|
||||
let actual: String = hasher.finalize().iter().map(|b| format!("{b:02x}")).collect();
|
||||
if !actual.eq_ignore_ascii_case(&expect) {
|
||||
return Err(format!("校验失败:期望 sha256 {expect},实际 {actual}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除暂存目录中除 `keep` 外的其他版本暂存文件。
|
||||
fn remove_other_staged(dir: &std::path::Path, keep: &std::path::Path) {
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p == *keep {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with("mqttx-v")
|
||||
&& name.contains(platform_triple().as_str())
|
||||
&& !name.ends_with(".tmp")
|
||||
&& !name.ends_with(".sha256")
|
||||
&& !name.ends_with(".7z")
|
||||
{
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 `.sha256` 侧车内容,返回其中的哈希值。没有侧车时返回 None。
|
||||
fn fetch_expected_sha256(info: &UpdateInfo) -> Result<Option<String>, String> {
|
||||
let Some(url) = &info.sha256_url else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut body = agent()
|
||||
.get(url)
|
||||
.call()
|
||||
.map_err(|e| format!("获取校验文件失败: {e}"))?
|
||||
.into_body();
|
||||
let text = body
|
||||
.read_to_string()
|
||||
.map_err(|e| format!("读取校验文件失败: {e}"))?;
|
||||
// 常见格式:<hex> <filename> 或纯 hex
|
||||
let hex = text
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
Ok(Some(hex))
|
||||
} else {
|
||||
Err("校验文件格式异常".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// 多个版本并存时取最新版本(readdir 顺序不保证有序)。
|
||||
pub fn load_staged() -> Option<StagedUpdate> {
|
||||
let dir = stage_dir().ok()?;
|
||||
let mut best: Option<StagedUpdate> = None;
|
||||
for e in std::fs::read_dir(&dir).ok()?.flatten() {
|
||||
if !e.path().is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy().to_string();
|
||||
let Some(version) = parse_staged_name(&name) else {
|
||||
continue;
|
||||
};
|
||||
if !is_newer(&version, current_version()) {
|
||||
continue;
|
||||
}
|
||||
if best.as_ref().is_none_or(|b| is_newer(&version, &b.version)) {
|
||||
// 启动扫描时重算哈希:安装同意标记绑定了它,缺了就无法判定"是否已同意"
|
||||
let Ok(sha256) = file_sha256(&e.path()) else {
|
||||
continue;
|
||||
};
|
||||
best = Some(StagedUpdate {
|
||||
version,
|
||||
path: e.path(),
|
||||
sha256,
|
||||
});
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// 安装暂存的更新:替换自身并拉起新进程(旧文件改名 `.old`,
|
||||
/// 新进程启动时由 [`cleanup_old`] 清理)。成功后调用方应退出当前进程。
|
||||
pub fn install_staged(staged: &StagedUpdate) -> Result<(), String> {
|
||||
// 安装前复核:从"下载校验通过"到"下次启动安装"之间,暂存文件可能被替换或损坏
|
||||
let actual = file_sha256(&staged.path)?;
|
||||
if !actual.eq_ignore_ascii_case(&staged.sha256) {
|
||||
return Err(format!(
|
||||
"暂存文件校验失败(可能被替换或损坏):期望 {},实际 {actual}",
|
||||
staged.sha256
|
||||
));
|
||||
}
|
||||
|
||||
let cur = std::env::current_exe().map_err(|e| format!("无法定位自身: {e}"))?;
|
||||
let mut old_name = cur.clone().into_os_string();
|
||||
old_name.push(".old");
|
||||
let old = PathBuf::from(old_name);
|
||||
|
||||
// 安装互斥:两个实例同时安装会互删暂存包、争抢同一个 .old
|
||||
let lock = InstallLock::acquire()?;
|
||||
|
||||
// 旧残留先清掉,避免改名失败
|
||||
let _ = std::fs::remove_file(&old);
|
||||
std::fs::rename(&cur, &old).map_err(|e| format!("替换失败(可能无写入权限): {e}"))?;
|
||||
if let Err(e) = place_file(&staged.path, &cur) {
|
||||
// 回滚,尽量保住当前可执行文件
|
||||
let rollback = std::fs::rename(&old, &cur);
|
||||
return Err(format!(
|
||||
"替换失败: {e};{}",
|
||||
rollback_result(rollback, &old)
|
||||
));
|
||||
}
|
||||
if let Err(e) = std::process::Command::new(&cur).spawn() {
|
||||
// 新文件无法启动(常见:杀毒软件锁定刚落地的文件):恢复旧版本
|
||||
let _ = std::fs::remove_file(&cur);
|
||||
let rollback = std::fs::rename(&old, &cur);
|
||||
return Err(format!(
|
||||
"启动新版本失败: {e};{}",
|
||||
rollback_result(rollback, &old)
|
||||
));
|
||||
}
|
||||
clear_install_consent();
|
||||
// 显式释放:调用方随后就 exit/quit,Drop 不保证执行,残留锁会让下次安装被误判为并发
|
||||
drop(lock);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 描述回滚结果;回滚失败时提示 `.old` 备份仍保留在原处。
|
||||
fn rollback_result(rollback: std::io::Result<()>, old: &std::path::Path) -> String {
|
||||
match rollback {
|
||||
Ok(()) => "已恢复原版本".to_string(),
|
||||
Err(re) => format!(
|
||||
"回滚失败({re}),原版本备份保留在 {}",
|
||||
old.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装同意标记:仅当用户在更新对话框明确选择「下次启动安装」后才写入,
|
||||
/// 启动时据此自动安装——用户未选择的更新不会被强制替换。
|
||||
fn consent_path() -> Result<PathBuf, String> {
|
||||
stage_dir().map(|d| d.join("install-consent.txt"))
|
||||
}
|
||||
|
||||
/// 记录安装同意:写入「版本 + 暂存文件 sha256」两行。
|
||||
/// 绑定哈希后,暂存文件被替换就不再被视为"用户已同意安装的那一份"。
|
||||
pub fn mark_install_consent(staged: &StagedUpdate) {
|
||||
if let Ok(p) = consent_path() {
|
||||
let _ = std::fs::write(p, format!("{}\n{}\n", staged.version, staged.sha256));
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除安装同意标记。
|
||||
pub fn clear_install_consent() {
|
||||
if let Ok(p) = consent_path() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// 同意标记是否与暂存的版本**与哈希**都匹配(任一不匹配视为未同意)。
|
||||
pub fn install_consent_matches(staged: &StagedUpdate) -> bool {
|
||||
let Some(p) = consent_path().ok() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(p) else {
|
||||
return false;
|
||||
};
|
||||
let mut lines = text.lines();
|
||||
let version_ok = lines.next().map(str::trim) == Some(staged.version.as_str());
|
||||
let sha_ok = lines.next().map(str::trim) == Some(staged.sha256.as_str());
|
||||
version_ok && sha_ok
|
||||
}
|
||||
|
||||
/// 用户拒绝本次更新:删除暂存包与同意标记。
|
||||
pub fn discard_staged(staged: &StagedUpdate) {
|
||||
let _ = std::fs::remove_file(&staged.path);
|
||||
clear_install_consent();
|
||||
}
|
||||
|
||||
/// `.old` 回滚备份的保留时长:新版本首启不立即删除备份,
|
||||
/// 否则新版本一旦启动即崩溃,用户就再也没有自助回退的路径。
|
||||
const OLD_BACKUP_KEEP: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 3600);
|
||||
|
||||
/// 启动时清理上次更新遗留的旧文件(改名失败的 `.old`、中断的 `.tmp`)。
|
||||
///
|
||||
/// `.old` 是本版本唯一的回滚备份:**不能一启动就删**。策略是保留最近一份直到超过
|
||||
/// `OLD_BACKUP_KEEP`,更早的备份立即清理。
|
||||
pub fn cleanup_old() {
|
||||
if let Ok(cur) = std::env::current_exe()
|
||||
&& let Some(dir) = cur.parent()
|
||||
&& let Ok(entries) = std::fs::read_dir(dir)
|
||||
{
|
||||
let mut backups: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
|
||||
for e in entries.flatten() {
|
||||
// Windows 文件系统不区分大小写,统一按小写匹配
|
||||
let name = e.file_name();
|
||||
let lower = name.to_string_lossy().to_lowercase();
|
||||
if lower.ends_with(".old") && lower.starts_with("mqttx") {
|
||||
let modified = e
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
backups.push((modified, e.path()));
|
||||
}
|
||||
}
|
||||
// 新的在前
|
||||
backups.sort_by_key(|b| std::cmp::Reverse(b.0));
|
||||
for (i, (modified, path)) in backups.iter().enumerate() {
|
||||
let age = modified.elapsed().unwrap_or_default();
|
||||
// 保留最近一份作为回滚备份,直到超过保留期;其余立即清理
|
||||
if i == 0 && age < OLD_BACKUP_KEEP {
|
||||
continue;
|
||||
}
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
// 暂存目录里中断的 .tmp。跳过最近修改的文件,避免误删
|
||||
// 并行实例正在写入的临时文件。
|
||||
if let Ok(dir) = stage_dir()
|
||||
&& let Ok(entries) = std::fs::read_dir(&dir)
|
||||
{
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name();
|
||||
if !name.to_string_lossy().ends_with(".tmp") {
|
||||
continue;
|
||||
}
|
||||
let recent = std::fs::metadata(e.path())
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.elapsed().ok())
|
||||
.is_some_and(|age| age < std::time::Duration::from_secs(10 * 60));
|
||||
if recent {
|
||||
continue;
|
||||
}
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 单元测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// sha256→hex 转换(digest 0.11 起 Output 不再实现 LowerHex)是 OTA
|
||||
/// 完整性校验的关键路径,用固定向量锁定。
|
||||
#[test]
|
||||
fn file_sha256_matches_known_vector() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"mqttx-sha-test-{}-{}.txt",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::write(&path, b"abc").expect("写入测试文件");
|
||||
let hex = file_sha256(&path).expect("计算 sha256");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
// echo -n abc | sha256sum
|
||||
assert_eq!(hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! OTA 自更新:以 GitHub Release 为更新源。
|
||||
//!
|
||||
//! - [`check`] 检查最新版本与资产挑选
|
||||
//! - [`install`] 下载暂存、校验安装、同意标记与清理
|
||||
//!
|
||||
//! 本模块只暴露版本比较/资产命名等纯工具与二级模块的公开 API。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::Digest as _;
|
||||
|
||||
mod check;
|
||||
mod install;
|
||||
|
||||
pub use check::{check_and_download, check_latest, pick_assets};
|
||||
pub use install::{
|
||||
cleanup_old, clear_install_consent, discard_staged, download_and_stage,
|
||||
install_consent_matches, install_staged, load_staged, mark_install_consent,
|
||||
};
|
||||
|
||||
/// 更新源(GitHub Releases;仓库公开,API 与附件下载均无需认证)。
|
||||
///
|
||||
/// - api.github.com 未认证配额为 60 次/小时/IP:共享出口 IP 打满时检查会失败,
|
||||
/// 按「错误仅记录、不打扰用户」静默降级,下次成功检查自动追上;
|
||||
/// 附件下载走 github.com CDN,不受 API 配额限制。
|
||||
pub const RELEASES_API: &str = "https://api.github.com/repos/LiJoeAllen/mqttx/releases";
|
||||
/// Releases 页面(github.com,非 API、无配额限制):api.github.com 被限流时
|
||||
/// 兜底发现最新 tag —— 跟踪 `{RELEASES_PAGE}/latest` 的 302,从最终 URL 取 tag,
|
||||
/// 下载与校验按命名约定构造 URL,完整性(sha256)不受影响。
|
||||
pub const RELEASES_PAGE: &str = "https://github.com/LiJoeAllen/mqttx/releases";
|
||||
/// 更新源展示名(设置对话框「关于」)。
|
||||
pub const UPDATE_SOURCE: &str = "GitHub";
|
||||
|
||||
const HTTP_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// 发现的可用更新。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateInfo {
|
||||
/// 新版本号(不含 v 前缀),如 "1.0.1"
|
||||
pub version: String,
|
||||
/// Release 说明(Markdown 原文)
|
||||
pub notes: String,
|
||||
/// 附件名(判断 7z 压缩包 / 裸二进制)
|
||||
pub asset_name: String,
|
||||
/// 平台对应产物下载地址
|
||||
pub asset_url: String,
|
||||
/// sha256 校验文件地址(若 Release 提供了 `.sha256` 侧车)
|
||||
pub sha256_url: Option<String>,
|
||||
/// 产物字节数(API 提供,用于下载进度)
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// 当前版本。测试可用环境变量 `MQTTX_VERSION_OVERRIDE` 伪装旧版本,
|
||||
/// 验证"发现新版本"路径。
|
||||
pub fn current_version() -> &'static str {
|
||||
static VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
VERSION.get_or_init(|| {
|
||||
std::env::var("MQTTX_VERSION_OVERRIDE")
|
||||
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// 构建目标的平台三元组,需与 release.yml / publish 脚本的产物命名一致。
|
||||
///
|
||||
/// 架构取自 `std::env::consts::ARCH` 而非写死:写死 x86_64 会让 Apple Silicon /
|
||||
/// ARM Linux 客户端永远挑不到自己的产物(发布脚本能产出 aarch64 资产,但无人下载),
|
||||
/// 甚至在只有 x86_64 资产时把错误架构的二进制装上去。
|
||||
pub fn platform_triple() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
if cfg!(windows) {
|
||||
format!("{arch}-pc-windows-msvc")
|
||||
} else if cfg!(target_os = "macos") {
|
||||
format!("{arch}-apple-darwin")
|
||||
} else {
|
||||
format!("{arch}-unknown-linux-gnu")
|
||||
}
|
||||
}
|
||||
|
||||
/// 某版本解压后二进制的暂存名(与 CI 的命名约定 `mqttx-v<ver>-<triple>-mqttx[.exe]` 一致)。
|
||||
pub fn binary_name_for(version: &str) -> String {
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
format!("mqttx-v{}-{}-mqttx{}", version, platform_triple(), ext)
|
||||
}
|
||||
|
||||
/// 某版本的 7z 压缩附件名(打包端与此约定一致;内部为 [`binary_name_for`])。
|
||||
pub fn asset_name_for(version: &str) -> String {
|
||||
format!("{}.7z", binary_name_for(version))
|
||||
}
|
||||
|
||||
/// 去掉 v 前缀与预发布后缀(`-` 及之后),返回数字主体。
|
||||
fn release_part(v: &str) -> &str {
|
||||
let v = v.trim().trim_start_matches(['v', 'V']);
|
||||
match v.find('-') {
|
||||
Some(i) => &v[..i],
|
||||
None => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从版本字符串提取数字段(容忍 v 前缀;预发布后缀(`-` 之后)不参与比较)。
|
||||
pub fn parse_version(v: &str) -> Vec<u32> {
|
||||
release_part(v)
|
||||
.split('.')
|
||||
.filter_map(|s| s.parse::<u32>().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// a 是否比 b 新(按数字段逐位比较,缺失段视为 0)。
|
||||
/// 预发布版本(含 `-` 后缀)不会比同数字段的正式版新(semver 语义)。
|
||||
pub fn is_newer(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (parse_version(a), parse_version(b));
|
||||
let len = a.len().max(b.len());
|
||||
for i in 0..len {
|
||||
let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
|
||||
if x != y {
|
||||
return x > y;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 从 Release JSON 里挑选当前平台的产物与 sha256 侧车。
|
||||
/// 优先 7z 压缩包(体积小、下载快);Release 未提供 7z 时回退裸二进制
|
||||
/// (兼容旧发布方式)。
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StagedUpdate {
|
||||
pub version: String,
|
||||
pub path: PathBuf,
|
||||
/// 暂存二进制的 sha256(小写 hex)。
|
||||
///
|
||||
/// 注意它与 Release 附件的 `.sha256` 侧车不是同一个东西:侧车校验的是**下载到的
|
||||
/// 压缩包**,这里是**解压/落盘后的最终二进制**。安装同意标记绑定该哈希,安装前
|
||||
/// 也会用它复核,防止"下载校验通过"到"下次启动安装"之间文件被替换。
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
|
||||
/// 预发布后缀原样保留。
|
||||
pub fn parse_staged_name(name: &str) -> Option<String> {
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
let suffix = format!("-{}-mqttx{ext}", platform_triple());
|
||||
if name.contains(".tmp") {
|
||||
return None;
|
||||
}
|
||||
name.strip_prefix("mqttx-v")?
|
||||
.strip_suffix(&suffix)
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_compare() {
|
||||
assert!(is_newer("1.0.1", "1.0.0"));
|
||||
assert!(is_newer("v1.1.0", "1.0.9"));
|
||||
assert!(is_newer("2.0", "1.9.9"));
|
||||
// 预发布版本不比同数字段的正式版新(semver 语义)
|
||||
assert!(!is_newer("1.0.0-beta", "1.0.0"));
|
||||
assert!(!is_newer("1.0.0-beta.2", "1.0.0"));
|
||||
assert!(!is_newer("1.0.0", "1.0.0"));
|
||||
assert!(!is_newer("0.9", "1.0.0"));
|
||||
// 更高版本号的预发布仍是更新(1.0.1-beta > 1.0.0)
|
||||
assert!(is_newer("1.0.1-beta", "1.0.0"));
|
||||
assert_eq!(parse_version("v1.2.3-beta"), vec![1, 2, 3]);
|
||||
assert_eq!(parse_version("v1.2.3"), vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn staged_name_parsing_keeps_prerelease_suffix() {
|
||||
let triple = platform_triple();
|
||||
assert_eq!(
|
||||
parse_staged_name(&format!("mqttx-v1.0.3-{triple}-mqttx.exe")),
|
||||
Some("1.0.3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_staged_name(&format!("mqttx-v1.0.3-beta.1-{triple}-mqttx.exe")),
|
||||
Some("1.0.3-beta.1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_staged_name(&format!("mqttx-v1.0.3-{triple}-mqttx.exe.1234.56.tmp")),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_staged_name("其他文件.exe"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_naming_matches_ci_convention() {
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
// 附件为 7z 压缩包;解压出的二进制保留原命名
|
||||
assert_eq!(
|
||||
asset_name_for("1.0.1"),
|
||||
format!("mqttx-v1.0.1-{triple}-mqttx{ext}.7z", triple = platform_triple())
|
||||
);
|
||||
assert_eq!(
|
||||
binary_name_for("1.0.1"),
|
||||
format!("mqttx-v1.0.1-{triple}-mqttx{ext}", triple = platform_triple())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_assets_prefers_7z_over_raw_binary() {
|
||||
// 附件名按**当前平台**三元组构造:写死 x86_64-pc-windows-msvc 会让本测试
|
||||
// 只在 Windows 上通过,Linux CI 必红
|
||||
let triple = platform_triple();
|
||||
let bin = if cfg!(windows) { "mqttx.exe" } else { "mqttx" };
|
||||
let base = format!("mqttx-v1.0.1-{triple}-{bin}");
|
||||
let (n_bin, n_bin_sha, n_7z, n_7z_sha) = (
|
||||
base.clone(),
|
||||
format!("{base}.sha256"),
|
||||
format!("{base}.7z"),
|
||||
format!("{base}.7z.sha256"),
|
||||
);
|
||||
let rel: serde_json::Value = serde_json::json!({
|
||||
"tag_name": "v1.0.1",
|
||||
"assets": [
|
||||
{"name": n_bin, "size": 21000000, "browser_download_url": "https://x/bin"},
|
||||
{"name": n_bin_sha, "size": 65, "browser_download_url": "https://x/bin.sha256"},
|
||||
{"name": n_7z, "size": 9000000, "browser_download_url": "https://x/bin.7z"},
|
||||
{"name": n_7z_sha, "size": 65, "browser_download_url": "https://x/bin.7z.sha256"}
|
||||
]
|
||||
});
|
||||
let (name, url, size, sha) = pick_assets(&rel).expect("应选中 7z");
|
||||
assert!(name.ends_with(".7z"), "应优先 7z 压缩包,实际 {name}");
|
||||
assert_eq!(url, "https://x/bin.7z");
|
||||
assert_eq!(size, 9000000);
|
||||
assert_eq!(sha.as_deref(), Some("https://x/bin.7z.sha256"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_assets_falls_back_to_raw_binary() {
|
||||
// 旧发布方式只有裸二进制:仍可被选中
|
||||
let triple = platform_triple();
|
||||
let bin = if cfg!(windows) { "mqttx.exe" } else { "mqttx" };
|
||||
let base = format!("mqttx-v1.0.1-{triple}-{bin}");
|
||||
let (n_bin, n_sha) = (base.clone(), format!("{base}.sha256"));
|
||||
let rel: serde_json::Value = serde_json::json!({
|
||||
"tag_name": "v1.0.1",
|
||||
"assets": [
|
||||
{"name": n_bin, "size": 19132928, "browser_download_url": "https://x/bin"},
|
||||
{"name": n_sha, "size": 65, "browser_download_url": "https://x/bin.sha256"}
|
||||
]
|
||||
});
|
||||
let (name, url, _size, sha) = pick_assets(&rel).expect("应回退裸二进制");
|
||||
assert!(name.ends_with(bin), "实际 {name}");
|
||||
assert_eq!(url, "https://x/bin");
|
||||
assert_eq!(sha.as_deref(), Some("https://x/bin.sha256"));
|
||||
}
|
||||
|
||||
// 非约定命名的附件不得被选中:非 Windows 下旧实现的 ends_with("") 恒真,
|
||||
// 会把 .tar.gz / .deb 这类附件当成可执行文件下载并替换自身
|
||||
#[test]
|
||||
fn pick_assets_rejects_non_convention_assets() {
|
||||
let triple = platform_triple();
|
||||
let rel: serde_json::Value = serde_json::json!({
|
||||
"tag_name": "v1.0.1",
|
||||
"assets": [
|
||||
{"name": format!("mqttx-v1.0.1-{triple}.tar.gz"), "size": 1024,
|
||||
"browser_download_url": "https://x/pkg.tar.gz"},
|
||||
{"name": format!("mqttx-v1.0.1-{triple}-debug"), "size": 1024,
|
||||
"browser_download_url": "https://x/dbg"}
|
||||
]
|
||||
});
|
||||
assert!(pick_assets(&rel).is_none(), "非约定命名的附件不应被选中");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_assets_skips_sidecars_and_other_platforms() {
|
||||
let rel: serde_json::Value = serde_json::json!({
|
||||
"tag_name": "v1.0.1",
|
||||
"assets": [
|
||||
{"name": "mqttx-v1.0.1-x86_64-pc-windows-msvc-mqttx.exe.sha256", "size": 65,
|
||||
"browser_download_url": "https://x/side"},
|
||||
]
|
||||
});
|
||||
if cfg!(windows) {
|
||||
// 只有侧车、没有主体 → 不应选中侧车
|
||||
assert!(pick_assets(&rel).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Adventure",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Adventure",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#25292d",
|
||||
"accent.foreground": "#feffff",
|
||||
"background": "#040404",
|
||||
"border": "#282828",
|
||||
"danger.background": "#d84a33",
|
||||
"foreground": "#feffff",
|
||||
"input.border": "#333333",
|
||||
"link.active.foreground": "#417AB3",
|
||||
"link.foreground": "#417AB3",
|
||||
"link.hover.foreground": "#417AB3",
|
||||
"list.active.background": "#5da60222",
|
||||
"list.active.border": "#5da602",
|
||||
"list.even.background": "#0e0e0e",
|
||||
"muted.background": "#171717",
|
||||
"muted.foreground": "#5d6165",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#040404",
|
||||
"popover.foreground": "#feffff",
|
||||
"primary.active.background": "#0265a799",
|
||||
"primary.background": "#4384ad",
|
||||
"primary.foreground": "#feffff",
|
||||
"primary.hover.background": "#417AB3",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#606060",
|
||||
"secondary.background": "#171a1c",
|
||||
"secondary.active.background": "#191d1e",
|
||||
"secondary.foreground": "#e4d5c7",
|
||||
"secondary.hover.background": "#1e2224",
|
||||
"tab.active.background": "#040404",
|
||||
"tab.active.foreground": "#feffff",
|
||||
"tab.background": "#04040400",
|
||||
"tab.foreground": "#677179",
|
||||
"tab_bar.background": "#040404",
|
||||
"table.background": "#040404",
|
||||
"table.head.foreground": "#feffffb3",
|
||||
"table.row.border": "#4b647970",
|
||||
"title_bar.background": "#0f1112",
|
||||
"title_bar.border": "#282828",
|
||||
"ring": "#5da602",
|
||||
"base.red": "#d84a33",
|
||||
"base.red.light": "#d76b42",
|
||||
"base.green": "#5da602",
|
||||
"base.green.light": "#99b52c",
|
||||
"base.blue": "#417ab3",
|
||||
"base.blue.light": "#97d7ef",
|
||||
"base.cyan": "#41b3a9",
|
||||
"base.cyan.light": "#97efd6",
|
||||
"base.magenta": "#882252",
|
||||
"base.magenta.light": "#e599bc",
|
||||
"base.yellow": "#aa7900",
|
||||
"base.yellow.light": "#ffb670"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#feffff",
|
||||
"editor.background": "#040404",
|
||||
"editor.active_line.background": "#0e0e0e",
|
||||
"editor.line_number": "#5d6165",
|
||||
"editor.active_line_number": "#feffff",
|
||||
"editor.invisible": "#5d616566",
|
||||
"conflict": "#d84a33",
|
||||
"created": "#5da602",
|
||||
"deleted": "#d84a33",
|
||||
"error": "#d84a33",
|
||||
"hidden": "#5d6165",
|
||||
"hint": "#99b52c",
|
||||
"ignored": "#5d6165",
|
||||
"info": "#417ab3",
|
||||
"modified": "#eebb6e",
|
||||
"predictive": "#5d6165",
|
||||
"renamed": "#5da602",
|
||||
"success": "#5da602",
|
||||
"unreachable": "#5d6165",
|
||||
"warning": "#eebb6e",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5d6165",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5d6165",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d84a33"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#feffff"
|
||||
},
|
||||
"function": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#417ab3"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#97d7ef",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#4b6479",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#d84a33"
|
||||
},
|
||||
"string": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#5da602"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#d84a33"
|
||||
},
|
||||
"title": {
|
||||
"color": "#97d7ef",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#eebb6e"
|
||||
},
|
||||
"property": {
|
||||
"color": "#feffff"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#d84a33"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Adventure Time",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#36345f",
|
||||
"accent.foreground": "#C7C7D4",
|
||||
"background": "#1f1d45",
|
||||
"border": "#333150",
|
||||
"foreground": "#C7C7D4",
|
||||
"input.border": "#555465",
|
||||
"link.active.foreground": "#4b61bf",
|
||||
"link.foreground": "#5f72c6",
|
||||
"link.hover.foreground": "#8593d3",
|
||||
"list.active.background": "#4ab11822",
|
||||
"list.active.border": "#549235",
|
||||
"list.even.background": "#1c1a37",
|
||||
"muted.background": "#29274a",
|
||||
"muted.foreground": "#717192",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#1f1d45",
|
||||
"popover.foreground": "#C7C7D4",
|
||||
"primary.active.background": "#5f72c699",
|
||||
"primary.background": "#5f72c6",
|
||||
"primary.foreground": "#ffffff",
|
||||
"primary.hover.background": "#5f72c6aa",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#555465",
|
||||
"secondary.background": "#2e2c51",
|
||||
"secondary.active.background": "#36345f",
|
||||
"secondary.foreground": "#C7C7D4",
|
||||
"secondary.hover.background": "#34325b",
|
||||
"tab.active.background": "#1f1d45",
|
||||
"tab.active.foreground": "#C7C7D4",
|
||||
"tab.background": "#1f1d4500",
|
||||
"tab.foreground": "#aeab9e",
|
||||
"tab_bar.background": "#1f1d45",
|
||||
"table.active.background": "#4ab11811",
|
||||
"table.active.border": "#549235",
|
||||
"table.head.foreground": "#f8dcc0b3",
|
||||
"table.row.border": "#333333",
|
||||
"title_bar.background": "#1c1a3e",
|
||||
"title_bar.border": "#333150",
|
||||
"base.red": "#a02733",
|
||||
"base.red.light": "#fc5f5a",
|
||||
"base.green": "#549235",
|
||||
"base.green.light": "#9eff6e",
|
||||
"base.blue": "#2b53ab",
|
||||
"base.blue.light": "#1997c6",
|
||||
"base.cyan": "#26977b",
|
||||
"base.cyan.light": "#5afcd4",
|
||||
"base.magenta": "#665993",
|
||||
"base.magenta.light": "#9b5953",
|
||||
"base.yellow": "#ce7837",
|
||||
"base.yellow.light": "#efc11a"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#f8dcc0",
|
||||
"editor.background": "#1f1d45",
|
||||
"editor.active_line.background": "#36345f",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#5d616566",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Alduin",
|
||||
"author": "AlessandroYorba",
|
||||
"url": "https://github.com/AlessandroYorba/Alduin",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Alduin",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2e2b29",
|
||||
"accent.foreground": "#ebdbb2",
|
||||
"background": "#1C1C1C",
|
||||
"border": "#3a3a3a",
|
||||
"danger.background": "#cc241d",
|
||||
"danger.active.background": "#cc241d",
|
||||
"danger.hover.background": "#fb4934",
|
||||
"foreground": "#9E9E9E",
|
||||
"input.border": "#504945",
|
||||
"link.active.foreground": "#83a598",
|
||||
"link.foreground": "#83a598",
|
||||
"link.hover.foreground": "#83a598",
|
||||
"list.active.background": "#262626",
|
||||
"list.active.border": "#9E9E9E",
|
||||
"list.even.background": "#262626",
|
||||
"list.head.background": "#1C1C1C",
|
||||
"muted.background": "#262626",
|
||||
"muted.foreground": "#878787",
|
||||
"panel.background": "#282828",
|
||||
"primary.active.background": "#45858899",
|
||||
"primary.background": "#458588",
|
||||
"primary.foreground": "#F0F0F0",
|
||||
"primary.hover.background": "#458588AA",
|
||||
"scrollbar.background": "#28282800",
|
||||
"scrollbar.thumb.background": "#504945",
|
||||
"secondary.background": "#282828",
|
||||
"secondary.active.background": "#35322f",
|
||||
"secondary.foreground": "#9E9E9E",
|
||||
"secondary.hover.background": "#36323099",
|
||||
"tab.active.background": "#1C1C1C",
|
||||
"tab.active.foreground": "#ebdbb2",
|
||||
"tab.background": "#14141400",
|
||||
"tab.foreground": "#848382",
|
||||
"table.row.border": "#50494570",
|
||||
"title_bar.background": "#262626",
|
||||
"title_bar.border": "#3a3a3a",
|
||||
"ring": "#9E9E9E",
|
||||
"base.blue": "#87afaf",
|
||||
"base.cyan": "#878787",
|
||||
"base.green": "#7a875f",
|
||||
"base.magenta": "#af8787",
|
||||
"base.magenta.light": "#af878788",
|
||||
"base.red": "#8b5f61",
|
||||
"base.yellow": "#9d906c"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#9E9E9E66",
|
||||
"conflict": "#8b5f61",
|
||||
"created": "#87afaf",
|
||||
"error": "#8b5f61",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#af8787",
|
||||
"info": "#878787",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Asciinema",
|
||||
"author": "asciinema.org",
|
||||
"url": "https://asciinema.org",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Asciinema",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#262829",
|
||||
"accent.foreground": "#cccccc",
|
||||
"background": "#121314",
|
||||
"border": "#303030",
|
||||
"danger.background": "#dd3c69",
|
||||
"danger.active.background": "#dd3c69",
|
||||
"danger.hover.background": "#e94f7a",
|
||||
"foreground": "#cccccc",
|
||||
"input.border": "#3a3a3a",
|
||||
"link.active.foreground": "#26b0d7",
|
||||
"link.foreground": "#26b0d7",
|
||||
"link.hover.foreground": "#3cc0e7",
|
||||
"list.active.background": "#1a1b1c",
|
||||
"list.active.border": "#cccccc",
|
||||
"list.even.background": "#1a1b1c",
|
||||
"list.head.background": "#121314",
|
||||
"muted.background": "#1a1b1c",
|
||||
"muted.foreground": "#6d6d6d",
|
||||
"panel.background": "#181919",
|
||||
"primary.active.background": "#26b0d799",
|
||||
"primary.background": "#26b0d7",
|
||||
"primary.foreground": "#ffffff",
|
||||
"primary.hover.background": "#26b0d7AA",
|
||||
"scrollbar.background": "#12131400",
|
||||
"scrollbar.thumb.background": "#2a2a2a",
|
||||
"secondary.background": "#181919",
|
||||
"secondary.active.background": "#1f2020",
|
||||
"secondary.foreground": "#cccccc",
|
||||
"secondary.hover.background": "#1f202099",
|
||||
"tab.active.background": "#121314",
|
||||
"tab.active.foreground": "#cccccc",
|
||||
"tab.background": "#12131400",
|
||||
"tab.foreground": "#6d6d6d",
|
||||
"table.row.border": "#2a2a2a70",
|
||||
"title_bar.background": "#181919",
|
||||
"title_bar.border": "#303030",
|
||||
"ring": "#5d5d5d",
|
||||
"base.blue": "#26b0d7",
|
||||
"base.cyan": "#54e1b9",
|
||||
"base.green": "#4ebf22",
|
||||
"base.magenta": "#b954e1",
|
||||
"base.red": "#dd3c69",
|
||||
"base.yellow": "#ddaf3c"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#cccccc",
|
||||
"editor.background": "#121314",
|
||||
"editor.active_line.background": "#1a1b1c",
|
||||
"editor.line_number": "#6d6d6d",
|
||||
"editor.active_line_number": "#cccccc",
|
||||
"editor.invisible": "#6d6d6d66",
|
||||
"conflict": "#dd3c69",
|
||||
"created": "#4ebf22",
|
||||
"error": "#dd3c69",
|
||||
"hidden": "#6d6d6d",
|
||||
"hint": "#b954e1",
|
||||
"info": "#26b0d7",
|
||||
"modified": "#ddaf3c",
|
||||
"predictive": "#2a2a2a",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#ddaf3c"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#b954e1"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5d5d5d"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5d5d5d"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#b954e1"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#26b0d7"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#cccccc"
|
||||
},
|
||||
"function": {
|
||||
"color": "#54e1b9"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#dd3c69"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#26b0d7",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#54e1b9",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#b954e1"
|
||||
},
|
||||
"string": {
|
||||
"color": "#4ebf22"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#54e1b9"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#4ebf22"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#ddaf3c"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#ddaf3c"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#dd3c69"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cccccc"
|
||||
},
|
||||
"title": {
|
||||
"color": "#ddaf3c",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#26b0d7"
|
||||
},
|
||||
"property": {
|
||||
"color": "#cccccc"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#dd3c69"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Aurora",
|
||||
"author": "Longbridge",
|
||||
"url": "https://github.com/longbridge/gpui-kit",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Aurora Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#F1F5F9",
|
||||
"accent.foreground": "#0F172A",
|
||||
"accordion.background": "#FFFFFF",
|
||||
"accordion.hover.background": "#F8FAFC",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E2E8F0",
|
||||
"button.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"button.hover.background": "linear-gradient(180deg, #F8FAFC, #F1F5F9)",
|
||||
"button.active.background": "linear-gradient(180deg, #F1F5F9, #E2E8F0)",
|
||||
"button.foreground": "#0F172A",
|
||||
"button.primary.background": "linear-gradient(180deg, #1E293B, #0F172A)",
|
||||
"button.primary.hover.background": "linear-gradient(180deg, #334155, #1E293B)",
|
||||
"button.primary.active.background": "linear-gradient(180deg, #0F172A, #020617)",
|
||||
"button.primary.foreground": "#F8FAFC",
|
||||
"button.secondary.background": "linear-gradient(180deg, #F1F5F9, #E2E8F0)",
|
||||
"button.secondary.hover.background": "linear-gradient(180deg, #E2E8F0, #CBD5E1)",
|
||||
"button.secondary.active.background": "linear-gradient(180deg, #CBD5E1, #94A3B8)",
|
||||
"button.secondary.foreground": "#0F172A",
|
||||
"button.danger.background": "linear-gradient(180deg, #FEF2F2, #FEE2E2)",
|
||||
"button.danger.hover.background": "linear-gradient(180deg, #FEE2E2, #FECACA)",
|
||||
"button.danger.active.background": "linear-gradient(180deg, #FECACA, #FCA5A5)",
|
||||
"button.danger.foreground": "#B91C1C",
|
||||
"button.info.background": "linear-gradient(180deg, #ECFEFF, #CFFAFE)",
|
||||
"button.info.hover.background": "linear-gradient(180deg, #CFFAFE, #A5F3FC)",
|
||||
"button.info.active.background": "linear-gradient(180deg, #A5F3FC, #67E8F9)",
|
||||
"button.info.foreground": "#0E7490",
|
||||
"button.success.background": "linear-gradient(180deg, #F0FDF4, #DCFCE7)",
|
||||
"button.success.hover.background": "linear-gradient(180deg, #DCFCE7, #BBF7D0)",
|
||||
"button.success.active.background": "linear-gradient(180deg, #BBF7D0, #86EFAC)",
|
||||
"button.success.foreground": "#15803D",
|
||||
"button.warning.background": "linear-gradient(180deg, #FEFCE8, #FEF3C7)",
|
||||
"button.warning.hover.background": "linear-gradient(180deg, #FEF3C7, #FDE68A)",
|
||||
"button.warning.active.background": "linear-gradient(180deg, #FDE68A, #FCD34D)",
|
||||
"button.warning.foreground": "#A16207",
|
||||
"caret": "#0F172A",
|
||||
"chart.1": "#93C5FD",
|
||||
"chart.2": "#3B82F6",
|
||||
"chart.3": "#2563EB",
|
||||
"chart.4": "#1D4ED8",
|
||||
"chart.5": "#1E40AF",
|
||||
"chart.bullish": "green-600",
|
||||
"chart.bearish": "red-600",
|
||||
"danger.background": "linear-gradient(180deg, #EF4444, #DC2626)",
|
||||
"danger.active.background": "linear-gradient(180deg, #DC2626, #B91C1C)",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"danger.hover.background": "linear-gradient(180deg, #F87171, #EF4444)",
|
||||
"description_list.label.background": "#F8FAFC",
|
||||
"description_list.label.foreground": "#0F172A",
|
||||
"drag.border": "#3B82F6",
|
||||
"drop_target.background": "#3B82F640",
|
||||
"foreground": "#020617",
|
||||
"group_box.background": "#F8FAFC",
|
||||
"group_box.foreground": "#0F172A",
|
||||
"info.background": "linear-gradient(180deg, #06B6D4, #0891B2)",
|
||||
"info.active.background": "linear-gradient(180deg, #0891B2, #0E7490)",
|
||||
"info.foreground": "#FFFFFF",
|
||||
"info.hover.background": "linear-gradient(180deg, #22D3EE, #06B6D4)",
|
||||
"input.border": "#E2E8F0",
|
||||
"link": "#0F172A",
|
||||
"link.active": "#020617",
|
||||
"link.hover": "#334155",
|
||||
"list.background": "#FFFFFF",
|
||||
"list.active.background": "#BFDBFE33",
|
||||
"list.active.border": "#60A5FA",
|
||||
"list.even.background": "#F8FAFC",
|
||||
"list.head.background": "#F8FAFC",
|
||||
"list.hover.background": "#F1F5F9",
|
||||
"muted.background": "#F1F5F9",
|
||||
"muted.foreground": "#64748B",
|
||||
"popover.background": "#FFFFFF",
|
||||
"popover.foreground": "#020617",
|
||||
"primary.background": "linear-gradient(180deg, #1E293B, #0F172A)",
|
||||
"primary.active.background": "linear-gradient(180deg, #0F172A, #020617)",
|
||||
"primary.foreground": "#F8FAFC",
|
||||
"primary.hover.background": "linear-gradient(180deg, #334155, #1E293B)",
|
||||
"progress.bar.background": "linear-gradient(180deg, #1E293B, #0F172A)",
|
||||
"ring": "#0F172A",
|
||||
"scrollbar.background": "#F8FAFC00",
|
||||
"scrollbar.thumb.background": "linear-gradient(to right, #CBD5E173, #94A3B873)",
|
||||
"scrollbar.thumb.hover.background": "linear-gradient(to right, #CBD5E199, #94A3B899)",
|
||||
"secondary.background": "#E2E8F0",
|
||||
"secondary.active.background": "#CBD5E1",
|
||||
"secondary.foreground": "#0F172A",
|
||||
"secondary.hover.background": "#E2E8F0",
|
||||
"selection.background": "#55A0FC",
|
||||
"sidebar.background": "#F8FAFC",
|
||||
"sidebar.accent.background": "#E2E8F0",
|
||||
"sidebar.accent.foreground": "#0F172A",
|
||||
"sidebar.border": "#E2E8F0",
|
||||
"sidebar.foreground": "#0F172A",
|
||||
"sidebar.primary.background": "#0F172A",
|
||||
"sidebar.primary.foreground": "#F8FAFC",
|
||||
"skeleton.background": "#F1F5F9",
|
||||
"slider.background": "#0F172A",
|
||||
"slider.thumb.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"success.background": "linear-gradient(180deg, #22C55E, #16A34A)",
|
||||
"success.active.background": "linear-gradient(180deg, #16A34A, #15803D)",
|
||||
"success.foreground": "#FFFFFF",
|
||||
"success.hover.background": "linear-gradient(180deg, #4ADE80, #22C55E)",
|
||||
"switch.background": "linear-gradient(180deg, #E2E8F0, #CBD5E1)",
|
||||
"switch.thumb.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"tab.background": "#00000000",
|
||||
"tab.active.background": "#FFFFFF",
|
||||
"tab.active.foreground": "#0F172A",
|
||||
"tab.foreground": "#334155",
|
||||
"tab_bar.background": "#F1F5F9",
|
||||
"tab_bar.segmented.background": "#F1F5F9",
|
||||
"table.background": "#FFFFFF",
|
||||
"table.active.background": "#BFDBFE33",
|
||||
"table.active.border": "#60A5FA",
|
||||
"table.even.background": "#F8FAFC",
|
||||
"table.head.background": "#F8FAFC",
|
||||
"table.head.foreground": "#64748B",
|
||||
"table.foot.background": "#F8FAFC",
|
||||
"table.foot.foreground": "#64748B",
|
||||
"table.hover.background": "#F1F5F9",
|
||||
"table.row.border": "#E2E8F0B3",
|
||||
"title_bar.background": "linear-gradient(180deg, #FFFFFF, #F8FAFC)",
|
||||
"title_bar.border": "#E2E8F0",
|
||||
"status_bar.background": "linear-gradient(180deg, #F8FAFC, #F1F5F9)",
|
||||
"status_bar.border": "#E2E8F0",
|
||||
"warning.background": "linear-gradient(180deg, #EAB308, #CA8A04)",
|
||||
"warning.active.background": "linear-gradient(180deg, #CA8A04, #A16207)",
|
||||
"warning.hover.background": "linear-gradient(180deg, #FACC15, #EAB308)",
|
||||
"warning.foreground": "#422006",
|
||||
"overlay": "#0000000D",
|
||||
"window.border": "#E2E8F0",
|
||||
"base.red": "red-600",
|
||||
"base.red.light": "red-400",
|
||||
"base.green": "green-600",
|
||||
"base.green.light": "green-400",
|
||||
"base.blue": "blue-600",
|
||||
"base.blue.light": "blue-400",
|
||||
"base.yellow": "yellow-600",
|
||||
"base.yellow.light": "yellow-400",
|
||||
"base.magenta": "purple-600",
|
||||
"base.magenta.light": "purple-400",
|
||||
"base.cyan": "cyan-600",
|
||||
"base.cyan.light": "cyan-400"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#020617",
|
||||
"editor.background": "#FFFFFF",
|
||||
"editor.active_line.background": "#F8FAFC",
|
||||
"editor.line_number": "#94A3B8",
|
||||
"editor.active_line_number": "#0F172A",
|
||||
"editor.invisible": "#64748B66",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#957931"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"emphasis": {
|
||||
"font_style": "italic"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"font_weight": 700
|
||||
},
|
||||
"function": {
|
||||
"color": "#0000A2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#0000A2",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6A7293",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"title": {
|
||||
"color": "#0433FF"
|
||||
},
|
||||
"type": {
|
||||
"color": "#6f42c1"
|
||||
},
|
||||
"property": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#C5060B"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Ayu",
|
||||
"author": "Zed Industries",
|
||||
"url": "https://github.com/zed-industries/zed/blob/e62dd2a0e584154886ff86cc1e9e3e060558b977/assets/themes/ayu",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Ayu Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#dadadc",
|
||||
"accent.foreground": "#5C6773",
|
||||
"background": "#FCFCFC",
|
||||
"foreground": "#5c6166",
|
||||
"border": "#cfd1d2ff",
|
||||
"ring": "#FF9940",
|
||||
"input.border": "#D9DCE3",
|
||||
"list.active.background": "#55B4D422",
|
||||
"list.active.border": "#55B4D4",
|
||||
"list.even.background": "#E6E6E6",
|
||||
"list.head.background": "#F4F4F599",
|
||||
"muted.background": "#F4F4F5",
|
||||
"muted.foreground": "#99a0a6",
|
||||
"panel.background": "#F3F4F5",
|
||||
"popover.background": "#ECECED",
|
||||
"popover.foreground": "#5C6773",
|
||||
"primary.active.background": "#42accf",
|
||||
"primary.background": "#55b4d3",
|
||||
"primary.foreground": "#FAFAFA",
|
||||
"primary.hover.background": "#5fb9d7",
|
||||
"scrollbar.background": "#FAFAFA00",
|
||||
"scrollbar.thumb.background": "#5c61664c",
|
||||
"secondary.active.background": "#CECECE",
|
||||
"secondary.background": "#ECECED",
|
||||
"secondary.foreground": "#5C6773",
|
||||
"secondary.hover.background": "#e0e0e0",
|
||||
"tab.active.background": "#FCFCFC",
|
||||
"tab.active.foreground": "#5C6773",
|
||||
"tab.background": "#ECECED00",
|
||||
"tab_bar.background": "#F4F4F5",
|
||||
"title_bar.background": "#ECECED",
|
||||
"title_bar.border": "#CFD1D2",
|
||||
"base.yellow": "#F1AD49",
|
||||
"base.red": "#F07171",
|
||||
"base.blue": "#55b4d3",
|
||||
"base.green": "#85b304",
|
||||
"base.magenta": "#9371f0",
|
||||
"base.cyan": "#4dbf99"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#5C6773",
|
||||
"editor.background": "#FCFCFC",
|
||||
"editor.active_line.background": "#ECECED",
|
||||
"editor.line_number": "#ABB0B6",
|
||||
"editor.active_line_number": "#5C6773",
|
||||
"editor.invisible": "#73777b66",
|
||||
"conflict": "#f1ad49ff",
|
||||
"conflict.background": "#ffeedaff",
|
||||
"conflict.border": "#ffe1beff",
|
||||
"created": "#85b304ff",
|
||||
"created.background": "#e9efd2ff",
|
||||
"created.border": "#d7e3aeff",
|
||||
"deleted": "#ef7271ff",
|
||||
"deleted.background": "#ffe3e1ff",
|
||||
"deleted.border": "#ffcdcaff",
|
||||
"error": "#ef7271ff",
|
||||
"error.background": "#ffe3e1ff",
|
||||
"error.border": "#ffcdcaff",
|
||||
"hidden": "#a9acaeff",
|
||||
"hidden.background": "#dcdddeff",
|
||||
"hidden.border": "#d5d6d8ff",
|
||||
"hint": "#8ca7c2ff",
|
||||
"hint.background": "#deebfaff",
|
||||
"hint.border": "#c4daf6ff",
|
||||
"ignored": "#a9acaeff",
|
||||
"ignored.background": "#dcdddeff",
|
||||
"ignored.border": "#cfd1d2ff",
|
||||
"info": "#3b9ee5ff",
|
||||
"info.background": "#deebfaff",
|
||||
"info.border": "#c4daf6ff",
|
||||
"modified": "#f1ad49ff",
|
||||
"modified.background": "#ffeedaff",
|
||||
"modified.border": "#ffe1beff",
|
||||
"predictive": "#9eb9d3ff",
|
||||
"predictive.background": "#e9efd2ff",
|
||||
"predictive.border": "#d7e3aeff",
|
||||
"renamed": "#3b9ee5ff",
|
||||
"renamed.background": "#deebfaff",
|
||||
"renamed.border": "#c4daf6ff",
|
||||
"success": "#85b304ff",
|
||||
"success.background": "#e9efd2ff",
|
||||
"success.border": "#d7e3aeff",
|
||||
"unreachable": "#8b8e92ff",
|
||||
"unreachable.background": "#dcdddeff",
|
||||
"unreachable.border": "#cfd1d2ff",
|
||||
"warning": "#f1ad49ff",
|
||||
"warning.background": "#ffeedaff",
|
||||
"warning.border": "#ffe1beff",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#898d90ff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#898d90ff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"emphasis": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"color": "#3b9ee5ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"enum": {
|
||||
"color": "#f98d3fff"
|
||||
},
|
||||
"function": {
|
||||
"color": "#f2ad48ff"
|
||||
},
|
||||
"hint": {
|
||||
"color": "#8ca7c2ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#fa8d3eff"
|
||||
},
|
||||
"label": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#f98d3fff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#85b304ff"
|
||||
},
|
||||
"namespace": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"number": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"operator": {
|
||||
"color": "#ed9365ff"
|
||||
},
|
||||
"predictive": {
|
||||
"color": "#9eb9d3ff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"preproc": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"primary": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"property": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#73777bff"
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"selector": {
|
||||
"color": "#a37accff"
|
||||
},
|
||||
"selector.pseudo": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#86b300ff"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#898d90ff"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#4bbf98ff"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e6ba7eff"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f98d3fff"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#3b9ee5ff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#f98d3fff"
|
||||
},
|
||||
"title": {
|
||||
"color": "#5c6166ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"type": {
|
||||
"color": "#389ee6ff"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#5c6166ff"
|
||||
},
|
||||
"variant": {
|
||||
"color": "#3b9ee5ff"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Ayu Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#20242b",
|
||||
"accent.foreground": "#B3B1AD",
|
||||
"background": "#0D1016",
|
||||
"border": "#292a2c",
|
||||
"ring": "#FFB454",
|
||||
"foreground": "#B3B1AD",
|
||||
"input.border": "#2D2F34",
|
||||
"list.active.background": "#36A3D922",
|
||||
"list.active.border": "#36A3D9",
|
||||
"list.even.background": "#191F2A99",
|
||||
"muted.background": "#16191F",
|
||||
"muted.foreground": "#52514f",
|
||||
"popover.background": "#0D1016",
|
||||
"popover.foreground": "#B3B1AD",
|
||||
"primary.active.background": "#36A3D9",
|
||||
"primary.background": "#5ac1fe",
|
||||
"primary.foreground": "#1F2430",
|
||||
"primary.hover.background": "#3DAEE9",
|
||||
"scrollbar.background": "#0A0E1400",
|
||||
"scrollbar.thumb.background": "#bfbdb64c",
|
||||
"secondary.active.background": "#26282f",
|
||||
"secondary.background": "#1F2127",
|
||||
"secondary.foreground": "#B3B1AD",
|
||||
"secondary.hover.background": "#26282f99",
|
||||
"tab.active.foreground": "#B3B1AD",
|
||||
"tab.active.background": "#0D1016",
|
||||
"tab_bar.background": "#16191F",
|
||||
"title_bar.background": "#16191F",
|
||||
"title_bar.border": "#292a2c",
|
||||
"base.yellow": "#FEB454",
|
||||
"base.red": "#ef7177",
|
||||
"base.blue": "#5ac1fe",
|
||||
"base.green": "#aad84c",
|
||||
"base.magenta": "#d2a6ff",
|
||||
"base.cyan": "#5a728b"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#bfbdb6",
|
||||
"editor.background": "#0d1016",
|
||||
"editor.active_line.background": "#1f2127bf",
|
||||
"editor.line_number": "#4b4c4e",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#73777b66",
|
||||
"conflict": "#feb454ff",
|
||||
"conflict.background": "#572815ff",
|
||||
"conflict.border": "#754221ff",
|
||||
"created": "#aad84cff",
|
||||
"created.background": "#294113ff",
|
||||
"created.border": "#405c1cff",
|
||||
"deleted": "#ef7177ff",
|
||||
"deleted.background": "#48161bff",
|
||||
"deleted.border": "#66272dff",
|
||||
"error": "#ef7177ff",
|
||||
"error.background": "#48161bff",
|
||||
"error.border": "#66272dff",
|
||||
"hidden": "#696a6aff",
|
||||
"hidden.background": "#313337ff",
|
||||
"hidden.border": "#383a3eff",
|
||||
"hint": "#628b80ff",
|
||||
"hint.background": "#0d2f4eff",
|
||||
"hint.border": "#1b4a6eff",
|
||||
"ignored": "#696a6aff",
|
||||
"ignored.background": "#313337ff",
|
||||
"ignored.border": "#3f4043ff",
|
||||
"info": "#5ac1feff",
|
||||
"info.background": "#0d2f4eff",
|
||||
"info.border": "#1b4a6eff",
|
||||
"modified": "#feb454ff",
|
||||
"modified.background": "#572815ff",
|
||||
"modified.border": "#754221ff",
|
||||
"predictive": "#5a728bff",
|
||||
"predictive.background": "#294113ff",
|
||||
"predictive.border": "#405c1cff",
|
||||
"renamed": "#5ac1feff",
|
||||
"renamed.background": "#0d2f4eff",
|
||||
"renamed.border": "#1b4a6eff",
|
||||
"success": "#aad84cff",
|
||||
"success.background": "#294113ff",
|
||||
"success.border": "#405c1cff",
|
||||
"unreachable": "#8a8986ff",
|
||||
"unreachable.background": "#313337ff",
|
||||
"unreachable.border": "#3f4043ff",
|
||||
"warning": "#feb454ff",
|
||||
"warning.background": "#572815ff",
|
||||
"warning.border": "#754221ff",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#8c8b88ff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#8c8b88ff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"emphasis": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"color": "#5ac1feff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"enum": {
|
||||
"color": "#fe8f40ff"
|
||||
},
|
||||
"function": {
|
||||
"color": "#ffb353ff"
|
||||
},
|
||||
"hint": {
|
||||
"color": "#628b80ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#ff8f3fff"
|
||||
},
|
||||
"label": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#fe8f40ff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#aad84cff"
|
||||
},
|
||||
"namespace": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"number": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"operator": {
|
||||
"color": "#f29668ff"
|
||||
},
|
||||
"predictive": {
|
||||
"color": "#5a728bff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"preproc": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"primary": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"property": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#a6a5a0ff"
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"selector": {
|
||||
"color": "#d2a6ffff"
|
||||
},
|
||||
"selector.pseudo": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a9d94bff"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#8c8b88ff"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#95e6cbff"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e5b572ff"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#fe8f40ff"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#5ac1feff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#fe8f40ff"
|
||||
},
|
||||
"title": {
|
||||
"color": "#bfbdb6ff",
|
||||
"font_weight": 700
|
||||
},
|
||||
"type": {
|
||||
"color": "#59c2ffff"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#bfbdb6ff"
|
||||
},
|
||||
"variant": {
|
||||
"color": "#5ac1feff"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Catppuccin",
|
||||
"author": "Catppuccino",
|
||||
"url": "https://github.com/catppuccin/catppuccin",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Catppuccin Latte",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#d3d8e0",
|
||||
"accent.foreground": "#4c4f69",
|
||||
"background": "#E5E9EF",
|
||||
"border": "#CCD0DA",
|
||||
"ring": "#7287fd",
|
||||
"foreground": "#4c4f69",
|
||||
"input.border": "#acb0be",
|
||||
"link.active.foreground": "#7287fd",
|
||||
"link.foreground": "#7287fd",
|
||||
"link.hover.foreground": "#7287fd",
|
||||
"list.active.background": "#7287fd22",
|
||||
"list.active.border": "#7287fd",
|
||||
"list.even.background": "#EFF1F5",
|
||||
"list.head.background": "#dce0e8",
|
||||
"muted.background": "#dce0e8",
|
||||
"muted.foreground": "#9a9db2",
|
||||
"panel.background": "#dce0e8",
|
||||
"primary.active.background": "#7287fd",
|
||||
"primary.background": "#7287fd",
|
||||
"primary.foreground": "#EFF1F5",
|
||||
"scrollbar.background": "#EFF1F500",
|
||||
"scrollbar.thumb.background": "#acb0be",
|
||||
"secondary.active.background": "#CCD2DE",
|
||||
"secondary.background": "#dce0e8",
|
||||
"secondary.foreground": "#4c4f69",
|
||||
"secondary.hover.background": "#CCD2DE99",
|
||||
"tab.active.background": "#E5E9EF",
|
||||
"tab.active.foreground": "#4c4f69",
|
||||
"tab.background": "#D2D7E200",
|
||||
"tab.foreground": "#82848c",
|
||||
"tab_bar.background": "#DCE0E8",
|
||||
"title_bar.background": "#DCE0E8",
|
||||
"title_bar.border": "#bec3d0",
|
||||
"base.red": "#d26a53",
|
||||
"base.green": "#5aa93b",
|
||||
"base.yellow": "#df8e1d",
|
||||
"base.blue": "#78acdc",
|
||||
"base.magenta": "#8778dc",
|
||||
"base.magenta.light": "#9978dc66",
|
||||
"base.cyan": "#53d2b0"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#4c4f69",
|
||||
"editor.background": "#EFF1F5",
|
||||
"editor.active_line.background": "#dce0e8",
|
||||
"editor.line_number": "#9a9db2",
|
||||
"editor.active_line_number": "#4c4f69",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#d20f39",
|
||||
"created": "#85dc78",
|
||||
"deleted": "#dc8a78",
|
||||
"error": "#d20f39",
|
||||
"hidden": "#9a9db2",
|
||||
"hint": "#7287fd",
|
||||
"ignored": "#acb0be",
|
||||
"info": "#1e66f5",
|
||||
"modified": "#df8e1d",
|
||||
"predictive": "#9a9db2",
|
||||
"renamed": "#9978dc",
|
||||
"success": "#85dc78",
|
||||
"unreachable": "#acb0be",
|
||||
"warning": "#df8e1d",
|
||||
"syntax": {
|
||||
"variable": {
|
||||
"color": "#4c4f69"
|
||||
},
|
||||
"variable.builtin": {
|
||||
"color": "#d20f39"
|
||||
},
|
||||
"variable.parameter": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"variable.member": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#d20f39",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"constant.builtin": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"constant.macro": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"module": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"label": {
|
||||
"color": "#209fb5"
|
||||
},
|
||||
"string": {
|
||||
"color": "#85dc78"
|
||||
},
|
||||
"string.documentation": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"string.regexp": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.special.path": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#dd7878"
|
||||
},
|
||||
"string.special.url": {
|
||||
"color": "#dc8a78",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"character": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"character.special": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"number": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"number.float": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"type": {
|
||||
"color": "#df8e1d"
|
||||
},
|
||||
"type.builtin": {
|
||||
"color": "#8839ef",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"type.definition": {
|
||||
"color": "#df8e1d"
|
||||
},
|
||||
"type.interface": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"type.super": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"attribute": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"property": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function.builtin": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"function.call": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function.macro": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"function.method": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"function.method.call": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#dd7878"
|
||||
},
|
||||
"operator": {
|
||||
"color": "#04a5e5"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.modifier": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.type": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.coroutine": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.function": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.operator": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.import": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.repeat": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.return": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.debug": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.exception": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.conditional": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.conditional.ternary": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"keyword.directive": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"keyword.directive.define": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"keyword.export": {
|
||||
"color": "#04a5e5"
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#7c7f93"
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#7c7f93"
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#7c7f93"
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"punctuation.special.symbol": {
|
||||
"color": "#dd7878"
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#7c7f93",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#7c7f93",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.documentation": {
|
||||
"color": "#7c7f93",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.error": {
|
||||
"color": "#d20f39",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.warning": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.hint": {
|
||||
"color": "#1e66f5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.todo": {
|
||||
"color": "#dd7878",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.note": {
|
||||
"color": "#dc8a78",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"diff.plus": {
|
||||
"color": "#40a02b"
|
||||
},
|
||||
"diff.minus": {
|
||||
"color": "#d20f39"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#1e66f5"
|
||||
},
|
||||
"tag.attribute": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"tag.delimiter": {
|
||||
"color": "#179299"
|
||||
},
|
||||
"parameter": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"field": {
|
||||
"color": "#7287fd"
|
||||
},
|
||||
"namespace": {
|
||||
"color": "#df8e1d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"float": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"symbol": {
|
||||
"color": "#ea76cb"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"text": {
|
||||
"color": "#4c4f69"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"color": "#e64553",
|
||||
"font_weight": 700
|
||||
},
|
||||
"emphasis": {
|
||||
"color": "#e64553",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#40a02b"
|
||||
},
|
||||
"concept": {
|
||||
"color": "#209fb5"
|
||||
},
|
||||
"enum": {
|
||||
"color": "#179299",
|
||||
"font_weight": 700
|
||||
},
|
||||
"function.decorator": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"type.class.definition": {
|
||||
"color": "#df8e1d",
|
||||
"font_weight": 700
|
||||
},
|
||||
"hint": {
|
||||
"color": "#acb0be",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7287fd"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#1e66f5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"parent": {
|
||||
"color": "#fe640b"
|
||||
},
|
||||
"predictive": {
|
||||
"color": "#9ca0b0"
|
||||
},
|
||||
"predoc": {
|
||||
"color": "#d20f39"
|
||||
},
|
||||
"primary": {
|
||||
"color": "#e64553"
|
||||
},
|
||||
"tag.doctype": {
|
||||
"color": "#8839ef"
|
||||
},
|
||||
"string.doc": {
|
||||
"color": "#179299",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"title": {
|
||||
"color": "#4c4f69",
|
||||
"font_weight": 800
|
||||
},
|
||||
"variant": {
|
||||
"color": "#d20f39"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Catppuccin Frappe",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#3b3f4f",
|
||||
"accent.foreground": "#c6d0f5",
|
||||
"background": "#232634",
|
||||
"border": "#3e4255",
|
||||
"ring": "#f2d5cf",
|
||||
"foreground": "#c6d0f5",
|
||||
"input.border": "#51576d",
|
||||
"list.active.background": "#8caaee15",
|
||||
"list.even.background": "#303446",
|
||||
"list.head.background": "#2A2C3C",
|
||||
"muted.background": "#2c303f",
|
||||
"muted.foreground": "#979db5",
|
||||
"panel.background": "#414559",
|
||||
"popover.background": "#1E202B",
|
||||
"popover.foreground": "#c6d0f5",
|
||||
"primary.active.background": "#8caaee",
|
||||
"primary.background": "#8caaee",
|
||||
"primary.foreground": "#232634",
|
||||
"primary.hover.background": "#8caaee",
|
||||
"scrollbar.background": "#30344600",
|
||||
"scrollbar.thumb.background": "#51576d",
|
||||
"secondary.active.background": "#313445",
|
||||
"secondary.background": "#414559",
|
||||
"secondary.foreground": "#c6d0f5",
|
||||
"secondary.hover.background": "#31344599",
|
||||
"tab.active.background": "#232634",
|
||||
"tab.active.foreground": "#dce2f9",
|
||||
"tab.background": "#1E202B00",
|
||||
"tab.foreground": "#abb5d9",
|
||||
"tab_bar.background": "#1E202B",
|
||||
"title_bar.background": "#1D202B",
|
||||
"title_bar.border": "#30354b",
|
||||
"base.red": "#e78284",
|
||||
"base.green": "#a6d189",
|
||||
"base.yellow": "#e7d682",
|
||||
"base.blue": "#8caaee",
|
||||
"base.magenta": "#9882e7",
|
||||
"base.magenta.light": "#9882e766",
|
||||
"base.cyan": "#81c8be"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#c6d0f5",
|
||||
"editor.background": "#232634",
|
||||
"editor.active_line.background": "#41455977",
|
||||
"editor.line_number": "#979db5",
|
||||
"editor.active_line_number": "#c6d0f5",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#e78284",
|
||||
"created": "#a6d189",
|
||||
"deleted": "#3b2e2e",
|
||||
"error": "#3b2e2e",
|
||||
"hidden": "#51576d",
|
||||
"hint": "#8caaee",
|
||||
"info": "#1e2b4d",
|
||||
"modified": "#e7d682",
|
||||
"predictive": "#51576d",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#e78284"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#51576d"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#51576d"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#c6d0f5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#8caaee"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e78284"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#8caaee",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#51576d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e78284"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a6d189"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#e7d682"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#8caaee"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c6d0f5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#e78284",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#8caaee"
|
||||
},
|
||||
"property": {
|
||||
"color": "#c6d0f5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e78284"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Catppuccin Macchiato",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#30344d",
|
||||
"accent.foreground": "#cad3f5",
|
||||
"action_bar.background": "#1E2030",
|
||||
"background": "#1E2030",
|
||||
"border": "#494d64",
|
||||
"ring": "#f4dbd6",
|
||||
"danger.background": "#ed8796",
|
||||
"foreground": "#cad3f5",
|
||||
"input.border": "#494d64",
|
||||
"link.active.foreground": "#8aadf4",
|
||||
"link.foreground": "#8aadf4",
|
||||
"link.hover.foreground": "#8aadf4",
|
||||
"list.active.background": "#8aadf420",
|
||||
"list.active.border": "#8aadf4",
|
||||
"list.even.background": "#24273a",
|
||||
"list.head.background": "#363a4f33",
|
||||
"muted.background": "#282a3a",
|
||||
"muted.foreground": "#b8c0e0",
|
||||
"popover.foreground": "#cad3f5",
|
||||
"primary.active.background": "#8aadf499",
|
||||
"primary.background": "#8aadf4",
|
||||
"primary.foreground": "#24273a",
|
||||
"primary.hover.background": "#8aadf4",
|
||||
"scrollbar.background": "#24273a00",
|
||||
"scrollbar.thumb.background": "#494d64",
|
||||
"secondary.active.background": "#3a3f55",
|
||||
"secondary.background": "#363a4f",
|
||||
"secondary.foreground": "#cad3f5",
|
||||
"secondary.hover.background": "#24273a99",
|
||||
"tab.background": "#17192600",
|
||||
"tab.foreground": "#aeb8db",
|
||||
"tab_bar.background": "#171926",
|
||||
"title_bar.background": "#171926",
|
||||
"title_bar.border": "#363A4F",
|
||||
"base.red": "#ed8796",
|
||||
"base.green": "#a6da95",
|
||||
"base.yellow": "#eed49f",
|
||||
"base.blue": "#8aadf4",
|
||||
"base.magenta": "#f5bde6",
|
||||
"base.magenta.light": "#f5bde666",
|
||||
"base.cyan": "#8bd5ca"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#cad3f5",
|
||||
"editor.background": "#1E2030",
|
||||
"editor.active_line.background": "#363a4f",
|
||||
"editor.line_number": "#b8c0e0",
|
||||
"editor.active_line_number": "#cad3f5",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#ed8796",
|
||||
"created": "#a6da95",
|
||||
"deleted": "#ed8796",
|
||||
"error": "#ed8796",
|
||||
"hidden": "#b8c0e0",
|
||||
"hint": "#8aadf4",
|
||||
"ignored": "#494d64",
|
||||
"info": "#8aadf4",
|
||||
"modified": "#eed49f",
|
||||
"predictive": "#b8c0e0",
|
||||
"renamed": "#f5bde6",
|
||||
"success": "#a6da95",
|
||||
"unreachable": "#b8c0e0",
|
||||
"warning": "#eed49f",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f5bde6"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#b8c0e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#b8c0e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#cad3f5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#f5bde6"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#8aadf4",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#b8c0e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a6da95"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a6da95"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a6da95"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#eed49f"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cad3f5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#f5bde6",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#8aadf4"
|
||||
},
|
||||
"property": {
|
||||
"color": "#cad3f5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#f5bde6"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Catppuccin Mocha",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2e2e3e",
|
||||
"accent.foreground": "#cdd6f4",
|
||||
"background": "#181825",
|
||||
"border": "#313244",
|
||||
"ring": "#cba6f7",
|
||||
"danger.background": "#f38ba8",
|
||||
"danger.active.background": "#eba0ac",
|
||||
"danger.hover.background": "#f38ba8",
|
||||
"foreground": "#cdd6f4",
|
||||
"input.border": "#6c7086",
|
||||
"link.active.foreground": "#89b4fa",
|
||||
"link.foreground": "#89b4fa",
|
||||
"link.hover.foreground": "#89b4fa",
|
||||
"list.active.background": "#89b4fa15",
|
||||
"list.active.border": "#89b4fa77",
|
||||
"list.hover.background": "#202031",
|
||||
"list.even.background": "#161622",
|
||||
"list.head.background": "#1E1E2E",
|
||||
"muted.background": "#302d41",
|
||||
"muted.foreground": "#6c7086",
|
||||
"panel.background": "#302d41",
|
||||
"popover.background": "#1e1e2e",
|
||||
"popover.foreground": "#cdd6f4",
|
||||
"primary.active.background": "#89b4fa",
|
||||
"primary.background": "#89b4fa",
|
||||
"primary.foreground": "#1e1e2e",
|
||||
"primary.hover.background": "#74c7ec",
|
||||
"scrollbar.background": "#1e1e2e00",
|
||||
"scrollbar.thumb.background": "#4e4e5e",
|
||||
"secondary.active.background": "#45475a",
|
||||
"secondary.background": "#302d41",
|
||||
"secondary.foreground": "#cdd6f4",
|
||||
"secondary.hover.background": "#575268",
|
||||
"tab.background": "#0B0B1100",
|
||||
"tab.foreground": "#aeb7d5",
|
||||
"tab_bar.background": "#0B0B11",
|
||||
"title_bar.background": "#11111B",
|
||||
"title_bar.border": "#313244",
|
||||
"base.red": "#f38ba8",
|
||||
"base.green": "#a6e3a1",
|
||||
"base.yellow": "#f9e2af",
|
||||
"base.blue": "#89b4fa",
|
||||
"base.magenta": "#f5c2e7",
|
||||
"base.magenta.light": "#f38ba866",
|
||||
"base.cyan": "#94e2d5"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#cdd6f4",
|
||||
"editor.background": "#181825",
|
||||
"editor.active_line.background": "#222230AA",
|
||||
"editor.line_number": "#6c7086",
|
||||
"editor.active_line_number": "#cdd6f4",
|
||||
"editor.invisible": "#7c7f9366",
|
||||
"conflict": "#f38ba8",
|
||||
"created": "#a6e3a1",
|
||||
"deleted": "#f38ba8",
|
||||
"error": "#f38ba8",
|
||||
"hidden": "#6c7086",
|
||||
"hint": "#89b4fa",
|
||||
"ignored": "#6c7086",
|
||||
"info": "#89b4fa",
|
||||
"modified": "#f9e2af",
|
||||
"predictive": "#6c7086",
|
||||
"renamed": "#f5c2e7",
|
||||
"success": "#a6e3a1",
|
||||
"unreachable": "#6c7086",
|
||||
"warning": "#f9e2af",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f5c2e7"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#6c7086",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#6c7086",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#cdd6f4"
|
||||
},
|
||||
"function": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#f5c2e7"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#89b4fa",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6c7086",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a6e3a1"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a6e3a1"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a6e3a1"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f9e2af"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cdd6f4"
|
||||
},
|
||||
"title": {
|
||||
"color": "#f5c2e7",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#89b4fa"
|
||||
},
|
||||
"property": {
|
||||
"color": "#cdd6f4"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#f5c2e7"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Everforest",
|
||||
"author": "sainnhe",
|
||||
"url": "https://github.com/sainnhe/everforest",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Everforest Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#E7E5D4",
|
||||
"accent.foreground": "#5F6D75",
|
||||
"background": "#FEFCEE",
|
||||
"border": "#E7E5D4",
|
||||
"danger.background": "#e67e80",
|
||||
"danger.foreground": "#ffffff",
|
||||
"foreground": "#5F6D75",
|
||||
"input.border": "#E7E5D4",
|
||||
"link.active.foreground": "#7fbbb3",
|
||||
"link.foreground": "#7fbbb3",
|
||||
"link.hover.foreground": "#7fbbb3",
|
||||
"list.active.background": "#a7c08025",
|
||||
"list.active.border": "#a7c080",
|
||||
"list.even.background": "#F4F1E2",
|
||||
"list.head.background": "#F4F1E2",
|
||||
"muted.background": "#f1f0e2",
|
||||
"muted.foreground": "#959a9d",
|
||||
"popover.background": "#FEFCEE",
|
||||
"popover.foreground": "#5F6D75",
|
||||
"primary.background": "#e69875",
|
||||
"primary.foreground": "#FEFCEE",
|
||||
"scrollbar.background": "#e0e0e000",
|
||||
"scrollbar.thumb.background": "#D7D5C4",
|
||||
"secondary.background": "#EEEADA",
|
||||
"secondary.foreground": "#5F6D75",
|
||||
"tab.active.background": "#FEFCEE",
|
||||
"tab.active.foreground": "#5F6D75",
|
||||
"tab.background": "#F4F1E200",
|
||||
"tab.foreground": "#6b7b84",
|
||||
"tab_bar.background": "#F4F1E2",
|
||||
"title_bar.background": "#F9F5E4",
|
||||
"title_bar.border": "#E7E5D4",
|
||||
"status_bar.background": "#F9F5E4",
|
||||
"base.red": "#e67e80",
|
||||
"base.green": "#a7c080",
|
||||
"base.yellow": "#dbbc7f",
|
||||
"base.blue": "#7fbbb3",
|
||||
"base.magenta": "#d699b6",
|
||||
"base.magenta.light": "#d699b699",
|
||||
"base.cyan": "#83c092"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#5F6D75",
|
||||
"editor.background": "#FEFCEE",
|
||||
"editor.active_line.background": "#E7E5D4",
|
||||
"editor.line_number": "#959a9d",
|
||||
"editor.active_line_number": "#5F6D75",
|
||||
"editor.invisible": "#959a9d66",
|
||||
"conflict": "#e67e80",
|
||||
"created": "#a7c080",
|
||||
"deleted": "#e67e80",
|
||||
"error": "#e67e80",
|
||||
"hidden": "#959a9d",
|
||||
"hint": "#7fbbb3",
|
||||
"ignored": "#959a9d",
|
||||
"info": "#7fbbb3",
|
||||
"modified": "#dbbc7f",
|
||||
"predictive": "#5F6D75",
|
||||
"renamed": "#a7c080",
|
||||
"success": "#a7c080",
|
||||
"unreachable": "#959a9d",
|
||||
"warning": "#dbbc7f",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#e69875"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#959a9d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#959a9d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#e69875"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#5F6D75"
|
||||
},
|
||||
"function": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e67e80"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7fbbb3",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#5F6D75",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e69875"
|
||||
},
|
||||
"string": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#a7c080"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#dbbc7f"
|
||||
},
|
||||
"title": {
|
||||
"color": "#e69875",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#7fbbb3"
|
||||
},
|
||||
"property": {
|
||||
"color": "#5F6D75"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e67e80"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Everforest Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#3c4448",
|
||||
"accent.foreground": "#d3c6aa",
|
||||
"background": "#262E34",
|
||||
"border": "#40484c",
|
||||
"foreground": "#d3c6aa",
|
||||
"input.border": "#485156",
|
||||
"link.active.foreground": "#7fbbb3",
|
||||
"link.foreground": "#7fbbb3",
|
||||
"link.hover.foreground": "#7fbbb3",
|
||||
"list.active.background": "#a7c08022",
|
||||
"list.active.border": "#a7c08088",
|
||||
"list.even.background": "#2E383B",
|
||||
"list.head.background": "#2E383B",
|
||||
"muted.background": "#2E383B",
|
||||
"muted.foreground": "#6D7873",
|
||||
"panel.background": "#2E383B",
|
||||
"popover.background": "#262E34",
|
||||
"popover.foreground": "#d3c6aa",
|
||||
"primary.background": "#e69875",
|
||||
"primary.foreground": "#262E34",
|
||||
"scrollbar.background": "#2E383B00",
|
||||
"scrollbar.thumb.background": "#485156",
|
||||
"secondary.background": "#2E383B",
|
||||
"secondary.foreground": "#849087",
|
||||
"secondary.active.background": "#303b3e",
|
||||
"secondary.hover.background": "#3E474B99",
|
||||
"tab.active.background": "#262E34",
|
||||
"tab.active.foreground": "#d3c6aa",
|
||||
"tab.background": "#262E3400",
|
||||
"tab.foreground": "#849087",
|
||||
"tab_bar.background": "#2E383B",
|
||||
"title_bar.background": "#1f262b",
|
||||
"title_bar.border": "#40484c",
|
||||
"base.red": "#e67e80",
|
||||
"base.green": "#a7c080",
|
||||
"base.yellow": "#dbbc7f",
|
||||
"base.blue": "#7fbbb3",
|
||||
"base.magenta": "#844d64",
|
||||
"base.magenta.light": "#844d6499",
|
||||
"base.cyan": "#83c092"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#d3c6aa",
|
||||
"editor.background": "#262E34",
|
||||
"editor.active_line.background": "#3c4448",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#959a9d66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Fahrenheit",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Fahrenheit",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#171717",
|
||||
"accent.foreground": "#FFFFCE",
|
||||
"background": "#000000",
|
||||
"border": "#252525",
|
||||
"danger.background": "#593002",
|
||||
"foreground": "#FFFFCE",
|
||||
"input.border": "#252525",
|
||||
"list.active.background": "#72020222",
|
||||
"list.active.border": "#720202",
|
||||
"list.even.background": "#090909",
|
||||
"list.hover.background": "#111111",
|
||||
"muted.background": "#20202099",
|
||||
"muted.foreground": "#828282",
|
||||
"panel.background": "#1e1e1e",
|
||||
"popover.background": "#090909",
|
||||
"popover.foreground": "#FFFFCE",
|
||||
"primary.background": "#720202",
|
||||
"primary.foreground": "#FFFFCE",
|
||||
"scrollbar.background": "#1e1e1e00",
|
||||
"scrollbar.thumb.background": "#3e3e3e",
|
||||
"secondary.background": "#202020",
|
||||
"secondary.active.background": "#202020DD",
|
||||
"secondary.foreground": "#979797",
|
||||
"secondary.hover.background": "#20202099",
|
||||
"tab.foreground": "#808080",
|
||||
"title_bar.background": "#0e0e0e",
|
||||
"table.background": "#1e1e1e00",
|
||||
"table.hover.background": "#1E1E1E",
|
||||
"ring": "#570101",
|
||||
"base.red": "#723202",
|
||||
"base.red.light": "#c97636",
|
||||
"base.green": "#027225",
|
||||
"base.green.light": "#39c936",
|
||||
"base.yellow": "#726302",
|
||||
"base.yellow.light": "#c9b536",
|
||||
"base.blue": "#022d72",
|
||||
"base.blue.light": "#0551cb",
|
||||
"base.magenta": "#3a286c",
|
||||
"base.magenta.light": "#58197a",
|
||||
"base.cyan": "#027272",
|
||||
"base.cyan.light": "#36c9c9"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#FFFFCE",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#1e1e1e",
|
||||
"editor.line_number": "#828282",
|
||||
"editor.invisible": "#82828266",
|
||||
"warning.border": "#720202",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#fecf75"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#828282"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#828282"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#dcdcdc"
|
||||
},
|
||||
"function": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#cda074",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#9e744d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"string": {
|
||||
"color": "#cc734d"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#cc734d"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#cc734d"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#cb4a05"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#fd9f4d"
|
||||
},
|
||||
"title": {
|
||||
"color": "#cda074",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#cda074"
|
||||
},
|
||||
"property": {
|
||||
"color": "#dcdcdc"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#cb4a05"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Flexoki",
|
||||
"author": "kepano",
|
||||
"url": "https://github.com/kepano/flexoki",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Flexoki Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#E8E4CE",
|
||||
"accent.foreground": "#100F0F",
|
||||
"background": "#FFFCF0",
|
||||
"foreground": "#100F0F",
|
||||
"border": "#E6E4D9",
|
||||
"ring": "#D0A215",
|
||||
"selection.background": "#D0A21577",
|
||||
"input.border": "#E6E4D9",
|
||||
"list.active.background": "#D0A21520",
|
||||
"list.active.border": "#D0A215",
|
||||
"list.background": "#FFFCF0",
|
||||
"list.even.background": "#F2F0E5",
|
||||
"muted.background": "#F2F0E5",
|
||||
"muted.foreground": "#6F6E69",
|
||||
"primary.background": "#3AA99F",
|
||||
"primary.foreground": "#FAFAFA",
|
||||
"info.background": "#4385BE",
|
||||
"scrollbar.background": "#FAFAFA00",
|
||||
"scrollbar.thumb.background": "#E6E4D9",
|
||||
"scrollbar.thumb.hover.background": "#DAD8CE",
|
||||
"secondary.active.background": "#DAD8CE",
|
||||
"secondary.background": "#F2F0E5",
|
||||
"secondary.foreground": "#100F0F",
|
||||
"secondary.hover.background": "#E6E4D9",
|
||||
"tab.active.background": "#FFFCF0",
|
||||
"tab.active.foreground": "#100F0F",
|
||||
"tab.background": "#ECECED00",
|
||||
"tab.foreground": "#8d8986",
|
||||
"tab_bar.background": "#F2F0E5",
|
||||
"title_bar.background": "#F2F0E5",
|
||||
"title_bar.border": "#DAD8CE",
|
||||
"base.yellow": "#D0A215",
|
||||
"base.red": "#D14D41",
|
||||
"base.blue": "#4385BE",
|
||||
"base.green": "#879A39",
|
||||
"base.magenta": "#CE5D97",
|
||||
"base.cyan": "#3AA99F"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#100F0F",
|
||||
"editor.background": "#FFFCF0",
|
||||
"editor.active_line.background": "#F2F0E5",
|
||||
"editor.line_number": "#B7B5AC",
|
||||
"editor.active_line_number": "#24837B",
|
||||
"editor.invisible": "#B7B5AC66",
|
||||
"conflict": "#AD8301",
|
||||
"conflict.background": "#FAEEC6",
|
||||
"conflict.border": "#AD8301",
|
||||
"created": "#66800B",
|
||||
"created.background": "#EDEECF",
|
||||
"created.border": "#66800B",
|
||||
"deleted": "#AF3029",
|
||||
"deleted.background": "#FFE1D5",
|
||||
"deleted.border": "#AF3029",
|
||||
"error": "#AF3029",
|
||||
"error.background": "#FFE1D5",
|
||||
"error.border": "#AF3029",
|
||||
"hidden": "#B7B5AC",
|
||||
"hidden.background": "#F2F0E5",
|
||||
"hidden.border": "#B7B5AC",
|
||||
"hint": "#B7B5AC",
|
||||
"hint.background": "#F2F0E5",
|
||||
"hint.border": "#B7B5AC",
|
||||
"ignored": "#B7B5AC",
|
||||
"ignored.background": "#F2F0E5",
|
||||
"ignored.border": "#B7B5AC",
|
||||
"info": "#24837B",
|
||||
"info.background": "#DDF1E4",
|
||||
"info.border": "#24837B",
|
||||
"modified": "#AD8301",
|
||||
"modified.background": "#FAEEC6",
|
||||
"modified.border": "#AD8301",
|
||||
"predictive": "#B7B5AC",
|
||||
"renamed": "#24837B",
|
||||
"renamed.background": "#DDF1E4",
|
||||
"renamed.border": "#24837B",
|
||||
"success": "#66800B",
|
||||
"success.background": "#EDEECF",
|
||||
"success.border": "#66800B",
|
||||
"unreachable": "#6F6E69",
|
||||
"unreachable.background": "#F2F0E5",
|
||||
"unreachable.border": "#6F6E69",
|
||||
"warning": "#AD8301",
|
||||
"warning.background": "#FAEEC6",
|
||||
"warning.border": "#AD8301",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#205EA6"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#BC5215"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#B7B5AC"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#B7B5AC"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#F07171"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#205EA6"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#5C6773"
|
||||
},
|
||||
"function": {
|
||||
"color": "#BC5215"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#66800B"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"number": {
|
||||
"color": "#5E409D"
|
||||
},
|
||||
"string": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#205EA6"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#24837B"
|
||||
},
|
||||
"title": {
|
||||
"color": "#24837B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#AD8301"
|
||||
},
|
||||
"property": {
|
||||
"color": "#BC5215"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#205EA6"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Flexoki Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#242221",
|
||||
"accent.foreground": "#CECDC3",
|
||||
"background": "#100F0F",
|
||||
"border": "#282726",
|
||||
"ring": "#AD8301",
|
||||
"selection.background": "#D0A21577",
|
||||
"foreground": "#CECDC3",
|
||||
"input.border": "#282726",
|
||||
"list.active.background": "#D0A21515",
|
||||
"list.active.border": "#AD8301",
|
||||
"list.even.background": "#1C1B1A",
|
||||
"muted.background": "#1C1B1A",
|
||||
"muted.foreground": "#878580",
|
||||
"panel.background": "#1C1B1A",
|
||||
"popover.background": "#100F0F",
|
||||
"popover.foreground": "#CECDC3",
|
||||
"primary.background": "#24837B",
|
||||
"primary.foreground": "#FFFCF0",
|
||||
"info.background": "#205EA6",
|
||||
"scrollbar.background": "#100F0F00",
|
||||
"scrollbar.thumb.background": "#282726",
|
||||
"scrollbar.thumb.hover.background": "#343331",
|
||||
"secondary.background": "#232221",
|
||||
"secondary.active.background": "#262423",
|
||||
"secondary.foreground": "#CECDC3",
|
||||
"secondary.hover.background": "#232221",
|
||||
"tab.active.background": "#100F0F",
|
||||
"tab.active.foreground": "#CECDC3",
|
||||
"tab.background": "#1C1B1A",
|
||||
"tab.foreground": "#878580",
|
||||
"tab_bar.background": "#1C1B1A99",
|
||||
"title_bar.background": "#191818",
|
||||
"base.yellow": "#AD8301",
|
||||
"base.yellow.light": "#D0A215",
|
||||
"base.red": "#AF3029",
|
||||
"base.red.light": "#D14D41",
|
||||
"base.blue": "#205EA6",
|
||||
"base.blue.light": "#4385BE",
|
||||
"base.green": "#66800B",
|
||||
"base.green.light": "#879A39",
|
||||
"base.magenta": "#A02F6F",
|
||||
"base.magenta.light": "#CE5D97",
|
||||
"base.cyan": "#24837B",
|
||||
"base.cyan.light": "#3AA99F"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#CECDC3",
|
||||
"editor.background": "#100F0F",
|
||||
"editor.active_line.background": "#1C1B1A",
|
||||
"editor.line_number": "#575653",
|
||||
"editor.active_line_number": "#3AA99F",
|
||||
"editor.invisible": "#B7B5AC66",
|
||||
"conflict": "#D0A215",
|
||||
"created": "#879A39",
|
||||
"hidden": "#575653",
|
||||
"hint": "#575653",
|
||||
"modified": "#D0A215",
|
||||
"predictive": "#878580",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#4385BE"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#DA702C"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#575653"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#575653"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#CE5D97"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#4385BE"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#878580"
|
||||
},
|
||||
"function": {
|
||||
"color": "#DA702C"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#879A39"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"number": {
|
||||
"color": "#8B7EC8"
|
||||
},
|
||||
"string": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#4385BE"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#3AA99F"
|
||||
},
|
||||
"title": {
|
||||
"color": "#D0A215",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#D0A215"
|
||||
},
|
||||
"property": {
|
||||
"color": "#DA702C"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#4385BE"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Gruvbox",
|
||||
"author": "Pavel Pertsev",
|
||||
"url": "https://github.com/morhetz/gruvbox",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Gruvbox Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#e6d19e",
|
||||
"accent.foreground": "#3c3836",
|
||||
"background": "#fbf1c7",
|
||||
"border": "#d5c4a1",
|
||||
"danger.background": "#fb4934",
|
||||
"danger.active.background": "#cc241d",
|
||||
"danger.foreground": "#fbf1c7",
|
||||
"foreground": "#3c3836",
|
||||
"input.border": "#d5c4a1",
|
||||
"link.active.foreground": "#458588",
|
||||
"link.foreground": "#458588",
|
||||
"link.hover.foreground": "#458588",
|
||||
"list.active.background": "#d7992122",
|
||||
"list.hover.background": "#ebdbb2",
|
||||
"list.active.border": "#d79921",
|
||||
"list.even.background": "#f9ecba",
|
||||
"muted.background": "#d5c4a166",
|
||||
"muted.foreground": "#928374",
|
||||
"panel.background": "#ebdbb2",
|
||||
"popover.background": "#fbf1c7",
|
||||
"popover.foreground": "#3c3836",
|
||||
"primary.active.background": "#b7841f",
|
||||
"primary.background": "#d79921",
|
||||
"primary.foreground": "#fbf1c7",
|
||||
"primary.hover.background": "#d79921ee",
|
||||
"ring": "#d79921",
|
||||
"scrollbar.background": "#fbf1c700",
|
||||
"scrollbar.thumb.background": "#d5c4a1",
|
||||
"secondary.background": "#EBDBB2",
|
||||
"secondary.active.background": "#d5c4a1",
|
||||
"secondary.foreground": "#3c3836",
|
||||
"secondary.hover.background": "#e8d5a6",
|
||||
"tab.active.background": "#fbf1c7",
|
||||
"tab.active.foreground": "#3c3836",
|
||||
"tab.background": "#ebdbb200",
|
||||
"tab.foreground": "#928374",
|
||||
"tab_bar.background": "#ebdbb2",
|
||||
"title_bar.background": "#ebdbb2",
|
||||
"title_bar.border": "#d5c4a1",
|
||||
"base.magenta.light": "#D3869B66",
|
||||
"base.red": "#fb4934",
|
||||
"base.green": "#67a64f",
|
||||
"base.yellow": "#b57614",
|
||||
"base.blue": "#076678",
|
||||
"base.magenta": "#8f3f71",
|
||||
"base.cyan": "#427b58"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#3c3836",
|
||||
"editor.background": "#fbf1c7",
|
||||
"editor.active_line.background": "#ebdbb2",
|
||||
"editor.line_number": "#928374",
|
||||
"editor.active_line_number": "#3c3836",
|
||||
"editor.invisible": "#92837466",
|
||||
"conflict": "#cc241d",
|
||||
"created": "#79740e",
|
||||
"deleted": "#9d0006",
|
||||
"error": "#cc241d",
|
||||
"hidden": "#928374",
|
||||
"hint": "#458588",
|
||||
"ignored": "#928374",
|
||||
"info": "#458588",
|
||||
"modified": "#b57614",
|
||||
"predictive": "#928374",
|
||||
"renamed": "#b57614",
|
||||
"success": "#79740e",
|
||||
"unreachable": "#928374",
|
||||
"warning": "#d79921",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#928374",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#928374",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#3c3836"
|
||||
},
|
||||
"function": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#cc241d"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#458588",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#076678",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#cc241d"
|
||||
},
|
||||
"string": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"title": {
|
||||
"color": "#b57614",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"property": {
|
||||
"color": "#3c3836"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#cc241d"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Gruvbox Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#303030",
|
||||
"accent.foreground": "#ebdbb2",
|
||||
"background": "#1d2021",
|
||||
"border": "#3e3936",
|
||||
"danger.active.background": "#fb4934",
|
||||
"foreground": "#ebdbb2",
|
||||
"input.border": "#504945",
|
||||
"link.active.foreground": "#83a598",
|
||||
"link.foreground": "#83a598",
|
||||
"link.hover.foreground": "#83a598",
|
||||
"list.active.background": "#d7992111",
|
||||
"list.active.border": "#b17f1b",
|
||||
"list.even.background": "#282828",
|
||||
"muted.background": "#3c383655",
|
||||
"muted.foreground": "#928374",
|
||||
"panel.background": "#282828",
|
||||
"popover.background": "#1d2021",
|
||||
"popover.foreground": "#ebdbb2",
|
||||
"primary.background": "#d79921",
|
||||
"primary.foreground": "#282828",
|
||||
"primary.hover.background": "#fabd2f",
|
||||
"ring": "#b17f1b",
|
||||
"scrollbar.background": "#1d202100",
|
||||
"scrollbar.thumb.background": "#504945",
|
||||
"secondary.active.background": "#333332",
|
||||
"secondary.background": "#282828",
|
||||
"secondary.foreground": "#ebdbb2",
|
||||
"secondary.hover.background": "#33333299",
|
||||
"tab.active.background": "#1d2021",
|
||||
"tab.active.foreground": "#ebdbb2",
|
||||
"tab.background": "#28282800",
|
||||
"tab.foreground": "#928374",
|
||||
"tab_bar.background": "#28282899",
|
||||
"title_bar.background": "#252525",
|
||||
"title_bar.border": "#3e3936",
|
||||
"base.magenta.light": "#D3869B66",
|
||||
"base.red": "#f06555",
|
||||
"base.green": "#98971a",
|
||||
"base.yellow": "#d79921",
|
||||
"base.blue": "#458588",
|
||||
"base.magenta": "#b16286",
|
||||
"base.cyan": "#689d6a"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#1d2021",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#92837466",
|
||||
"conflict": "#f06555",
|
||||
"created": "#3f72e2",
|
||||
"deleted": "#9d0006",
|
||||
"error": "#cc241d",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"ignored": "#928374",
|
||||
"info": "#458588",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"renamed": "#b57614",
|
||||
"success": "#79740e",
|
||||
"unreachable": "#928374",
|
||||
"warning": "#d79921",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Harper",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Harper",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#27222c",
|
||||
"accent.foreground": "#a8a49d",
|
||||
"background": "#010101",
|
||||
"border": "#333333",
|
||||
"foreground": "#a8a49d",
|
||||
"input.border": "#333333",
|
||||
"list.active.background": "#B196C622",
|
||||
"list.active.border": "#B196C699",
|
||||
"list.even.background": "#18151B77",
|
||||
"muted.background": "#171717",
|
||||
"muted.foreground": "#726E69",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#010101",
|
||||
"popover.foreground": "#a8a49d",
|
||||
"primary.background": "#B196C6",
|
||||
"primary.foreground": "#000000",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#726E69",
|
||||
"secondary.background": "#1b1b1b",
|
||||
"secondary.active.background": "#2c2632",
|
||||
"secondary.foreground": "#A8A49D",
|
||||
"secondary.hover.background": "#23232399",
|
||||
"tab.active.background": "#010101",
|
||||
"tab.active.foreground": "#a8a49d",
|
||||
"tab.foreground": "#787878",
|
||||
"title_bar.background": "#101010",
|
||||
"base.red": "#ff5874",
|
||||
"base.red.light": "#ff587499",
|
||||
"base.green": "#489e48",
|
||||
"base.green.light": "#489e4899",
|
||||
"base.blue": "#7fb5e1",
|
||||
"base.blue.light": "#7fb5e199",
|
||||
"base.cyan": "#bff5e5",
|
||||
"base.cyan.light": "#bff5e599",
|
||||
"base.magenta": "#b296c6",
|
||||
"base.magenta.light": "#b296c699",
|
||||
"base.yellow": "#f8b63f",
|
||||
"base.yellow.light": "#f8b63f99"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#a8a49d",
|
||||
"editor.background": "#010101",
|
||||
"editor.active_line.background": "#18151B",
|
||||
"editor.line_number": "#726E69",
|
||||
"editor.active_line_number": "#a8a49d",
|
||||
"editor.invisible": "#726E6966",
|
||||
"conflict": "#ff5874",
|
||||
"created": "#489e48",
|
||||
"deleted": "#ff5874",
|
||||
"error": "#ff5874",
|
||||
"hidden": "#726E69",
|
||||
"hint": "#7fb5e1",
|
||||
"ignored": "#726E69",
|
||||
"info": "#7fb5e1",
|
||||
"modified": "#b296c6",
|
||||
"predictive": "#726E69",
|
||||
"renamed": "#b296c6",
|
||||
"success": "#489e48",
|
||||
"unreachable": "#726E69",
|
||||
"warning": "#f8b63f",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#a8a49d"
|
||||
},
|
||||
"function": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#ff5874"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7fb5e1",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#bff5e5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#ff5874"
|
||||
},
|
||||
"string": {
|
||||
"color": "#489e48"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#489e48"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#489e48"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#f8b63f"
|
||||
},
|
||||
"title": {
|
||||
"color": "#b296c6",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#b296c6"
|
||||
},
|
||||
"property": {
|
||||
"color": "#a8a49d"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#ff5874"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Hybrid",
|
||||
"author": "Andrew Wong",
|
||||
"url": "https://github.com/w0ng/vim-hybrid",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Hybrid Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#c8ced1",
|
||||
"accent.foreground": "#1c1c1c",
|
||||
"background": "#E4E4E4",
|
||||
"border": "#CACACA",
|
||||
"danger.background": "#ff5f5f",
|
||||
"danger.foreground": "#ffffff",
|
||||
"foreground": "#1c1c1c",
|
||||
"input.border": "#CACACA",
|
||||
"link.active.foreground": "#005f87",
|
||||
"link.foreground": "#005f87",
|
||||
"link.hover.foreground": "#005f87",
|
||||
"list.active.background": "#79792C20",
|
||||
"list.active.border": "#79792C",
|
||||
"list.hover.background": "#D5D5D5",
|
||||
"list.even.background": "#DFDFDF",
|
||||
"muted.background": "#d7d7d7",
|
||||
"muted.foreground": "#5f5f5f",
|
||||
"primary.background": "#005f87",
|
||||
"primary.foreground": "#ffffff",
|
||||
"scrollbar.background": "#E0E0E000",
|
||||
"scrollbar.thumb.background": "#8f8f8f",
|
||||
"secondary.background": "#D0D0D0",
|
||||
"secondary.active.background": "#c4c4c4",
|
||||
"secondary.foreground": "#1c1c1c",
|
||||
"secondary.hover.background": "#c4c4c499",
|
||||
"tab.active.background": "#E4E4E4",
|
||||
"tab.active.foreground": "#1c1c1c",
|
||||
"tab.background": "#e8e8e800",
|
||||
"tab.foreground": "#5f5f5f",
|
||||
"tab_bar.background": "#e8e8e8",
|
||||
"title_bar.background": "#D0D0D0",
|
||||
"title_bar.border": "#B3B3B3",
|
||||
"status_bar.background": "#DADADA",
|
||||
"base.red": "#5F0000",
|
||||
"base.green": "#005F00",
|
||||
"base.yellow": "#948000",
|
||||
"base.blue": "#00195f",
|
||||
"base.magenta": "#5f1c51",
|
||||
"base.magenta.light": "#5f1c5111",
|
||||
"base.cyan": "#005a5f"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#1c1c1c",
|
||||
"editor.background": "#E4E4E4",
|
||||
"editor.active_line.background": "#D3D3D3",
|
||||
"editor.line_number": "#5F5F5F",
|
||||
"editor.active_line_number": "#1C1C1C",
|
||||
"editor.invisible": "#5F5F5F66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3F72E2",
|
||||
"deleted": "#FF5F5F",
|
||||
"error": "#FF5F5F",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#5F87AF",
|
||||
"ignored": "#B3B3B3",
|
||||
"info": "#005F87",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"renamed": "#5F87AF",
|
||||
"success": "#005F00",
|
||||
"unreachable": "#9E9E9E",
|
||||
"warning": "#948000",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#948000"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5F5F5F",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5F5F5F",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#79792C"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#1C1C1C"
|
||||
},
|
||||
"function": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#5F1C51"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#005F87",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#005F87",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#005F00"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#79792C"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#005F87"
|
||||
},
|
||||
"title": {
|
||||
"color": "#005F87",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#5F1C51"
|
||||
},
|
||||
"property": {
|
||||
"color": "#1C1C1C"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#5F1C51"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Hybrid Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#31393a",
|
||||
"accent.foreground": "#e8e8e8",
|
||||
"background": "#1D1F21",
|
||||
"border": "#34373c",
|
||||
"foreground": "#e8e8e8",
|
||||
"input.border": "#34373c",
|
||||
"link.active.foreground": "#87d7ff",
|
||||
"link.foreground": "#81A2BE",
|
||||
"link.hover.foreground": "#006f9f",
|
||||
"list.active.background": "#15678a10",
|
||||
"list.active.border": "#15678a",
|
||||
"list.even.background": "#282A2E",
|
||||
"muted.background": "#282A2E99",
|
||||
"muted.foreground": "#878787",
|
||||
"panel.background": "#1D1F21",
|
||||
"popover.background": "#1D1F21",
|
||||
"popover.foreground": "#e8e8e8",
|
||||
"primary.background": "#15678a",
|
||||
"primary.foreground": "#e8e8e8",
|
||||
"scrollbar.background": "#1D1F2100",
|
||||
"scrollbar.thumb.background": "#5f5f5f",
|
||||
"secondary.background": "#303336",
|
||||
"secondary.active.background": "#33363b",
|
||||
"secondary.foreground": "#e8e8e8",
|
||||
"secondary.hover.background": "#373b3e",
|
||||
"tab.active.background": "#1D1F21",
|
||||
"tab.active.foreground": "#d4d4d4",
|
||||
"tab.background": "#24262700",
|
||||
"tab.foreground": "#999999",
|
||||
"tab_bar.background": "#242627",
|
||||
"title_bar.background": "#242629",
|
||||
"title_bar.border": "#34373c",
|
||||
"base.red": "#8a1515",
|
||||
"base.red.light": "#ff5f5f",
|
||||
"base.green": "#7c8a15",
|
||||
"base.green.light": "#b3bf5a",
|
||||
"base.yellow": "#8a7c15",
|
||||
"base.yellow.light": "#e4b55e",
|
||||
"base.blue": "#15678a",
|
||||
"base.blue.light": "#6e90b0",
|
||||
"base.magenta": "#8a1567",
|
||||
"base.magenta.light": "#B294BB",
|
||||
"base.cyan": "#15678a",
|
||||
"base.cyan.light": "#7fbfb4"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#5F5F5F66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#802207",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0C194D",
|
||||
"info.border": "#082190",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Jellybeans",
|
||||
"author": "nanotech",
|
||||
"url": "https://github.com/nanotech/jellybeans.vim",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Jellybeans",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#292929",
|
||||
"accent.foreground": "#e8e8e8",
|
||||
"background": "#151515",
|
||||
"border": "#2e2e2e",
|
||||
"danger.foreground": "#151515",
|
||||
"foreground": "#E8E8D3",
|
||||
"input.border": "#4E4847",
|
||||
"link.active.foreground": "#97bedc",
|
||||
"link.foreground": "#97bedc",
|
||||
"link.hover.foreground": "#97bedc",
|
||||
"list.active.background": "#97bedc15",
|
||||
"list.active.border": "#97bedc",
|
||||
"list.even.background": "#1C1C1C",
|
||||
"muted.background": "#242424",
|
||||
"muted.foreground": "#767676",
|
||||
"panel.background": "#151515",
|
||||
"popover.background": "#151515",
|
||||
"popover.foreground": "#e8e8e8",
|
||||
"primary.background": "#97bedc",
|
||||
"primary.foreground": "#151515",
|
||||
"scrollbar.background": "#26262600",
|
||||
"scrollbar.thumb.background": "#6e6e6e",
|
||||
"secondary.background": "#2F2F2F",
|
||||
"secondary.active.background": "#2f2f2f",
|
||||
"secondary.foreground": "#e8e8e8",
|
||||
"secondary.hover.background": "#252525",
|
||||
"tab.active.background": "#151515",
|
||||
"tab.active.foreground": "#e8e8e8",
|
||||
"tab.background": "#10101000",
|
||||
"tab.foreground": "#969696",
|
||||
"tab_bar.background": "#101010",
|
||||
"title_bar.background": "#101010",
|
||||
"title_bar.border": "#2e2e2e",
|
||||
"base.red": "#e27373",
|
||||
"base.red.light": "#ffa1a1",
|
||||
"base.green": "#94b979",
|
||||
"base.green.light": "#bddeab",
|
||||
"base.yellow": "#ffba7b",
|
||||
"base.yellow.light": "#ffdca0",
|
||||
"base.blue": "#97bedc",
|
||||
"base.blue.light": "#b1d8f6",
|
||||
"base.magenta": "#B294BB",
|
||||
"base.magenta.light": "#fbdaff",
|
||||
"base.cyan": "#00988e",
|
||||
"base.cyan.light": "#1ab2a8"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#9E9E9E66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Kibble",
|
||||
"author": "iTerm2-Color-Schemes",
|
||||
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Kibble",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#252525",
|
||||
"accent.foreground": "#f7f7f7",
|
||||
"background": "#0e100a",
|
||||
"border": "#292a24",
|
||||
"danger.background": "#e07b5c",
|
||||
"foreground": "#f7f7f7",
|
||||
"input.border": "#333333",
|
||||
"list.active.background": "#2BCF1315",
|
||||
"list.active.border": "#2BCF13",
|
||||
"list.hover.background": "#2c292b54",
|
||||
"list.even.background": "#24222355",
|
||||
"muted.background": "#292a2455",
|
||||
"muted.foreground": "#777777",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#0e100a",
|
||||
"popover.foreground": "#f7f7f7",
|
||||
"primary.active.background": "#6ce05c99",
|
||||
"primary.background": "#6ce05c",
|
||||
"primary.foreground": "#101010",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#726E69",
|
||||
"secondary.background": "#22231f",
|
||||
"secondary.active.background": "#292825",
|
||||
"secondary.foreground": "#f7f7f7",
|
||||
"secondary.hover.background": "#22231f",
|
||||
"success.background": "#5c6ee0",
|
||||
"warning.background": "#e0c85c",
|
||||
"info.background": "#5cd1e0",
|
||||
"selection.background": "#9ba787",
|
||||
"tab.active.background": "#0e100a",
|
||||
"tab.active.foreground": "#f7f7f7",
|
||||
"tab.foreground": "#969696",
|
||||
"title_bar.background": "#161712",
|
||||
"base.red": "#C70231",
|
||||
"base.red.light": "#e07b5c",
|
||||
"base.green": "#2BCF13",
|
||||
"base.green.light": "#6ce05c",
|
||||
"base.blue": "#3449d1",
|
||||
"base.blue.light": "#5c6ee0",
|
||||
"base.cyan": "#0798ab",
|
||||
"base.cyan.light": "#5cd1e0",
|
||||
"base.magenta": "#8400ff",
|
||||
"base.magenta.light": "#C495F0",
|
||||
"base.yellow": "#c7a302",
|
||||
"base.yellow.light": "#e0c85c"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#f7f7f7",
|
||||
"editor.background": "#0e100a",
|
||||
"editor.active_line.background": "#242223",
|
||||
"editor.line_number": "#726E69",
|
||||
"editor.active_line_number": "#f7f7f7",
|
||||
"editor.invisible": "#726E6966",
|
||||
"conflict": "#c70031",
|
||||
"created": "#2BCF13",
|
||||
"deleted": "#c70031",
|
||||
"error": "#c70031",
|
||||
"hidden": "#726E69",
|
||||
"hint": "#0798ab",
|
||||
"ignored": "#726E69",
|
||||
"info": "#0798ab",
|
||||
"modified": "#3449d1",
|
||||
"predictive": "#726E69",
|
||||
"renamed": "#3449d1",
|
||||
"success": "#2BCF13",
|
||||
"unreachable": "#726E69",
|
||||
"warning": "#c7a302",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#726E69",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#f7f7f7"
|
||||
},
|
||||
"function": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#c70031"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#0798ab",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#68f2e0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#c70031"
|
||||
},
|
||||
"string": {
|
||||
"color": "#2BCF13"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#2BCF13"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#2BCF13"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c7a302"
|
||||
},
|
||||
"title": {
|
||||
"color": "#3449d1",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#3449d1"
|
||||
},
|
||||
"property": {
|
||||
"color": "#f7f7f7"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#c70031"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "macOS Classic",
|
||||
"author": "huacnlee",
|
||||
"url": "https://github.com/huacnlee/zed-theme-macos-classic",
|
||||
"themes": [
|
||||
{
|
||||
"name": "macOS Classic Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#E0E0E0",
|
||||
"accent.foreground": "#000000",
|
||||
"background": "#F9F9F9",
|
||||
"foreground": "#000000",
|
||||
"border": "#D2D2D2",
|
||||
"ring": "#0060de",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"list.active.background": "#0060de15",
|
||||
"list.active.border": "#0060de",
|
||||
"list.even.background": "#EFEFEF",
|
||||
"list.hover.background": "#E7E7E7",
|
||||
"muted.background": "#EAEAEA",
|
||||
"muted.foreground": "#707070",
|
||||
"popover.background": "#F5F5F5",
|
||||
"popover.foreground": "#000000",
|
||||
"primary.background": "#0060de",
|
||||
"primary.foreground": "#FFFFFF",
|
||||
"scrollbar.background": "#FFFFFF00",
|
||||
"scrollbar.thumb.background": "#C8C8C8",
|
||||
"secondary.active.background": "#D9D9D9",
|
||||
"secondary.background": "#E0E0E0",
|
||||
"secondary.foreground": "#000000",
|
||||
"secondary.hover.background": "#E5E5E5",
|
||||
"tab.active.foreground": "#000000",
|
||||
"tab.background": "#E9E9E9",
|
||||
"tab.foreground": "#606060",
|
||||
"tab_bar.background": "#E9E9E9",
|
||||
"title_bar.background": "#F7F7F7",
|
||||
"title_bar.border": "#DADADA",
|
||||
"status_bar.background": "#E9E9E9",
|
||||
"base.yellow": "#B59A00",
|
||||
"base.red": "#d21f07",
|
||||
"base.blue": "#0060de",
|
||||
"base.green": "#319a00",
|
||||
"base.magenta": "#9A0068",
|
||||
"base.cyan": "#007E8A"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#000000",
|
||||
"editor.background": "#ffffff",
|
||||
"editor.active_line.background": "#F5F5F5",
|
||||
"editor.line_number": "#929292",
|
||||
"editor.active_line_number": "#000000",
|
||||
"editor.invisible": "#007fff66",
|
||||
"conflict": "#d21f07",
|
||||
"created": "#0060de",
|
||||
"hidden": "#6D6D6D",
|
||||
"hint": "#9A0068",
|
||||
"modified": "#319a00",
|
||||
"predictive": "#A4ABB6",
|
||||
"warning": "#B59A00",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#957931"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"function": {
|
||||
"color": "#0000A2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#0000A2",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6A7293",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"title": {
|
||||
"color": "#0433FF"
|
||||
},
|
||||
"type": {
|
||||
"color": "#6f42c1"
|
||||
},
|
||||
"property": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#C5060B"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "macOS Classic Dark",
|
||||
"mode": "dark",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#282629",
|
||||
"accent.foreground": "#CACCCA",
|
||||
"background": "#131313",
|
||||
"border": "#303030",
|
||||
"ring": "#077CFD",
|
||||
"foreground": "#DEDEDE",
|
||||
"danger.background": "#E44A4F",
|
||||
"list.even.background": "#232323",
|
||||
"list.active.background": "#0059D115",
|
||||
"list.active.border": "#077CFD",
|
||||
"muted.background": "#202020",
|
||||
"muted.foreground": "#9D9D9D",
|
||||
"popover.background": "#101010",
|
||||
"popover.foreground": "#CACCCA",
|
||||
"primary.background": "#077CFD",
|
||||
"primary.foreground": "#F2F9FF",
|
||||
"switch.background": "#393939",
|
||||
"switch.thumb.background": "#DAECFF",
|
||||
"scrollbar.background": "#13131300",
|
||||
"scrollbar.thumb.background": "#9F9F9F",
|
||||
"secondary.active.background": "#353535",
|
||||
"secondary.background": "#353535",
|
||||
"secondary.foreground": "#DEDEDE",
|
||||
"secondary.hover.background": "#35353599",
|
||||
"link": "#419CFF",
|
||||
"tab_bar.background": "#1C1C1E",
|
||||
"tab.active.background": "#131313",
|
||||
"tab.foreground": "#8F8F8F",
|
||||
"title_bar.background": "#171717",
|
||||
"selection.background": "#3F638B",
|
||||
"base.blue": "#419CFF",
|
||||
"base.cyan": "#07FDD3",
|
||||
"base.green": "#30D158",
|
||||
"base.magenta": "#A550A7",
|
||||
"base.red": "#FF5257",
|
||||
"base.yellow": "#FFC600"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#CACCCA",
|
||||
"editor.background": "#131313",
|
||||
"editor.active_line.background": "#35343666",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#007fff66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#E44A4F",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0059D1",
|
||||
"info.border": "#0059D1",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#7FAEF9"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9D9D9D"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9D9D9D"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#CBA6F7"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#B3C5F3"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#87B1F6"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#419CFF",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#7faef9",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"string": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#68DC7C"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#68DC7C"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#419CFF"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"title": {
|
||||
"color": "#CC9E00",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#CBA6F7"
|
||||
},
|
||||
"property": {
|
||||
"color": "#BCC4E0"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#419CFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Mellifluous",
|
||||
"author": "Ramojus Lapinskas",
|
||||
"url": "https://github.com/ramojus/mellifluous.nvim",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Mellifluous Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#d4d4d4",
|
||||
"accent.foreground": "#383a42",
|
||||
"background": "#E7E7E7",
|
||||
"border": "#CACACA",
|
||||
"danger.active.background": "#e06c75",
|
||||
"danger.hover.background": "#be5046",
|
||||
"foreground": "#383a42",
|
||||
"link.active.foreground": "#5A6599",
|
||||
"link.foreground": "#5A6599",
|
||||
"link.hover.foreground": "#5A6599",
|
||||
"list.hover.background": "#d5d5d5",
|
||||
"list.active.background": "#5A659912",
|
||||
"list.active.border": "#5A6599",
|
||||
"list.even.background": "#F0F0F0",
|
||||
"muted.background": "#E0E0E0",
|
||||
"muted.foreground": "#828997",
|
||||
"panel.background": "#fafafa",
|
||||
"popover.background": "#E4E4E4",
|
||||
"popover.foreground": "#383a42",
|
||||
"primary.active.background": "#5A6599AA",
|
||||
"primary.background": "#5A6599",
|
||||
"primary.foreground": "#F0F0F0",
|
||||
"primary.hover.background": "#626ca2",
|
||||
"scrollbar.background": "#fafafa00",
|
||||
"scrollbar.thumb.background": "#c0c0c0",
|
||||
"secondary.background": "#d0d0d0",
|
||||
"secondary.active.background": "#cccccc",
|
||||
"secondary.foreground": "#383a42",
|
||||
"secondary.hover.background": "#d7d7d7",
|
||||
"tab.active.background": "#E7E7E7",
|
||||
"tab.active.foreground": "#383a42",
|
||||
"tab.background": "#F0F0F000",
|
||||
"tab.foreground": "#727272",
|
||||
"tab_bar.background": "#E7E7E7",
|
||||
"title_bar.background": "#dfdfdf",
|
||||
"title_bar.border": "#CACACA",
|
||||
"status_bar.background": "#dfdfdf",
|
||||
"base.red": "#C95954",
|
||||
"base.green": "#828040",
|
||||
"base.yellow": "#c98f54",
|
||||
"base.blue": "#a8a1be",
|
||||
"base.magenta": "#b39fb0",
|
||||
"base.magenta.light": "#9C699588",
|
||||
"base.cyan": "#54c981"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#383a42",
|
||||
"editor.background": "#E7E7E7",
|
||||
"editor.active_line.background": "#d5d5d5",
|
||||
"editor.line_number": "#727272",
|
||||
"editor.active_line_number": "#383a42",
|
||||
"editor.invisible": "#A0A0A066",
|
||||
"conflict": "#e06c75",
|
||||
"created": "#828040",
|
||||
"deleted": "#be5046",
|
||||
"error": "#e06c75",
|
||||
"hidden": "#727272",
|
||||
"hint": "#5A6599",
|
||||
"ignored": "#727272",
|
||||
"info": "#5A6599",
|
||||
"modified": "#727272",
|
||||
"predictive": "#727272",
|
||||
"renamed": "#727272",
|
||||
"success": "#828040",
|
||||
"unreachable": "#727272",
|
||||
"warning": "#cbaa89",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#A0A0A0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#A0A0A0",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#383a42"
|
||||
},
|
||||
"function": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"string": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#cbaa89"
|
||||
},
|
||||
"title": {
|
||||
"color": "#727272",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#727272"
|
||||
},
|
||||
"property": {
|
||||
"color": "#383a42"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e06c75"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Mellifluous Dark",
|
||||
"mode": "dark",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#2f2f2f",
|
||||
"accent.foreground": "#abb2bf",
|
||||
"background": "#1A1A1A",
|
||||
"border": "#444444",
|
||||
"foreground": "#abb2bf",
|
||||
"input.border": "#444444",
|
||||
"link.active.foreground": "#5A6599",
|
||||
"link.foreground": "#5A6599",
|
||||
"link.hover.foreground": "#5A6599",
|
||||
"list.active.background": "#5A659922",
|
||||
"list.active.border": "#5A6599",
|
||||
"list.even.background": "#29292988",
|
||||
"muted.background": "#44444455",
|
||||
"muted.foreground": "#828997",
|
||||
"panel.background": "#282c34",
|
||||
"popover.background": "#1A1A1A",
|
||||
"popover.foreground": "#abb2bf",
|
||||
"primary.active.background": "#5A6599AA",
|
||||
"primary.background": "#5A6599",
|
||||
"primary.foreground": "#ecedf4",
|
||||
"primary.hover.background": "#626ca2",
|
||||
"scrollbar.background": "#282c3400",
|
||||
"scrollbar.thumb.background": "#444444",
|
||||
"secondary.background": "#292929",
|
||||
"secondary.active.background": "#323232",
|
||||
"secondary.foreground": "#abb2bf",
|
||||
"secondary.hover.background": "#292929",
|
||||
"tab.active.background": "#1A1A1A",
|
||||
"tab.active.foreground": "#abb2bf",
|
||||
"tab.background": "#1A1A1A00",
|
||||
"tab.foreground": "#abb2bf",
|
||||
"title_bar.background": "#171717",
|
||||
"title_bar.border": "#444444",
|
||||
"base.red": "#C95954",
|
||||
"base.green": "#828040",
|
||||
"base.yellow": "#c98d54",
|
||||
"base.blue": "#5481c9",
|
||||
"base.magenta": "#9C6995",
|
||||
"base.magenta.light": "#9C699577",
|
||||
"base.cyan": "#54c9bd"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#abb2bf",
|
||||
"editor.background": "#1A1A1A",
|
||||
"editor.active_line.background": "#29292977",
|
||||
"editor.line_number": "#828997",
|
||||
"editor.active_line_number": "#abb2bf",
|
||||
"editor.invisible": "#A0A0A066",
|
||||
"conflict": "#e06c75",
|
||||
"conflict.background": "#1A1A1A",
|
||||
"conflict.border": "#be5046",
|
||||
"created": "#828040",
|
||||
"created.background": "#292929",
|
||||
"created.border": "#929292",
|
||||
"deleted": "#be5046",
|
||||
"deleted.background": "#1A1A1A",
|
||||
"deleted.border": "#e06c75",
|
||||
"error": "#e06c75",
|
||||
"error.background": "#1A1A1A",
|
||||
"error.border": "#be5046",
|
||||
"hidden": "#828997",
|
||||
"hidden.background": "#292929",
|
||||
"hidden.border": "#444444",
|
||||
"hint": "#5A6599",
|
||||
"hint.background": "#292929",
|
||||
"hint.border": "#626ca2",
|
||||
"ignored": "#828997",
|
||||
"ignored.background": "#292929",
|
||||
"ignored.border": "#444444",
|
||||
"info": "#5A6599",
|
||||
"info.background": "#292929",
|
||||
"info.border": "#626ca2",
|
||||
"modified": "#929292",
|
||||
"modified.background": "#292929",
|
||||
"modified.border": "#444444",
|
||||
"predictive": "#828997",
|
||||
"predictive.background": "#292929",
|
||||
"predictive.border": "#444444",
|
||||
"renamed": "#929292",
|
||||
"renamed.background": "#292929",
|
||||
"renamed.border": "#444444",
|
||||
"success": "#828040",
|
||||
"success.background": "#292929",
|
||||
"success.border": "#929292",
|
||||
"unreachable": "#828997",
|
||||
"unreachable.background": "#292929",
|
||||
"unreachable.border": "#444444",
|
||||
"warning": "#d19a66",
|
||||
"warning.background": "#292929",
|
||||
"warning.border": "#929292",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#828997",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#828997",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#abb2bf"
|
||||
},
|
||||
"function": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#5A6599",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e06c75"
|
||||
},
|
||||
"string": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#828040"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#d19a66"
|
||||
},
|
||||
"title": {
|
||||
"color": "#929292",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#929292"
|
||||
},
|
||||
"property": {
|
||||
"color": "#abb2bf"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e06c75"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Molokai",
|
||||
"author": "Tomas Restrepo",
|
||||
"url": "https://github.com/tomasr/molokai",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Molokai Light",
|
||||
"mode": "light",
|
||||
"shadow": false,
|
||||
"colors": {
|
||||
"accent.background": "#EBE4E1",
|
||||
"accent.foreground": "#060606",
|
||||
"background": "#FEFAF9",
|
||||
"border": "#E4DEDA",
|
||||
"window_border": "#d6d6d6",
|
||||
"ring": "#a0a0ab",
|
||||
"foreground": "#0a0a0a",
|
||||
"input.border": "#E4DEDA",
|
||||
"list.active.background": "#E1477411",
|
||||
"list.active.border": "#E14774AA",
|
||||
"list.even.background": "#E4DEDA33",
|
||||
"muted.background": "#f1eded",
|
||||
"muted.foreground": "#767676",
|
||||
"panel.background": "#FEFAF9",
|
||||
"popover.background": "#FEFAF9",
|
||||
"popover.foreground": "#0a0a0a",
|
||||
"primary.background": "#E14774",
|
||||
"primary.foreground": "#fafafa",
|
||||
"scrollbar.background": "#FEFAF900",
|
||||
"scrollbar.thumb.background": "#ADADB0",
|
||||
"secondary.background": "#E4DEDA",
|
||||
"secondary.foreground": "#060606",
|
||||
"secondary.hover.background": "#E4DEDA99",
|
||||
"title_bar.background": "#e8e3e0",
|
||||
"title_bar.border": "#d5ccc6",
|
||||
"base.red": "#e17047",
|
||||
"base.green": "#82cc0a",
|
||||
"base.yellow": "#ccac0a",
|
||||
"base.blue": "#e16032",
|
||||
"base.blue.light": "#e16032",
|
||||
"base.magenta": "#7058be",
|
||||
"base.cyan": "#0acc78"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#0a0a0a",
|
||||
"editor.background": "#FEFAF9",
|
||||
"editor.active_line.background": "#E4DEDA",
|
||||
"editor.line_number": "#767676",
|
||||
"editor.active_line_number": "#0a0a0a",
|
||||
"editor.invisible": "#76767666",
|
||||
"conflict": "#e14775",
|
||||
"created": "#269D69",
|
||||
"deleted": "#e14775",
|
||||
"error": "#e14775",
|
||||
"hidden": "#767676",
|
||||
"hint": "#7058be",
|
||||
"ignored": "#767676",
|
||||
"info": "#1c8ca8",
|
||||
"modified": "#cc7a0a",
|
||||
"predictive": "#ADADB0",
|
||||
"renamed": "#E14774",
|
||||
"success": "#269D69",
|
||||
"unreachable": "#767676",
|
||||
"warning": "#cc7a0a",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#cc7a0a"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#767676",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#767676",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#269D69"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#1c8ca8"
|
||||
},
|
||||
"function": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#e14775",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#cc7a0a",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#7058be",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#cc7a0a"
|
||||
},
|
||||
"string": {
|
||||
"color": "#269D69"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#269D69"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#e14775"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"title": {
|
||||
"color": "#cc7a0a",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#1c8ca8"
|
||||
},
|
||||
"property": {
|
||||
"color": "#7058be"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#e14775"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Molokai Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2b2f30",
|
||||
"accent.foreground": "#f8f8f2",
|
||||
"background": "#1b1d1e",
|
||||
"border": "#393939",
|
||||
"foreground": "#f8f8f2",
|
||||
"input.border": "#3b3b3b",
|
||||
"list.active.background": "#f9267211",
|
||||
"list.active.border": "#f9267288",
|
||||
"list.even.background": "#292c2e",
|
||||
"muted.background": "#232526",
|
||||
"muted.foreground": "#5b5a54",
|
||||
"panel.background": "#1b1d1e",
|
||||
"popover.background": "#1b1d1e",
|
||||
"popover.foreground": "#f8f8f2",
|
||||
"primary.background": "#dc1860",
|
||||
"primary.foreground": "#f8f8f2",
|
||||
"scrollbar.background": "#1b1d1e00",
|
||||
"scrollbar.thumb.background": "#4b4b4b",
|
||||
"secondary.background": "#292c2e",
|
||||
"secondary.active.background": "#303436",
|
||||
"secondary.foreground": "#f5f5f4",
|
||||
"secondary.hover.background": "#30343699",
|
||||
"table.row.border": "#3b3b3b70",
|
||||
"title_bar.background": "#202223",
|
||||
"title_bar.border": "#3b3b3b",
|
||||
"base.red": "#dc1860",
|
||||
"base.green": "#82cc0a",
|
||||
"base.yellow": "#807607",
|
||||
"base.blue": "#075880",
|
||||
"base.magenta": "#800780",
|
||||
"base.cyan": "#07807e"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#76767666",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#802207",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0C194D",
|
||||
"info.border": "#082190",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Solarized",
|
||||
"author": "Ethan Schoonover",
|
||||
"url": "https://ethanschoonover.com/solarized",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Solarized Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#ebe4ce",
|
||||
"accent.foreground": "#073642",
|
||||
"background": "#FDF6E3",
|
||||
"border": "#DCD4BC",
|
||||
"foreground": "#586E75",
|
||||
"input.border": "#DCD4BC",
|
||||
"link.foreground": "#587573",
|
||||
"list.active.background": "#586E75",
|
||||
"list.active.border": "#586E75",
|
||||
"list.even.background": "#EEE8D599",
|
||||
"muted.background": "#eee8d5",
|
||||
"muted.foreground": "#93a1a1",
|
||||
"panel.background": "#fdf6e3",
|
||||
"popover.background": "#fdf6e3",
|
||||
"popover.foreground": "#073642",
|
||||
"primary.background": "#586E75",
|
||||
"primary.foreground": "#fdf6e3",
|
||||
"scrollbar.background": "#EEE8D500",
|
||||
"scrollbar.thumb.background": "#d5cbaf",
|
||||
"secondary.background": "#EEE8D5",
|
||||
"secondary.active.background": "#DCD4BC",
|
||||
"secondary.foreground": "#073642",
|
||||
"secondary.hover.background": "#DCD4BC88",
|
||||
"tab.active.background": "#fdf6e3",
|
||||
"tab.active.foreground": "#073642",
|
||||
"tab.background": "#fdf6e300",
|
||||
"tab.foreground": "#657b83",
|
||||
"tab_bar.background": "#eee8d5",
|
||||
"title_bar.background": "#f4ecd7",
|
||||
"title_bar.border": "#dcd4bc",
|
||||
"chart.grid": "#ebe5d4",
|
||||
"base.red": "#755858",
|
||||
"base.green": "#717558",
|
||||
"base.yellow": "#756e58",
|
||||
"base.blue": "#586975",
|
||||
"base.magenta": "#755866",
|
||||
"base.cyan": "#587573"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#586E75",
|
||||
"editor.background": "#FDF6E3",
|
||||
"editor.active_line.background": "#EEE8D5",
|
||||
"editor.line_number": "#93A1A1",
|
||||
"editor.active_line_number": "#073642",
|
||||
"editor.invisible": "#93A1A166",
|
||||
"conflict": "#DC322F",
|
||||
"created": "#859900",
|
||||
"deleted": "#DC322F",
|
||||
"error": "#DC322F",
|
||||
"hidden": "#93A1A1",
|
||||
"hint": "#2AA198",
|
||||
"ignored": "#93A1A1",
|
||||
"info": "#268BD2",
|
||||
"modified": "#B58900",
|
||||
"predictive": "#586E75",
|
||||
"renamed": "#2AA198",
|
||||
"success": "#859900",
|
||||
"unreachable": "#DC322F",
|
||||
"warning": "#B58900",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#CB4B16"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#93A1A1",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#93A1A1",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#859900"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"function": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#DC322F"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#268BD2",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#2AA198",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#D33682"
|
||||
},
|
||||
"string": {
|
||||
"color": "#859900"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#D33682"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#586E75"
|
||||
},
|
||||
"title": {
|
||||
"color": "#073642",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"property": {
|
||||
"color": "#586E75"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#DC322F"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Solarized Dark",
|
||||
"author": "Ethan Schoonover",
|
||||
"url": "https://ethanschoonover.com/solarized",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#073945",
|
||||
"accent.foreground": "#fdf6e3",
|
||||
"background": "#002B36",
|
||||
"border": "#103a44",
|
||||
"foreground": "#fdf6e3",
|
||||
"input.border": "#083e4c",
|
||||
"list.active.background": "#0d667d25",
|
||||
"list.active.border": "#0d667d",
|
||||
"list.even.background": "#15333A99",
|
||||
"muted.background": "#073642",
|
||||
"muted.foreground": "#839496",
|
||||
"panel.background": "#002b36",
|
||||
"popover.background": "#002b36",
|
||||
"popover.foreground": "#fdf6e3",
|
||||
"primary.background": "#0d667d",
|
||||
"primary.foreground": "#fdf6e3",
|
||||
"scrollbar.background": "#002b3600",
|
||||
"scrollbar.thumb.background": "#586e75",
|
||||
"secondary.background": "#073642",
|
||||
"secondary.active.background": "#083e4b",
|
||||
"secondary.foreground": "#fdf6e3",
|
||||
"secondary.hover.background": "#073945",
|
||||
"tab.active.background": "#002b36",
|
||||
"tab.active.foreground": "#fdf6e3",
|
||||
"tab.foreground": "#93a1a1",
|
||||
"tab_bar.background": "#07364266",
|
||||
"title_bar.background": "#09323d",
|
||||
"title_bar.border": "#16404b",
|
||||
"base.red": "#7d2f0d",
|
||||
"base.green": "#0d7d3f",
|
||||
"base.yellow": "#7d650d",
|
||||
"base.blue": "#0d507d",
|
||||
"base.magenta": "#7d0d43",
|
||||
"base.cyan": "#0d7d70"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#002B36",
|
||||
"editor.active_line.background": "#073642",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#93A1A166",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#802207",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0C194D",
|
||||
"info.border": "#082190",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"name": "Spaceduck",
|
||||
"author": "Guillermo Rodriguez",
|
||||
"url": "https://github.com/pineapplegiant/spaceduck",
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Spaceduck",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#212440",
|
||||
"accent.foreground": "#a3a49d",
|
||||
"background": "#0F111B",
|
||||
"border": "#272C42",
|
||||
"foreground": "#a3a49d",
|
||||
"input.border": "#272C42",
|
||||
"link.active.foreground": "#089CC5",
|
||||
"link.foreground": "#089CC5",
|
||||
"link.hover.foreground": "#089CC5",
|
||||
"list.active.background": "#089CC511",
|
||||
"list.active.border": "#089CC5",
|
||||
"list.even.background": "#161928",
|
||||
"muted.background": "#272C4255",
|
||||
"muted.foreground": "#4b6479",
|
||||
"panel.background": "#003a5b",
|
||||
"popover.background": "#0F111B",
|
||||
"popover.foreground": "#a3a49d",
|
||||
"primary.background": "#089CC5",
|
||||
"primary.foreground": "#ecf0c1",
|
||||
"scrollbar.background": "#003a5b00",
|
||||
"scrollbar.thumb.background": "#24303a",
|
||||
"secondary.background": "#242547",
|
||||
"secondary.active.background": "#292b51",
|
||||
"secondary.foreground": "#a3a49d",
|
||||
"secondary.hover.background": "#242547",
|
||||
"tab.active.background": "#0F111B",
|
||||
"tab.foreground": "#838182",
|
||||
"title_bar.background": "#141724",
|
||||
"title_bar.border": "#272C42",
|
||||
"base.red": "#e33400",
|
||||
"base.green": "#5ccc96",
|
||||
"base.blue": "#00a3cc",
|
||||
"base.cyan": "#089CC5",
|
||||
"base.magenta": "#C86D8C",
|
||||
"base.magenta.light": "#C86D8C33",
|
||||
"base.yellow": "#b89c00"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#ecf0c1",
|
||||
"editor.background": "#0F111B",
|
||||
"editor.active_line.background": "#1c1d39",
|
||||
"editor.line_number": "#4b6479",
|
||||
"editor.active_line_number": "#ecf0c1",
|
||||
"editor.invisible": "#4b647966",
|
||||
"conflict": "#e33400",
|
||||
"created": "#5ccc96",
|
||||
"deleted": "#e33400",
|
||||
"error": "#e33400",
|
||||
"hidden": "#4b6479",
|
||||
"hint": "#C86D8C",
|
||||
"ignored": "#4b6479",
|
||||
"info": "#089CC5",
|
||||
"modified": "#f2ce00",
|
||||
"predictive": "#5D5945",
|
||||
"renamed": "#089CC5",
|
||||
"success": "#5ccc96",
|
||||
"unreachable": "#4b6479",
|
||||
"warning": "#f2ce00",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#4b6479",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#4b6479",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#e33400"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#ecf0c1"
|
||||
},
|
||||
"function": {
|
||||
"color": "#089CC5"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#C86D8C"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#089CC5",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#089CC5",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"string": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#5ccc96"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#f2ce00"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#C86D8C"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#ecf0c1"
|
||||
},
|
||||
"title": {
|
||||
"color": "#f2ce00",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#089CC5"
|
||||
},
|
||||
"property": {
|
||||
"color": "#ecf0c1"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#C86D8C"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
{
|
||||
"name": "Tokyo",
|
||||
"author": "Folke Lemaitre",
|
||||
"url": "https://github.com/folke/tokyonight.nvim",
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Tokyo Night",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2C3045",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#1a1b26",
|
||||
"border": "#292e42",
|
||||
"window_border": "#3d4462",
|
||||
"ring": "#7aa2f7",
|
||||
"foreground": "#c0caf5",
|
||||
"link.active.foreground": "#7aa2f722",
|
||||
"link.foreground": "#7aa2f7",
|
||||
"link.hover.foreground": "#7aa2f7",
|
||||
"list.active.background": "#7aa2f722",
|
||||
"list.active.border": "#7aa2f7",
|
||||
"list.even.background": "#2C304599",
|
||||
"muted.background": "#2C304544",
|
||||
"muted.foreground": "#565f89",
|
||||
"panel.background": "#292e42",
|
||||
"popover.background": "#1a1b26",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#7aa2f7",
|
||||
"primary.foreground": "#1a1b26",
|
||||
"scrollbar.background": "#1a1b2600",
|
||||
"scrollbar.thumb.background": "#414868",
|
||||
"secondary.active.background": "#292e42",
|
||||
"secondary.background": "#292e42",
|
||||
"secondary.foreground": "#C0CAF5",
|
||||
"secondary.hover.background": "#2d3349",
|
||||
"title_bar.background": "#161720",
|
||||
"title_bar.border": "#292e42",
|
||||
"chart.grid": "#222332",
|
||||
"base.red": "#f7768e",
|
||||
"base.green": "#9ece6a",
|
||||
"base.yellow": "#e0af68",
|
||||
"base.blue": "#7aa2f7",
|
||||
"base.magenta": "#565f89",
|
||||
"base.cyan": "#7dcfff"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#c0caf5",
|
||||
"editor.background": "#1a1b26",
|
||||
"editor.active_line.background": "#292e42",
|
||||
"editor.line_number": "#565f89",
|
||||
"editor.active_line_number": "#c0caf5",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#f7768e",
|
||||
"created": "#9ece6a",
|
||||
"deleted": "#f7768e",
|
||||
"error": "#f7768e",
|
||||
"hidden": "#565f89",
|
||||
"hint": "#7dcfff",
|
||||
"ignored": "#565f89",
|
||||
"info": "#7aa2f7",
|
||||
"modified": "#e0af68",
|
||||
"predictive": "#565f89",
|
||||
"renamed": "#7aa2f7",
|
||||
"success": "#9ece6a",
|
||||
"unreachable": "#565f89",
|
||||
"warning": "#e0af68",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#565f89",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#565f89",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#7aa2f7"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#7aa2f7"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#f7768e"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7dcfff",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#7aa2f7",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"string": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#7dcfff"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#f7768e"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#7aa2f7",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#7dcfff"
|
||||
},
|
||||
"property": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#f7768e"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Tokyo Storm",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#414868",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#24283b",
|
||||
"border": "#3d4462",
|
||||
"window_border": "#3d4462",
|
||||
"ring": "#7aa2f7",
|
||||
"foreground": "#c0caf5",
|
||||
"input.border": "#414868",
|
||||
"link.active.foreground": "#7aa2f7",
|
||||
"link.foreground": "#7aa2f7",
|
||||
"link.hover.foreground": "#7aa2f7",
|
||||
"list.active.background": "#7aa2f711",
|
||||
"list.active.border": "#7aa2f7",
|
||||
"list.even.background": "#41486844",
|
||||
"muted.background": "#292e42",
|
||||
"muted.foreground": "#565f89",
|
||||
"panel.background": "#292e42",
|
||||
"popover.background": "#24283b",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#7aa2f7",
|
||||
"primary.foreground": "#24283b",
|
||||
"scrollbar.background": "#24283b00",
|
||||
"scrollbar.thumb.background": "#414868",
|
||||
"secondary.active.background": "#323750",
|
||||
"secondary.background": "#292e42",
|
||||
"secondary.foreground": "#c0caf5",
|
||||
"secondary.hover.background": "#32375099",
|
||||
"title_bar.background": "#202435",
|
||||
"title_bar.border": "#3d4462",
|
||||
"base.red": "#f7768e",
|
||||
"base.green": "#9ece6a",
|
||||
"base.yellow": "#e0af68",
|
||||
"base.blue": "#7aa2f7",
|
||||
"base.magenta": "#b283f8",
|
||||
"base.cyan": "#7dcfff"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#B0B9E2",
|
||||
"editor.background": "#24283B",
|
||||
"editor.active_line.background": "#292E42",
|
||||
"editor.line_number": "#363C58",
|
||||
"editor.active_line_number": "#B0B9E2",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"deleted": "#f7768e",
|
||||
"error": "#f7768e",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"success": "#9ece6a",
|
||||
"warning": "#e0af68",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Tokyo Moon",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#32364E",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#222436",
|
||||
"border": "#323752",
|
||||
"window_border": "#404668",
|
||||
"ring": "#82aaff",
|
||||
"foreground": "#c0caf5",
|
||||
"input.border": "#444b6a",
|
||||
"link.active.foreground": "#82aaff",
|
||||
"link.foreground": "#82aaff",
|
||||
"link.hover.foreground": "#82aaff",
|
||||
"list.active.background": "#82aaff22",
|
||||
"list.active.border": "#82aaff",
|
||||
"list.background": "#222436",
|
||||
"list.even.background": "#282a40",
|
||||
"muted.background": "#2d3149",
|
||||
"muted.foreground": "#6e738d",
|
||||
"panel.background": "#2d3149",
|
||||
"popover.background": "#222436",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#82aaff",
|
||||
"primary.foreground": "#222436",
|
||||
"scrollbar.background": "#22243600",
|
||||
"scrollbar.thumb.background": "#444b6a",
|
||||
"secondary.active.background": "#2f334c",
|
||||
"secondary.background": "#2d3149",
|
||||
"secondary.foreground": "#8c94b5",
|
||||
"secondary.hover.background": "#31354f",
|
||||
"table.row.border": "#2d3149",
|
||||
"title_bar.background": "#1e2030",
|
||||
"title_bar.border": "#323752",
|
||||
"base.red": "#ff757f",
|
||||
"base.green": "#c3e88d",
|
||||
"base.yellow": "#ffc777",
|
||||
"base.blue": "#82aaff",
|
||||
"base.magenta": "#6e738d",
|
||||
"base.cyan": "#86e1fc"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#c0caf5",
|
||||
"editor.background": "#222436",
|
||||
"editor.active_line.background": "#2d3149",
|
||||
"editor.line_number": "#6e738d",
|
||||
"editor.active_line_number": "#c0caf5",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#ed8796",
|
||||
"created": "#c3e88d",
|
||||
"deleted": "#ed8796",
|
||||
"error": "#ed8796",
|
||||
"hidden": "#6e738d",
|
||||
"hint": "#86e1fc",
|
||||
"ignored": "#6e738d",
|
||||
"info": "#82aaff",
|
||||
"modified": "#ffc777",
|
||||
"predictive": "#6e738d",
|
||||
"renamed": "#82aaff",
|
||||
"success": "#c3e88d",
|
||||
"unreachable": "#6e738d",
|
||||
"warning": "#ffc777",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#6e738d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#6e738d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#82aaff"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#82aaff"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#ed8796"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#86e1fc",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#82aaff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"string": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#86e1fc"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#ed8796"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#82aaff",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#86e1fc"
|
||||
},
|
||||
"property": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#ed8796"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-kit/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Twilight",
|
||||
"author": "MacroMates",
|
||||
"url": "https://macromates.com",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Twilight",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2b2b2b",
|
||||
"accent.foreground": "#dcdcdc",
|
||||
"background": "#141414",
|
||||
"border": "#343434",
|
||||
"foreground": "#dcdcdc",
|
||||
"input.border": "#3e3e3e",
|
||||
"list.active.background": "#CDA86911",
|
||||
"list.active.border": "#CDA86988",
|
||||
"list.even.background": "#1E1E1E99",
|
||||
"muted.background": "#2e2e2e88",
|
||||
"muted.foreground": "#828282",
|
||||
"primary.background": "#CDA869",
|
||||
"primary.foreground": "#1e1e1e",
|
||||
"scrollbar.background": "#1e1e1e00",
|
||||
"scrollbar.thumb.background": "#3e3e3e",
|
||||
"secondary.background": "#262626",
|
||||
"secondary.active.background": "#2a2a2a",
|
||||
"secondary.foreground": "#dcdcdc",
|
||||
"secondary.hover.background": "#272727",
|
||||
"tab.active.background": "#141414",
|
||||
"tab.active.foreground": "#dcdcdc",
|
||||
"tab.background": "#1E1E1E00",
|
||||
"tab.foreground": "#A9A9A9",
|
||||
"tab_bar.background": "#1E1E1E",
|
||||
"table.background": "#1e1e1e00",
|
||||
"title_bar.background": "#1e1e1e",
|
||||
"title_bar.border": "#343434",
|
||||
"base.red": "#c06d44",
|
||||
"base.red.light": "#de7c4c",
|
||||
"base.green": "#afb97a",
|
||||
"base.green.light": "#ccd88c",
|
||||
"base.yellow": "#c2a86c",
|
||||
"base.yellow.light": "#e2c47e",
|
||||
"base.blue": "#44474a",
|
||||
"base.blue.light": "#5a5e62",
|
||||
"base.magenta": "#b4be7c",
|
||||
"base.magenta.light": "#d0dc8e99",
|
||||
"base.cyan": "#778385",
|
||||
"base.cyan.light": "#8a989b"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#000000",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#9E9E9E66",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/mqttx.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MQTTX - MQTT v5 调试工具</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "mqttx",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"@visual-json/core": "^0.4.0",
|
||||
"@visual-json/vue": "^0.4.0",
|
||||
"element-plus": "^2.14.4",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
Generated
-1032
File diff suppressed because it is too large
Load Diff
@@ -1,33 +0,0 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="mqttx-bg" x1="96" y1="80" x2="928" y2="944" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#7dab7c"/>
|
||||
<stop offset="0.55" stop-color="#5b8c5a"/>
|
||||
<stop offset="1" stop-color="#446f43"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="mqttx-gloss" x1="256" y1="128" x2="768" y2="896" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.16"/>
|
||||
<stop offset="0.45" stop-color="#ffffff" stop-opacity="0"/>
|
||||
<stop offset="0.55" stop-color="#000000" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#000000" stop-opacity="0.10"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Badge -->
|
||||
<rect x="64" y="64" width="896" height="896" rx="224" fill="url(#mqttx-bg)"/>
|
||||
<rect x="64" y="64" width="896" height="896" rx="224" fill="url(#mqttx-gloss)"/>
|
||||
|
||||
<!-- M / signal mark -->
|
||||
<path
|
||||
d="M300 711 L408 361 Q424 313 462 339 L512 459 L562 339 Q600 313 616 361 L724 711"
|
||||
stroke="#ffffff"
|
||||
stroke-width="96"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
<!-- Live pulse dot -->
|
||||
<circle cx="790" cy="256" r="46" fill="#ffc131"/>
|
||||
<circle cx="790" cy="256" r="66" stroke="#ffffff" stroke-opacity="0.55" stroke-width="12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,303 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
发布构建产物到 Gitea:Generic Package + Release 附件(OTA 更新源)。
|
||||
|
||||
.DESCRIPTION
|
||||
1) Generic Package API 上传:
|
||||
PUT {GITEA_URL}/api/packages/{owner}/generic/{pkg}/{version}/{filename}
|
||||
2) Release 附件上传(应用内 OTA 只认 Release 附件):
|
||||
POST {GITEA_URL}/api/v1/repos/{owner}/{repo}/releases/{id}/assets?name=...
|
||||
命名约定 mqttx-v<ver>-<triple>-mqttx[.exe],含 .sha256 侧车。
|
||||
认证:Authorization: token <GITEA_TOKEN>
|
||||
同名包版本重复上传会得到 409,脚本按"已存在"处理。
|
||||
|
||||
.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 Repo
|
||||
覆盖 GITEA_REPO(缺省时从 git remote origin 推断,失败为 mqttx)。
|
||||
.PARAMETER PackageName
|
||||
覆盖 GITEA_PKG(默认 mqttx)。
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string[]]$Files = @(),
|
||||
[string]$Version = "",
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[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 }
|
||||
|
||||
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
$Pkg = if ($PackageName) { $PackageName } elseif ($env:GITEA_PKG) { $env:GITEA_PKG } else { "mqttx" }
|
||||
|
||||
# ── 必填配置 ────────────────────────────────────────────────────────────────
|
||||
# 地址:优先环境变量,否则从 git remote origin 推断
|
||||
$GiteaUrl = $env:GITEA_URL
|
||||
if (-not $GiteaUrl) {
|
||||
$remote = git -C $Root remote get-url origin 2>$null
|
||||
if ($remote) {
|
||||
if ($remote -match "://") { $hostPart = (($remote -split "://", 2)[-1] -split "/", 2)[0] }
|
||||
else { $hostPart = ($remote -split ":", 2)[0] }
|
||||
$hostPart = ($hostPart -split "@")[-1]
|
||||
if ($hostPart) { $GiteaUrl = "https://$hostPart" }
|
||||
}
|
||||
}
|
||||
if (-not $GiteaUrl) { Die "缺少环境变量 GITEA_URL(如 https://gitea.example.com),且无法从 git remote 推断" }
|
||||
$GiteaUrl = $GiteaUrl.TrimEnd("/")
|
||||
|
||||
# 令牌:优先环境变量,否则取 Git Credential Manager 中同一主机的凭证
|
||||
# (即推送用的那条 OAuth 凭证,无需另行创建 API token)
|
||||
$Token = $env:GITEA_TOKEN
|
||||
if (-not $Token) {
|
||||
# 从 Git Credential Manager 取同一主机的凭证(与 git 推送同源)。
|
||||
# 注意:git credential fill 对 CRLF 管道输入敏感(PS 管道送出 \r\n
|
||||
# 会被拒收),必须写 LF 临时文件、由 cmd 做重定向,stderr 也留在 cmd
|
||||
# 内部(PS 5.1 在 EAP=Stop 下对原生命令的 2>$null 会抛异常)。
|
||||
$credHost = $GiteaUrl -replace "^https?://", ""
|
||||
$req = Join-Path $env:TEMP ("mqttx-cred-" + [guid]::NewGuid().ToString("N") + ".txt")
|
||||
[IO.File]::WriteAllText($req, "protocol=https`nhost=$credHost`n`n")
|
||||
try {
|
||||
$raw = cmd /c "git credential fill < ""$req"" 2>nul"
|
||||
$Token = ($raw | Where-Object { $_ -like "password=*" }) -replace "^password=", ""
|
||||
}
|
||||
catch { $Token = $null }
|
||||
finally { Remove-Item $req -ErrorAction SilentlyContinue }
|
||||
}
|
||||
if (-not $Token) { Die "缺少环境变量 GITEA_TOKEN,且无法从 git credential store 自动获取" }
|
||||
|
||||
# ── owner/repo:参数 > 环境变量 > git remote origin 推断 ─────────────────────
|
||||
if (-not $Owner) { $Owner = $env:GITEA_OWNER }
|
||||
if (-not $Repo) { $Repo = $env:GITEA_REPO }
|
||||
if (-not $Owner -or -not $Repo) {
|
||||
try { $remote = git -C $Root remote get-url origin 2>$null } catch { $remote = $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) {
|
||||
if (-not $Owner) { $Owner = $seg[0] }
|
||||
if (-not $Repo) { $Repo = $seg[1] }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $Owner) { Die "无法推断 owner,请用 -Owner 或 GITEA_OWNER 指定" }
|
||||
if (-not $Repo) { $Repo = $Pkg }
|
||||
|
||||
# ── 版本:参数 > 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 "→ 仓库: $Owner/$Repo"
|
||||
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
|
||||
|
||||
# ── Release 附件:OTA 更新源(应用内 update.rs 只查 /releases)──────────────
|
||||
# 规范化版本号:tag 推断出的 "v1.0.2" 与 Cargo 的 "1.0.2" 统一为无 v 数字段
|
||||
$VerNum = $Version -replace "^v", ""
|
||||
$Triple = "x86_64-pc-windows-msvc"
|
||||
$Api = "$GiteaUrl/api/v1/repos/$Owner/$Repo"
|
||||
|
||||
# 取(或创建)与版本同名 tag 的 Release
|
||||
$rel = $null
|
||||
try {
|
||||
$rel = Invoke-RestMethod -Headers @{ Authorization = "token $Token" } -Uri "$Api/releases/tags/v$VerNum"
|
||||
} catch { }
|
||||
if (-not $rel) {
|
||||
try {
|
||||
$rel = Invoke-RestMethod -Method Post `
|
||||
-Headers @{ Authorization = "token $Token" } `
|
||||
-ContentType "application/json" `
|
||||
-Body (@{ tag_name = "v$VerNum"; name = "v$VerNum"; draft = $false; prerelease = $false } | ConvertTo-Json) `
|
||||
-Uri "$Api/releases"
|
||||
Write-Host "→ 已创建 Release v$VerNum"
|
||||
}
|
||||
catch {
|
||||
Write-Host " ! Release 创建/查询失败:$($_.Exception.Message);跳过附件上传" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
}
|
||||
$releaseId = $rel.id
|
||||
Write-Host "→ Release v$VerNum (id=$releaseId),上传附件…"
|
||||
|
||||
function Upload-ReleaseAsset($uri, $filePath) {
|
||||
# Windows PowerShell 5.1 没有 -Form,用 .NET HttpClient 组 multipart(字段名必须为 attachment)
|
||||
Add-Type -AssemblyName System.Net.Http
|
||||
$client = New-Object System.Net.Http.HttpClient
|
||||
$client.DefaultRequestHeaders.Authorization =
|
||||
New-Object System.Net.Http.Headers.AuthenticationHeaderValue("token", $Token)
|
||||
$content = New-Object System.Net.Http.MultipartFormDataContent
|
||||
$fs = [System.IO.File]::OpenRead($filePath)
|
||||
try {
|
||||
$fileContent = New-Object System.Net.Http.StreamContent($fs)
|
||||
$fileContent.Headers.ContentType =
|
||||
[System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("application/octet-stream")
|
||||
$content.Add($fileContent, "attachment", (Split-Path -Leaf $filePath))
|
||||
$resp = $client.PostAsync($uri, $content).GetAwaiter().GetResult()
|
||||
return [int]$resp.StatusCode
|
||||
}
|
||||
finally {
|
||||
$fs.Dispose()
|
||||
$client.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($f in $Files) {
|
||||
$name = Split-Path -Leaf $f
|
||||
# 已符合 OTA 命名约定的原样使用;否则按约定规范重命名后上传
|
||||
if ($name -match "^mqttx-v.*-mqttx(\.exe)?$") { $asset = $name }
|
||||
elseif ($name.EndsWith(".exe")) { $asset = "mqttx-v$VerNum-$Triple-mqttx.exe" }
|
||||
else { $asset = "mqttx-v$VerNum-$Triple-mqttx" }
|
||||
|
||||
# ── 7z 压缩附件(OTA 主通道:体积小、下载快)──
|
||||
# 内部文件名 = $asset(应用解压后按此名暂存安装)
|
||||
$SevenZip = @(
|
||||
"$env:ProgramFiles\7-Zip\7z.exe",
|
||||
"${env:ProgramFiles(x86)}\7-Zip\7z.exe",
|
||||
"D:\Program Files\7-Zip\7z.exe",
|
||||
"C:\Program Files (x86)\7-Zip\7z.exe"
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $SevenZip) {
|
||||
$SevenZip = (Get-Command 7z -ErrorAction SilentlyContinue).Source
|
||||
}
|
||||
if (-not $SevenZip) {
|
||||
# 官方安装器写入注册表(Path 指向安装目录)
|
||||
try {
|
||||
$reg = Get-ItemProperty "HKLM:\SOFTWARE\7-Zip" -ErrorAction Stop
|
||||
if ($reg.Path) { $cand = Join-Path $reg.Path "7z.exe"; if (Test-Path $cand) { $SevenZip = $cand } }
|
||||
} catch { }
|
||||
}
|
||||
if ($SevenZip) {
|
||||
$packDir = Join-Path $env:TEMP ("mqttx-pack-" + [guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Path $packDir | Out-Null
|
||||
Copy-Item $f (Join-Path $packDir $asset)
|
||||
$archive = Join-Path $env:TEMP "$asset.7z"
|
||||
& $SevenZip a -y -mx=9 $archive (Join-Path $packDir $asset) | Out-Null
|
||||
if ($LASTEXITCODE -eq 0 -and (Test-Path $archive)) {
|
||||
$asum = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLower()
|
||||
"$asum $asset.7z" | Set-Content -NoNewline -Encoding ascii "$archive.sha256"
|
||||
foreach ($a in @("$asset.7z", "$asset.7z.sha256")) {
|
||||
$code = Upload-ReleaseAsset "$Api/releases/$releaseId/assets?name=$a" (Join-Path $env:TEMP $a)
|
||||
if ($code -eq 201) {
|
||||
Write-Host " ✓ Release 附件 $a" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " ! Release 附件 $a 上传失败 (HTTP $code)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " ! 7z 打包失败(exit=$LASTEXITCODE),跳过压缩附件" -ForegroundColor Yellow
|
||||
}
|
||||
Remove-Item $packDir, $archive, "$archive.sha256" -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
Write-Host " ! 未找到 7-Zip,跳过 7z 附件(仅上传裸二进制)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# ── 裸二进制附件:v1.0.3 及更早客户端不认识 7z,需要它升级 ──
|
||||
$tmp = Join-Path $env:TEMP $asset
|
||||
Copy-Item $f $tmp -Force
|
||||
$sum = (Get-FileHash -Algorithm SHA256 $f).Hash.ToLower()
|
||||
"$sum $asset" | Set-Content -NoNewline -Encoding ascii "$tmp.sha256"
|
||||
foreach ($a in @($asset, "$asset.sha256")) {
|
||||
$code = Upload-ReleaseAsset "$Api/releases/$releaseId/assets?name=$a" (Join-Path $env:TEMP $a)
|
||||
if ($code -eq 201) {
|
||||
Write-Host " ✓ Release 附件 $a" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " ! Release 附件 $a 上传失败 (HTTP $code)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
Remove-Item $tmp, "$tmp.sha256" -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "完成。Release 页面: $GiteaUrl/$Owner/$Repo/releases/tag/v$VerNum" -ForegroundColor Cyan
|
||||
@@ -1,241 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 发布构建产物到 Gitea:
|
||||
# 1) Generic Package Registry(API 包,GET /api/packages/...)
|
||||
# 2) Release 附件 —— OTA 更新源只认 Release 附件
|
||||
# (命名约定 mqttx-v<ver>-<triple>-mqttx[.exe],含 .sha256 侧车)
|
||||
#
|
||||
# 用法:
|
||||
# GITEA_URL=https://gitea.example.com GITEA_TOKEN=xxx \
|
||||
# ./scripts/publish-gitea.sh [文件...] [版本]
|
||||
#
|
||||
# 环境变量:
|
||||
# GITEA_URL 实例地址,如 https://gitea.example.com(必填)
|
||||
# GITEA_TOKEN 访问令牌,需 package + repo 写权限(必填)
|
||||
# GITEA_OWNER 软件包所属用户/组织(默认:由 git remote 推断,失败则报错)
|
||||
# GITEA_REPO 仓库名(默认:由 git remote 推断,失败为 mqttx)
|
||||
# GITEA_PKG 软件包名(默认 mqttx)
|
||||
#
|
||||
# 版本推断顺序:参数 > git describe --tags > gpui-app/Cargo.toml。
|
||||
set -euo pipefail
|
||||
|
||||
die() { echo "错误: $*" >&2; exit 1; }
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# ── 地址:优先环境变量,否则从 git remote origin 推断 ────────────────────────
|
||||
if [ -z "${GITEA_URL:-}" ]; then
|
||||
remote_url="$(git -C "$ROOT" remote get-url origin 2>/dev/null || true)"
|
||||
case "$remote_url" in
|
||||
*://*) host="${remote_url#*://}"; host="${host%%/*}" ;;
|
||||
*:*) host="${remote_url%%:*}" ;;
|
||||
*) host="" ;;
|
||||
esac
|
||||
host="${host##*@}" # 去掉 git@user@ 前缀
|
||||
[ -n "$host" ] && GITEA_URL="https://$host"
|
||||
fi
|
||||
[ -n "${GITEA_URL:-}" ] || die "缺少 GITEA_URL(或配置 git remote origin)"
|
||||
GITEA_URL="${GITEA_URL%/}"
|
||||
|
||||
# ── 令牌:优先环境变量,否则取 Git Credential Manager 中同一主机的凭证 ────────
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
GITEA_TOKEN="$(printf 'protocol=https\nhost=%s\n\n' "${GITEA_URL#*://}" \
|
||||
| GCM_INTERACTIVE=Never GIT_TERMINAL_PROMPT=0 git credential fill 2>/dev/null \
|
||||
| sed -n 's/^password=//p')"
|
||||
[ -n "${GITEA_TOKEN:-}" ] || die "缺少 GITEA_TOKEN,且无法从 git credential store 自动获取"
|
||||
fi
|
||||
PKG="${GITEA_PKG:-mqttx}"
|
||||
|
||||
# ── owner/repo:优先环境变量,其次从 git remote 推断 ─────────────────────────
|
||||
remote_owner_repo() {
|
||||
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%%/*}" "${path#*/}"
|
||||
}
|
||||
OWNER="${GITEA_OWNER:-$(remote_owner_repo | head -1 || true)}"
|
||||
REPO="${GITEA_REPO:-$(remote_owner_repo | tail -1 || true)}"
|
||||
[ -n "${OWNER:-}" ] || die "无法推断 GITEA_OWNER,请显式设置"
|
||||
[ -n "${REPO:-}" ] || REPO="$PKG"
|
||||
|
||||
# ── 版本:参数 > 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 "→ 仓库: $OWNER/$REPO"
|
||||
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
|
||||
}
|
||||
|
||||
# OTA 产物命名约定的平台三元组(与 update.rs::platform_triple 一致)
|
||||
triple_of() {
|
||||
case "$(uname -s)/$(uname -m)" in
|
||||
MINGW*/*x86_64*|MSYS*/*x86_64*|CYGWIN*/*x86_64*) echo "x86_64-pc-windows-msvc" ;;
|
||||
Linux/x86_64|Linux/amd64) echo "x86_64-unknown-linux-gnu" ;;
|
||||
Darwin/x86_64) echo "x86_64-apple-darwin" ;;
|
||||
Darwin/arm64) echo "aarch64-apple-darwin" ;;
|
||||
Linux/aarch64|Linux/arm64) echo "aarch64-unknown-linux-gnu" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
TRIPLE="${GITEA_TRIPLE:-$(triple_of)}"
|
||||
|
||||
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"
|
||||
|
||||
# ── Release 附件:OTA 更新源(update.rs 只查 /releases)──────────────────────
|
||||
API="${GITEA_URL}/api/v1/repos/${OWNER}/${REPO}"
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
|
||||
# 规范化版本号:tag 推断出的 "v1.0.2" 与 Cargo 的 "1.0.2" 统一为无 v 数字段
|
||||
VER_NUM="${VERSION#v}"
|
||||
[ -n "$TRIPLE" ] || TRIPLE="unknown"
|
||||
|
||||
# 取(或创建)与版本同名 tag 的 Release;找不到时按需创建
|
||||
code="$(curl -sS -o "$STAGE/rel.json" -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API}/releases/tags/v${VER_NUM}")"
|
||||
if [ "$code" = "404" ]; then
|
||||
echo "→ Release v${VER_NUM} 不存在,创建中…"
|
||||
code="$(curl -sS -o "$STAGE/rel.json" -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"v${VER_NUM}\",\"name\":\"v${VER_NUM}\",\"draft\":false,\"prerelease\":false}" \
|
||||
"${API}/releases")"
|
||||
if [ "$code" != "201" ]; then
|
||||
echo " ! Release 创建失败 (HTTP $code),跳过附件上传" >&2
|
||||
cat "$STAGE/rel.json" >&2 || true
|
||||
exit 0
|
||||
fi
|
||||
elif [ "$code" != "200" ]; then
|
||||
echo " ! 查询 Release 失败 (HTTP $code),跳过附件上传" >&2
|
||||
exit 0
|
||||
fi
|
||||
# 顶层 "id" 是首个出现的 id 字段
|
||||
RELEASE_ID="$(sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$STAGE/rel.json" | head -1)"
|
||||
[ -n "$RELEASE_ID" ] || { echo " ! 无法解析 Release id,跳过附件上传" >&2; exit 0; }
|
||||
echo "→ Release v${VER_NUM} (id=$RELEASE_ID),上传附件…"
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
name="$(basename "$f")"
|
||||
# 已符合 OTA 命名约定的原样使用;否则按约定规范重命名后上传
|
||||
case "$name" in
|
||||
mqttx-v*-mqttx|mqttx-v*-mqttx.exe) asset="$name" ;;
|
||||
*.exe) asset="mqttx-v${VER_NUM}-${TRIPLE}-mqttx.exe" ;;
|
||||
*) asset="mqttx-v${VER_NUM}-${TRIPLE}-mqttx" ;;
|
||||
esac
|
||||
|
||||
# ── 7z 压缩附件(OTA 主通道):内部文件名 = $asset(应用解压后按此名安装)──
|
||||
SEVENZIP="$(command -v 7z 7za 2>/dev/null | head -1)"
|
||||
[ -z "$SEVENZIP" ] && [ -x "/c/Program Files/7-Zip/7z.exe" ] && SEVENZIP="/c/Program Files/7-Zip/7z.exe"
|
||||
if [ -n "$SEVENZIP" ]; then
|
||||
"$SEVENZIP" a -y -mx=9 "$STAGE/$asset.7z" "$f" >/dev/null
|
||||
# 归档内的文件名需为 $asset:7z 存的是源文件名,先复制改名再压
|
||||
rm -f "$STAGE/$asset.7z"
|
||||
cp -f "$f" "$STAGE/$asset"
|
||||
"$SEVENZIP" a -y -mx=9 "$STAGE/$asset.7z" "$STAGE/$asset" >/dev/null
|
||||
if [ -f "$STAGE/$asset.7z" ]; then
|
||||
printf '%s %s\n' "$(sha256 "$STAGE/$asset.7z")" "$asset.7z" > "$STAGE/$asset.7z.sha256"
|
||||
for a in "$asset.7z" "$asset.7z.sha256"; do
|
||||
code="$(curl -sS -o "$STAGE/up.json" -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${STAGE}/${a}" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${a}")"
|
||||
case "$code" in
|
||||
201) echo " ✓ Release 附件 $a" ;;
|
||||
*) echo " ! Release 附件 $a 上传失败 (HTTP $code)" >&2; cat "$STAGE/up.json" >&2 || true ;;
|
||||
esac
|
||||
done
|
||||
else
|
||||
echo " ! 7z 打包失败,跳过压缩附件" >&2
|
||||
fi
|
||||
else
|
||||
echo " ! 未找到 7z/7za,跳过 7z 附件(仅上传裸二进制)" >&2
|
||||
fi
|
||||
|
||||
# ── 裸二进制附件:v1.0.3 及更早客户端不认识 7z,需要它升级 ──
|
||||
cp -f "$f" "$STAGE/$asset"
|
||||
printf '%s %s\n' "$(sha256 "$f")" "$asset" > "$STAGE/$asset.sha256"
|
||||
for a in "$asset" "$asset.sha256"; do
|
||||
code="$(curl -sS -o "$STAGE/up.json" -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${STAGE}/${a}" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${a}")"
|
||||
case "$code" in
|
||||
201) echo " ✓ Release 附件 $a" ;;
|
||||
*) echo " ! Release 附件 $a 上传失败 (HTTP $code)" >&2; cat "$STAGE/up.json" >&2 || true ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
echo "完成。Release 页面: ${GITEA_URL}/${OWNER}/${REPO}/releases/tag/v${VER_NUM}"
|
||||
@@ -1,7 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
Generated
-5741
File diff suppressed because it is too large
Load Diff
@@ -1,43 +0,0 @@
|
||||
[package]
|
||||
name = "mqttx"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[package.metadata]
|
||||
# Tauri configuration metadata
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "mqttx_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# rumqttc-v5-next 与 rumqttc-v4-next 的 lib 目标名都是 "rumqttc",必须重命名依赖才能共存
|
||||
rumqttc = { package = "rumqttc-v5-next", version = "0.34" }
|
||||
rumqttc-v4 = { package = "rumqttc-v4-next", version = "0.34" }
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
|
||||
# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = 3
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -1,14 +0,0 @@
|
||||
fn main() {
|
||||
// Workaround for tauri-build issue with cargo's OUT_DIR format change.
|
||||
// The newer cargo version uses `{package}/{hash}/out` instead of `{package}-{hash}/out`,
|
||||
// which breaks the target_dir calculation in tauri-build 2.6.3.
|
||||
// We create the expected `build/build` directory to prevent the fs::read_dir error.
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let out_path = std::path::Path::new(&out_dir);
|
||||
if let Some(parent) = out_path.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) {
|
||||
let build_build = parent.join("build");
|
||||
let _ = std::fs::create_dir_all(&build_build);
|
||||
}
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user