39 Commits
Author SHA1 Message Date
JoeAllen 6d63b93f20 fix(i18n): 连接/订阅动作按钮改用动词键,英文界面不再显示复数名词 2026-10-14 18:43:07 +08:00
JoeAllen 60501b6465 fix(i18n): MQTT 引擎与阿里云模块用户可见文案接入语言包 2026-10-14 18:31:41 +08:00
JoeAllen 5b8edede46 fix(aliyun): Token 模式补校验 instance_id,防止空值拼出以点开头的非法接入点 2026-10-14 18:14:34 +08:00
JoeAllen 61d8d6fa77 fix(windows): 发布构建不再弹出控制台窗口
Windows 上入口默认 console 子系统,运行时自动创建控制台窗口。
按 debug_assertions 分流:release 用 windows 子系统(纯 GUI,无
控制台;eprintln/tracing 输出丢弃),debug 保留 console 子系统
(日志直接可见)。程序化验证:release 无 ConsoleWindowClass
窗口,debug 保留控制台连接。
2026-10-13 00:31:35 +08:00
JoeAllen 82f26652a4 chore(release): v1.1.0 2026-10-12 19:58:55 +08:00
JoeAllen dd57077e0c fix(theme): 修复注册循环被 bisect 残留覆盖——亮色预设全部失效
Solarized Light 等亮色预设不生效的根因:此前栈溢出二分时
register_bundled_themes 被改为「只注册 catppuccin 4 个」的探针版,
后续恢复只还原了 truncate 而漏掉了注册循环本体(BUNDLED_THEME_FILES
全量循环留在 0..0 死代码里)。暗色预设(Catppuccin Mocha)碰巧可用,
亮色一律回退深色。

恢复全量注册循环;39 个预设名(Linear + 38)注册验证通过;
Catppuccin Mocha / Solarized Light 双预设实机截图验证生效。
顺带移除排查期临时打点,恢复用户 settings 为 system/linear。
2026-10-12 19:31:07 +08:00
JoeAllen ac5b6e9862 feat: 接入 gpui-kit 全部 38 个官方主题预设 + 按语言拆分 locales + 0.7.3 升级
主题预设("我全都要"):
- gpui-app/themes/ 内嵌官方捆绑 21 个文件 36 变体 + Default Light/Dark
- theme.rs:register_bundled_themes(bootstrap 注册)/ apply_theme_preset
  (Theme::update + apply_config 整包应用)/ theme_preset_names
- settings.theme_preset 字段(serde 默认 linear);保存/启动按预设分派,
  外观模式与跟随系统监听仅对 Linear 生效(预设自带明暗不被冲掉)
- 设置页「主题预设」下拉(Linear + 38 注册名),切换即时生效

语言包按语言分文件(用户要求):
- locales/ui.yml → locales/en.yml + locales/zh-CN.yml
  (rust-i18n 文件名 stem 即 locale,扁平 "ns.key": "value")
- i18n.rs 测试改为双文件键集合一致性 + 占位符强制

修复:设置对话框打开即栈溢出
- gpui-kit 0.7.0→0.7.3 无法解决;根因是 Windows 主线程 8MB 栈对
  Dialog 覆盖层内 Select 渲染的深调用链不足,注册多主题后越过阈值
  (64MB 试验栈下不复现,证实深调用链而非无限递归)
- .cargo/config.toml:Windows 主线程栈 8MB→64MB
- 注册时机移到 bootstrap(main 的 init_themes,gpui_kit::init 后、
  窗口创建前),对齐官方 story 示例的注册姿势
- 误伤修复:gpui-app/Cargo.toml 被批量编辑误删 bin.name 与
  build-dependencies 段;examples/flood.rs 误删已恢复

官方 skill 同步:npx skills add 被墙,gh-proxy 镜像克隆后手动覆盖
~/.agents/skills 与 ~/.zcode/skills 的 gpui-kit + gpui-kit-design-guides。
2026-10-12 17:07:40 +08:00
JoeAllen c2c89014d1 fix: 恢复误删的 examples/flood.rs(清理探针时误连目录删除) 2026-10-12 15:12:18 +08:00
JoeAllen 05b37a8997 refactor: 按 gpui-kit 官方指南对齐(locales 拆分/render 无突变/动作统一)
skill 安装:npx skills add 因网络不可用,改为 gh-proxy 镜像克隆仓库后
手动覆盖 ~/.agents/skills 与 ~/.zcode/skills 两处安装(与官方最新一致)。

语言包按语言分文件(用户要求):
- locales/ui.yml(合并格式)→ locales/en.yml + locales/zh-CN.yml
  (rust-i18n 以文件名 stem 识别 locale,扁平 "ns.key": "value")
- i18n.rs 测试适配双文件:键集合一致性 + 占位符两侧一致强制保留

对齐 coding-guides / design-guides / auto-update:
- render 无突变:分组过滤悬空复位从 render 移到 delete_connection/
  save_connection 的 action 路径(normalize_group_filter)
- 消除 UI 层 hsla 字面量:消息色条改 when_some 条件上色;色板描边
  从白 60% 改主题底色(双主题下均为「与背景的缝隙」语义)
- 动作统一:标题栏菜单的资源监控/导出/导入/设置改 dispatch_action,
  与快捷键共享同一 Action(菜单/快捷键单一命令策略)
- auto-update 对齐官方指南:自动检查加 24h 节流(.last-update-check
  时间戳),手动检查更新不受限

保留(记录豁免理由):
- 手写 collapsible_section:官方 Accordion 非受控(内部索引状态),
  替代会丢「已配置默认展开」的受控行为
- 自研 update/:官方无内置 updater,指南推荐 self_update+应用自有
  状态机,现有实现(状态机/后台 worker/sha256/consent/原子替换)
  已符合该精神;换 self_update 反而放弃已有代理兜底与 7z 通道
- px() 几何常量:对话框/面板宽度与色点尺寸属文档化几何豁免,
  机械换算会引入视觉回归;min 约束的 px(0.) 为布局断言

42 测试全绿,实机冒烟通过。
2026-10-12 15:11:48 +08:00
JoeAllen 762ae4bddb style: 修正 init_i18n 调用点缩进 2026-10-05 00:51:21 +08:00
JoeAllen 5db0972263 fix(i18n): 全局复查修复——多行调用点漏替换、动态 label 回归与死键清理
复查发现(audit 脚本 + 修正后的单测扫描器):
- 27 处多行 tf("中文") 调用点未被键迁移的单行替换覆盖,英文模式下
  回退显示中文;正则补丁全部换为语义键
- DirFilter/DetailFormat::label() 仍返回中文常量,t() 动态查键必然
  miss(英文模式方向过滤/格式按钮显示中文);label() 改返回
  common.all/dir.*、common.auto/text(Hex/Base64 语言中性字面量)
- 单测扫描器与 audit 脚本的共同盲区:t(/tf( 与引号之间的空白换行
  不被匹配;扫描器改为跳过空白后取字面量,多行调用纳入强制覆盖
- 6 处 UI 直写补翻:连接测试成功通知、复制连接"(copy)"后缀、
  rfd 导入/导出对话框标题、设置对话框标题、订阅标识符字段标签
- store 导入/导出错误与 PayloadFormat 编码错误(均经 Notification
  展示)key 化,共 14 条新词条
- 删除 10 个真死键;mqtt/tls/update 层诊断错误维持中文(技术诊断
  流,不进 UI 渲染路径),边界记录于模块文档

语言包现状:354 键、en/zh-CN 双语并排、零死键、零 UI 中文直写;
42 测试全绿;英文模式实机截图复核(含回归点)通过。
2026-10-04 23:49:28 +08:00
JoeAllen 35e2b6fb60 refactor(i18n): 迁移到 rust-i18n 语义键体系(ns.key),废弃中文原文作 key
旧实现的问题(按用户反馈):
- 中文原文作 key:文案措辞与代码耦合,一词多义撞 key("主题" Topic/Theme)
- 巨型 match 文案表:重复键靠编译警告暴露;漏翻静默回退,无任何校验
- tf 参数到处 &n.to_string() 噪音

新体系(gpui-kit 官方方案):
- locales/ui.yml 语言包(rust-i18n v2,en/zh-CN 双语并排),key 为
  语义标识符(common.save / conn.connect / status.connected…),
  与自然语言彻底解耦;改措辞不动代码
- crate 根 i18n! 编译期内嵌;init_i18n() 经 extend! 并入 gpui-kit
  组件内置文案(按钮/空态等随语言切换)
- t/tf 薄门面查 _rust_i18n_translate(运行时 key),模型 label()
  改返回语义 key;tf 参数只需 Display
- 四个强制单测:语言包 en/zh-CN 完整、占位符 {x} 两侧一致、
  全源码 t()/tf() 字面量必须存在词条、模型 label 必须有真实译文
- 语言中性文案("QoS"、"/mqtt" 等)去掉 t() 包裹
- 顺带补翻 30 条漏网文案(资源监控统计行/表单校验/阿里云说明段等)

42 测试全绿;中英双语实机截图验证零裸 key。
2026-10-04 12:27:55 +08:00
JoeAllen 0406dee7a4 fix(ci): 发布前显式拉取附注 tag 对象,保证发布正文取自 tag message
actions/checkout 会把 tag 在本地重建为轻量 tag,%(contents) 退化为
commit message(首发的发布正文因此变成了修复提交的信息)。发布步骤
先 git fetch 显式拉取 tag 对象再提取附注。
2026-10-01 16:14:10 +08:00
JoeAllen 2330d362f6 fix(ci): 修复发布正文提取的多行 sed 写法导致的 workflow 解析失败
sed 的 \n 转义在生成文件时被展开为真实换行,把 notes 提取命令劈成
两行,GitHub 判定 workflow 文件无效(startup_failure)。改用单行
awk 剥签名块与空行,语义不变。
2026-10-01 15:59:09 +08:00
JoeAllen b23c0039db chore(release): v1.0.0 2026-10-01 15:55:44 +08:00
JoeAllen 94fdb6a507 feat: 国际化 / 键盘操作 / 无障碍 / 性能优化
国际化
- 新增 ui/i18n 双语文案表(中文原文为 key,英文集中映射,tf 支持占位符)
- AppSettings 增加 language(跟随系统/中文/English),启动与保存时即时生效
- 全部界面文案(标题栏/侧栏/工作区/发布/订阅/表单/设置/全部对话框/通知)入表

键盘操作
- Ctrl+W 关闭当前标签页;Ctrl+Tab / Ctrl+Shift+Tab 循环切换标签
- Ctrl+F 聚焦当前工作区消息过滤框

无障碍
- 全部纯图标按钮补 accessibility_label(屏幕阅读器 / UIA 自动化可识别)

性能
- 折叠态预览(JSON 美化/压平+截断)改为入环形缓冲时计算一次,
  随 MqttRecord 缓存(Arc<str>),渲染热路径对最多数百条可见行
  不再逐帧重复 serde_json 解析
2026-10-01 15:54:18 +08:00
JoeAllen c05b258c5c feat(ui): 侧栏/订阅面板/发布栏抽屉式折叠
- 连接侧栏:Ctrl+B 或把手按钮收起为 40px 窄 rail(展开把手 + 新建连接),
  状态随 ui-state.json 持久化;展开时标题行带收起按钮
- 订阅面板:标题行收起按钮,收起后左侧 32px rail 拉回
- 发布面板:工具条右端把手收起为细条(34px),细条上拉回;
  收起后全局 Ctrl+Enter 发送仍可用
2026-09-30 16:36:59 +08:00
JoeAllen 1dca18873c chore(release): v1.1.0 2026-09-30 16:16:32 +08:00
JoeAllen 967bf9dd78 feat(ui): 界面体验一轮打磨(快捷键/暂停滚动/会话恢复/JSON 预览)
- 全局 Ctrl+Enter 发送;订阅框 Enter 提交、Esc 退出编辑;过滤框 Esc 清空
- 消息流新增「暂停/恢复」:暂停时画面不随新消息跳动,消息仍入环形缓冲
- JSON 负载折叠态预览按原始数据美化展示(多行缩进,超 40 行兜底截断)
- 标签页持久化(ui-state.json),重启恢复工作区;无可恢复标签时自动打开
  首个自连连接的工作区,避免「已连上却演空态」
- 侧边栏 Error 连接行内红字显示失败原因;侧边栏/订阅面板补竖分隔线
- 消息订阅色条上下留隙,不再连成一整条竖线
- 发布被当前过滤条件隐藏时给出「已发送」提示
- 连接表单:主机+端口同行;TLS 证书三个路径字段加「浏览…」(rfd)
- 主题切换/系统外观变化后强制整窗重绘,修未收敛区域的旧色残影
- 设置主题文案改「跟随系统」(跟随逻辑此前已实现)
2026-09-30 16:15:58 +08:00
JoeAllen f5cd0cdc4f chore: 移除项目中所有 Gitea 痕迹,origin 仅作 git 同步远程
- settings_dialog:仓库链接 REPO_URL 与展示名改指 GitHub,
  更新检查文案改「检查 GitHub Releases 上的新版本」
- update/mod.rs、check.rs 注释与 README 去除 Gitea 提法
- 删除 docs/deep-analysis-2026-09-30.md:其发布流水线章节描述的
  Gitea 旧渠道已整体下线,报告失去现实参照(git 历史 5ad4536 可找回)
2026-09-30 14:02:38 +08:00
JoeAllen f4315dce1e ci: macOS 只出 Apple Silicon(aarch64-apple-darwin),不做 Intel 2026-09-30 13:39:02 +08:00
JoeAllen 4e7ddfd4e9 ci: 发布矩阵加入 macOS(aarch64 原生 + x86_64 交叉编译)
客户端 platform_triple 本就支持 apple 三元组,补齐 CI 构建;Intel 侧
在 ARM runner 上交叉编译(Apple SDK 双架构通用)。README 补充 macOS
Gatekeeper 未签名产物的说明。
2026-09-30 13:38:10 +08:00
JoeAllen f01601114c fix(cross-platform): 非 Windows 分支委托给 cfg 配对的桩函数
上一提交内联 false 使非 Windows 版 set_topmost_windows 桩失去调用者,
在 -D warnings 下报 never used。恢复委托调用。
2026-09-30 13:17:21 +08:00
JoeAllen 5783a500e8 fix(cross-platform): cfg 掉的 Windows 路径遗留 dead_code 在 -D warnings 下报错
CI 的 setup-rust-toolchain 默认 RUSTFLAGS=-Dwarnings:非 Windows 下
sample_windows/set_topmost_windows 被 cfg 移除后,其专用字段
(Sampler.pid/last_cpu/last_percent)与常量(SWP_*)成为 dead code。
字段加 cfg_attr(not(windows) allow(dead_code)),常量加 cfg 门控。
2026-09-30 13:15:01 +08:00
JoeAllen 22e0c70b90 fix(cross-platform): set_topmost/sample 改属性 cfg 分派,修复非 Windows 编译
cfg!() 只是运行时常量,其分支永远参与类型检查 —— 非 Windows 下
sample_windows / set_topmost_windows 被属性 cfg 移除后调用点报
E0599 方法不存在(Linux CI Test job 第二跑暴露)。改为
#[cfg] 属性块分派,两条路径各平台上只有一个参与编译。
2026-09-30 13:12:36 +08:00
JoeAllen 7bff64e477 fix(build): build.rs 用 cfg 门控 embed_resource,修复 Linux 编译
embed_resource 只声明在 Windows 的 build-dependencies,Linux 编译
build script 时模块路径无法解析(CI Test job 首跑即暴露:旧 Gitea
runner 从未真正跑过流水线,此问题一直潜伏)。运行时 env 判断无效,
改用 #[cfg(target_os = "windows")] 编译期门控。
2026-09-30 13:09:37 +08:00
JoeAllen 8614c66131 chore(release): 弃用 Gitea 渠道,GitHub 发布历史自 v1.0.0 独立开始
- 删除 mirror-gitea job 与 scripts/publish-gitea.*(不再向 Gitea 镜像,
  不管旧版本兼容)
- update/mod.rs 与 README 同步:Gitea 标记为历史渠道已弃用
2026-09-30 13:01:42 +08:00
JoeAllen 7b41e7f13d feat(update): OTA 兜底发现与系统代理支持
- check_latest 加免 API 配额的兜底路径:api.github.com 被限流(60 次/
  小时/IP,共享出口 IP 实测已打满)时,跟踪 github.com/releases/latest
  的 302 从最终 URL 提取 tag,下载与 .sha256 按命名约定构造;notes 置空、
  大小置 0(进度未知、对账跳过),sha256 校验仍强制执行
- ureq 启用 win-system-proxy:Windows 系统代理注册表设置自动生效;
  HTTP(S)_PROXY 环境变量代理 ureq 默认就读取,无需配置
- 实测:github.com 直连与代理均可达(302 正常),api.github.com 直连
  与代理出口 IP 的未认证配额均耗尽 —— 兜底路径为必需品而非锦上添花
2026-09-30 12:54:57 +08:00
JoeAllen 68273e8e63 feat(update): OTA 更新源切换到 GitHub Releases
- RELEASES_API 指向 api.github.com/repos/LiJoeAllen/mqttx/releases;
  latest 端点优先,回退列表分页改 per_page=5(GitHub 参数)
- agent 显式设置 User-Agent: mqttx-ota(GitHub API 要求携带 UA)
- api.github.com 未认证配额 60 次/小时/IP:共享出口 IP 打满时检查静默
  降级(错误仅记录),附件下载走 github.com CDN 不受配额限制
- 设置对话框「关于」的更新源展示改为 GitHub(UPDATE_SOURCE 常量)
- v1.0.x 老客户端 OTA 源仍是 Gitea,靠 CI mirror-gitea job 喂养
  (配置 GITEA_URL/GITEA_TOKEN/GITEA_OWNER Secrets 后生效),
  老客户端升级完毕后可移除镜像 job 与 publish-gitea 脚本
2026-09-30 12:41:11 +08:00
JoeAllen 1132cdd41d ci: 工作流迁移至 GitHub Actions
- 新增 .github/workflows/release.yml:v* tag 触发,Linux + Windows 双平台
  构建(启用 windows-latest,替代原本地 publish-gitea.ps1 补位的 Windows 发布),
  发布到 GitHub Release(7z/裸二进制/.sha256,OTA 命名约定不变)
- 可选 mirror-gitea job:配置 GITEA_URL/GITEA_TOKEN/GITEA_OWNER Secrets 后
  自动把产物镜像到自建 Gitea Release,保住已装机 v1.0.x 客户端的 OTA 更新源;
  未配置时自动跳过。客户端 OTA 切换到 GitHub 后可删除该 job
- 删除 .gitea/workflows/release.yml(已迁移);scripts/publish-gitea.* 保留
  (镜像 job 与本地应急发布仍使用)
- README 发布章节同步更新
2026-09-30 12:34:21 +08:00
JoeAllen 6f09a5729f chore(deps): 全量升级依赖,适配 gpui-kit 0.7 与 ureq 3
- gpui-kit 0.6→0.7:main.rs 改用 gpui_kit::open_window 自动挂载 Root,
  移除内容视图手动挂载的 dialog/sheet/notification 层
- ureq 2→3:Agent::config_builder 配置超时(global/connect/recv_response),
  响应体改用 read_json / read_to_string / into_body().into_reader()
- hmac 0.13 需显式导入 KeyInit;digest 0.11 的 Output 不再实现 LowerHex,
  sha256 转 hex 改为逐字节格式化,并补已知向量测试
- windows 0.62:SetWindowPos 插入位置参数改为 Option<HWND>
- dirs 6→7、base64 0.22→0.23,其余锁内更新(tokio/sentry/rustls 等)
- TLS 栈不变(rustls + webpki-roots),无 openssl/native-tls 混入

已验证:cargo check 零警告;35 测试通过;运行应用截图核验主界面、
新建连接对话框打开与关闭均正常
2026-09-30 11:43:40 +08:00
JoeAllen b22a645588 chore: 添加 ZCode 忽略规则文件 .zcodeignore 2026-09-30 10:19:47 +08:00
JoeAllen 5ad4536703 docs: 项目深度分析报告(架构/问题清单/修复记录)
只读分析:架构与并发模型、34 项问题清单(P0/P1/P2 分级 + 可信度标注)、发布链路/OTA 信任模型/性能专项、测试覆盖评估、README 差异、修复路线图;0.4/0.5 节记录两批修复与验证证据。
2026-09-30 10:16:45 +08:00
JoeAllen 7684eedd0f fix(ui): 表单校验加固与对话框生命周期
- 端口 1~65535(0 不是可连接端口);接收上限/最大报文长度/主题别名上限拒 0——MQTT 5 规范定义为 Protocol Error,broker 直接断连且值会被持久化,表现为每次连接都失败
- 遗嘱主题 trim 后入库,并拒绝包含通配符 + / #(通配符只用于订阅过滤器)
- 16 个被校验字段订阅输入变更:编辑即清空字段错误,红字不再滞后于实际输入(此前错误只在保存时登记,改好值后红字仍挂着)
- 传输切换只按「上一个方式」的默认端口覆盖端口:与全部默认端口比较会把用户手输的 8084/8883 误判为默认值,WSS:9443 → TCP → WSS 往返丢端口
- 五个对话框 open() 统一加 has_active_dialog 守卫:对话框是栈式叠加,多入口重复 open 会压入第二个模态,同 id 控件在两层间串台
- 阿里云预设对话框表单预填即选中第一项:此前 selected=None,打开即保存会 push 一条内容相同的重复预设
- OptionDelegate::position 按框架契约实现(按值反查索引):恒返回 None 会让 set_selected_value 失效、下拉显示空
- 非 Windows 不渲染置顶按钮:set_topmost 恒 false,显示必然报错的按钮只会让人困惑
- 主题键名对齐 gpui-component 的 serde rename:font.size / radius.lg 此前被静默忽略,14px 紧凑排版从未生效(实际渲染 16px)
- aliyun.rs DeviceName 空值校验;Cargo.toml 声明 Apache-2.0(与关于页一致)
2026-09-30 10:16:17 +08:00
JoeAllen 9b6df8b9eb fix(app): OTA 信任链加固、引擎背压与协议正确性
OTA(update/):
- 解压前校验 7z 条目清单:sevenz-rust 0.6.1 的 decompress_file 直接 dest.join(entry.name()),不校验 ".."、绝对路径与盘符,恶意归档可越界写文件;现在要求恰好一个条目且名字与约定二进制一致(fail-closed)
- 无 .sha256 侧车时拒绝安装,不再静默跳过校验而 UI 仍宣称已校验
- 安装前重算暂存文件哈希;同意标记从仅版本号改为绑定「版本 + 哈希」
- 跨卷 rename 失败回退「复制 + fsync + 删源」(暂存在 APPDATA、exe 在安装目录,不同卷时必失败且报错误导)
- .old 回滚备份保留 7 天:原实现新版本首启即删,新版本一旦崩溃用户无任何回退路径
- 新增安装互斥锁:两实例同时安装会互删暂存包、争抢同一个 .old
- platform_triple 用 std::env::consts::ARCH:写死 x86_64 让 ARM 客户端永远挑不到产物
- pick_assets 按命名约定精确后缀匹配:非 Windows 下 ends_with("") 恒真,.tar.gz/.deb 会被当成可执行文件下载并替换自身

引擎(mqtt/):
- 事件通道改有界并分级:高频事件(消息/日志)满即丢并计数、每 1000 条提示一次;状态/订阅结果/发布回执异步补投。原无界通道在到达速率超过消费速率时内存无上界(单条最长 128KB)
- 重连改指数退避(1s→30s 封顶)+ 0~500ms 抖动,替代固定 2 秒的惊群式重试
- 测试连接的建连(读证书,阻塞 IO)移入 spawn_blocking,与 connect 一致

正确性与性能:
- session_params_changed 补齐 4 个 v5 CONNECT 属性(会话过期/接收上限/最大报文/主题别名),漏掉会让"重连后生效"提示静默失效;测试改逐字段防漏式断言
- ConnectionConfig / AliyunPreset 手写脱敏 Debug:password 与 AccessKeySecret/DeviceSecret 输出 <redacted>,杜绝一次 {:?} 把凭据送进 stderr 与 Sentry
- MqttRecord.user_properties 改 Arc<[(String,String)]>、应用日志改 Arc<LogEntry>:消息面板每帧 300 条、日志面板每帧 3000 条的深拷贝降为引用计数
- 阿里云 DeviceName 空值校验,避免拼出非法 clientId 后服务端报错难懂
2026-09-30 10:15:40 +08:00
JoeAllen 2661a734ee fix(release): 修复 CI 阻断与发布链路,强制版本一致性
- sysmon 采样测试改为分平台断言:此前无条件断言 working_set > 0,非 Windows 上 cargo test 必失败,v* tag 会停在测试阶段(test job 恰好跑在 ubuntu-latest)
- CI test job 补齐 GPUI 的 Linux 系统依赖,--lib --tests 让集成测试参与编译
- meta job 校验 tag 与 gpui-app/Cargo.toml 版本一致:忘 bump 会导致新版本装完仍自报旧版,客户端反复提示同一更新
- 发布脚本:--exact-match 取 tag 并强制 semver 校验(--always 会产出 v1.0.0-4-gbc9f55c 这类客户端 release_part 截断后判定不新的版本,且该 Release 会占据 /releases/latest 遮蔽真更新);Release 创建/查询/解析失败一律 exit 1,不再让 job 变绿而 OTA 通道为空
- sysmon CPU 差分基准只在真正算出结果时前移:采样间隔过近沿用上次读数,修复手点立即刷新永远读到 0%;修正 cpu_percent 注释口径(0~100,整机占比)
- README 订正发布流程:CI 实际仅出 Linux 产物;补 Release 附件(7z/裸二进制/.sha256)这一 OTA 唯一通道与命名约定;token 需 repo 写权限
2026-09-30 10:14:18 +08:00
JoeAllen bc9f55c889 refactor(ui): 拆分 app/connection_view/widgets 为子组件,对话框归拢 dialogs/
- ui/app/ 拆为门面、events(引擎事件泵)、actions(用户动作)、io(导入导出)、
  theme(主题)、titlebar/sidebar/main_area(渲染分区)
- ui/connection_view/ 拆为门面、subscribe、messages、publish、vars、util(纯函数)
- ui/widgets/ 拆为 select(可搜索下拉)、kv_editor、common(字段行/状态/时间)
- 五个对话框归拢 ui/dialogs/,linear-theme.json 随 theme.rs 迁入 ui/app/
- clippy 0 警告,全部路径经门面保持稳定
2026-09-29 22:20:53 +08:00
JoeAllen b5089f5898 refactor(core): 拆分 model/mqtt/update 为单一职责子模块
- model/ 按领域拆为 connection/subscription/message/publish/variable/log/settings
- mqtt/ 拆为门面(引擎与事件)、tls、v5、v4 四块
- update/ 拆为门面(版本工具与命名约定)、check(检查)、install(下载/安装/清理)
- sysmon 只保留进程资源采样,窗口置顶迁至新 platform.rs
- 对外路径经 mod.rs 再导出保持不变,测试随门面保留(33 通过)
2026-09-29 22:20:53 +08:00
JoeAllen 303b348ffe chore: 移除 Tauri + Vue 遗留实现,仅维护原生 gpui 程序
- 删除 src/(Vue 前端)、src-tauri/(Tauri 后端与全套图标)、
  Vite/TS/pnpm 配置、.github 旧 CI、.vscode 推荐
- workspace 去掉 src-tauri exclude;.gitignore 精简为纯 Rust 项目
- README 指向归档分支 tauri-legacy(保留完整历史可随时查阅)
2026-09-29 20:12:31 +08:00
194 changed files with 17808 additions and 24623 deletions
+4 -10
View File
@@ -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"]
-133
View File
@@ -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 }}"
-111
View File
@@ -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/
+197
View File
@@ -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
View File
@@ -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/
-7
View File
@@ -1,7 +0,0 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}
+51
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,7 +1,6 @@
[workspace]
resolver = "2"
members = ["gpui-app"]
exclude = ["src-tauri"]
[workspace.package]
edition = "2024"
+72 -53
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+407
View File
@@ -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 两文件键集合必须一致——单测强制。
+407
View File
@@ -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
View File
@@ -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()
}
}
+21
View File
@@ -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
View File
@@ -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
+312
View File
@@ -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()
}
}
// ─── 订阅 ────────────────────────────────────────────────────────────────────
+45
View File
@@ -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>,
}
// ─── 连接状态 ────────────────────────────────────────────────────────────────
+290
View File
@@ -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("非文本"));
}
}
+395
View File
@@ -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));
}
}
+57
View File
@@ -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,
}
}
}
// ─── 全局变量 ────────────────────────────────────────────────────────────────
+120
View File
@@ -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,
}
}
}
// ─── 单元测试 ────────────────────────────────────────────────────────────────
+80
View File
@@ -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,
}
}
}
+71
View File
@@ -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));
}
}
// ─── 日志 ────────────────────────────────────────────────────────────────────
-1418
View File
File diff suppressed because it is too large Load Diff
+699
View File
@@ -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", &params.topic),
("qos", &params.qos),
("retain", &params.retain),
],
),
None,
);
engine.emit(EngineEvent::Published {
connection_id,
topic: params.topic,
payload: sent_payload,
qos: params.qos,
retain: params.retain,
content_type: params.content_type,
user_properties: params.user_properties,
response_topic: params.response_topic,
correlation_data: params.correlation_data,
message_expiry_interval: params.message_expiry_interval,
});
}
Err(e) => {
engine.log(
&connection_id,
LogLevel::Error,
"publish_error",
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)
}
+182
View File
@@ -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())
}
+338
View File
@@ -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);
});
}
+374
View File
@@ -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);
});
}
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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
+329
View File
@@ -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", &copy.name)]);
copy.client_id =
format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
self.connections.push(copy);
self.storage.save_connections(&self.connections);
}
/// 分组被改名/删除后过滤器可能悬空(列表恒空),在数据变更的 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,
);
}
}
}),
),
)
});
}
}
+286
View File
@@ -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 / 生命周期 ──────────────────────────────────────────────
}
+153
View File
@@ -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",
+108
View File
@@ -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()
}
}
+528
View File
@@ -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)),
)
}
}
+425
View File
@@ -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
})
});
}))
})
}
}
+131
View File
@@ -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()
}
}
+185
View File
@@ -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
View File
@@ -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
+838
View File
@@ -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)
}
}
+367
View File
@@ -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()
})
}
}
+641
View File
@@ -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(&params.topic, &vars);
params.payload = render_template(&params.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>(&params.payload) {
window.push_notification(
Notification::warning(i18n::tf("pub.json_invalid", &[("e", &e)])),
cx,
);
return;
}
match params.payload_format.encode(&params.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, &params.topic))
.unwrap_or(false)
|| (!query.is_empty()
&& !contains_ignore_case(&params.topic, &query)
&& !contains_ignore_case(&params.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", &params.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(&params, 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))
}
}
+209
View File
@@ -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!["名称"]);
}
}
+169
View File
@@ -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())
});
+7
View File
@@ -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
+324
View File
@@ -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)
}
}
+4 -6
View File
@@ -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 已随应用加载)。
-272
View File
@@ -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)
}
+68
View File
@@ -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()
}
}
+122
View File
@@ -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();
})),
)
}
}
// ─── 时间格式化 ──────────────────────────────────────────────────────────────
+9
View File
@@ -0,0 +1,9 @@
//! 跨界面复用的小组件:枚举下拉、键值对编辑器、字段行、状态指示等。
mod common;
mod kv_editor;
mod select;
pub use common::*;
pub use kv_editor::*;
pub use select::*;
+97
View File
@@ -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)
}
-658
View File
@@ -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());
}
}
}
+237
View File
@@ -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
);
}
}
+546
View File
@@ -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");
}
}
+283
View File
@@ -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());
}
}
}
+307
View File
@@ -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"
}
}
}
}
]
}
+148
View File
@@ -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"
}
}
}
}
]
}
+147
View File
@@ -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"
}
}
}
}
]
}
+246
View File
@@ -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"
}
}
}
}
]
}
+459
View File
@@ -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"
}
}
}
}
]
}
+829
View File
@@ -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"
}
}
}
}
]
}
+286
View File
@@ -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"
}
}
}
}
]
}
+137
View File
@@ -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"
}
}
}
}
]
}
+307
View File
@@ -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"
}
}
}
}
]
}
+302
View File
@@ -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"
}
}
}
}
]
}
+150
View File
@@ -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"
}
}
}
}
]
}
+303
View File
@@ -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"
}
}
}
}
]
}
+147
View File
@@ -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"
}
}
}
}
]
}
+157
View File
@@ -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"
}
}
}
}
]
}
+288
View File
@@ -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"
}
}
}
}
]
}
+330
View File
@@ -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"
}
}
}
}
]
}
+283
View File
@@ -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"
}
}
}
}
]
}
+291
View File
@@ -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"
}
}
}
}
]
}
+148
View File
@@ -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"
}
}
}
}
]
}
+421
View File
@@ -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"
}
}
}
}
]
}
+141
View File
@@ -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
View File
@@ -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>
-30
View File
@@ -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"
}
}
-1032
View File
File diff suppressed because it is too large Load Diff
-33
View File
@@ -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

-303
View File
@@ -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
-241
View File
@@ -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}"
-7
View File
@@ -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
-5741
View File
File diff suppressed because it is too large Load Diff
-43
View File
@@ -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
-14
View File
@@ -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