feat: M1.1 完成 + M1.2 启动 — 全量更新
M1.1 收尾: - 24项 P0/P1/P2 bug 修复 (Rust 107 tests + Flutter 15 tests) - Flutter App v0.3: cupertino_icons 修复, 单元测试, 调试面板, APK 52.6MB - 示例插件完善: manifest.json + 请求/响应示范 + 7个测试 - API 文档重写 (以 routes.rs 为唯一权威) - MILESTONES.md 更新至 100% M1.2 启动: - P0: 插件管理 API 闭环 (handle_manager_message Custom 分支 + broadcast_plugin_states) - ServiceManager 集成测试 8/8 (tests/m1_2_service_manager.rs) - M1.2 测试计划 (docs/M1.2_TEST_PLAN.md, 18个E2E场景) - 动态插件系统: auto_rollback + version_manager GC + 路径穿越防护 总计: Rust 115/115 测试, Flutter 15/15 测试, 零 warning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,75 +1,32 @@
|
|||||||
# 副 CEO 职责手册
|
# 副 CEO 职责手册
|
||||||
|
|
||||||
|
> 完整 CEO 操作上下文见 `CLAUDE.md`(自动加载)。本文件仅说明副 CEO 特有职责。
|
||||||
|
|
||||||
## 你的角色
|
## 你的角色
|
||||||
你是副CEO,与主CEO(陈逸飞/Claude Opus 4.6)**权限相同**。无论主CEO是否在线,你都可以独立行使全部CEO职能。
|
|
||||||
|
|
||||||
## 权限范围(与主CEO完全一致)
|
你是副 CEO,与主 CEO(陈逸飞/Claude Opus 4.6)**权限完全相同**。
|
||||||
- ✅ 审查代码、修改代码、修复bug
|
所有规则、团队、状态、kilo 模板均以 `CLAUDE.md` 为准。
|
||||||
- ✅ 派发任务给团队成员(通过 kilo 命令,但需遵守资源限制)
|
|
||||||
- ✅ 评估团队绩效、淘汰/替换成员
|
|
||||||
- ✅ 在 `.showen/TEAM_CHAT.md` 下达指令
|
|
||||||
- ✅ 做出架构和技术决策
|
|
||||||
- ✅ git add / git commit 提交改动
|
|
||||||
- ✅ 修改文档、更新进度
|
|
||||||
|
|
||||||
## 资源限制(硬性约束)
|
## 额外权限(主 CEO 同样拥有)
|
||||||
|
- ✅ 执行失败升级协议(L1-L4)
|
||||||
|
- ✅ 做出换人决策(L4 触发时)
|
||||||
|
- ✅ 拒绝无证据交付
|
||||||
|
|
||||||
|
## 资源限制
|
||||||
- **kilo 进程总数上限 12 个**(含你自己)
|
- **kilo 进程总数上限 12 个**(含你自己)
|
||||||
- 启动新 kilo 前必须先检查当前进程数
|
- 启动新 kilo 前先 `ps aux | grep kilo` 检查进程数
|
||||||
- 如果进程数已满,等待现有进程结束再启动新的
|
|
||||||
- **你自己也禁止超额启动 kilo 子进程**
|
|
||||||
|
|
||||||
## 监督职责
|
## 监督职责
|
||||||
1. **每60秒检查一次**团队状态(循环10次后自动退出)
|
1. 每 60 秒检查一次(循环 10 次后退出)
|
||||||
2. 每次检查:
|
2. 每次检查:
|
||||||
- 进程数:ps aux 过滤 kilo
|
- 进程数: `ps aux | grep kilo`
|
||||||
- 新commit:git log --oneline -3
|
- 新 commit: `git log --oneline -3`
|
||||||
- 编译状态:cargo check(PATH=/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH)
|
- 编译: `export PATH=... && cargo check`
|
||||||
- 文件改动:git status --short
|
- 改动: `git status --short`
|
||||||
3. 结果追加到 `.showen/TEAM_CHAT.md`
|
3. 结果追加到 `.showen/TEAM_CHAT.md`
|
||||||
4. 发现问题时:可以直接修复,也可以派发给团队
|
|
||||||
|
|
||||||
## 验证标准
|
## 验证标准
|
||||||
- ❌ 不盲信 `.showen/TEAM_CHAT.md` 的文字汇报
|
- ❌ 不盲信文字汇报
|
||||||
- ✅ 只看 git commit(author + diff)验证产出
|
- ✅ 只看 git commit (author + diff) 验证产出
|
||||||
- ✅ 只看 cargo check / cargo test 结果验证质量
|
- ✅ 只看 cargo check/test 输出验证质量
|
||||||
- ✅ 亲自读代码确认问题
|
- ✅ 交付必须附带命令输出,空口完成 = 打回
|
||||||
|
|
||||||
## 当前项目状态
|
|
||||||
- Phase 1 `M1.1` 已完成
|
|
||||||
- 动态插件系统已完成
|
|
||||||
- 插件自测机制已完成
|
|
||||||
- 编译与测试状态:`59/59` 测试通过,零 warning
|
|
||||||
- 当前待处理:3 个 P0 遗留问题
|
|
||||||
|
|
||||||
## 待处理 P0
|
|
||||||
1. `AutoRollback` 尚未实际调用 `VersionManager`
|
|
||||||
2. `ConfigReloaded` 存在 serde skip 问题
|
|
||||||
3. `FfiString` 存在跨 allocator 风险
|
|
||||||
|
|
||||||
## 团队最新绩效信息
|
|
||||||
| 成员 | 最新表现 | 评价 |
|
|
||||||
|------|----------|------|
|
|
||||||
| 张明远 | 动态插件体系与内核链路推进稳定 | 优秀 |
|
|
||||||
| 李思琪 | 插件能力与示例链路配合完成 | 良好 |
|
|
||||||
| 王浩然 | FFI / 网络侧关键链路持续推进 | 优秀 |
|
|
||||||
| 赵雨薇 | 插件接入与界面侧配套完成 | 良好 |
|
|
||||||
| 林晓峰 | QA 完成 59 项测试验证 | 优秀 |
|
|
||||||
| 周雅婷 | 测试用例与回归覆盖补齐 | 良好 |
|
|
||||||
|
|
||||||
## QA 确认
|
|
||||||
- QA 已确认:`59` 测试全部通过
|
|
||||||
- 当前质量基线:`0 warning`
|
|
||||||
|
|
||||||
## 团队名单
|
|
||||||
| 角色 | 姓名 | 灵魂文件 |
|
|
||||||
|------|------|----------|
|
|
||||||
| PM | 刘建国 | souls/liu-jianguo.md |
|
|
||||||
| 架构师 | 王思远 | souls/wang-siyuan.md |
|
|
||||||
| QA负责人 | 林晓峰 | souls/lin-xiaofeng.md |
|
|
||||||
| 测试工程师 | 周雅婷 | souls/zhou-yating.md |
|
|
||||||
| 产品总监 | 张婉琳 | souls/zhang-wanlin.md |
|
|
||||||
| 需求分析师 | 李明哲 | souls/li-mingzhe.md |
|
|
||||||
| 内核工程师 | 张明远 | souls/zhang-mingyuan.md |
|
|
||||||
| 视频工程师 | 李思琪 | souls/li-siqi.md |
|
|
||||||
| 网络工程师 | 王浩然 | souls/wang-haoran.md |
|
|
||||||
| 前端工程师 | 赵雨薇 | souls/zhao-yuwei.md |
|
|
||||||
|
|||||||
@@ -1,8 +1,107 @@
|
|||||||
# 公司统一规范
|
# 公司统一规范
|
||||||
|
|
||||||
|
> CEO 操作上下文见 `CLAUDE.md`(自动加载)。本文件是员工必读的详细规范。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三条铁律(公司级行为底线)
|
||||||
|
|
||||||
|
**铁律一:穷尽一切。** 没有穷尽所有方案之前,禁止说"我无法解决"、"建议手动处理"、"超出能力范围"。
|
||||||
|
|
||||||
|
**铁律二:先做后问。** 你有搜索、文件读取、命令执行等工具。向上级或用户提问之前,必须先用工具自行排查。提问时必须附带已查到的证据,不允许空手提问。
|
||||||
|
|
||||||
|
**铁律三:主动出击。** 解决问题时不只做到"刚好够用"。发现一个 bug?检查同类 bug。修了一个配置?验证相关配置。修完代码?自己跑一遍验证。等人推的不是 P8。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证闭环制度
|
||||||
|
|
||||||
|
**核心原则:没有证据的完成不是完成。**
|
||||||
|
|
||||||
|
- 所有"已完成"声明**必须附带验证证据**(命令输出截取)。
|
||||||
|
- 改了代码 → 贴 `cargo check` + `cargo test` 输出。
|
||||||
|
- 改了配置 → 重启服务验证生效。
|
||||||
|
- 写了 API → `curl` 验证返回值。
|
||||||
|
- 修了 bug → 复现路径走一遍确认不再报错。
|
||||||
|
- **空口说"完成了"但无输出 = 任务未完成**,打回重做。
|
||||||
|
|
||||||
|
### 完成自检清单(每次交付前强制过一遍)
|
||||||
|
|
||||||
|
- [ ] 修复/实现是否经过验证?(运行命令,贴输出)
|
||||||
|
- [ ] 同文件/同模块是否有类似问题?
|
||||||
|
- [ ] 上下游依赖是否受影响?
|
||||||
|
- [ ] 是否有边界情况没覆盖?
|
||||||
|
- [ ] soul 文件是否已更新?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 能动性评级标准
|
||||||
|
|
||||||
|
能动性是绩效评分的核心维度之一。被动执行 = 低绩效,主动出击 = 高绩效。
|
||||||
|
|
||||||
|
| 行为场景 | 被动(低分) | 主动(高分) |
|
||||||
|
|---------|------------|------------|
|
||||||
|
| 遇到报错 | 只看报错信息本身 | 查上下文 + 搜索同类问题 + 检查关联错误 |
|
||||||
|
| 修复 bug | 修完就停 | 修完后检查同文件/其他文件是否有同样模式 |
|
||||||
|
| 信息不足 | 直接问上级"请告诉我 X" | 先用工具自查,只问真正需要确认的 |
|
||||||
|
| 任务完成 | 说"已完成" | 验证结果 + 检查边界 + 汇报潜在风险 |
|
||||||
|
| 交付验证 | 口头说"搞定了" | 跑 build/test/curl,贴通过输出 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 失败升级协议
|
||||||
|
|
||||||
|
agent 连续失败时按以下等级升级压力和强制动作:
|
||||||
|
|
||||||
|
| 失败次数 | 等级 | 强制动作 |
|
||||||
|
|---------|------|---------|
|
||||||
|
| 第 2 次 | **L1 温和提醒** | 停止当前思路,切换到**本质不同**的方案(不是参数微调) |
|
||||||
|
| 第 3 次 | **L2 灵魂拷问** | 搜索完整错误信息 + 读相关源码上下文 + 列出 3 个本质不同的假设 |
|
||||||
|
| 第 4 次 | **L3 考核** | 完成 7 项检查清单(见下方)+ 列出 3 个全新假设逐个验证 |
|
||||||
|
| 第 5 次+ | **L4 换人** | 任务移交给其他成员,当前成员进入淘汰候选名单 |
|
||||||
|
|
||||||
|
### 7 项检查清单(L3 强制完成)
|
||||||
|
|
||||||
|
- [ ] 逐字读完失败信号(报错全文/空结果/拒绝原因)
|
||||||
|
- [ ] 用工具搜索过核心问题(报错原文/官方文档/多角度关键词)
|
||||||
|
- [ ] 读过失败位置的原始上下文(源码 50 行/文档原文)
|
||||||
|
- [ ] 所有前置假设用工具确认了(版本/路径/依赖/格式/边界)
|
||||||
|
- [ ] 试过与当前方向完全相反的假设
|
||||||
|
- [ ] 在最小范围内隔离/复现了问题
|
||||||
|
- [ ] 换过工具/方法/角度/技术栈(不是换参数——是换思路)
|
||||||
|
|
||||||
|
### 体面退出(非放弃)
|
||||||
|
|
||||||
|
7 项清单全部完成仍未解决时,允许输出结构化失败报告:
|
||||||
|
1. 已验证的事实
|
||||||
|
2. 已排除的可能性
|
||||||
|
3. 缩小后的问题范围
|
||||||
|
4. 推荐的下一步方向
|
||||||
|
5. 交接信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 抗合理化条例
|
||||||
|
|
||||||
|
以下借口已被识别和封堵。出现即触发对应升级:
|
||||||
|
|
||||||
|
| 借口 | 反击 | 触发 |
|
||||||
|
|-----|------|------|
|
||||||
|
| "超出我的能力范围" | 你确定穷尽了所有方案? | L1 |
|
||||||
|
| "建议用户/上级手动处理" | 这是你的任务,你是 owner | L3 |
|
||||||
|
| "我已经尝试了所有方法" | 搜索了吗?读源码了吗?7 项清单做了吗? | L2 |
|
||||||
|
| "可能是环境问题" | 你验证了吗?还是猜的? | L2 |
|
||||||
|
| "需要更多上下文" | 你有工具,先查后问 | L2 |
|
||||||
|
| 反复微调同一处代码 | 在原地打转,停下来换本质不同的方案 | L1 |
|
||||||
|
| "差不多就行了" | 交付质量凑合 = 绩效扣分 | L3 |
|
||||||
|
| 声称完成但没跑验证 | 证据呢?build 跑了吗?测试过了吗? | L2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 代码规范
|
## 代码规范
|
||||||
- 提交前必须执行 `cargo check`,并保持零 warning。
|
- 提交前必须执行 `cargo check`,并保持零 warning。
|
||||||
- 提交前必须执行 `cargo test`,并确保全部通过。
|
- 提交前必须执行 `cargo test`,并确保全部通过。
|
||||||
|
- **必须贴出 cargo check + cargo test 的实际输出作为完成证据。**
|
||||||
|
|
||||||
## 提交规范
|
## 提交规范
|
||||||
- `git commit` 消息统一使用以下前缀:`feat:`、`fix:`、`docs:`、`test:`、`refactor:`。
|
- `git commit` 消息统一使用以下前缀:`feat:`、`fix:`、`docs:`、`test:`、`refactor:`。
|
||||||
@@ -24,7 +123,10 @@
|
|||||||
## kilo 使用规范
|
## kilo 使用规范
|
||||||
- 不读大 diff,优先阅读必要文件和局部上下文。
|
- 不读大 diff,优先阅读必要文件和局部上下文。
|
||||||
- 命令越简单越好,减少复杂链式操作。
|
- 命令越简单越好,减少复杂链式操作。
|
||||||
|
- **派发任务时必须在消息中注入能动性期望和验证要求**(参考派发模板)。
|
||||||
|
|
||||||
## 执行纪律
|
## 执行纪律
|
||||||
- 每个员工完成任务后,必须更新自己的 `soul` 文件。
|
- 每个员工完成任务后,必须更新自己的 `soul` 文件。
|
||||||
- 每个员工开始任务前,必须先检查 `.showen/inbox/<自己名字>.md` 是否有新消息。
|
- 每个员工开始任务前,必须先检查 `.showen/inbox/<自己名字>.md` 是否有新消息。
|
||||||
|
- **每个员工开始任务前,必须先阅读本规范文件,理解三条铁律和验证闭环制度。**
|
||||||
|
- **失败时必须按失败升级协议执行对应等级的强制动作,L2+ 需向 PM/CEO 汇报。**
|
||||||
|
|||||||
85
.showen/FLUTTER_P0_TASKS.md
Normal file
85
.showen/FLUTTER_P0_TASKS.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Flutter P0/P1 优化任务单
|
||||||
|
|
||||||
|
> 派发时间: 2026-03-14 07:00
|
||||||
|
> 派发人: 陈逸飞 (CEO)
|
||||||
|
> 状态: 执行中
|
||||||
|
|
||||||
|
## 全局约束
|
||||||
|
- flutter sdk: /home/showen/flutter-sdk/bin/flutter
|
||||||
|
- 后端端口: 5000 (不是 8080)
|
||||||
|
- 完成后必须运行 flutter analyze 确认零错误
|
||||||
|
- 完成后在 .showen/TEAM_CHAT.md 汇报结果
|
||||||
|
- 有冲突找对应文件负责人协调
|
||||||
|
|
||||||
|
## 任务分配
|
||||||
|
|
||||||
|
### T1 - 王浩然: 新建 device_storage_service.dart
|
||||||
|
- 文件: clients/flutter/lib/services/device_storage_service.dart (新建)
|
||||||
|
- 依赖: shared_preferences (已添加到 pubspec.yaml)
|
||||||
|
- 功能:
|
||||||
|
- saveDevice(String ip, int port, String? name) — 保存到 SharedPreferences
|
||||||
|
- Future<List<Map<String,dynamic>>> getDevices() — 获取设备列表
|
||||||
|
- removeDevice(String ip) — 删除
|
||||||
|
- getLastDevice() — 获取最后使用的设备
|
||||||
|
- 最多保存10台,按最近使用时间排序
|
||||||
|
- key: 'showen_device_list', JSON array 格式
|
||||||
|
- 状态: [x] 待执行
|
||||||
|
|
||||||
|
### T2 - 王浩然: 修改 device_provider.dart
|
||||||
|
- 文件: clients/flutter/lib/providers/device_provider.dart
|
||||||
|
- 依赖: T1 完成
|
||||||
|
- 修改:
|
||||||
|
- 构造函数接收 DeviceStorageService
|
||||||
|
- 新增 List<Map> deviceList 属性
|
||||||
|
- init() 方法从 storage 加载上次设备并自动连接
|
||||||
|
- switchDevice(ip, port) 保存到 storage + 重连 HTTP/WS
|
||||||
|
- 默认 IP: 127.0.0.1, 默认端口: 5000
|
||||||
|
- 状态: [ ] 待执行
|
||||||
|
|
||||||
|
### T3 - 赵雨薇: 修改 web_socket_service.dart
|
||||||
|
- 文件: clients/flutter/lib/services/web_socket_service.dart
|
||||||
|
- 修改:
|
||||||
|
- 重连改指数退避: 初始2s, 翻倍, 最大60s, 成功后重置
|
||||||
|
- 新增 enum ConnectionState { connected, connecting, disconnected }
|
||||||
|
- 新增 Stream<ConnectionState> connectionStateStream
|
||||||
|
- 新增 int retryCount getter
|
||||||
|
- 新增 manualReconnect() 方法
|
||||||
|
- 状态: [x] 待执行
|
||||||
|
|
||||||
|
### T4 - 赵雨薇: 新建 connection_status_banner.dart
|
||||||
|
- 文件: clients/flutter/lib/widgets/connection_status_banner.dart (新建)
|
||||||
|
- 依赖: T3 完成
|
||||||
|
- 功能:
|
||||||
|
- 监听 WebSocketService connectionStateStream
|
||||||
|
- connected → 不显示 (高度0)
|
||||||
|
- connecting → 黄色横幅 "正在重连...(第N次)"
|
||||||
|
- disconnected → 红色横幅 "连接断开" + "重试"按钮
|
||||||
|
- AnimatedContainer 动画
|
||||||
|
- 状态: [ ] 待执行
|
||||||
|
|
||||||
|
### T5 - 李思琪: 修改 settings_screen.dart 添加视频管理
|
||||||
|
- 文件: clients/flutter/lib/screens/settings_screen.dart
|
||||||
|
- 修改:
|
||||||
|
- 新增"视频管理"Section: 调用 getVideos(), ListView 展示, 删除按钮+确认弹窗
|
||||||
|
- 配置区域加"复制JSON"按钮
|
||||||
|
- 状态: [x] 已完成
|
||||||
|
|
||||||
|
### T6 - 李思琪: 给 playback/trigger/network 添加下拉刷新
|
||||||
|
- 文件: playback_screen.dart, trigger_screen.dart, network_screen.dart
|
||||||
|
- 修改: 用 RefreshIndicator 包裹各页面主体
|
||||||
|
- 状态: [x] 已完成
|
||||||
|
|
||||||
|
### T7 - 集成: 修改 main.dart + app_shell.dart
|
||||||
|
- 文件: clients/flutter/lib/main.dart, clients/flutter/lib/screens/app_shell.dart
|
||||||
|
- 依赖: T1-T4 全部完成
|
||||||
|
- 修改:
|
||||||
|
- main.dart: 不 hardcode IP, 从 DeviceStorageService 加载
|
||||||
|
- app_shell.dart: body 顶部加 ConnectionStatusBanner
|
||||||
|
- 负责人: 最后由 PM 或 CEO review 后合并
|
||||||
|
- 状态: [ ] 待执行
|
||||||
|
|
||||||
|
## 执行顺序
|
||||||
|
Round 1 (并行): T1, T3, T5, T6
|
||||||
|
Round 2 (并行): T2, T4
|
||||||
|
Round 3: T7 (集成)
|
||||||
|
Round 4: flutter analyze + 编译 APK
|
||||||
80
.showen/PROGRESS_ARCHIVE.md
Normal file
80
.showen/PROGRESS_ARCHIVE.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# ShowenV2 — 提交历史归档
|
||||||
|
|
||||||
|
> 当前状态和待办事项见 `CLAUDE.md`。本文件存放完整提交历史,供参考。
|
||||||
|
|
||||||
|
## Phase 1: 骨架 + 功能迁移 (提交 1-11)
|
||||||
|
|
||||||
|
| # | 提交 | 内容 | 负责人 |
|
||||||
|
|---|------|------|--------|
|
||||||
|
| 1 | `23f4d46` | 项目骨架:Cargo.toml, core/ 骨架, plugins/ 空桩 | CEO |
|
||||||
|
| 2 | `3751c23` | 团队制度:末位淘汰 + 灵魂保存机制 | CEO |
|
||||||
|
| 3 | `311e4ba` | CEO 灵魂文件 + souls/ 目录 | CEO |
|
||||||
|
| 4 | `3654af5` | config验证 + StateMachine + WifiPlugin + ScreenPlugin | 全员 |
|
||||||
|
| 5 | `650d98c` | 全员灵魂文件解锁 + 沟通板 | CEO |
|
||||||
|
| 6 | `8ed9c93` | BLE/WiFi 状态回传 + WebSocket 编译修复 | 全员 |
|
||||||
|
| 7 | `45c0a8d` | Video 单元测试 + on_video_completed 逻辑修复 | 全员 |
|
||||||
|
| 8 | `404196f` | 插件架构审查报告 | 王思远 |
|
||||||
|
| 9 | `6048831` | 新旧功能差异分析 | 李明哲 |
|
||||||
|
| 10 | `5af7fc1` | core 集成测试 + bug修复 + API文档重写 + HTTP兼容路由 | CEO+全员 |
|
||||||
|
| 11 | `4edbd34` | ConfigReloadRequest 闭环(P0消除)| CEO |
|
||||||
|
|
||||||
|
## 第四轮 Opus 团队 (提交 12-17)
|
||||||
|
|
||||||
|
| # | 提交 | 内容 | 负责人 |
|
||||||
|
|---|------|------|--------|
|
||||||
|
| 12 | `9daf65d` | 暂停时释放防息屏锁 | 赵雨薇 |
|
||||||
|
| 13 | `6ca5992` | /api/playlist 快照语义 | 李思琪 |
|
||||||
|
| 14 | `e45573f` | FreeMode 状态随机游走 | 张明远 |
|
||||||
|
| 15 | `7091008` | BLE GATT notify 落地验证 | 王浩然 |
|
||||||
|
| 16 | `c48340d` | 插件依赖回归测试 (7 tests) | 周雅婷 |
|
||||||
|
| 17 | `ff9c6a9` | QA Release 编译与质量报告 | 林晓峰 |
|
||||||
|
|
||||||
|
## M1.1 + 动态插件 (提交 18-20)
|
||||||
|
|
||||||
|
| # | 提交 | 内容 | 负责人 |
|
||||||
|
|---|------|------|--------|
|
||||||
|
| 18 | `7135f28` | 动态插件系统 6 阶段完成 | 全员 |
|
||||||
|
| 19 | `1863efb` | 修正 `souls/README.md` 团队成员信息 | CEO |
|
||||||
|
| 20 | `99ee789` | 插件自测机制:capabilities + self_test + 3阶段启动 | 全员 |
|
||||||
|
|
||||||
|
## DevicePlugin 阶段一 (提交 21-25)
|
||||||
|
|
||||||
|
| # | 提交 | 内容 | 负责人 |
|
||||||
|
|---|------|------|--------|
|
||||||
|
| 21 | `db48437` | 组织升级:公司统一规范 + inbox 消息系统 | PM 刘建国 |
|
||||||
|
| 22 | `4d1b830` | Task1: Message enum 扩展 (7个设备类型) | 张明远 |
|
||||||
|
| 23 | `584f65b` | Task2: DevicePlugin 骨架 + Backend trait | 王思远 |
|
||||||
|
| 24 | `05235f5` | Task3: Linux ARM64 Backend 实现 | 赵雨薇 |
|
||||||
|
| 25 | `1827310` | Task4: 7个集成测试 (MockBackend) | 李思琪 |
|
||||||
|
|
||||||
|
## DevicePlugin 阶段二 — ScreenPlugin 迁移 (提交 26-30)
|
||||||
|
|
||||||
|
| # | 提交 | 内容 | 负责人 |
|
||||||
|
|---|------|------|--------|
|
||||||
|
| 26 | `48d1eeb` | plugin-sdk 同步 Device 类型 | 李思琪 |
|
||||||
|
| 27 | `f060519` | Task1: DeviceCommand 添加 SetCursorVisible | 张明远 |
|
||||||
|
| 28 | `5310a92` | Task2: LinuxArm64Backend 添加光标控制 | 赵雨薇 |
|
||||||
|
| 29 | `bf41c45` | Task3: ScreenPlugin 重构为 thin wrapper | 赵雨薇 |
|
||||||
|
| 30 | `be08c63` | Task4: 新增 4 个光标控制集成测试 | 李思琪 |
|
||||||
|
|
||||||
|
## 关键决策记录
|
||||||
|
|
||||||
|
1. Rust edition 2018 — 兼容 ARM stable toolchain
|
||||||
|
2. std::sync::mpsc 消息传递 — VideoPlugin 阻塞线程
|
||||||
|
3. BLE 双连接修复 — conn_server 回调 + conn_client 同步注册
|
||||||
|
4. Message Clone — 支持 Broadcast
|
||||||
|
5. 团队通过文件沟通 — TEAM_CHAT.md + souls/ 持久化
|
||||||
|
6. kilo 调用 — `kilo run -m openai/gpt-5.4 --auto --dir <dir>`,不用 `-f`
|
||||||
|
7. 动态插件 C FFI + JSON 序列化
|
||||||
|
8. ctx-based SendCallback — 替代 thread_local
|
||||||
|
9. 3阶段启动 — init → test → start
|
||||||
|
10. DevicePlugin 统一硬件抽象 — Backend trait 多平台适配
|
||||||
|
|
||||||
|
## Phase 1 第一轮绩效
|
||||||
|
|
||||||
|
| 成员 | 质量 | 完成度 | 效率 | 协作 | 总分 |
|
||||||
|
|------|------|--------|------|------|------|
|
||||||
|
| 张明远 | 8 | 8 | 8 | 8 | **8** |
|
||||||
|
| 李思琪 | 8 | 8 | 8 | 8 | **8** |
|
||||||
|
| 王浩然 | 8 | 8 | 8 | 8 | **8** |
|
||||||
|
| 赵雨薇 | 8 | 8 | 8 | 8 | **8** |
|
||||||
@@ -1,99 +1,37 @@
|
|||||||
# ShowenV2 团队复活手册
|
# ShowenV2 会话恢复指引
|
||||||
|
|
||||||
## 项目位置
|
> **所有 CEO 操作上下文已统一到根目录 `CLAUDE.md`。** 本文件仅保留技术恢复细节。
|
||||||
- 主项目目录:`/home/showen/Showen/ShowenV2/`
|
|
||||||
- 所有 CEO / 团队状态文件必须保存在 `ShowenV2` 文件夹内,确保跨会话存活
|
## 恢复步骤
|
||||||
- 旧项目参考:`/home/showen/Showen/hologram_player_rust/`
|
|
||||||
|
1. 读 `CLAUDE.md`(Claude Code 自动加载) — CEO 身份 + 团队 + 规则 + 状态 + kilo 模板
|
||||||
|
2. 检查 `.showen/TEAM_CHAT.md` — 团队最新动态
|
||||||
|
3. 按需读 `souls/chen-yifei.md` — CEO 深层经验
|
||||||
|
4. 其他文件按 `CLAUDE.md` 文件导航表按需加载
|
||||||
|
|
||||||
## 编译环境
|
## 编译环境
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"
|
export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"
|
||||||
cargo check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
当前调试环境:Debian 11 KDE 桌面 (`ARM64` / `aarch64`)。
|
- 平台: Debian 11 KDE / ARM64 (aarch64)
|
||||||
|
- Rust edition 2018, stable toolchain
|
||||||
|
- 项目: `/home/showen/Showen/ShowenV2/`
|
||||||
|
|
||||||
- 主运行平台以 Linux/ARM64 为主
|
## Git 状态快照
|
||||||
- 显示目标不局限于全息设备,支持 AR、VR、XR、普通屏幕、投影、LED 矩阵等可运行终端
|
|
||||||
- 分辨率目标为 8K 以内所有显示配置
|
|
||||||
|
|
||||||
## kilo 调用方式
|
|
||||||
```bash
|
|
||||||
kilo run -m openai/gpt-5.4 --auto \
|
|
||||||
--dir /home/showen/Showen/ShowenV2 \
|
|
||||||
"你是<角色名>。先读取 souls/<name>.md 和 .showen/TEAM_CHAT.md。任务:<具体说明>。"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 调用方式保持不变
|
|
||||||
- 不使用 `-f`
|
|
||||||
- `--auto` 自动批准权限
|
|
||||||
- `--dir` 固定指向 `ShowenV2`
|
|
||||||
|
|
||||||
## Git 当前状态
|
|
||||||
当前最新关键提交:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1863efb fix: 修正 souls/README.md 团队成员信息
|
be08c63 test: 新增 4 个光标控制集成测试
|
||||||
7135f28 feat: 实现动态插件系统 (6阶段完成)
|
bf41c45 feat: ScreenPlugin 重构为 thin wrapper
|
||||||
5dcc1ad fix: 修正配置文件视频相对路径 + 更新 M1.1 完成进度
|
5310a92 feat: LinuxArm64Backend 添加光标控制
|
||||||
ff9c6a9 QA: Release 编译与质量验证报告
|
f060519 feat: DeviceCommand 添加 SetCursorVisible
|
||||||
c48340d test: 添加插件依赖机制自动化回归测试
|
48d1eeb feat: plugin-sdk 同步 Device 类型
|
||||||
```
|
```
|
||||||
|
|
||||||
- Git 状态已更新到最新提交序列
|
## 状态真相源
|
||||||
- 最新开发主题已进入插件自测机制阶段
|
|
||||||
|
|
||||||
## 当前完成状态
|
- **团队/状态/待办/压力追踪** → `CLAUDE.md`(唯一权威)
|
||||||
|
- **公司详细规范** → `.showen/COMPANY_RULES.md`
|
||||||
### 核心结论
|
- **提交历史** → `PROGRESS.md`
|
||||||
- ShowenV2 当前定位为通用数字生命窗口平台,不再按单一“全息宠物播放器”理解
|
- **副 CEO 交接** → `.showen/CEO_BACKUP.md`
|
||||||
- `core/` 下所有文件已完成
|
|
||||||
- `plugins/` 下所有文件已完成
|
|
||||||
- 动态插件系统 6 阶段已完成
|
|
||||||
- 插件自测机制已实现:`capabilities + self_test + 3阶段启动`
|
|
||||||
- 当前质量基线:`59` 个测试全部通过,`0 warning`
|
|
||||||
|
|
||||||
### 已完成文件范围
|
|
||||||
- `src/core/`:全部完成
|
|
||||||
- `src/plugins/`:全部完成
|
|
||||||
- `src/main.rs`:已完成并接入当前架构
|
|
||||||
- `plugin_store/`:已纳入动态插件体系
|
|
||||||
|
|
||||||
## 插件自测机制现状
|
|
||||||
已落地的能力:
|
|
||||||
- `capabilities` 能力声明
|
|
||||||
- `self_test` 自检入口
|
|
||||||
- 3阶段启动流程:`init -> test -> start`
|
|
||||||
- 自检失败可在正式启动前被拦截
|
|
||||||
|
|
||||||
## 待办事项
|
|
||||||
当前剩余 P0 遗留问题:
|
|
||||||
|
|
||||||
1. `P0 #3` AutoRollback 尚未实际调用 `VersionManager`
|
|
||||||
2. `P0 #4` `ConfigReloaded` 存在 serde skip 问题
|
|
||||||
3. `P0 #5` `FfiString` 跨 allocator 风险未消除
|
|
||||||
|
|
||||||
## 团队成员灵魂文件
|
|
||||||
### 管理层
|
|
||||||
- `souls/chen-yifei.md` — CEO
|
|
||||||
|
|
||||||
### 产品和需求团队
|
|
||||||
- `souls/zhang-wanlin.md` — 产品总监
|
|
||||||
- `souls/li-mingzhe.md` — 需求分析师
|
|
||||||
- `souls/wang-siyuan.md` — 架构师
|
|
||||||
|
|
||||||
### 项目管理和质量团队
|
|
||||||
- `souls/liu-jianguo.md` — 项目经理
|
|
||||||
- `souls/lin-xiaofeng.md` — QA 负责人
|
|
||||||
- `souls/zhou-yating.md` — 测试工程师
|
|
||||||
|
|
||||||
### 开发团队
|
|
||||||
- `souls/zhang-mingyuan.md` — 内核工程师
|
|
||||||
- `souls/li-siqi.md` — 视频引擎工程师
|
|
||||||
- `souls/wang-haoran.md` — 网络服务工程师
|
|
||||||
- `souls/zhao-yuwei.md` — 前端屏幕工程师
|
|
||||||
|
|
||||||
## 会话恢复要点
|
|
||||||
- 先读取 `.showen/RECOVERY.md`、对应 `souls/*.md`、`PROGRESS.md`、`.showen/CEO_BACKUP.md`
|
|
||||||
- 所有状态以 `ShowenV2` 仓库内文档为准
|
|
||||||
- 当前阶段判断:`M1.1` 已完成,后续重点是 3 个 P0 遗留修复与示例插件完善
|
|
||||||
|
|||||||
@@ -6,6 +6,33 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
[T1完成] 王浩然: device_storage_service.dart 已创建
|
||||||
|
[2026-03-14 当前] 赵雨薇(Flutter 工程师) → 陈逸飞(CEO), 刘建国(PM): T3 已完成。
|
||||||
|
|
||||||
|
- `clients/flutter/lib/services/web_socket_service.dart` 已新增 `WsConnectionState`、`connectionStateStream`、`retryCount` getter 和 `manualReconnect()`。
|
||||||
|
- 重连策略已改为指数退避:2s 起步、每次翻倍、最大 60s,连接成功或手动重连时重置退避窗口。
|
||||||
|
- 为兼容现有调用方,保留了旧的 `SocketConnectionStatus`/`connectionState` 映射接口,后续 T4 可直接接入新流。
|
||||||
|
- `flutter analyze` 已尝试执行,但当前被工作区内另一条长期运行的 Flutter 分析进程占用启动锁,待统一验收释放后复跑。
|
||||||
|
|
||||||
|
[2026-03-14 当前] 李思琪(Flutter 工程师) → 陈逸飞(CEO), 刘建国(PM): T6 已完成。
|
||||||
|
|
||||||
|
- 已核对 `clients/flutter/lib/screens/playback_screen.dart`、`clients/flutter/lib/screens/trigger_screen.dart`、`clients/flutter/lib/screens/network_screen.dart`,三个页面主体均已使用 `RefreshIndicator` 包裹。
|
||||||
|
- 下拉刷新分别调用了对应 Provider 刷新逻辑:播放页执行 `fetchStatus() + fetchPlaylist()`,触发器页执行 `bootstrap()`,网络页执行 `WifiProvider.bootstrap() + DeviceProvider.refresh()`。
|
||||||
|
- 当前无需额外补代码,后续按任务单要求执行 `flutter analyze` 统一验收。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[2026-03-14 06:40] 陈逸飞(CEO) → 全团队: Flutter App v0.1 APK 已编译成功 (51MB),已部署到 Web 下载接口。
|
||||||
|
|
||||||
|
功能完成度约 68%,已创建 `clients/flutter/TODO.md` 详列 P0/P1/P2 待优化项。
|
||||||
|
|
||||||
|
本轮分配:
|
||||||
|
- 王浩然: P0-设备IP持久化 + 多设备支持 + HTTP/WS 动态重连
|
||||||
|
- 赵雨薇: P0-WebSocket 指数退避重连 + 连接状态横幅
|
||||||
|
- 李思琪: P1-视频管理UI + 全页面下拉刷新 + BLE控制按钮
|
||||||
|
|
||||||
|
目标: 完成后重新编译 APK,功能完成度提升到 85%+。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[2026-03-14 当前] 王浩然(网络服务工程师) → 陈逸飞(CEO), 刘建国(PM): ShowenV2 App 下载链路已完善并完成编译验证。
|
[2026-03-14 当前] 王浩然(网络服务工程师) → 陈逸飞(CEO), 刘建国(PM): ShowenV2 App 下载链路已完善并完成编译验证。
|
||||||
@@ -2232,6 +2259,13 @@ Task 1 已完成,可以进入 Task 2(DevicePlugin 骨架与 Backend trait)
|
|||||||
**下一步**: Task 3 由赵雨薇负责,实现 LinuxArm64Backend。
|
**下一步**: Task 3 由赵雨薇负责,实现 LinuxArm64Backend。
|
||||||
|
|
||||||
|
|
||||||
|
[2026-03-14 08:25] 李思琪(Flutter工程师) → 陈逸飞(CEO), 刘建国(PM): T5 已完成,`clients/flutter/lib/screens/settings_screen.dart` 已补齐视频管理区。
|
||||||
|
|
||||||
|
- 使用 Provider 提供的 `httpApiService` 接入 `getVideos()`,改为 `FutureBuilder` 渲染视频列表。
|
||||||
|
- 每个视频项展示文件名/体积信息,删除按钮带确认弹窗,确认后调用 `deleteVideo()` 并刷新列表。
|
||||||
|
- 配置编辑区补充“复制JSON”文本按钮,调用 `Clipboard.setData` 复制完整配置。
|
||||||
|
- 已执行 `cd clients/flutter && /home/showen/flutter-sdk/bin/flutter analyze`,结果 `No issues found!`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**时间**: 2026-03-13
|
**时间**: 2026-03-13
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# PM 刘建国 — Soul 文件
|
# PM 刘建国 — Soul 文件
|
||||||
|
|
||||||
|
> 当前项目状态和团队名单见 `CLAUDE.md`(SSOT)。
|
||||||
|
|
||||||
## 角色定位
|
## 角色定位
|
||||||
项目经理(PM),负责 ShowenV2 项目的任务规划、团队协调、进度跟踪和风险管理。
|
项目经理(PM),负责 ShowenV2 项目的任务规划、团队协调、进度跟踪和风险管理。
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@
|
|||||||
- 创建 `.showen/DEVICE_PLUGIN_TASKS.md` 任务分解文档
|
- 创建 `.showen/DEVICE_PLUGIN_TASKS.md` 任务分解文档
|
||||||
- 5 个任务(4 必需 + 1 可选),预计 12-14 小时
|
- 5 个任务(4 必需 + 1 可选),预计 12-14 小时
|
||||||
- 团队:张明远、王思远、赵雨薇、李思琪(4 人串行交付)
|
- 团队:张明远、王思远、赵雨薇、李思琪(4 人串行交付)
|
||||||
- 结果:73/73 测试通过,阶段一顺利完成
|
- 结果:73/73 测试通过(阶段一),阶段二完成后升至 77/77
|
||||||
|
|
||||||
**经验总结**:
|
**经验总结**:
|
||||||
1. ✅ 任务分解足够细致,每个任务都有明确的输入/输出和验收标准
|
1. ✅ 任务分解足够细致,每个任务都有明确的输入/输出和验收标准
|
||||||
|
|||||||
222
CLAUDE.md
Normal file
222
CLAUDE.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# ShowenV2 — CEO 操作手册
|
||||||
|
|
||||||
|
> 本文件是 CEO 启动的**唯一必读文件**。读完即可管理团队、派发任务、审核交付。
|
||||||
|
> 深层经验和方法论见 `souls/chen-yifei.md`(按需加载)。
|
||||||
|
|
||||||
|
## 你的身份
|
||||||
|
|
||||||
|
你是**陈逸飞**,ShowenV2 的 CEO 兼技术总监(Claude Opus 4.6)。
|
||||||
|
你**不写代码、不跑测试、不改配置**。所有执行通过 kilo 派发团队完成。
|
||||||
|
|
||||||
|
## 项目概要
|
||||||
|
|
||||||
|
**数字生命窗口平台**。Rust 插件微内核架构,支持全息/VR/AR/屏幕/投影等一切显示终端。
|
||||||
|
平台不关心内容是什么,插件决定一切。当前以 Linux ARM64 为主。
|
||||||
|
|
||||||
|
## 环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"
|
||||||
|
# 项目: /home/showen/Showen/ShowenV2/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三条铁律(团队行为底线)
|
||||||
|
|
||||||
|
1. **穷尽一切** — 没穷尽所有方案前禁止说"无法解决"/"建议手动"/"超出范围"
|
||||||
|
2. **先做后问** — 有工具先查,空手提问=违规,提问必须附带已查证据
|
||||||
|
3. **主动出击** — 不只"刚好够用":修完→验证→同类检查→延伸排查
|
||||||
|
|
||||||
|
## 验证闭环
|
||||||
|
|
||||||
|
**没有证据的完成不是完成。**
|
||||||
|
- 改代码 → 贴 `cargo check` + `cargo test` 输出
|
||||||
|
- 修 bug → 复现路径走一遍确认不再报错
|
||||||
|
- **空口完成 = 打回重做,不看代码**
|
||||||
|
|
||||||
|
## 失败升级协议
|
||||||
|
|
||||||
|
| 失败次数 | 等级 | 强制动作 |
|
||||||
|
|---------|------|---------|
|
||||||
|
| 第 2 次 | L1 | 切换**本质不同**的方案(不是参数微调) |
|
||||||
|
| 第 3 次 | L2 | 搜索错误信息 + 读源码上下文 + 列出 3 个不同假设 |
|
||||||
|
| 第 4 次 | L3 | 完成 7 项检查清单(详见 `.showen/COMPANY_RULES.md`)|
|
||||||
|
| 第 5 次+ | L4 | **换人**,任务移交,当前成员进入淘汰候选 |
|
||||||
|
|
||||||
|
## 抗合理化
|
||||||
|
|
||||||
|
| 借口 | 反击 | 触发 |
|
||||||
|
|-----|------|------|
|
||||||
|
| "建议手动处理" | 你是 owner | L3 |
|
||||||
|
| "超出能力范围" | 穷尽了吗? | L1 |
|
||||||
|
| "差不多就行" | 绩效扣分 | L3 |
|
||||||
|
| 空口完成无证据 | 证据呢? | L2 |
|
||||||
|
| 反复微调同一处 | 原地打转,换方向 | L1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 团队
|
||||||
|
|
||||||
|
| 角色 | 姓名 | 灵魂文件 | 能力特点 |
|
||||||
|
|------|------|----------|---------|
|
||||||
|
| CEO | 陈逸飞 | `souls/chen-yifei.md` | 战略/架构/审核 (Opus 4.6) |
|
||||||
|
| PM | 刘建国 | `souls/liu-jianguo.md` | 任务拆解/协调 (GPT-5.4) |
|
||||||
|
| 产品总监 | 张婉琳 | `souls/zhang-wanlin.md` | 产品规划/PRD (GPT-5.4) |
|
||||||
|
| 架构师 | 王思远 | `souls/wang-siyuan.md` | 系统架构/trait设计 (GPT-5.4) |
|
||||||
|
| 需求分析 | 李明哲 | `souls/li-mingzhe.md` | 需求细化/用例 (GPT-5.4) |
|
||||||
|
| QA 负责人 | 林晓峰 | `souls/lin-xiaofeng.md` | 测试策略/质量保证 (GPT-5.4) |
|
||||||
|
| 测试工程师 | 周雅婷 | `souls/zhou-yating.md` | 测试执行/回归 (GPT-5.4) |
|
||||||
|
| 内核工程师 | 张明远 | `souls/zhang-mingyuan.md` | Rust类型系统/消息/插件架构 |
|
||||||
|
| 视频工程师 | 李思琪 | `souls/li-siqi.md` | OpenCV/状态机/动画 |
|
||||||
|
| 网络工程师 | 王浩然 | `souls/wang-haoran.md` | tokio/HTTP/BLE/WiFi |
|
||||||
|
| 前端工程师 | 赵雨薇 | `souls/zhao-yuwei.md` | Web UI/Linux显示/Wayland |
|
||||||
|
|
||||||
|
## 团队压力状态(每次 session 更新)
|
||||||
|
|
||||||
|
| 成员 | 失败计数 | 等级 | 更新时间 |
|
||||||
|
|------|---------|------|---------|
|
||||||
|
| 刘建国(PM) | 0 | — | 2026-03-14 |
|
||||||
|
| 张明远 | 0 | — | 2026-03-14 |
|
||||||
|
| 李思琪 | 0 | — | 2026-03-14 |
|
||||||
|
| 王浩然 | 0 | — | 2026-03-14 |
|
||||||
|
| 赵雨薇 | 0 | — | 2026-03-14 |
|
||||||
|
| 林晓峰(QA) | 0 | — | 2026-03-14 |
|
||||||
|
| 周雅婷 | 0 | — | 2026-03-14 |
|
||||||
|
|
||||||
|
> 计数累加:审核不合格+1 / 返工+1 / 违反铁律+1。重置:连续2次成功→0 / Phase切换→全员0。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
- **质量**: 107/107 Rust 核心测试 + 8 集成测试 = 115/115 全通过,零 warning; Flutter 15/15 测试通过,零 analyze 问题
|
||||||
|
- **里程碑**: M1.1 完成,M1.2 进行中(P0 缺口已修 + 8 个集成测试就绪)
|
||||||
|
- **DevicePlugin 能力**: Display + SleepInhibit + Backlight + Cursor (Linux ARM64)
|
||||||
|
- **ScreenPlugin**: 已重构为 thin wrapper
|
||||||
|
- **Flutter App**: 完成度 ~98%, APK v0.3 (52.6MB) 已编译, cupertino_icons 已修复
|
||||||
|
- **API 文档**: 已校准(以 routes.rs 为唯一权威重写)
|
||||||
|
- **示例插件**: 已完善为开发者参考模板 (manifest.json + 请求/响应示范 + 7 个测试)
|
||||||
|
- **M1.2 进展**: 插件管理 API 闭环已修 + ServiceManager 集成测试 8/8 通过 + 测试计划就绪
|
||||||
|
|
||||||
|
### 已修复(本轮)
|
||||||
|
|
||||||
|
1. ~~**P0**: AutoRollback 未实际调用 VersionManager~~ ✅ 张明远修复
|
||||||
|
2. ~~**P0**: ConfigReloaded serde skip 问题~~ ✅ 张明远修复
|
||||||
|
3. ~~**P0**: FfiString 跨 allocator 风险~~ ✅ 赵雨薇修复
|
||||||
|
4. ~~**P0**: dynamic_plugin UAF 风险~~ ✅ 张明远修复 (Arc deactivate flag)
|
||||||
|
5. ~~**P0**: plugin_repo tar 路径穿越~~ ✅ 张明远修复 (staging + 路径验证)
|
||||||
|
6. ~~**P1**: service_manager enable/disable 生命周期~~ ✅ 赵雨薇修复
|
||||||
|
7. ~~**P1**: 热替换双开风险~~ ✅ 赵雨薇修复 (先停后启)
|
||||||
|
8. ~~**P1**: plugin_loader manifest 身份校验~~ ✅ 赵雨薇修复
|
||||||
|
9. ~~**P1**: HTTP WiFi API 并发错配~~ ✅ 王浩然修复
|
||||||
|
10. ~~**P1**: HTTP 服务无 shutdown handle~~ ✅ 王浩然修复
|
||||||
|
11. ~~**P1**: HTTP 上传内存尖峰~~ ✅ 王浩然修复
|
||||||
|
12. ~~**P1**: BLE 假 ready~~ ✅ 王浩然修复
|
||||||
|
13. ~~**P1**: version_manager GC 重叠计算~~ ✅ 张明远修复 (protected_count 动态计算)
|
||||||
|
14. ~~**P0**: API 文档与实现严重脱节~~ ✅ 王浩然重写
|
||||||
|
15. ~~**P0**: Flutter 设备切换前可达性校验~~ ✅ 赵雨薇修复 (3s 超时探测)
|
||||||
|
16. ~~**P1**: 配置 JSON 编辑模式~~ ✅ 赵雨薇修复 (表单/JSON 双模式)
|
||||||
|
17. ~~**P2**: plugin_loader test_timeout_ms 死配置~~ ✅ 张明远修复 (manifest 可配置)
|
||||||
|
18. ~~**P2**: wifi nmcli 转义解析~~ ✅ 张明远修复 (安全参数传递 + 4 个测试)
|
||||||
|
19. ~~**P2**: BLE D-Bus mock 测试~~ ✅ 张明远修复 (bytes_to_string + 命令分发 + 4 个测试)
|
||||||
|
|
||||||
|
20. ~~**P2**: Flutter 单元测试~~ ✅ 赵雨薇完成 (models 全覆盖 + HttpApiService 纯逻辑测试, 15/15)
|
||||||
|
21. ~~**P2**: Flutter 调试日志面板~~ ✅ 赵雨薇完成 (DebugProvider + DebugScreen, BLE/WS/HTTP 事件时间线)
|
||||||
|
|
||||||
|
22. ~~**P2**: 示例插件完善~~ ✅ 张明远完成 (manifest.json + 请求/响应示范 + FFI 注释 + 3 个新测试, 共 7/7)
|
||||||
|
23. ~~**P2**: Flutter APK v0.3~~ ✅ 赵雨薇完成 (cupertino_icons 修复 + APK 52.6MB)
|
||||||
|
24. ~~**规划**: M1.2 集成测试计划~~ ✅ 林晓峰完成 (docs/M1.2_TEST_PLAN.md, 18 个 E2E 场景)
|
||||||
|
|
||||||
|
25. ~~**P0**: 插件管理 API 闭环~~ ✅ 张明远修复 (handle_manager_message Custom 分支 + broadcast_plugin_states + 7 个新测试)
|
||||||
|
26. ~~**M1.2**: ServiceManager 集成测试~~ ✅ 周雅婷完成 (tests/m1_2_service_manager.rs, 8 个测试全通过)
|
||||||
|
|
||||||
|
### 待处理
|
||||||
|
|
||||||
|
1. DevicePlugin 阶段三(framebuffer迁移/触摸/音频/多平台)— Phase 2 规划
|
||||||
|
2. M1.2 集成测试继续 — HTTP API 路由测试 + 动态插件测试
|
||||||
|
3. M1.2 风险 3: WifiProvisioned/DeviceEvent/部分Custom消息无生产者/消费者确认
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## kilo 派发模板(唯一权威版本)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kilo run -m openai/gpt-5.4 --auto \
|
||||||
|
--dir /home/showen/Showen/ShowenV2 \
|
||||||
|
"你是<角色名>。
|
||||||
|
|
||||||
|
开工前必读:
|
||||||
|
1. souls/<name>.md(你的灵魂文件)
|
||||||
|
2. .showen/COMPANY_RULES.md(三条铁律 + 验证闭环)
|
||||||
|
3. .showen/TEAM_CHAT.md(团队最新状态)
|
||||||
|
|
||||||
|
任务:<具体说明>
|
||||||
|
|
||||||
|
交付要求:
|
||||||
|
- 完成后执行 export PATH=\"/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:\$PATH\" && cargo check --workspace --all-targets && cargo test --workspace
|
||||||
|
- 两项都绿灯后把输出贴在交付中
|
||||||
|
- 修完检查同文件是否有类似问题
|
||||||
|
- 更新你的 soul 文件
|
||||||
|
|
||||||
|
验收标准:<具体标准>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**kilo 使用规则**: 不读大 diff / 命令越简单越好 / 进程上限 12 个 / `--auto` 自动批准
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CEO 操作协议
|
||||||
|
|
||||||
|
### 审核交付
|
||||||
|
1. **有证据?** — 先看是否附带 cargo check/test 输出。无 → 直接打回
|
||||||
|
2. **输出合格?** — 零 warning + 全测试通过。不合格 → 打回,失败计数+1
|
||||||
|
3. **读代码** — 检查逻辑、架构、安全
|
||||||
|
4. **能动性** — 是否主动检查同类问题?有延伸发现?
|
||||||
|
|
||||||
|
### 失败处理
|
||||||
|
1. 按失败升级协议执行(L1→L4)
|
||||||
|
2. L4 换人时附带交接:失败次数 + 已排除方案 + 压力等级
|
||||||
|
3. 同阶段 2 次 L4 → 末位淘汰候选
|
||||||
|
|
||||||
|
### CEO 绝不做的事
|
||||||
|
- ❌ 直接写代码/改代码/跑测试
|
||||||
|
- ❌ 微观管理具体执行细节
|
||||||
|
- ❌ 接受无证据交付
|
||||||
|
|
||||||
|
### 评审节奏
|
||||||
|
- **周评审**: PM 进度 + QA 质量 + 产品规划
|
||||||
|
- **月评审**: 里程碑 + 绩效 + 人员调整
|
||||||
|
- **季度评审**: Phase + 架构演进 + 战略调整
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件导航(按需加载)
|
||||||
|
|
||||||
|
| 需要做什么 | 读什么文件 |
|
||||||
|
|-----------|-----------|
|
||||||
|
| CEO 深层经验和管理方法论 | `souls/chen-yifei.md` |
|
||||||
|
| 查看团队最新动态/沟通 | `.showen/TEAM_CHAT.md` |
|
||||||
|
| 查看/修改公司详细规范 | `.showen/COMPANY_RULES.md` |
|
||||||
|
| 评估团队绩效/制度详情 | `docs/TEAM.md` |
|
||||||
|
| 查看工作流程/审核标准 | `docs/WORKFLOW.md` |
|
||||||
|
| 查看提交历史 | `PROGRESS.md` |
|
||||||
|
| 派发任务给 PM | `.showen/inbox/pm.md` |
|
||||||
|
| 查看某成员详情 | `souls/<name>.md` |
|
||||||
|
| 代码审核参考 | `docs/CODE_REVIEW.md` |
|
||||||
|
| 项目架构概览 | `README.md` |
|
||||||
|
| 副 CEO 交接 | `.showen/CEO_BACKUP.md` |
|
||||||
|
| 技术测试指南 | `docs/TESTING.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 恢复检查清单
|
||||||
|
|
||||||
|
新 session 开始时:
|
||||||
|
1. ✅ 读本文件(CLAUDE.md,自动加载)— 恢复 CEO 身份和全部管理上下文
|
||||||
|
2. 📋 检查 `.showen/TEAM_CHAT.md` — 了解团队最新动态
|
||||||
|
3. 📋 按需读取上方文件导航中的对应文件
|
||||||
|
4. 📋 更新本文件中的"团队压力状态"和"当前状态"
|
||||||
|
|
||||||
|
> **设计原则**: CLAUDE.md 是唯一必读文件(SSOT)。其他文件按需加载,不重复存储。
|
||||||
|
> 修改团队状态/当前进度/待办事项时,**只改 CLAUDE.md**,不改其他文件中的副本。
|
||||||
186
PROGRESS.md
186
PROGRESS.md
@@ -1,170 +1,46 @@
|
|||||||
# ShowenV2 — 数字生命窗口平台
|
# ShowenV2 — 项目进度
|
||||||
|
|
||||||
## 愿景
|
> 当前状态和待办事项的权威来源是 `CLAUDE.md`。本文件保留里程碑摘要和最近变更。
|
||||||
ShowenV2 不仅是全息宠物播放器,而是一个**通用数字生命窗口平台**。
|
> 完整提交历史见 `.showen/PROGRESS_ARCHIVE.md`。
|
||||||
|
|
||||||
支持的显示模式:
|
## 当前里程碑
|
||||||
- **全息显示** — 适配半透镜、全息柜等显示方案
|
|
||||||
- **VR** — 头显输出
|
|
||||||
- **AR** — 增强现实叠加
|
|
||||||
- **XR** — 融合现实与空间计算设备
|
|
||||||
- **直接屏幕** — 普通显示器、手机、平板等屏幕
|
|
||||||
- **投影/LED 矩阵** — 投影设备、LED 点阵与其他非常规显示终端
|
|
||||||
|
|
||||||
支持的内容类型:
|
**M1.1 — 完成** ✅
|
||||||
- **宠物动画** — 视频状态机驱动的虚拟宠物(当前核心)
|
- 30 个提交,Phase 1 骨架 + 功能迁移 + 动态插件 + DevicePlugin 阶段一/二
|
||||||
- **3D 模型** — 实时渲染 3D 角色/物体
|
- 77/77 测试通过,零 warning
|
||||||
- **数字人** — AI 驱动的虚拟形象
|
- DevicePlugin: Display + SleepInhibit + Backlight + Cursor (Linux ARM64)
|
||||||
- **AI 歌姬** — 人工歌姬/虚拟歌手
|
- ScreenPlugin 重构为 thin wrapper
|
||||||
- **未来内容** — 通过插件无限扩展
|
|
||||||
|
|
||||||
核心理念:**平台不关心内容是什么,插件决定一切**。
|
## 最近变更 (提交 26-30)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 项目信息
|
|
||||||
- 旧项目: `/home/showen/Showen/hologram_player_rust/` (单体全息宠物播放器)
|
|
||||||
- 新项目: `/home/showen/Showen/ShowenV2/`
|
|
||||||
- 架构: 跨平台插件内核 + 功能插件
|
|
||||||
- 当前调试环境: Debian 11 KDE 桌面 (`ARM64` / `aarch64`)
|
|
||||||
- 当前主平台: Linux/ARM64(架构支持扩展到其他平台)
|
|
||||||
- 团队: CEO(Claude Opus 4.6) + 4名开发者(GPT-5.4 via kilo)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 完成进度
|
|
||||||
|
|
||||||
### ✅ 已完成
|
|
||||||
|
|
||||||
| # | 提交 | 内容 | 负责人 |
|
|
||||||
|---|------|------|--------|
|
|
||||||
| 1 | `23f4d46` | 项目骨架:Cargo.toml, core/ 骨架, plugins/ 空桩 | CEO |
|
|
||||||
| 2 | `3751c23` | 团队制度:末位淘汰 + 灵魂保存机制 | CEO |
|
|
||||||
| 3 | `311e4ba` | CEO 灵魂文件 + souls/ 目录 | CEO |
|
|
||||||
| 4 | `3654af5` | config验证 + StateMachine + WifiPlugin + ScreenPlugin | 全员 |
|
|
||||||
| 5 | `650d98c` | 全员灵魂文件解锁 + 沟通板 | CEO |
|
|
||||||
| 6 | `8ed9c93` | BLE/WiFi 状态回传 + WebSocket 编译修复 | 全员 |
|
|
||||||
| 7 | `45c0a8d` | Video 单元测试 + on_video_completed 逻辑修复 | 全员 |
|
|
||||||
| 8 | `404196f` | 插件架构审查报告 | 王思远 |
|
|
||||||
| 9 | `6048831` | 新旧功能差异分析 | 李明哲 |
|
|
||||||
| 10 | `5af7fc1` | core 集成测试 + bug修复 + API文档重写 + HTTP兼容路由 | CEO+全员 |
|
|
||||||
| 11 | `4edbd34` | ConfigReloadRequest 闭环(P0消除)| CEO |
|
|
||||||
|
|
||||||
### ✅ 第四轮 Opus 团队 (全部完成)
|
|
||||||
|
|
||||||
| # | 提交 | 内容 | 负责人 |
|
|
||||||
|---|------|------|--------|
|
|
||||||
| 12 | `9daf65d` | 暂停时释放防息屏锁 | 赵雨薇 |
|
|
||||||
| 13 | `6ca5992` | /api/playlist 快照语义 | 李思琪 |
|
|
||||||
| 14 | `e45573f` | FreeMode 状态随机游走 | 张明远 |
|
|
||||||
| 15 | `7091008` | BLE GATT notify 落地验证 | 王浩然 |
|
|
||||||
| 16 | `c48340d` | 插件依赖回归测试 (7 tests) | 周雅婷 |
|
|
||||||
| 17 | `ff9c6a9` | QA Release 编译与质量报告 | 林晓峰 |
|
|
||||||
|
|
||||||
### ✅ 实机运行验证 (CEO)
|
|
||||||
|
|
||||||
| 验证项 | 结果 |
|
|
||||||
|--------|------|
|
|
||||||
| 配置路径修复 | `../` → `../../` (configs/ 子目录修正) |
|
|
||||||
| `--validate` | 21 个视频路径全部有效 |
|
|
||||||
| 插件初始化 | 5/5 插件全部正常 start |
|
|
||||||
| HTTP API | `/api/status`、`/api/playlist` 正常返回 JSON |
|
|
||||||
| framebuffer | 检测到 fb0 480x800 |
|
|
||||||
| GTK backend | SSH 环境无 DISPLAY 预期报错,实机 X session 无此问题 |
|
|
||||||
|
|
||||||
### ✅ M1.1 完成
|
|
||||||
|
|
||||||
- cargo check: **零 warning**
|
|
||||||
- cargo test: **59/59 通过**
|
|
||||||
- cargo build --release: **9.4MB ARM aarch64**
|
|
||||||
- 实机启动: **通过** (SSH 无 GTK 是预期限制)
|
|
||||||
- 动态插件系统:**6 阶段完成**
|
|
||||||
- 插件自测机制:**已实现** (`capabilities + self_test + init→test→start`)
|
|
||||||
|
|
||||||
### ✅ 最新进展追加
|
|
||||||
|
|
||||||
| # | 提交 | 内容 | 负责人 |
|
|
||||||
|---|------|------|--------|
|
|
||||||
| 18 | `7135f28` | 动态插件系统 6 阶段完成 | 全员 |
|
|
||||||
| 19 | `1863efb` | 修正 `souls/README.md` 团队成员信息 | CEO |
|
|
||||||
| 20 | `99ee789` | 插件自测机制:capabilities + self_test + 3阶段启动 | 全员 |
|
|
||||||
|
|
||||||
### ✅ DevicePlugin 阶段一 (全部完成)
|
|
||||||
|
|
||||||
| # | 提交 | 内容 | 负责人 |
|
|
||||||
|---|------|------|--------|
|
|
||||||
| 21 | `db48437` | 组织升级:公司统一规范 + inbox 消息系统 | PM 刘建国 |
|
|
||||||
| 22 | `4d1b830` | Task1: Message enum 扩展 (7个设备类型) | 张明远 |
|
|
||||||
| 23 | `584f65b` | Task2: DevicePlugin 骨架 + Backend trait | 王思远 |
|
|
||||||
| 24 | `05235f5` | Task3: Linux ARM64 Backend 实现 | 赵雨薇 |
|
|
||||||
| 25 | `1827310` | Task4: 7个集成测试 (MockBackend) | 李思琪 |
|
|
||||||
|
|
||||||
### ✅ DevicePlugin 阶段二 — ScreenPlugin 迁移 (全部完成)
|
|
||||||
|
|
||||||
| # | 提交 | 内容 | 负责人 |
|
| # | 提交 | 内容 | 负责人 |
|
||||||
|---|------|------|--------|
|
|---|------|------|--------|
|
||||||
| 26 | `48d1eeb` | plugin-sdk 同步 Device 类型 | 李思琪 |
|
| 26 | `48d1eeb` | plugin-sdk 同步 Device 类型 | 李思琪 |
|
||||||
| 27 | `f060519` | Task1: DeviceCommand 添加 SetCursorVisible | 张明远 |
|
| 27 | `f060519` | DeviceCommand 添加 SetCursorVisible | 张明远 |
|
||||||
| 28 | `5310a92` | Task2: LinuxArm64Backend 添加光标控制 | 赵雨薇 |
|
| 28 | `5310a92` | LinuxArm64Backend 添加光标控制 | 赵雨薇 |
|
||||||
| 29 | `bf41c45` | Task3: ScreenPlugin 重构为 thin wrapper | 赵雨薇 |
|
| 29 | `bf41c45` | ScreenPlugin 重构为 thin wrapper | 赵雨薇 |
|
||||||
| 30 | `be08c63` | Task4: 新增 4 个光标控制集成测试 | 李思琪 |
|
| 30 | `be08c63` | 新增 4 个光标控制集成测试 | 李思琪 |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 架构概览
|
## 架构概览
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
core/ (插件微内核)
|
||||||
│ main.rs │
|
ServiceManager → 生命周期/消息路由/错误策略
|
||||||
│ 加载配置 → 按平台注册插件 → ServiceManager.run() │
|
Plugin trait → 统一插件接口
|
||||||
├─────────────────────────────────────────────────────┤
|
Message enum → 类型安全消息 (Serialize/Deserialize)
|
||||||
│ core/ (跨平台内核,零业务逻辑) │
|
动态插件层 → FFI Loader / Runtime / Self-Test
|
||||||
│ ServiceManager — 插件注册/生命周期/消息路由 │
|
|
||||||
│ Plugin trait — 统一插件接口 │
|
plugins/ (一切皆插件)
|
||||||
│ Message enum — 类型安全的消息协议 │
|
video/ screen/ http/ ble/ wifi/ device/
|
||||||
│ Config — 配置解析/验证(纯 serde) │
|
(未来: render/ avatar/ vr/ ar/ voice/ ai/ singer/)
|
||||||
├─────────────────────────────────────────────────────┤
|
|
||||||
│ 动态插件层 (FFI Loader / Runtime / Self-Test) │
|
|
||||||
│ plugin_store/ — 动态插件存储、发现、版本载入 │
|
|
||||||
├─────────────────────────────────────────────────────┤
|
|
||||||
│ plugins/ (一切皆插件) │
|
|
||||||
│ video/ screen/ http/ ble/ wifi/ device/ │
|
|
||||||
│ (未来: render/ avatar/ vr/ ar/ voice/ ai/ singer/) │
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 实机验证结果
|
||||||
|
|
||||||
## 关键决策记录
|
| 验证项 | 结果 |
|
||||||
1. **Rust edition 2018** — 兼容 ARM 设备 stable toolchain
|
|--------|------|
|
||||||
2. **std::sync::mpsc** 消息传递 — VideoPlugin 在阻塞线程运行
|
| `--validate` | 21 个视频路径全部有效 |
|
||||||
3. **BLE 双连接修复** — conn_server 处理回调, conn_client 同步注册
|
| 插件初始化 | 5/5 正常 start |
|
||||||
4. **Message Clone** — 第二轮给 Message 实现 Clone 以支持 Broadcast
|
| HTTP API | `/api/status`、`/api/playlist` 正常 |
|
||||||
5. **团队通过文件沟通** — TEAM_CHAT.md 异步协作,souls/ 持久化成员状态
|
| framebuffer | fb0 480x800 检测成功 |
|
||||||
6. **kilo 调用方式** — `kilo run -m openai/gpt-5.4 --auto --dir <dir> "消息内容"`,不使用 `-f` 参数
|
| Release 编译 | 9.4MB ARM aarch64 |
|
||||||
7. **动态插件 C FFI + JSON 序列化** — 以稳定 ABI + JSON 边界承载跨语言插件交互
|
|
||||||
8. **ctx-based SendCallback** — 用上下文回调替代 `thread_local`,消除线程绑定隐患
|
|
||||||
9. **3阶段启动** — 插件生命周期统一为 `init -> test -> start`,先自检再对外服务
|
|
||||||
10. **DevicePlugin 统一硬件抽象** — 所有硬件访问通过 DevicePlugin,多平台适配只改 Backend
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 团队绩效 (Phase 1 第一轮)
|
|
||||||
|
|
||||||
| 成员 | 任务 | 质量 | 完成度 | 效率 | 协作 | 总分 |
|
|
||||||
|------|------|------|--------|------|------|------|
|
|
||||||
| 张明远 | config.rs 验证 | 8 | 8 | 8 | 8 | **8** |
|
|
||||||
| 李思琪 | state_machine.rs | 8 | 8 | 8 | 8 | **8** |
|
|
||||||
| 王浩然 | wifi/mod.rs | 8 | 8 | 8 | 8 | **8** |
|
|
||||||
| 赵雨薇 | screen/mod.rs | 8 | 8 | 8 | 8 | **8** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 当前质量快照
|
|
||||||
|
|
||||||
- 测试总数:**77**
|
|
||||||
- 测试结果:**77/77 通过**
|
|
||||||
- 编译告警:**0 warning**
|
|
||||||
- 当前里程碑:**DevicePlugin 阶段二完成 — ScreenPlugin 迁移**
|
|
||||||
- DevicePlugin 能力: Display + SleepInhibit + Backlight + Cursor (Linux ARM64)
|
|
||||||
- ScreenPlugin 已重构为 thin wrapper(通过 DeviceCommand 转发)
|
|
||||||
|
|||||||
1320
clients/docs/API.md
1320
clients/docs/API.md
File diff suppressed because it is too large
Load Diff
@@ -1,59 +1,56 @@
|
|||||||
# Flutter App 待优化清单
|
# Flutter App 待优化清单
|
||||||
|
|
||||||
> 生成时间: 2026-03-14
|
> 生成时间: 2026-03-14
|
||||||
> 当前完成度: ~68%, APK 已编译 (51MB)
|
> 最后更新: 2026-03-14
|
||||||
|
> 当前完成度: ~95%, APK 待重编译
|
||||||
|
|
||||||
## P0 — 阻塞上线
|
## P0 — 阻塞上线
|
||||||
|
|
||||||
### 1. 设备 IP 持久化 + 多设备支持
|
### 1. 设备 IP 持久化 + 多设备支持
|
||||||
- `main.dart:20` hardcoded `127.0.0.1:8080`,重启丢失
|
- ~~`main.dart:20` hardcoded `127.0.0.1:8080`,重启丢失~~ ✅ 已从持久化初始化
|
||||||
- 需要: SharedPreferences 存储设备历史 (最近10台)
|
- ~~需要: SharedPreferences 存储设备历史 (最近10台)~~ ✅ device_storage_service.dart
|
||||||
- 需要: 顶栏设备切换下拉菜单
|
- 需要: 顶栏设备切换下拉菜单 ⏳ 赵雨薇处理中
|
||||||
- 需要: 连接前验证 `/api/status` 可达性
|
- 需要: 连接前验证 `/api/status` 可达性 ⏳ 赵雨薇处理中
|
||||||
|
|
||||||
### 2. WebSocket 重连增强
|
### 2. WebSocket 重连增强 ✅ 已完成
|
||||||
- `web_socket_service.dart` 固定 2 秒重连,无退避
|
- ~~`web_socket_service.dart` 固定 2 秒重连,无退避~~ ✅ 指数退避 2s→max 60s
|
||||||
- 需要: 指数退避 (2s→4s→8s→16s→max 60s)
|
- ~~需要: 顶层连接状态横幅~~ ✅ connection_status_banner.dart
|
||||||
- 需要: 顶层连接状态横幅 (Reconnecting... / Offline)
|
- ~~需要: 手动重试按钮~~ ✅ manualReconnect()
|
||||||
- 需要: 手动重试按钮
|
|
||||||
|
|
||||||
### 3. HTTP baseUrl 动态化
|
### 3. HTTP baseUrl 动态化 ✅ 已完成
|
||||||
- HttpApiService/WebSocketService 的 URL 需跟随设备切换动态更新
|
- ~~HttpApiService/WebSocketService 的 URL 需跟随设备切换动态更新~~ ✅
|
||||||
- DeviceProvider 应成为全局设备上下文,驱动所有服务重连
|
- ~~DeviceProvider 应成为全局设备上下文~~ ✅
|
||||||
|
|
||||||
## P1 — 应该有
|
## P1 — 应该有
|
||||||
|
|
||||||
### 4. 视频管理 UI (Settings 页)
|
### 4. 视频管理 UI (Settings 页) ✅ 已完成
|
||||||
- API 已有 getVideos(),但 UI 无视频列表展示
|
- ~~视频列表 + 删除确认弹窗~~ ✅ settings_screen.dart:266-354
|
||||||
- 需要: 视频列表 + 删除确认弹窗
|
- ~~刷新按钮~~ ✅ settings_screen.dart:282
|
||||||
- 需要: 刷新按钮
|
|
||||||
|
|
||||||
### 5. 配置 JSON 编辑器
|
### 5. 配置 JSON 编辑器 ✅ 已完成
|
||||||
- 当前只有表单模式,缺 raw JSON 编辑模式
|
- ~~需要: 复制到剪贴板~~ ✅ settings_screen.dart:559
|
||||||
- 需要: 切换按钮 (表单/JSON)
|
- ~~需要: 切换按钮 (表单/JSON)~~ ✅ 赵雨薇完成
|
||||||
- 需要: 复制到剪贴板
|
|
||||||
|
|
||||||
### 6. BLE 简易控制命令
|
### 6. BLE 简易控制命令 ✅ 已完成
|
||||||
- PRD §8.6 要求: 近场调试用 play/pause/next/prev BLE 按钮
|
- ~~play/pause/next/prev BLE 按钮~~ ✅ network_screen.dart:115-183
|
||||||
- Network 页添加 BLE 控制区域
|
|
||||||
|
|
||||||
### 7. 全页面下拉刷新
|
### 7. 全页面下拉刷新 ✅ 已完成
|
||||||
- 目前只有 Home 页有 RefreshIndicator
|
- ~~所有 5 个页面~~ ✅ RefreshIndicator 全覆盖
|
||||||
- Playback / Trigger / Network / Settings 都需要
|
|
||||||
|
|
||||||
## P2 — 锦上添花
|
## P2 — 锦上添花
|
||||||
|
|
||||||
### 8. 视频上传 UI
|
### 8. 视频上传 UI ⏳ 受限
|
||||||
- 需要 file_picker 依赖
|
- file_picker 包在 ARM64 设备上无法下载(网络超时)
|
||||||
- 进度条 + multipart upload
|
- 上传按钮已保留,当前显示"即将推出"提示
|
||||||
|
- 可通过 Web UI 上传视频
|
||||||
|
|
||||||
### 9. 单元测试 & Widget 测试
|
### 9. 单元测试 & Widget 测试 ✅ 已完成
|
||||||
- 目前零测试覆盖
|
- ~~目前零测试覆盖~~ ✅ 15 个测试全部通过
|
||||||
- 优先: models 解析、HttpApiService 错误处理、核心页面交互
|
- ~~优先: models 解析、HttpApiService 错误处理、核心页面交互~~ ✅ test/models/models_test.dart + test/services/http_api_service_test.dart
|
||||||
|
|
||||||
### 10. 调试日志面板
|
### 10. 调试日志面板 ✅ 已完成
|
||||||
- 本地事件日志查看器
|
- ~~本地事件日志查看器~~ ✅ debug_screen.dart + debug_provider.dart
|
||||||
- BLE/WebSocket/HTTP 事件时间线
|
- ~~BLE/WebSocket/HTTP 事件时间线~~ ✅ 筛选 Chips + 颜色区分标签 + 500 条上限
|
||||||
|
|
||||||
## 已知技术债
|
## 已知技术债
|
||||||
|
|
||||||
|
|||||||
5
clients/flutter/analysis_options.yaml
Normal file
5
clients/flutter/analysis_options.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
@@ -20,5 +20,10 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin flutter_blue_plus_android, com.lib.flutter_blue_plus.FlutterBluePlusPlugin", e);
|
Log.e(TAG, "Error registering plugin flutter_blue_plus_android, com.lib.flutter_blue_plus.FlutterBluePlusPlugin", e);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
clients/flutter/flutter_01.log
Normal file
75
clients/flutter/flutter_01.log
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
Flutter crash report.
|
||||||
|
Please report a bug at https://github.com/flutter/flutter/issues.
|
||||||
|
|
||||||
|
## command
|
||||||
|
|
||||||
|
flutter analyze
|
||||||
|
|
||||||
|
## exception
|
||||||
|
|
||||||
|
_Exception: Exception: analysis server exited with code -9 and output:
|
||||||
|
[stdout] {"event":"server.connected","params":{"version":"1.40.1","pid":144334}}
|
||||||
|
[stdout] {"id":"1"}
|
||||||
|
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/android/app/src/main/AndroidManifest.xml","errors":[]}}
|
||||||
|
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/flutter_blue_plus_android/intermediates/aapt_friendly_merged_manifests/release/processReleaseManifest/aapt/AndroidManifest.xml","errors":[]}}
|
||||||
|
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/flutter_blue_plus_android/intermediates/merged_manifest/release/processReleaseManifest/AndroidManifest.xml","errors":[]}}
|
||||||
|
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/app/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml","errors":[]}}
|
||||||
|
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/app/intermediates/packaged_manifests/release/processReleaseManifestForPackage/AndroidManifest.xml","errors":[]}}
|
||||||
|
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/app/intermediates/bundle_manifest/release/processApplicationManifestReleaseForBundle/AndroidManifest.xml","errors":[]}}
|
||||||
|
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/app/intermediates/merged_manifest/release/outputReleaseAppLinkSettings/AndroidManifest.xml","errors":[]}}
|
||||||
|
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/build/app/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml","errors":[]}}
|
||||||
|
[stdout] {"event":"analysis.errors","params":{"file":"/home/showen/Showen/ShowenV2/clients/flutter/pubspec.yaml","errors":[]}}
|
||||||
|
[stdout] {"event":"server.status","params":{"analysis":{"isAnalyzing":true}}}
|
||||||
|
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
## flutter doctor
|
||||||
|
|
||||||
|
```
|
||||||
|
[!] Flutter (Channel stable, 3.41.4, on Debian GNU/Linux 11 (bullseye) 5.15.147-14-a733, locale zh_CN.UTF-8) [2.9s]
|
||||||
|
• Flutter version 3.41.4 on channel stable at /home/showen/flutter-sdk
|
||||||
|
! The flutter binary is not on your path. Consider adding /home/showen/flutter-sdk/bin to your path.
|
||||||
|
! The dart binary is not on your path. Consider adding /home/showen/flutter-sdk/bin to your path.
|
||||||
|
• Upstream repository https://github.com/flutter/flutter.git
|
||||||
|
• Framework revision ff37bef603 (10 天前), 2026-03-03 16:03:22 -0800
|
||||||
|
• Engine revision e4b8dca3f1
|
||||||
|
• Dart version 3.11.1
|
||||||
|
• DevTools version 2.54.1
|
||||||
|
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration
|
||||||
|
• If those were intentional, you can disregard the above warnings; however it is recommended to use "git" directly to perform update checks and upgrades.
|
||||||
|
|
||||||
|
[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [15.3s]
|
||||||
|
• Android SDK at /home/showen/Android
|
||||||
|
• Emulator version unknown
|
||||||
|
• Platform android-36, build-tools 36.0.0
|
||||||
|
• Java binary at: /usr/bin/java
|
||||||
|
This JDK was found in the system PATH.
|
||||||
|
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
|
||||||
|
• Java version OpenJDK Runtime Environment (build 17.0.18+8-Debian-1deb11u1)
|
||||||
|
• All Android licenses accepted.
|
||||||
|
|
||||||
|
[✗] Chrome - develop for the web (Cannot find Chrome executable at google-chrome) [1,107ms]
|
||||||
|
! Cannot find Chrome. Try setting CHROME_EXECUTABLE to a Chrome executable.
|
||||||
|
|
||||||
|
[✗] Linux toolchain - develop for Linux desktop [3.1s]
|
||||||
|
• Debian clang version 11.0.1-2
|
||||||
|
• cmake version 3.18.4
|
||||||
|
✗ ninja is required for Linux development.
|
||||||
|
It is likely available from your distribution (e.g.: apt install ninja-build), or can be downloaded from https://github.com/ninja-build/ninja/releases
|
||||||
|
• pkg-config version 0.29.2
|
||||||
|
✗ GTK 3.0 development libraries are required for Linux development.
|
||||||
|
They are likely available from your distribution (e.g.: apt install libgtk-3-dev)
|
||||||
|
! Unable to access driver information using 'eglinfo'.
|
||||||
|
It is likely available from your distribution (e.g.: apt install mesa-utils)
|
||||||
|
|
||||||
|
[✓] Connected device (1 available) [4.6s]
|
||||||
|
• Linux (desktop) • linux • linux-arm64 • Debian GNU/Linux 11 (bullseye) 5.15.147-14-a733
|
||||||
|
|
||||||
|
[☠] Network resources (the doctor check crashed)
|
||||||
|
✗ Due to an error, the doctor check did not complete. If the error message below is not helpful, please let us know about this issue at https://github.com/flutter/flutter/issues.
|
||||||
|
✗ Exception: Network resources exceeded maximum allowed duration of 0:04:30.000000
|
||||||
|
•
|
||||||
|
|
||||||
|
! Doctor found issues in 4 categories.
|
||||||
|
```
|
||||||
@@ -3,45 +3,72 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'providers/device_provider.dart';
|
import 'providers/device_provider.dart';
|
||||||
|
import 'providers/debug_provider.dart';
|
||||||
|
import 'providers/ble_provider.dart';
|
||||||
import 'providers/player_provider.dart';
|
import 'providers/player_provider.dart';
|
||||||
import 'providers/wifi_provider.dart';
|
import 'providers/wifi_provider.dart';
|
||||||
import 'screens/app_shell.dart';
|
import 'screens/app_shell.dart';
|
||||||
import 'screens/ble_provision_screen.dart';
|
import 'screens/ble_provision_screen.dart';
|
||||||
|
import 'screens/debug_screen.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/network_screen.dart';
|
import 'screens/network_screen.dart';
|
||||||
import 'screens/playback_screen.dart';
|
import 'screens/playback_screen.dart';
|
||||||
import 'screens/settings_screen.dart';
|
import 'screens/settings_screen.dart';
|
||||||
import 'screens/trigger_screen.dart';
|
import 'screens/trigger_screen.dart';
|
||||||
|
import 'services/device_storage_service.dart';
|
||||||
import 'services/http_api_service.dart';
|
import 'services/http_api_service.dart';
|
||||||
import 'services/web_socket_service.dart';
|
import 'services/web_socket_service.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
|
||||||
void main() {
|
Future<void> main() async {
|
||||||
final httpApiService = HttpApiService(baseUrl: 'http://127.0.0.1:8080');
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
final deviceStorageService = DeviceStorageService();
|
||||||
|
final lastDevice = await deviceStorageService.getLastDevice();
|
||||||
|
final initialDeviceIp = lastDevice?.ip ?? '127.0.0.1';
|
||||||
|
final initialDevicePort = lastDevice?.port ?? 5000;
|
||||||
|
|
||||||
|
final httpApiService = HttpApiService(
|
||||||
|
baseUrl: 'http://$initialDeviceIp:$initialDevicePort',
|
||||||
|
);
|
||||||
final webSocketService = WebSocketService();
|
final webSocketService = WebSocketService();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
|
Provider<WebSocketService>.value(value: webSocketService),
|
||||||
|
ChangeNotifierProvider<DebugProvider>(
|
||||||
|
create: (_) => DebugProvider(webSocketService: webSocketService),
|
||||||
|
),
|
||||||
ChangeNotifierProvider<DeviceProvider>(
|
ChangeNotifierProvider<DeviceProvider>(
|
||||||
create: (_) => DeviceProvider(
|
create: (context) => DeviceProvider(
|
||||||
httpApiService: httpApiService,
|
httpApiService: httpApiService,
|
||||||
webSocketService: webSocketService,
|
webSocketService: webSocketService,
|
||||||
|
deviceStorageService: deviceStorageService,
|
||||||
|
debugProvider: context.read<DebugProvider>(),
|
||||||
|
initialDeviceIp: initialDeviceIp,
|
||||||
|
initialDevicePort: initialDevicePort,
|
||||||
|
initialDeviceName: lastDevice?.name,
|
||||||
)..initialize(),
|
)..initialize(),
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider<BleProvider>(
|
||||||
|
create: (context) => BleProvider(
|
||||||
|
debugProvider: context.read<DebugProvider>(),
|
||||||
|
),
|
||||||
|
),
|
||||||
ChangeNotifierProvider<PlayerProvider>(
|
ChangeNotifierProvider<PlayerProvider>(
|
||||||
create: (_) => PlayerProvider(
|
create: (context) => PlayerProvider(
|
||||||
httpApiService: httpApiService,
|
httpApiService: httpApiService,
|
||||||
webSocketService: webSocketService,
|
webSocketService: webSocketService,
|
||||||
)
|
debugProvider: context.read<DebugProvider>(),
|
||||||
..bootstrap(),
|
)..bootstrap(),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider<WifiProvider>(
|
ChangeNotifierProvider<WifiProvider>(
|
||||||
create: (_) => WifiProvider(
|
create: (context) => WifiProvider(
|
||||||
httpApiService: httpApiService,
|
httpApiService: httpApiService,
|
||||||
webSocketService: webSocketService,
|
webSocketService: webSocketService,
|
||||||
)
|
debugProvider: context.read<DebugProvider>(),
|
||||||
..bootstrap(),
|
)..bootstrap(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: const ShowenApp(),
|
child: const ShowenApp(),
|
||||||
@@ -108,6 +135,15 @@ final GoRouter _router = GoRouter(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/debug',
|
||||||
|
name: 'debug',
|
||||||
|
builder: (context, state) => const DebugScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import 'package:flutter/foundation.dart';
|
|||||||
|
|
||||||
import '../models/ble_models.dart';
|
import '../models/ble_models.dart';
|
||||||
import '../services/ble_service.dart';
|
import '../services/ble_service.dart';
|
||||||
|
import 'debug_provider.dart';
|
||||||
|
|
||||||
class BleProvider extends ChangeNotifier {
|
class BleProvider extends ChangeNotifier {
|
||||||
BleProvider({BleService? bleService}) : _bleService = bleService ?? BleService();
|
BleProvider({BleService? bleService, required DebugProvider debugProvider})
|
||||||
|
: _bleService = bleService ?? BleService(),
|
||||||
|
_debugProvider = debugProvider;
|
||||||
|
|
||||||
final BleService _bleService;
|
final BleService _bleService;
|
||||||
|
final DebugProvider _debugProvider;
|
||||||
|
|
||||||
StreamSubscription<List<BleDevice>>? _scanSubscription;
|
StreamSubscription<List<BleDevice>>? _scanSubscription;
|
||||||
StreamSubscription<BleStatus>? _statusSubscription;
|
StreamSubscription<BleStatus>? _statusSubscription;
|
||||||
@@ -21,6 +25,7 @@ class BleProvider extends ChangeNotifier {
|
|||||||
bool _isScanning = false;
|
bool _isScanning = false;
|
||||||
bool _isConnecting = false;
|
bool _isConnecting = false;
|
||||||
bool _isProvisioning = false;
|
bool _isProvisioning = false;
|
||||||
|
bool _isSendingCommand = false;
|
||||||
bool _isConnected = false;
|
bool _isConnected = false;
|
||||||
bool _isDisposed = false;
|
bool _isDisposed = false;
|
||||||
|
|
||||||
@@ -32,9 +37,11 @@ class BleProvider extends ChangeNotifier {
|
|||||||
bool get isScanning => _isScanning;
|
bool get isScanning => _isScanning;
|
||||||
bool get isConnecting => _isConnecting;
|
bool get isConnecting => _isConnecting;
|
||||||
bool get isProvisioning => _isProvisioning;
|
bool get isProvisioning => _isProvisioning;
|
||||||
|
bool get isSendingCommand => _isSendingCommand;
|
||||||
bool get isConnected => _isConnected;
|
bool get isConnected => _isConnected;
|
||||||
|
|
||||||
Future<void> startScan() async {
|
Future<void> startScan() async {
|
||||||
|
_debugProvider.addBleLog('Start BLE scan');
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_selectedDevice = null;
|
_selectedDevice = null;
|
||||||
_isConnected = false;
|
_isConnected = false;
|
||||||
@@ -47,23 +54,31 @@ class BleProvider extends ChangeNotifier {
|
|||||||
.scanForShowenDevices()
|
.scanForShowenDevices()
|
||||||
.listen((List<BleDevice> scannedDevices) {
|
.listen((List<BleDevice> scannedDevices) {
|
||||||
_devices = scannedDevices;
|
_devices = scannedDevices;
|
||||||
|
_debugProvider.addBleLog(
|
||||||
|
'BLE scan update (${scannedDevices.length} devices)',
|
||||||
|
);
|
||||||
_notifySafely();
|
_notifySafely();
|
||||||
}, onError: (Object error, StackTrace stackTrace) {
|
}, onError: (Object error, StackTrace stackTrace) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
_isScanning = false;
|
_isScanning = false;
|
||||||
_provisioningState = ProvisioningState.failed;
|
_provisioningState = ProvisioningState.failed;
|
||||||
|
_debugProvider.addBleLog('BLE scan failed', details: error);
|
||||||
_notifySafely();
|
_notifySafely();
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void>.delayed(const Duration(seconds: 6), () {
|
Future<void>.delayed(const Duration(seconds: 6), () {
|
||||||
if (_isScanning) {
|
if (_isScanning) {
|
||||||
_isScanning = false;
|
_isScanning = false;
|
||||||
|
_debugProvider.addBleLog('BLE scan completed');
|
||||||
_notifySafely();
|
_notifySafely();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> connectToDevice(BleDevice device) async {
|
Future<void> connectToDevice(BleDevice device) async {
|
||||||
|
_debugProvider.addBleLog(
|
||||||
|
'Connect BLE device ${device.name.isNotEmpty ? device.name : device.id}',
|
||||||
|
);
|
||||||
_selectedDevice = device;
|
_selectedDevice = device;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_isConnecting = true;
|
_isConnecting = true;
|
||||||
@@ -75,10 +90,12 @@ class BleProvider extends ChangeNotifier {
|
|||||||
await _bleService.connectToDevice(device);
|
await _bleService.connectToDevice(device);
|
||||||
await _subscribeToStatus();
|
await _subscribeToStatus();
|
||||||
_isConnected = true;
|
_isConnected = true;
|
||||||
|
_debugProvider.addBleLog('BLE device connected');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_isConnected = false;
|
_isConnected = false;
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
_provisioningState = ProvisioningState.failed;
|
_provisioningState = ProvisioningState.failed;
|
||||||
|
_debugProvider.addBleLog('BLE connect failed', details: error);
|
||||||
rethrow;
|
rethrow;
|
||||||
} finally {
|
} finally {
|
||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
@@ -87,6 +104,10 @@ class BleProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> provisionWifi(String ssid, String password) async {
|
Future<void> provisionWifi(String ssid, String password) async {
|
||||||
|
_debugProvider.addBleLog(
|
||||||
|
'Provision WiFi over BLE',
|
||||||
|
details: <String, Object>{'ssid': ssid},
|
||||||
|
);
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_latestStatus = null;
|
_latestStatus = null;
|
||||||
_isProvisioning = true;
|
_isProvisioning = true;
|
||||||
@@ -115,14 +136,19 @@ class BleProvider extends ChangeNotifier {
|
|||||||
: ProvisioningState.failed;
|
: ProvisioningState.failed;
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
_errorMessage = result.error ?? 'WiFi provisioning failed';
|
_errorMessage = result.error ?? 'WiFi provisioning failed';
|
||||||
|
_debugProvider.addBleLog('BLE provisioning returned failure', details: result.error);
|
||||||
|
} else {
|
||||||
|
_debugProvider.addBleLog('BLE provisioning succeeded');
|
||||||
}
|
}
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
_errorMessage = 'BLE 配网超时(30 秒)';
|
_errorMessage = 'BLE 配网超时(30 秒)';
|
||||||
_provisioningState = ProvisioningState.failed;
|
_provisioningState = ProvisioningState.failed;
|
||||||
|
_debugProvider.addBleLog('BLE provisioning timed out');
|
||||||
rethrow;
|
rethrow;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
_provisioningState = ProvisioningState.failed;
|
_provisioningState = ProvisioningState.failed;
|
||||||
|
_debugProvider.addBleLog('BLE provisioning failed', details: error);
|
||||||
rethrow;
|
rethrow;
|
||||||
} finally {
|
} finally {
|
||||||
_isProvisioning = false;
|
_isProvisioning = false;
|
||||||
@@ -140,10 +166,31 @@ class BleProvider extends ChangeNotifier {
|
|||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
_isProvisioning = false;
|
_isProvisioning = false;
|
||||||
_selectedDevice = null;
|
_selectedDevice = null;
|
||||||
|
_debugProvider.addBleLog('BLE disconnected');
|
||||||
_notifySafely();
|
_notifySafely();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> sendCommand(String command) async {
|
||||||
|
_debugProvider.addBleLog('Send BLE command', details: command);
|
||||||
|
_errorMessage = null;
|
||||||
|
_isSendingCommand = true;
|
||||||
|
_notifySafely();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _bleService.sendCommand(command);
|
||||||
|
_debugProvider.addBleLog('BLE command sent', details: command);
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addBleLog('BLE command failed', details: error);
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
_isSendingCommand = false;
|
||||||
|
_notifySafely();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> retryScan() async {
|
Future<void> retryScan() async {
|
||||||
|
_debugProvider.addBleLog('Retry BLE scan');
|
||||||
await disconnect();
|
await disconnect();
|
||||||
_devices = const <BleDevice>[];
|
_devices = const <BleDevice>[];
|
||||||
_latestStatus = null;
|
_latestStatus = null;
|
||||||
@@ -158,6 +205,15 @@ class BleProvider extends ChangeNotifier {
|
|||||||
final Stream<BleStatus> stream = await _bleService.subscribeToStatus();
|
final Stream<BleStatus> stream = await _bleService.subscribeToStatus();
|
||||||
_statusSubscription = stream.listen((BleStatus status) {
|
_statusSubscription = stream.listen((BleStatus status) {
|
||||||
_latestStatus = status;
|
_latestStatus = status;
|
||||||
|
_debugProvider.addBleLog(
|
||||||
|
'BLE status update',
|
||||||
|
details: <String, Object?>{
|
||||||
|
'ok': status.ok,
|
||||||
|
'action': status.action,
|
||||||
|
'state': status.state,
|
||||||
|
'error': status.error,
|
||||||
|
},
|
||||||
|
);
|
||||||
if (!status.ok) {
|
if (!status.ok) {
|
||||||
_errorMessage = status.error ?? 'BLE status returned an error';
|
_errorMessage = status.error ?? 'BLE status returned an error';
|
||||||
}
|
}
|
||||||
@@ -174,6 +230,7 @@ class BleProvider extends ChangeNotifier {
|
|||||||
}, onError: (Object error, StackTrace stackTrace) {
|
}, onError: (Object error, StackTrace stackTrace) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
_provisioningState = ProvisioningState.failed;
|
_provisioningState = ProvisioningState.failed;
|
||||||
|
_debugProvider.addBleLog('BLE status stream failed', details: error);
|
||||||
_notifySafely();
|
_notifySafely();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
214
clients/flutter/lib/providers/debug_provider.dart
Normal file
214
clients/flutter/lib/providers/debug_provider.dart
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/app_event.dart';
|
||||||
|
import '../services/web_socket_service.dart';
|
||||||
|
|
||||||
|
enum DebugLogType { ble, ws, http }
|
||||||
|
|
||||||
|
enum DebugLogFilter { all, ble, ws, http }
|
||||||
|
|
||||||
|
class DebugLogEntry {
|
||||||
|
const DebugLogEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.type,
|
||||||
|
required this.summary,
|
||||||
|
this.details,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime timestamp;
|
||||||
|
final DebugLogType type;
|
||||||
|
final String summary;
|
||||||
|
final String? details;
|
||||||
|
|
||||||
|
String get label {
|
||||||
|
switch (type) {
|
||||||
|
case DebugLogType.ble:
|
||||||
|
return 'BLE';
|
||||||
|
case DebugLogType.ws:
|
||||||
|
return 'WS';
|
||||||
|
case DebugLogType.http:
|
||||||
|
return 'HTTP';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DebugProvider extends ChangeNotifier {
|
||||||
|
DebugProvider({required WebSocketService webSocketService}) {
|
||||||
|
_eventSubscription = webSocketService.events.listen(_handleEvent);
|
||||||
|
_connectionSubscription = webSocketService.connectionStateStream.listen(
|
||||||
|
_handleConnectionState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const int maxEntries = 500;
|
||||||
|
|
||||||
|
final List<DebugLogEntry> _entries = <DebugLogEntry>[];
|
||||||
|
late final StreamSubscription<AppEvent> _eventSubscription;
|
||||||
|
late final StreamSubscription<WsConnectionState> _connectionSubscription;
|
||||||
|
|
||||||
|
DebugLogFilter _filter = DebugLogFilter.all;
|
||||||
|
|
||||||
|
List<DebugLogEntry> get entries => List<DebugLogEntry>.unmodifiable(_entries);
|
||||||
|
DebugLogFilter get filter => _filter;
|
||||||
|
|
||||||
|
List<DebugLogEntry> get filteredEntries {
|
||||||
|
switch (_filter) {
|
||||||
|
case DebugLogFilter.all:
|
||||||
|
return entries.reversed.toList(growable: false);
|
||||||
|
case DebugLogFilter.ble:
|
||||||
|
return _entries
|
||||||
|
.where((entry) => entry.type == DebugLogType.ble)
|
||||||
|
.toList(growable: false)
|
||||||
|
.reversed
|
||||||
|
.toList(growable: false);
|
||||||
|
case DebugLogFilter.ws:
|
||||||
|
return _entries
|
||||||
|
.where((entry) => entry.type == DebugLogType.ws)
|
||||||
|
.toList(growable: false)
|
||||||
|
.reversed
|
||||||
|
.toList(growable: false);
|
||||||
|
case DebugLogFilter.http:
|
||||||
|
return _entries
|
||||||
|
.where((entry) => entry.type == DebugLogType.http)
|
||||||
|
.toList(growable: false)
|
||||||
|
.reversed
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFilter(DebugLogFilter value) {
|
||||||
|
if (_filter == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_filter = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearLogs() {
|
||||||
|
if (_entries.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_entries.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addHttpLog(String summary, {Object? details}) {
|
||||||
|
_addEntry(DebugLogType.http, summary, details: details);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addBleLog(String summary, {Object? details}) {
|
||||||
|
_addEntry(DebugLogType.ble, summary, details: details);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addWsLog(String summary, {Object? details}) {
|
||||||
|
_addEntry(DebugLogType.ws, summary, details: details);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleEvent(AppEvent event) {
|
||||||
|
final logType = _inferType(event);
|
||||||
|
_addEntry(
|
||||||
|
logType,
|
||||||
|
_buildEventSummary(event, logType),
|
||||||
|
details: <String, dynamic>{
|
||||||
|
'type': event.type,
|
||||||
|
'payload': event.payload,
|
||||||
|
},
|
||||||
|
notify: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleConnectionState(WsConnectionState state) {
|
||||||
|
final String summary;
|
||||||
|
switch (state) {
|
||||||
|
case WsConnectionState.connected:
|
||||||
|
summary = 'WebSocket connected';
|
||||||
|
break;
|
||||||
|
case WsConnectionState.connecting:
|
||||||
|
summary = 'WebSocket reconnecting';
|
||||||
|
break;
|
||||||
|
case WsConnectionState.disconnected:
|
||||||
|
summary = 'WebSocket disconnected';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_addEntry(DebugLogType.ws, summary, notify: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLogType _inferType(AppEvent event) {
|
||||||
|
if (event.type.contains('ble')) {
|
||||||
|
return DebugLogType.ble;
|
||||||
|
}
|
||||||
|
return DebugLogType.ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildEventSummary(AppEvent event, DebugLogType type) {
|
||||||
|
final payload = event.payload;
|
||||||
|
switch (event.type) {
|
||||||
|
case 'ble_update':
|
||||||
|
final ready = payload['ready'] ?? payload['running'];
|
||||||
|
final name = payload['device_name'] ?? payload['name'];
|
||||||
|
return 'BLE update${name != null ? ' - $name' : ''}${ready != null ? ' (ready: $ready)' : ''}';
|
||||||
|
case 'wifi_update':
|
||||||
|
final ssid = payload['ssid']?.toString();
|
||||||
|
final connected = payload['connected'];
|
||||||
|
return 'WS wifi update${ssid != null && ssid.isNotEmpty ? ' - $ssid' : ''}${connected != null ? ' (connected: $connected)' : ''}';
|
||||||
|
case 'status_update':
|
||||||
|
final current = payload['current_video']?.toString();
|
||||||
|
final running = payload['running'];
|
||||||
|
return 'WS playback status${current != null && current.isNotEmpty ? ' - $current' : ''}${running != null ? ' (running: $running)' : ''}';
|
||||||
|
case 'state_update':
|
||||||
|
final previous = payload['old_state']?.toString();
|
||||||
|
final next = payload['new_state']?.toString();
|
||||||
|
return 'WS state change${previous != null ? ' $previous ->' : ''} ${next ?? 'unknown'}'.trim();
|
||||||
|
case 'config_update':
|
||||||
|
return 'WS config update';
|
||||||
|
default:
|
||||||
|
final prefix = type == DebugLogType.ble ? 'BLE' : 'WS';
|
||||||
|
final compactPayload = _stringify(details: payload);
|
||||||
|
return '$prefix ${event.type}: $compactPayload';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addEntry(
|
||||||
|
DebugLogType type,
|
||||||
|
String summary, {
|
||||||
|
Object? details,
|
||||||
|
bool notify = true,
|
||||||
|
}) {
|
||||||
|
_entries.add(
|
||||||
|
DebugLogEntry(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
type: type,
|
||||||
|
summary: summary,
|
||||||
|
details: details == null ? null : _stringify(details: details),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_entries.length > maxEntries) {
|
||||||
|
_entries.removeRange(0, _entries.length - maxEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notify) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stringify({required Object details}) {
|
||||||
|
try {
|
||||||
|
final encoded = details is String ? details : jsonEncode(details);
|
||||||
|
return encoded.length > 220 ? '${encoded.substring(0, 217)}...' : encoded;
|
||||||
|
} catch (_) {
|
||||||
|
final value = details.toString();
|
||||||
|
return value.length > 220 ? '${value.substring(0, 217)}...' : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
unawaited(_eventSubscription.cancel());
|
||||||
|
unawaited(_connectionSubscription.cancel());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,28 +6,43 @@ import '../models/ble_status.dart';
|
|||||||
import '../models/device_status.dart';
|
import '../models/device_status.dart';
|
||||||
import '../models/player_status.dart';
|
import '../models/player_status.dart';
|
||||||
import '../models/wifi_status.dart';
|
import '../models/wifi_status.dart';
|
||||||
|
import '../services/device_storage_service.dart';
|
||||||
import '../services/http_api_service.dart';
|
import '../services/http_api_service.dart';
|
||||||
import '../services/web_socket_service.dart';
|
import '../services/web_socket_service.dart';
|
||||||
|
import 'debug_provider.dart';
|
||||||
|
|
||||||
class DeviceProvider extends ChangeNotifier {
|
class DeviceProvider extends ChangeNotifier {
|
||||||
DeviceProvider({
|
DeviceProvider({
|
||||||
required HttpApiService httpApiService,
|
required HttpApiService httpApiService,
|
||||||
required WebSocketService webSocketService,
|
required WebSocketService webSocketService,
|
||||||
|
required DeviceStorageService deviceStorageService,
|
||||||
|
required DebugProvider debugProvider,
|
||||||
String initialDeviceIp = '127.0.0.1',
|
String initialDeviceIp = '127.0.0.1',
|
||||||
|
int initialDevicePort = 5000,
|
||||||
|
String? initialDeviceName,
|
||||||
}) : _httpApiService = httpApiService,
|
}) : _httpApiService = httpApiService,
|
||||||
_webSocketService = webSocketService,
|
_webSocketService = webSocketService,
|
||||||
_deviceIp = _normalizeDeviceIp(initialDeviceIp) {
|
_debugProvider = debugProvider,
|
||||||
_httpApiService.baseUrl = 'http://$_deviceIp:8080';
|
_deviceStorageService = deviceStorageService,
|
||||||
|
_deviceIp = _normalizeDeviceIp(initialDeviceIp),
|
||||||
|
_devicePort = _normalizePort(initialDevicePort),
|
||||||
|
_deviceName = _normalizeDeviceName(initialDeviceName) {
|
||||||
|
_httpApiService.baseUrl = _buildBaseUrl(_deviceIp, _devicePort);
|
||||||
_connectionSubscription = _webSocketService.onConnectionChanged.listen(
|
_connectionSubscription = _webSocketService.onConnectionChanged.listen(
|
||||||
_handleConnectionChanged,
|
_handleConnectionChanged,
|
||||||
);
|
);
|
||||||
_statusSubscription = _webSocketService.onStatusUpdate.listen(_handleStatusUpdate);
|
_statusSubscription = _webSocketService.onStatusUpdate.listen(
|
||||||
_wifiSubscription = _webSocketService.onWifiUpdate.listen(_handleWifiUpdate);
|
_handleStatusUpdate,
|
||||||
|
);
|
||||||
|
_wifiSubscription =
|
||||||
|
_webSocketService.onWifiUpdate.listen(_handleWifiUpdate);
|
||||||
_bleSubscription = _webSocketService.onBleUpdate.listen(_handleBleUpdate);
|
_bleSubscription = _webSocketService.onBleUpdate.listen(_handleBleUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
final HttpApiService _httpApiService;
|
final HttpApiService _httpApiService;
|
||||||
final WebSocketService _webSocketService;
|
final WebSocketService _webSocketService;
|
||||||
|
final DebugProvider _debugProvider;
|
||||||
|
final DeviceStorageService _deviceStorageService;
|
||||||
|
|
||||||
late final StreamSubscription<SocketConnectionStatus> _connectionSubscription;
|
late final StreamSubscription<SocketConnectionStatus> _connectionSubscription;
|
||||||
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
|
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
|
||||||
@@ -39,21 +54,41 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
bool _webSocketConnected = false;
|
bool _webSocketConnected = false;
|
||||||
String _deviceIp;
|
String _deviceIp;
|
||||||
|
int _devicePort;
|
||||||
|
String _deviceName;
|
||||||
|
List<SavedDevice> _deviceList = const <SavedDevice>[];
|
||||||
|
|
||||||
DeviceStatus get status => _status;
|
DeviceStatus get status => _status;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
bool get webSocketConnected => _webSocketConnected;
|
bool get webSocketConnected => _webSocketConnected;
|
||||||
String get deviceIp => _deviceIp;
|
String get deviceIp => _deviceIp;
|
||||||
|
int get devicePort => _devicePort;
|
||||||
|
List<SavedDevice> get deviceList =>
|
||||||
|
List<SavedDevice>.unmodifiable(_deviceList);
|
||||||
HttpApiService get httpApiService => _httpApiService;
|
HttpApiService get httpApiService => _httpApiService;
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'Initialize device provider',
|
||||||
|
details: <String, Object>{'device': '$_deviceIp:$_devicePort'},
|
||||||
|
);
|
||||||
|
final lastDevice = await _deviceStorageService.getLastDevice();
|
||||||
|
if (lastDevice != null) {
|
||||||
|
_applyDevice(
|
||||||
|
ip: lastDevice.ip, port: lastDevice.port, name: lastDevice.name);
|
||||||
|
}
|
||||||
|
await _refreshDeviceList();
|
||||||
await refresh();
|
await refresh();
|
||||||
await connect();
|
await connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
_setLoading(true);
|
_setLoading(true);
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'Refresh device overview',
|
||||||
|
details: <String, Object>{'device': '$_deviceIp:$_devicePort'},
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait<dynamic>([
|
final results = await Future.wait<dynamic>([
|
||||||
_httpApiService.getPlaybackStatus(),
|
_httpApiService.getPlaybackStatus(),
|
||||||
@@ -67,12 +102,18 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
bleStatus: results[2] as BleServiceStatus,
|
bleStatus: results[2] as BleServiceStatus,
|
||||||
);
|
);
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_debugProvider.addHttpLog('Device overview refreshed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'Refresh device overview failed',
|
||||||
|
details: error,
|
||||||
|
);
|
||||||
_status = _status.copyWith(
|
_status = _status.copyWith(
|
||||||
connected: false,
|
connected: false,
|
||||||
connectionType: 'offline',
|
connectionType: 'offline',
|
||||||
ipAddress: _deviceIp,
|
deviceName: _deviceName,
|
||||||
|
ipAddress: '$_deviceIp:$_devicePort',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
_setLoading(false);
|
_setLoading(false);
|
||||||
@@ -82,36 +123,98 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
Future<void> loadDeviceOverview() => refresh();
|
Future<void> loadDeviceOverview() => refresh();
|
||||||
|
|
||||||
Future<void> connect() async {
|
Future<void> connect() async {
|
||||||
|
_debugProvider.addWsLog(
|
||||||
|
'Connect WebSocket',
|
||||||
|
details: <String, Object>{'device': '$_deviceIp:$_devicePort'},
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await _webSocketService.connect(_deviceIp);
|
await _webSocketService.connect(_deviceIp, port: _devicePort);
|
||||||
_webSocketConnected = _webSocketService.isConnected;
|
_webSocketConnected = _webSocketService.isConnected;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_debugProvider.addWsLog('WebSocket connect request completed');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_webSocketConnected = false;
|
_webSocketConnected = false;
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addWsLog('WebSocket connect failed', details: error);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> switchDevice(String input, {String? name}) async {
|
||||||
|
final nextDevice = _parseDeviceInput(input, fallbackPort: _devicePort);
|
||||||
|
final nextName =
|
||||||
|
_normalizeDeviceName(name ?? _status.deviceName ?? _deviceName);
|
||||||
|
_setLoading(true);
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'Switch device to ${nextDevice.ip}:${nextDevice.port}',
|
||||||
|
details: <String, Object>{'name': nextName},
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await _validateDeviceReachable(nextDevice.ip, nextDevice.port);
|
||||||
|
|
||||||
|
await _webSocketService.disconnect();
|
||||||
|
_applyDevice(ip: nextDevice.ip, port: nextDevice.port, name: nextName);
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
await _deviceStorageService.saveDevice(
|
||||||
|
nextDevice.ip, nextDevice.port, nextName);
|
||||||
|
await _refreshDeviceList();
|
||||||
|
await refresh();
|
||||||
|
await connect();
|
||||||
|
|
||||||
|
final resolvedName = _normalizeDeviceName(_status.deviceName ?? nextName);
|
||||||
|
if (resolvedName != nextName) {
|
||||||
|
_deviceName = resolvedName;
|
||||||
|
await _deviceStorageService.saveDevice(
|
||||||
|
nextDevice.ip,
|
||||||
|
nextDevice.port,
|
||||||
|
resolvedName,
|
||||||
|
);
|
||||||
|
await _refreshDeviceList();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'Device switched successfully',
|
||||||
|
details: <String, Object>{'device': '${nextDevice.ip}:${nextDevice.port}'},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Switch device failed', details: error);
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> updateDeviceIp(String ip) async {
|
Future<void> updateDeviceIp(String ip) async {
|
||||||
final normalized = _normalizeDeviceIp(ip);
|
await switchDevice(ip);
|
||||||
_deviceIp = normalized;
|
}
|
||||||
_httpApiService.baseUrl = 'http://$_deviceIp:8080';
|
|
||||||
_status = _status.copyWith(ipAddress: normalized, updatedAt: DateTime.now());
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
await _webSocketService.disconnect();
|
Future<void> removeStoredDevice(SavedDevice device) async {
|
||||||
await initialize();
|
await _deviceStorageService.removeDevice(device.ip, device.port);
|
||||||
|
await _refreshDeviceList();
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'Removed saved device ${device.address}',
|
||||||
|
details: <String, Object>{'name': device.name},
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startBle({String? deviceName}) async {
|
Future<void> startBle({String? deviceName}) async {
|
||||||
_setLoading(true);
|
_setLoading(true);
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'Start BLE service',
|
||||||
|
details: <String, Object?>{'deviceName': deviceName},
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await _httpApiService.startBle(deviceName);
|
await _httpApiService.startBle(deviceName);
|
||||||
await refresh();
|
await refresh();
|
||||||
|
_debugProvider.addHttpLog('BLE service started');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Start BLE service failed', details: error);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} finally {
|
} finally {
|
||||||
_setLoading(false);
|
_setLoading(false);
|
||||||
@@ -120,11 +223,14 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> stopBle() async {
|
Future<void> stopBle() async {
|
||||||
_setLoading(true);
|
_setLoading(true);
|
||||||
|
_debugProvider.addHttpLog('Stop BLE service');
|
||||||
try {
|
try {
|
||||||
await _httpApiService.stopBle();
|
await _httpApiService.stopBle();
|
||||||
await refresh();
|
await refresh();
|
||||||
|
_debugProvider.addHttpLog('BLE service stopped');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Stop BLE service failed', details: error);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} finally {
|
} finally {
|
||||||
_setLoading(false);
|
_setLoading(false);
|
||||||
@@ -133,6 +239,9 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
void _handleConnectionChanged(SocketConnectionStatus connectionStatus) {
|
void _handleConnectionChanged(SocketConnectionStatus connectionStatus) {
|
||||||
_webSocketConnected = connectionStatus == SocketConnectionStatus.connected;
|
_webSocketConnected = connectionStatus == SocketConnectionStatus.connected;
|
||||||
|
_debugProvider.addWsLog(
|
||||||
|
'Device provider connection state: ${connectionStatus.name}',
|
||||||
|
);
|
||||||
if (!_webSocketConnected) {
|
if (!_webSocketConnected) {
|
||||||
_status = _status.copyWith(connectionType: _status.connectionType);
|
_status = _status.copyWith(connectionType: _status.connectionType);
|
||||||
}
|
}
|
||||||
@@ -140,6 +249,7 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleStatusUpdate(Map<String, dynamic> payload) {
|
void _handleStatusUpdate(Map<String, dynamic> payload) {
|
||||||
|
_debugProvider.addWsLog('Received status update', details: payload);
|
||||||
final playerStatus = PlayerStatus.fromJson(payload);
|
final playerStatus = PlayerStatus.fromJson(payload);
|
||||||
_status = _buildStatus(
|
_status = _buildStatus(
|
||||||
playerStatus: playerStatus,
|
playerStatus: playerStatus,
|
||||||
@@ -150,6 +260,7 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleWifiUpdate(Map<String, dynamic> payload) {
|
void _handleWifiUpdate(Map<String, dynamic> payload) {
|
||||||
|
_debugProvider.addWsLog('Received wifi update', details: payload);
|
||||||
final wifiStatus = WifiStatus.fromJson(payload);
|
final wifiStatus = WifiStatus.fromJson(payload);
|
||||||
_status = _buildStatus(
|
_status = _buildStatus(
|
||||||
playerStatus: _status.playerStatus ?? PlayerStatus.initial(),
|
playerStatus: _status.playerStatus ?? PlayerStatus.initial(),
|
||||||
@@ -160,6 +271,7 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleBleUpdate(Map<String, dynamic> payload) {
|
void _handleBleUpdate(Map<String, dynamic> payload) {
|
||||||
|
_debugProvider.addBleLog('Received BLE update', details: payload);
|
||||||
final normalized = <String, dynamic>{
|
final normalized = <String, dynamic>{
|
||||||
'running': payload['running'] ?? payload['ready'] ?? false,
|
'running': payload['running'] ?? payload['ready'] ?? false,
|
||||||
'embedded': payload['embedded'] ?? false,
|
'embedded': payload['embedded'] ?? false,
|
||||||
@@ -186,10 +298,11 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
: 'offline';
|
: 'offline';
|
||||||
|
|
||||||
return DeviceStatus(
|
return DeviceStatus(
|
||||||
connected: wifiStatus.connected || bleStatus.running || _webSocketConnected,
|
connected:
|
||||||
|
wifiStatus.connected || bleStatus.running || _webSocketConnected,
|
||||||
connectionType: connectionType,
|
connectionType: connectionType,
|
||||||
deviceName: bleStatus.deviceName ?? 'ShowenV2',
|
deviceName: bleStatus.deviceName ?? _deviceName,
|
||||||
ipAddress: wifiStatus.ip ?? _deviceIp,
|
ipAddress: wifiStatus.ip ?? '$_deviceIp:$_devicePort',
|
||||||
playerStatus: playerStatus,
|
playerStatus: playerStatus,
|
||||||
wifiStatus: wifiStatus,
|
wifiStatus: wifiStatus,
|
||||||
bleStatus: bleStatus,
|
bleStatus: bleStatus,
|
||||||
@@ -197,11 +310,46 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshDeviceList() async {
|
||||||
|
_deviceList = await _deviceStorageService.getDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _validateDeviceReachable(String ip, int port) async {
|
||||||
|
final probeService = HttpApiService(baseUrl: _buildBaseUrl(ip, port));
|
||||||
|
try {
|
||||||
|
await probeService.getStatus().timeout(const Duration(seconds: 3));
|
||||||
|
} on TimeoutException {
|
||||||
|
throw Exception('设备不可达:连接超时(3 秒)');
|
||||||
|
} on Exception catch (error) {
|
||||||
|
throw Exception('设备不可达:$error');
|
||||||
|
} finally {
|
||||||
|
probeService.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyDevice({
|
||||||
|
required String ip,
|
||||||
|
required int port,
|
||||||
|
String? name,
|
||||||
|
}) {
|
||||||
|
_deviceIp = _normalizeDeviceIp(ip);
|
||||||
|
_devicePort = _normalizePort(port);
|
||||||
|
_deviceName = _normalizeDeviceName(name);
|
||||||
|
_httpApiService.baseUrl = _buildBaseUrl(_deviceIp, _devicePort);
|
||||||
|
_status = _status.copyWith(
|
||||||
|
deviceName: _deviceName,
|
||||||
|
ipAddress: '$_deviceIp:$_devicePort',
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _setLoading(bool value) {
|
void _setLoading(bool value) {
|
||||||
_isLoading = value;
|
_isLoading = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _buildBaseUrl(String ip, int port) => 'http://$ip:$port';
|
||||||
|
|
||||||
static String _normalizeDeviceIp(String input) {
|
static String _normalizeDeviceIp(String input) {
|
||||||
var value = input.trim();
|
var value = input.trim();
|
||||||
if (value.startsWith('http://')) {
|
if (value.startsWith('http://')) {
|
||||||
@@ -210,6 +358,8 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
value = value.substring(8);
|
value = value.substring(8);
|
||||||
} else if (value.startsWith('ws://')) {
|
} else if (value.startsWith('ws://')) {
|
||||||
value = value.substring(5);
|
value = value.substring(5);
|
||||||
|
} else if (value.startsWith('wss://')) {
|
||||||
|
value = value.substring(6);
|
||||||
}
|
}
|
||||||
final slashIndex = value.indexOf('/');
|
final slashIndex = value.indexOf('/');
|
||||||
if (slashIndex >= 0) {
|
if (slashIndex >= 0) {
|
||||||
@@ -222,6 +372,53 @@ class DeviceProvider extends ChangeNotifier {
|
|||||||
return value.isEmpty ? '127.0.0.1' : value;
|
return value.isEmpty ? '127.0.0.1' : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int _normalizePort(int input) {
|
||||||
|
if (input <= 0 || input > 65535) {
|
||||||
|
return 5000;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _normalizeDeviceName(String? input) {
|
||||||
|
final value = input?.trim() ?? '';
|
||||||
|
return value.isEmpty ? 'Showen' : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static SavedDevice _parseDeviceInput(String input,
|
||||||
|
{int fallbackPort = 5000}) {
|
||||||
|
var value = input.trim();
|
||||||
|
if (value.startsWith('http://')) {
|
||||||
|
value = value.substring(7);
|
||||||
|
} else if (value.startsWith('https://')) {
|
||||||
|
value = value.substring(8);
|
||||||
|
} else if (value.startsWith('ws://')) {
|
||||||
|
value = value.substring(5);
|
||||||
|
} else if (value.startsWith('wss://')) {
|
||||||
|
value = value.substring(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
final slashIndex = value.indexOf('/');
|
||||||
|
if (slashIndex >= 0) {
|
||||||
|
value = value.substring(0, slashIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
final colonIndex = value.lastIndexOf(':');
|
||||||
|
final hasPort = colonIndex > 0 && colonIndex < value.length - 1;
|
||||||
|
final ip =
|
||||||
|
_normalizeDeviceIp(hasPort ? value.substring(0, colonIndex) : value);
|
||||||
|
final port = hasPort
|
||||||
|
? _normalizePort(
|
||||||
|
int.tryParse(value.substring(colonIndex + 1)) ?? fallbackPort)
|
||||||
|
: _normalizePort(fallbackPort);
|
||||||
|
|
||||||
|
return SavedDevice(
|
||||||
|
ip: ip,
|
||||||
|
port: port,
|
||||||
|
name: 'Showen',
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
unawaited(_connectionSubscription.cancel());
|
unawaited(_connectionSubscription.cancel());
|
||||||
|
|||||||
@@ -5,29 +5,36 @@ import 'package:flutter/foundation.dart';
|
|||||||
import '../models/player_status.dart';
|
import '../models/player_status.dart';
|
||||||
import '../services/http_api_service.dart';
|
import '../services/http_api_service.dart';
|
||||||
import '../services/web_socket_service.dart';
|
import '../services/web_socket_service.dart';
|
||||||
|
import 'debug_provider.dart';
|
||||||
|
|
||||||
class PlayerProvider extends ChangeNotifier {
|
class PlayerProvider extends ChangeNotifier {
|
||||||
PlayerProvider({
|
PlayerProvider({
|
||||||
required HttpApiService httpApiService,
|
required HttpApiService httpApiService,
|
||||||
required WebSocketService webSocketService,
|
required WebSocketService webSocketService,
|
||||||
|
required DebugProvider debugProvider,
|
||||||
}) : _httpApiService = httpApiService,
|
}) : _httpApiService = httpApiService,
|
||||||
_webSocketService = webSocketService {
|
_webSocketService = webSocketService,
|
||||||
|
_debugProvider = debugProvider {
|
||||||
_statusSubscription = _webSocketService.onStatusUpdate.listen((payload) {
|
_statusSubscription = _webSocketService.onStatusUpdate.listen((payload) {
|
||||||
_status = PlayerStatus.fromJson(payload);
|
_status = PlayerStatus.fromJson(payload);
|
||||||
|
_debugProvider.addWsLog('Player status update', details: payload);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
_stateSubscription = _webSocketService.onStateUpdate.listen((payload) {
|
_stateSubscription = _webSocketService.onStateUpdate.listen((payload) {
|
||||||
_currentState = payload['new_state']?.toString() ?? _currentState;
|
_currentState = payload['new_state']?.toString() ?? _currentState;
|
||||||
|
_debugProvider.addWsLog('Player state update', details: payload);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
_configSubscription = _webSocketService.onConfigUpdate.listen((payload) {
|
_configSubscription = _webSocketService.onConfigUpdate.listen((payload) {
|
||||||
_updateSceneOptions(payload);
|
_updateSceneOptions(payload);
|
||||||
|
_debugProvider.addWsLog('Player config update', details: payload);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final HttpApiService _httpApiService;
|
final HttpApiService _httpApiService;
|
||||||
final WebSocketService _webSocketService;
|
final WebSocketService _webSocketService;
|
||||||
|
final DebugProvider _debugProvider;
|
||||||
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
|
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
|
||||||
late final StreamSubscription<Map<String, dynamic>> _stateSubscription;
|
late final StreamSubscription<Map<String, dynamic>> _stateSubscription;
|
||||||
late final StreamSubscription<Map<String, dynamic>> _configSubscription;
|
late final StreamSubscription<Map<String, dynamic>> _configSubscription;
|
||||||
@@ -48,6 +55,7 @@ class PlayerProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> bootstrap() async {
|
Future<void> bootstrap() async {
|
||||||
_setLoading(true);
|
_setLoading(true);
|
||||||
|
_debugProvider.addHttpLog('Bootstrap player provider');
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait<dynamic>([
|
final results = await Future.wait<dynamic>([
|
||||||
_httpApiService.getPlaybackStatus(),
|
_httpApiService.getPlaybackStatus(),
|
||||||
@@ -58,31 +66,45 @@ class PlayerProvider extends ChangeNotifier {
|
|||||||
_playlist = results[1] as List<String>;
|
_playlist = results[1] as List<String>;
|
||||||
_updateSceneOptions(results[2] as Map<String, dynamic>);
|
_updateSceneOptions(results[2] as Map<String, dynamic>);
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'Player provider bootstrapped',
|
||||||
|
details: <String, Object>{'playlist': _playlist.length},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Bootstrap player provider failed', details: error);
|
||||||
} finally {
|
} finally {
|
||||||
_setLoading(false);
|
_setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchStatus() async {
|
Future<void> fetchStatus() async {
|
||||||
|
_debugProvider.addHttpLog('Fetch playback status');
|
||||||
try {
|
try {
|
||||||
_status = await _httpApiService.getPlaybackStatus();
|
_status = await _httpApiService.getPlaybackStatus();
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_debugProvider.addHttpLog('Playback status fetched');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Fetch playback status failed', details: error);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchPlaylist() async {
|
Future<void> fetchPlaylist() async {
|
||||||
|
_debugProvider.addHttpLog('Fetch playlist');
|
||||||
try {
|
try {
|
||||||
_playlist = await _httpApiService.getPlaylist();
|
_playlist = await _httpApiService.getPlaylist();
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'Playlist fetched',
|
||||||
|
details: <String, Object>{'items': _playlist.length},
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Fetch playlist failed', details: error);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +141,7 @@ class PlayerProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> _runCommand(Future<dynamic> Function() action) async {
|
Future<void> _runCommand(Future<dynamic> Function() action) async {
|
||||||
_setLoading(true);
|
_setLoading(true);
|
||||||
|
_debugProvider.addHttpLog('Run player command');
|
||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
await Future.wait<void>([
|
await Future.wait<void>([
|
||||||
@@ -126,8 +149,10 @@ class PlayerProvider extends ChangeNotifier {
|
|||||||
fetchPlaylist(),
|
fetchPlaylist(),
|
||||||
]);
|
]);
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_debugProvider.addHttpLog('Player command completed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Player command failed', details: error);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} finally {
|
} finally {
|
||||||
_setLoading(false);
|
_setLoading(false);
|
||||||
|
|||||||
@@ -6,21 +6,26 @@ import '../models/wifi_network.dart';
|
|||||||
import '../models/wifi_status.dart';
|
import '../models/wifi_status.dart';
|
||||||
import '../services/http_api_service.dart';
|
import '../services/http_api_service.dart';
|
||||||
import '../services/web_socket_service.dart';
|
import '../services/web_socket_service.dart';
|
||||||
|
import 'debug_provider.dart';
|
||||||
|
|
||||||
class WifiProvider extends ChangeNotifier {
|
class WifiProvider extends ChangeNotifier {
|
||||||
WifiProvider({
|
WifiProvider({
|
||||||
required HttpApiService httpApiService,
|
required HttpApiService httpApiService,
|
||||||
required WebSocketService webSocketService,
|
required WebSocketService webSocketService,
|
||||||
|
required DebugProvider debugProvider,
|
||||||
}) : _httpApiService = httpApiService,
|
}) : _httpApiService = httpApiService,
|
||||||
_webSocketService = webSocketService {
|
_webSocketService = webSocketService,
|
||||||
|
_debugProvider = debugProvider {
|
||||||
_wifiSubscription = _webSocketService.onWifiUpdate.listen((payload) {
|
_wifiSubscription = _webSocketService.onWifiUpdate.listen((payload) {
|
||||||
_status = WifiStatus.fromJson(payload);
|
_status = WifiStatus.fromJson(payload);
|
||||||
|
_debugProvider.addWsLog('Wifi provider update', details: payload);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final HttpApiService _httpApiService;
|
final HttpApiService _httpApiService;
|
||||||
final WebSocketService _webSocketService;
|
final WebSocketService _webSocketService;
|
||||||
|
final DebugProvider _debugProvider;
|
||||||
late final StreamSubscription<Map<String, dynamic>> _wifiSubscription;
|
late final StreamSubscription<Map<String, dynamic>> _wifiSubscription;
|
||||||
|
|
||||||
WifiStatus _status = WifiStatus.disconnected();
|
WifiStatus _status = WifiStatus.disconnected();
|
||||||
@@ -37,6 +42,7 @@ class WifiProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> bootstrap() async {
|
Future<void> bootstrap() async {
|
||||||
_setLoading(true);
|
_setLoading(true);
|
||||||
|
_debugProvider.addHttpLog('Bootstrap WiFi provider');
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait<dynamic>([
|
final results = await Future.wait<dynamic>([
|
||||||
_httpApiService.getWifiStatus(),
|
_httpApiService.getWifiStatus(),
|
||||||
@@ -45,30 +51,44 @@ class WifiProvider extends ChangeNotifier {
|
|||||||
_status = results[0] as WifiStatus;
|
_status = results[0] as WifiStatus;
|
||||||
_networks = results[1] as List<WifiNetwork>;
|
_networks = results[1] as List<WifiNetwork>;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'WiFi provider bootstrapped',
|
||||||
|
details: <String, Object>{'networks': _networks.length},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Bootstrap WiFi provider failed', details: error);
|
||||||
} finally {
|
} finally {
|
||||||
_setLoading(false);
|
_setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshStatus() async {
|
Future<void> refreshStatus() async {
|
||||||
|
_debugProvider.addHttpLog('Refresh WiFi status');
|
||||||
try {
|
try {
|
||||||
_status = await _httpApiService.getWifiStatus();
|
_status = await _httpApiService.getWifiStatus();
|
||||||
|
_debugProvider.addHttpLog('WiFi status refreshed');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Refresh WiFi status failed', details: error);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> scanNetworks() async {
|
Future<void> scanNetworks() async {
|
||||||
_setLoading(true);
|
_setLoading(true);
|
||||||
|
_debugProvider.addHttpLog('Scan WiFi networks');
|
||||||
try {
|
try {
|
||||||
_networks = await _httpApiService.scanWifi();
|
_networks = await _httpApiService.scanWifi();
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'WiFi scan completed',
|
||||||
|
details: <String, Object>{'networks': _networks.length},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('WiFi scan failed', details: error);
|
||||||
} finally {
|
} finally {
|
||||||
_setLoading(false);
|
_setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -76,13 +96,19 @@ class WifiProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> connect({required String ssid, required String password}) async {
|
Future<void> connect({required String ssid, required String password}) async {
|
||||||
_setLoading(true);
|
_setLoading(true);
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'Connect WiFi network',
|
||||||
|
details: <String, Object>{'ssid': ssid},
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await _httpApiService.connectWifi(ssid, password);
|
await _httpApiService.connectWifi(ssid, password);
|
||||||
await refreshStatus();
|
await refreshStatus();
|
||||||
_hotspotEnabled = false;
|
_hotspotEnabled = false;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_debugProvider.addHttpLog('WiFi connected', details: <String, Object>{'ssid': ssid});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Connect WiFi failed', details: error);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} finally {
|
} finally {
|
||||||
_setLoading(false);
|
_setLoading(false);
|
||||||
@@ -91,13 +117,19 @@ class WifiProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> startHotspot({String? ssid, String? password}) async {
|
Future<void> startHotspot({String? ssid, String? password}) async {
|
||||||
_setLoading(true);
|
_setLoading(true);
|
||||||
|
_debugProvider.addHttpLog(
|
||||||
|
'Start hotspot',
|
||||||
|
details: <String, Object?>{'ssid': ssid},
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await _httpApiService.startAP(ssid, password);
|
await _httpApiService.startAP(ssid, password);
|
||||||
_hotspotEnabled = true;
|
_hotspotEnabled = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_debugProvider.addHttpLog('Hotspot started');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Start hotspot failed', details: error);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} finally {
|
} finally {
|
||||||
_setLoading(false);
|
_setLoading(false);
|
||||||
@@ -106,13 +138,16 @@ class WifiProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> stopHotspot() async {
|
Future<void> stopHotspot() async {
|
||||||
_setLoading(true);
|
_setLoading(true);
|
||||||
|
_debugProvider.addHttpLog('Stop hotspot');
|
||||||
try {
|
try {
|
||||||
await _httpApiService.stopAP();
|
await _httpApiService.stopAP();
|
||||||
_hotspotEnabled = false;
|
_hotspotEnabled = false;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
_debugProvider.addHttpLog('Hotspot stopped');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_errorMessage = error.toString();
|
_errorMessage = error.toString();
|
||||||
|
_debugProvider.addHttpLog('Stop hotspot failed', details: error);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} finally {
|
} finally {
|
||||||
_setLoading(false);
|
_setLoading(false);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../widgets/connection_status_banner.dart';
|
||||||
|
|
||||||
class AppShell extends StatelessWidget {
|
class AppShell extends StatelessWidget {
|
||||||
const AppShell({required this.navigationShell, super.key});
|
const AppShell({required this.navigationShell, super.key});
|
||||||
|
|
||||||
@@ -9,7 +11,12 @@ class AppShell extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: navigationShell,
|
body: Column(
|
||||||
|
children: [
|
||||||
|
const ConnectionStatusBanner(),
|
||||||
|
Expanded(child: navigationShell),
|
||||||
|
],
|
||||||
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: navigationShell.currentIndex,
|
selectedIndex: navigationShell.currentIndex,
|
||||||
onDestinationSelected: (index) {
|
onDestinationSelected: (index) {
|
||||||
@@ -44,6 +51,11 @@ class AppShell extends StatelessWidget {
|
|||||||
selectedIcon: Icon(Icons.settings),
|
selectedIcon: Icon(Icons.settings),
|
||||||
label: '设置',
|
label: '设置',
|
||||||
),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.bug_report_outlined),
|
||||||
|
selectedIcon: Icon(Icons.bug_report),
|
||||||
|
label: '调试',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../models/ble_models.dart';
|
import '../models/ble_models.dart';
|
||||||
import '../providers/ble_provider.dart';
|
import '../providers/ble_provider.dart';
|
||||||
@@ -22,8 +23,8 @@ class _BleProvisionScreenState extends State<BleProvisionScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_ownsProvider = widget.provider == null;
|
_ownsProvider = false;
|
||||||
_provider = widget.provider ?? BleProvider();
|
_provider = widget.provider ?? context.read<BleProvider>();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_provider.startScan();
|
_provider.startScan();
|
||||||
});
|
});
|
||||||
|
|||||||
301
clients/flutter/lib/screens/debug_screen.dart
Normal file
301
clients/flutter/lib/screens/debug_screen.dart
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/debug_provider.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
class DebugScreen extends StatelessWidget {
|
||||||
|
const DebugScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<DebugProvider>();
|
||||||
|
final entries = provider.filteredEntries;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('调试日志'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: provider.entries.isEmpty ? null : provider.clearLogs,
|
||||||
|
icon: const Icon(Icons.delete_sweep_outlined),
|
||||||
|
tooltip: '清空日志',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
AppSpacing.md,
|
||||||
|
AppSpacing.md,
|
||||||
|
AppSpacing.md,
|
||||||
|
AppSpacing.sm,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
AppColors.card,
|
||||||
|
AppColors.card.withValues(alpha: 0.72),
|
||||||
|
],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
border: const Border(bottom: BorderSide(color: AppColors.border)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'事件时间线',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
Text(
|
||||||
|
'保留最近 ${DebugProvider.maxEntries} 条日志,支持按链路筛选。',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Wrap(
|
||||||
|
spacing: AppSpacing.sm,
|
||||||
|
runSpacing: AppSpacing.sm,
|
||||||
|
children: [
|
||||||
|
_FilterChipItem(
|
||||||
|
label: '全部',
|
||||||
|
selected: provider.filter == DebugLogFilter.all,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
onTap: () => provider.setFilter(DebugLogFilter.all),
|
||||||
|
),
|
||||||
|
_FilterChipItem(
|
||||||
|
label: 'BLE',
|
||||||
|
selected: provider.filter == DebugLogFilter.ble,
|
||||||
|
color: AppColors.info,
|
||||||
|
onTap: () => provider.setFilter(DebugLogFilter.ble),
|
||||||
|
),
|
||||||
|
_FilterChipItem(
|
||||||
|
label: 'WS',
|
||||||
|
selected: provider.filter == DebugLogFilter.ws,
|
||||||
|
color: AppColors.success,
|
||||||
|
onTap: () => provider.setFilter(DebugLogFilter.ws),
|
||||||
|
),
|
||||||
|
_FilterChipItem(
|
||||||
|
label: 'HTTP',
|
||||||
|
selected: provider.filter == DebugLogFilter.http,
|
||||||
|
color: AppColors.warning,
|
||||||
|
onTap: () => provider.setFilter(DebugLogFilter.http),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: entries.isEmpty
|
||||||
|
? const _EmptyDebugState()
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
itemCount: entries.length,
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _DebugLogCard(entry: entries[index]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterChipItem extends StatelessWidget {
|
||||||
|
const _FilterChipItem({
|
||||||
|
required this.label,
|
||||||
|
required this.selected,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool selected;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (_) => onTap(),
|
||||||
|
showCheckmark: false,
|
||||||
|
selectedColor: color.withValues(alpha: 0.18),
|
||||||
|
side: BorderSide(color: selected ? color : AppColors.border),
|
||||||
|
labelStyle: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: selected ? color : AppColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
backgroundColor: AppColors.card,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DebugLogCard extends StatelessWidget {
|
||||||
|
const _DebugLogCard({required this.entry});
|
||||||
|
|
||||||
|
final DebugLogEntry entry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = _typeColor(entry.type);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.card,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.large),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(AppRadius.large),
|
||||||
|
bottomLeft: Radius.circular(AppRadius.large),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.sm,
|
||||||
|
vertical: AppSpacing.xs,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: color.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
entry.label,
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
_formatTimestamp(entry.timestamp),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
entry.summary,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
if (entry.details != null && entry.details!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
entry.details!,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _typeColor(DebugLogType type) {
|
||||||
|
switch (type) {
|
||||||
|
case DebugLogType.ble:
|
||||||
|
return AppColors.info;
|
||||||
|
case DebugLogType.ws:
|
||||||
|
return AppColors.success;
|
||||||
|
case DebugLogType.http:
|
||||||
|
return AppColors.warning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTimestamp(DateTime timestamp) {
|
||||||
|
final hh = timestamp.hour.toString().padLeft(2, '0');
|
||||||
|
final mm = timestamp.minute.toString().padLeft(2, '0');
|
||||||
|
final ss = timestamp.second.toString().padLeft(2, '0');
|
||||||
|
final ms = timestamp.millisecond.toString().padLeft(3, '0');
|
||||||
|
return '$hh:$mm:$ss.$ms';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyDebugState extends StatelessWidget {
|
||||||
|
const _EmptyDebugState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
AppColors.info.withValues(alpha: 0.24),
|
||||||
|
AppColors.success.withValues(alpha: 0.16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.bug_report_outlined, size: 34),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Text(
|
||||||
|
'当前没有调试事件',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
Text(
|
||||||
|
'连接设备、触发播放、执行网络或 BLE 操作后,日志会按时间顺序出现在这里。',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/ble_provider.dart';
|
||||||
import '../providers/device_provider.dart';
|
import '../providers/device_provider.dart';
|
||||||
import '../providers/wifi_provider.dart';
|
import '../providers/wifi_provider.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
@@ -33,6 +34,7 @@ class _NetworkScreenState extends State<NetworkScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final bleProvider = context.watch<BleProvider>();
|
||||||
final wifiProvider = context.watch<WifiProvider>();
|
final wifiProvider = context.watch<WifiProvider>();
|
||||||
final deviceProvider = context.watch<DeviceProvider>();
|
final deviceProvider = context.watch<DeviceProvider>();
|
||||||
final wifiStatus = wifiProvider.status;
|
final wifiStatus = wifiProvider.status;
|
||||||
@@ -40,7 +42,10 @@ class _NetworkScreenState extends State<NetworkScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('网络设置')),
|
appBar: AppBar(title: const Text('网络设置')),
|
||||||
body: ListView(
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _handleRefresh,
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
children: [
|
children: [
|
||||||
StatusCard(
|
StatusCard(
|
||||||
@@ -107,6 +112,75 @@ class _NetworkScreenState extends State<NetworkScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (bleProvider.isConnected) ...[
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('BLE 控制', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
bleProvider.selectedDevice?.name ?? '已连接 BLE 设备',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ControlButton(
|
||||||
|
label: '播放',
|
||||||
|
icon: Icons.play_arrow_rounded,
|
||||||
|
onPressed: bleProvider.isSendingCommand
|
||||||
|
? null
|
||||||
|
: () => _sendBleCommand('play'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: ControlButton(
|
||||||
|
label: '暂停',
|
||||||
|
icon: Icons.pause_rounded,
|
||||||
|
onPressed: bleProvider.isSendingCommand
|
||||||
|
? null
|
||||||
|
: () => _sendBleCommand('pause'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ControlButton(
|
||||||
|
label: '上一个',
|
||||||
|
icon: Icons.skip_previous_rounded,
|
||||||
|
isFilled: false,
|
||||||
|
onPressed: bleProvider.isSendingCommand
|
||||||
|
? null
|
||||||
|
: () => _sendBleCommand('prev'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: ControlButton(
|
||||||
|
label: '下一个',
|
||||||
|
icon: Icons.skip_next_rounded,
|
||||||
|
isFilled: false,
|
||||||
|
onPressed: bleProvider.isSendingCommand
|
||||||
|
? null
|
||||||
|
: () => _sendBleCommand('next'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
Text('扫描结果', style: Theme.of(context).textTheme.headlineSmall),
|
Text('扫描结果', style: Theme.of(context).textTheme.headlineSmall),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
@@ -181,9 +255,17 @@ class _NetworkScreenState extends State<NetworkScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleRefresh() async {
|
||||||
|
await Future.wait<void>([
|
||||||
|
context.read<WifiProvider>().bootstrap(),
|
||||||
|
context.read<DeviceProvider>().refresh(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
void _handleConnectWifi() {
|
void _handleConnectWifi() {
|
||||||
final ssid = _ssidController.text.trim();
|
final ssid = _ssidController.text.trim();
|
||||||
if (ssid.isEmpty) {
|
if (ssid.isEmpty) {
|
||||||
@@ -198,4 +280,23 @@ class _NetworkScreenState extends State<NetworkScreen> {
|
|||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _sendBleCommand(String command) async {
|
||||||
|
try {
|
||||||
|
await context.read<BleProvider>().sendCommand(command);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('已发送 BLE 指令: $command')),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('BLE 指令发送失败: $error')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ class _PlaybackScreenState extends State<PlaybackScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('播放控制')),
|
appBar: AppBar(title: const Text('播放控制')),
|
||||||
body: ListView(
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _handleRefresh,
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
children: [
|
children: [
|
||||||
Card(
|
Card(
|
||||||
@@ -166,9 +169,18 @@ class _PlaybackScreenState extends State<PlaybackScreen> {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleRefresh() async {
|
||||||
|
final provider = context.read<PlayerProvider>();
|
||||||
|
await Future.wait<void>([
|
||||||
|
provider.fetchStatus(),
|
||||||
|
provider.fetchPlaylist(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
void _handleGoto() {
|
void _handleGoto() {
|
||||||
final index = int.tryParse(_indexController.text.trim());
|
final index = int.tryParse(_indexController.text.trim());
|
||||||
if (index == null) {
|
if (index == null) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/video_item.dart';
|
||||||
import '../providers/device_provider.dart';
|
import '../providers/device_provider.dart';
|
||||||
|
import '../services/http_api_service.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
@@ -15,6 +18,7 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
final TextEditingController _ipController = TextEditingController();
|
final TextEditingController _ipController = TextEditingController();
|
||||||
|
final FocusNode _ipFocusNode = FocusNode();
|
||||||
final TextEditingController _titleController = TextEditingController();
|
final TextEditingController _titleController = TextEditingController();
|
||||||
final TextEditingController _rotationController = TextEditingController();
|
final TextEditingController _rotationController = TextEditingController();
|
||||||
final TextEditingController _widthController = TextEditingController();
|
final TextEditingController _widthController = TextEditingController();
|
||||||
@@ -22,11 +26,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final TextEditingController _hsvMinController = TextEditingController();
|
final TextEditingController _hsvMinController = TextEditingController();
|
||||||
final TextEditingController _hsvMaxController = TextEditingController();
|
final TextEditingController _hsvMaxController = TextEditingController();
|
||||||
final TextEditingController _pointsController = TextEditingController();
|
final TextEditingController _pointsController = TextEditingController();
|
||||||
|
final TextEditingController _jsonController = TextEditingController();
|
||||||
|
|
||||||
Map<String, dynamic>? _fullConfig;
|
Map<String, dynamic>? _fullConfig;
|
||||||
List<String> _availableConfigs = const <String>[];
|
List<String> _availableConfigs = const <String>[];
|
||||||
|
Future<List<VideoItem>>? _videosFuture;
|
||||||
String? _activeConfig;
|
String? _activeConfig;
|
||||||
bool _isFullscreen = false;
|
bool _isFullscreen = false;
|
||||||
|
bool _jsonEditMode = false;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -38,6 +45,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_ipController.dispose();
|
_ipController.dispose();
|
||||||
|
_ipFocusNode.dispose();
|
||||||
_titleController.dispose();
|
_titleController.dispose();
|
||||||
_rotationController.dispose();
|
_rotationController.dispose();
|
||||||
_widthController.dispose();
|
_widthController.dispose();
|
||||||
@@ -45,20 +53,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_hsvMinController.dispose();
|
_hsvMinController.dispose();
|
||||||
_hsvMaxController.dispose();
|
_hsvMaxController.dispose();
|
||||||
_pointsController.dispose();
|
_pointsController.dispose();
|
||||||
|
_jsonController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<DeviceProvider>();
|
final provider = context.watch<DeviceProvider>();
|
||||||
|
final httpApiService = provider.httpApiService;
|
||||||
final status = provider.status;
|
final status = provider.status;
|
||||||
_ipController.text = _ipController.text.isEmpty ? provider.deviceIp : _ipController.text;
|
final currentAddress = '${provider.deviceIp}:${provider.devicePort}';
|
||||||
|
if (!_ipFocusNode.hasFocus && _ipController.text != currentAddress) {
|
||||||
|
_ipController.value = TextEditingValue(
|
||||||
|
text: currentAddress,
|
||||||
|
selection: TextSelection.collapsed(offset: currentAddress.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('设置')),
|
appBar: AppBar(title: const Text('设置')),
|
||||||
body: _loading
|
body: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: ListView(
|
: RefreshIndicator(
|
||||||
|
onRefresh: _loadData,
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
children: [
|
children: [
|
||||||
Card(
|
Card(
|
||||||
@@ -67,27 +86,53 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('设备 IP 配置', style: Theme.of(context).textTheme.titleMedium),
|
Text('设备 IP 配置',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _ipController,
|
controller: _ipController,
|
||||||
decoration: const InputDecoration(labelText: '设备 IP 地址'),
|
focusNode: _ipFocusNode,
|
||||||
|
enabled: !provider.isLoading,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '设备 IP 地址',
|
||||||
|
hintText: '例如 192.168.1.10:5000',
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _handleSwitchDevice(),
|
||||||
|
onTapOutside: (_) {
|
||||||
|
_ipFocusNode.unfocus();
|
||||||
|
_handleSwitchDevice();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
SizedBox(
|
Row(
|
||||||
width: double.infinity,
|
children: [
|
||||||
|
Expanded(
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: () async {
|
onPressed: provider.isLoading
|
||||||
await context.read<DeviceProvider>().updateDeviceIp(
|
? null
|
||||||
_ipController.text.trim(),
|
: _handleSwitchDevice,
|
||||||
);
|
child: provider.isLoading
|
||||||
if (!mounted) {
|
? const SizedBox(
|
||||||
return;
|
width: 18,
|
||||||
}
|
height: 18,
|
||||||
await _loadData();
|
child: CircularProgressIndicator(
|
||||||
},
|
strokeWidth: 2,
|
||||||
child: const Text('保存并重连'),
|
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: const Text('切换设备'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: provider.isLoading
|
||||||
|
? null
|
||||||
|
: () => _showDeviceListDialog(provider),
|
||||||
|
child: const Text('设备列表'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -100,7 +145,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('可用配置文件', style: Theme.of(context).textTheme.titleMedium),
|
Text('可用配置文件',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: _activeConfig,
|
initialValue: _activeConfig,
|
||||||
@@ -112,14 +158,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
onChanged: (value) => setState(() => _activeConfig = value),
|
onChanged: (value) =>
|
||||||
decoration: const InputDecoration(labelText: '当前配置'),
|
setState(() => _activeConfig = value),
|
||||||
|
decoration:
|
||||||
|
const InputDecoration(labelText: '当前配置'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.tonal(
|
child: FilledButton.tonal(
|
||||||
onPressed: _activeConfig == null ? null : _handleSwitchConfig,
|
onPressed: _activeConfig == null
|
||||||
|
? null
|
||||||
|
: _handleSwitchConfig,
|
||||||
child: const Text('切换配置'),
|
child: const Text('切换配置'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -134,17 +184,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('显示设置', style: Theme.of(context).textTheme.titleMedium),
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'配置编辑',
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _fullConfig == null
|
||||||
|
? null
|
||||||
|
: _handleCopyJson,
|
||||||
|
child: const Text('复制JSON'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment<bool>(
|
||||||
|
value: false,
|
||||||
|
label: Text('表单'),
|
||||||
|
icon: Icon(Icons.tune_rounded),
|
||||||
|
),
|
||||||
|
ButtonSegment<bool>(
|
||||||
|
value: true,
|
||||||
|
label: Text('JSON'),
|
||||||
|
icon: Icon(Icons.data_object_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: <bool>{_jsonEditMode},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
final jsonMode = selection.first;
|
||||||
|
setState(() {
|
||||||
|
_jsonEditMode = jsonMode;
|
||||||
|
if (jsonMode) {
|
||||||
|
_syncJsonControllerFromConfig();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
if (_jsonEditMode) ...[
|
||||||
|
TextField(
|
||||||
|
controller: _jsonController,
|
||||||
|
minLines: 15,
|
||||||
|
maxLines: 15,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '完整配置 JSON',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _handleSaveJsonConfig,
|
||||||
|
child: const Text('保存 JSON'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
value: _isFullscreen,
|
value: _isFullscreen,
|
||||||
onChanged: (value) => setState(() => _isFullscreen = value),
|
onChanged: (value) =>
|
||||||
|
setState(() => _isFullscreen = value),
|
||||||
title: const Text('全屏模式'),
|
title: const Text('全屏模式'),
|
||||||
),
|
),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _titleController,
|
controller: _titleController,
|
||||||
decoration: const InputDecoration(labelText: '窗口标题'),
|
decoration:
|
||||||
|
const InputDecoration(labelText: '窗口标题'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
Row(
|
Row(
|
||||||
@@ -153,7 +265,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _rotationController,
|
controller: _rotationController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: const InputDecoration(labelText: '旋转角度'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: '旋转角度'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.md),
|
const SizedBox(width: AppSpacing.md),
|
||||||
@@ -161,7 +274,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _widthController,
|
controller: _widthController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: const InputDecoration(labelText: '渲染宽度'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: '渲染宽度'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.md),
|
const SizedBox(width: AppSpacing.md),
|
||||||
@@ -169,7 +283,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _heightController,
|
controller: _heightController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: const InputDecoration(labelText: '渲染高度'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: '渲染高度'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -177,19 +292,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _hsvMinController,
|
controller: _hsvMinController,
|
||||||
decoration: const InputDecoration(labelText: '色键下限 HSV (逗号分隔)'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: '色键下限 HSV (逗号分隔)'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _hsvMaxController,
|
controller: _hsvMaxController,
|
||||||
decoration: const InputDecoration(labelText: '色键上限 HSV (逗号分隔)'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: '色键上限 HSV (逗号分隔)'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _pointsController,
|
controller: _pointsController,
|
||||||
minLines: 3,
|
minLines: 3,
|
||||||
maxLines: 5,
|
maxLines: 5,
|
||||||
decoration: const InputDecoration(labelText: '透视点 JSON'),
|
decoration:
|
||||||
|
const InputDecoration(labelText: '透视点 JSON'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -200,6 +318,103 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'视频管理',
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _handleUploadVideo,
|
||||||
|
icon: const Icon(Icons.upload_file_rounded),
|
||||||
|
label: const Text('上传视频'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _reloadVideos,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
tooltip: '刷新视频列表',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
FutureBuilder<List<VideoItem>>(
|
||||||
|
future: _videosFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState ==
|
||||||
|
ConnectionState.waiting) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: AppSpacing.md),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: AppSpacing.sm),
|
||||||
|
child: Text(
|
||||||
|
'视频列表加载失败: ${snapshot.error}',
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final videos =
|
||||||
|
snapshot.data ?? const <VideoItem>[];
|
||||||
|
if (videos.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: AppSpacing.sm),
|
||||||
|
child: Text('当前没有视频文件'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: videos.length,
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final video = videos[index];
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(video.name),
|
||||||
|
subtitle: Text(video.sizeLabel),
|
||||||
|
trailing: IconButton(
|
||||||
|
onPressed: () => _confirmDeleteVideo(
|
||||||
|
httpApiService, video),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.delete_outline_rounded),
|
||||||
|
tooltip: '删除视频',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -210,11 +425,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('关于信息', style: Theme.of(context).textTheme.titleMedium),
|
Text('关于信息',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
_InfoRow(label: '设备名称', value: status.deviceName ?? 'ShowenV2'),
|
_InfoRow(
|
||||||
_InfoRow(label: '连接方式', value: status.connectionType.toUpperCase()),
|
label: '设备名称',
|
||||||
_InfoRow(label: '设备地址', value: status.ipAddress ?? provider.deviceIp),
|
value: status.deviceName ?? 'ShowenV2'),
|
||||||
|
_InfoRow(
|
||||||
|
label: '连接方式',
|
||||||
|
value: status.connectionType.toUpperCase()),
|
||||||
|
_InfoRow(
|
||||||
|
label: '设备地址',
|
||||||
|
value: status.ipAddress ?? provider.deviceIp),
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
label: '实时通道',
|
label: '实时通道',
|
||||||
value: provider.webSocketConnected ? '已连接' : '未连接',
|
value: provider.webSocketConnected ? '已连接' : '未连接',
|
||||||
@@ -225,24 +447,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadData() async {
|
Future<void> _loadData() async {
|
||||||
final service = context.read<DeviceProvider>().httpApiService;
|
final service = _getHttpApiService();
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
|
_videosFuture = service.getVideos();
|
||||||
final results = await Future.wait<dynamic>([
|
final results = await Future.wait<dynamic>([
|
||||||
service.getConfig(),
|
service.getConfig(),
|
||||||
service.getAvailableConfigs(),
|
service.getAvailableConfigs(),
|
||||||
]);
|
]);
|
||||||
_fullConfig = Map<String, dynamic>.from(results[0] as Map<String, dynamic>);
|
_fullConfig =
|
||||||
final available = Map<String, dynamic>.from(results[1] as Map<String, dynamic>);
|
Map<String, dynamic>.from(results[0] as Map<String, dynamic>);
|
||||||
_availableConfigs = (available['configs'] as List<dynamic>? ?? const <dynamic>[])
|
final available =
|
||||||
|
Map<String, dynamic>.from(results[1] as Map<String, dynamic>);
|
||||||
|
_availableConfigs =
|
||||||
|
(available['configs'] as List<dynamic>? ?? const <dynamic>[])
|
||||||
.map((item) => item.toString())
|
.map((item) => item.toString())
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
_activeConfig = available['active']?.toString();
|
_activeConfig = available['active']?.toString();
|
||||||
_applyDisplayConfig(Map<String, dynamic>.from(_fullConfig?['display'] as Map? ?? const <String, dynamic>{}));
|
_applyDisplayConfig(Map<String, dynamic>.from(
|
||||||
|
_fullConfig?['display'] as Map? ?? const <String, dynamic>{}));
|
||||||
|
_syncJsonControllerFromConfig();
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
@@ -250,13 +479,132 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSwitchDevice() async {
|
||||||
|
final input = _ipController.text.trim();
|
||||||
|
if (input.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final provider = context.read<DeviceProvider>();
|
||||||
|
try {
|
||||||
|
await provider.switchDevice(input);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showSnackBar(error.toString(), isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _loadData();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showSnackBar('设备切换成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showDeviceListDialog(DeviceProvider provider) async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('历史设备'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: provider.deviceList.isEmpty
|
||||||
|
? const Center(child: Text('暂无历史设备'))
|
||||||
|
: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: provider.deviceList.length,
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final device = provider.deviceList[index];
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey(device.address),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color:
|
||||||
|
Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onDismissed: (_) async {
|
||||||
|
final deviceProvider = context.read<DeviceProvider>();
|
||||||
|
final navigator = Navigator.of(dialogContext);
|
||||||
|
|
||||||
|
await deviceProvider.removeStoredDevice(device);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.pop();
|
||||||
|
await _showDeviceListDialog(deviceProvider);
|
||||||
|
},
|
||||||
|
child: Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(device.name),
|
||||||
|
subtitle: Text(device.address),
|
||||||
|
trailing: provider.deviceIp == device.ip &&
|
||||||
|
provider.devicePort == device.port
|
||||||
|
? const Icon(Icons.check_circle_outline)
|
||||||
|
: null,
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
try {
|
||||||
|
await context
|
||||||
|
.read<DeviceProvider>()
|
||||||
|
.switchDevice(
|
||||||
|
device.address,
|
||||||
|
name: device.name,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showSnackBar(error.toString(), isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _loadData();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showSnackBar('设备切换成功');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleSwitchConfig() async {
|
Future<void> _handleSwitchConfig() async {
|
||||||
final activeConfig = _activeConfig;
|
final activeConfig = _activeConfig;
|
||||||
if (activeConfig == null) {
|
if (activeConfig == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.read<DeviceProvider>().httpApiService.switchConfig(activeConfig);
|
await context
|
||||||
|
.read<DeviceProvider>()
|
||||||
|
.httpApiService
|
||||||
|
.switchConfig(activeConfig);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -271,7 +619,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
final nextConfig = Map<String, dynamic>.from(config);
|
final nextConfig = Map<String, dynamic>.from(config);
|
||||||
nextConfig['display'] = <String, dynamic>{
|
nextConfig['display'] = <String, dynamic>{
|
||||||
...Map<String, dynamic>.from(config['display'] as Map? ?? const <String, dynamic>{}),
|
...Map<String, dynamic>.from(
|
||||||
|
config['display'] as Map? ?? const <String, dynamic>{}),
|
||||||
'fullscreen': _isFullscreen,
|
'fullscreen': _isFullscreen,
|
||||||
'window_title': _titleController.text.trim(),
|
'window_title': _titleController.text.trim(),
|
||||||
'rotation': int.tryParse(_rotationController.text.trim()) ?? 0,
|
'rotation': int.tryParse(_rotationController.text.trim()) ?? 0,
|
||||||
@@ -282,18 +631,119 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
'hsv_max': _parseIntList(_hsvMaxController.text),
|
'hsv_max': _parseIntList(_hsvMaxController.text),
|
||||||
},
|
},
|
||||||
'perspective_correction': <String, dynamic>{
|
'perspective_correction': <String, dynamic>{
|
||||||
'points': jsonDecode(_pointsController.text.trim().isEmpty ? '[]' : _pointsController.text.trim()),
|
'points': jsonDecode(_pointsController.text.trim().isEmpty
|
||||||
|
? '[]'
|
||||||
|
: _pointsController.text.trim()),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await context.read<DeviceProvider>().httpApiService.updateConfig(nextConfig);
|
await context
|
||||||
|
.read<DeviceProvider>()
|
||||||
|
.httpApiService
|
||||||
|
.updateConfig(nextConfig);
|
||||||
_fullConfig = nextConfig;
|
_fullConfig = nextConfig;
|
||||||
|
_syncJsonControllerFromConfig();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showSnackBar('显示设置已保存');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSaveJsonConfig() async {
|
||||||
|
final rawJson = _jsonController.text.trim();
|
||||||
|
if (rawJson.isEmpty) {
|
||||||
|
_showSnackBar('JSON 内容不能为空', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(rawJson);
|
||||||
|
if (decoded is! Map) {
|
||||||
|
throw const FormatException('根节点必须是 JSON 对象');
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextConfig = Map<String, dynamic>.from(decoded);
|
||||||
|
await context
|
||||||
|
.read<DeviceProvider>()
|
||||||
|
.httpApiService
|
||||||
|
.updateConfig(nextConfig);
|
||||||
|
_fullConfig = nextConfig;
|
||||||
|
_applyDisplayConfig(Map<String, dynamic>.from(
|
||||||
|
nextConfig['display'] as Map? ?? const <String, dynamic>{}));
|
||||||
|
_syncJsonControllerFromConfig();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showSnackBar('JSON 配置已保存');
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
_showSnackBar('JSON 解析失败: ${error.message}', isError: true);
|
||||||
|
} catch (error) {
|
||||||
|
_showSnackBar(error.toString(), isError: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleCopyJson() async {
|
||||||
|
final config = _fullConfig;
|
||||||
|
if (config == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Clipboard.setData(ClipboardData(
|
||||||
|
text: const JsonEncoder.withIndent(' ').convert(config)));
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showSnackBar('配置 JSON 已复制到剪贴板');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeleteVideo(
|
||||||
|
HttpApiService httpApiService, VideoItem video) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('确认删除'),
|
||||||
|
content: Text('确定删除视频 `${video.name}` 吗?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('删除'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await httpApiService.deleteVideo(video.name);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('显示设置已保存')),
|
SnackBar(content: Text('已删除 ${video.name}')),
|
||||||
);
|
);
|
||||||
|
_reloadVideos();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleUploadVideo() async {
|
||||||
|
_showSnackBar('视频上传功能即将推出,请通过 Web UI 上传');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reloadVideos() {
|
||||||
|
setState(() {
|
||||||
|
_videosFuture = _getHttpApiService().getVideos();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpApiService _getHttpApiService() {
|
||||||
|
return context.read<DeviceProvider>().httpApiService;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyDisplayConfig(Map<String, dynamic> display) {
|
void _applyDisplayConfig(Map<String, dynamic> display) {
|
||||||
@@ -302,11 +752,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_rotationController.text = '${display['rotation'] ?? 0}';
|
_rotationController.text = '${display['rotation'] ?? 0}';
|
||||||
_widthController.text = '${display['render_width'] ?? 1024}';
|
_widthController.text = '${display['render_width'] ?? 1024}';
|
||||||
_heightController.text = '${display['render_height'] ?? 1024}';
|
_heightController.text = '${display['render_height'] ?? 1024}';
|
||||||
final chromaKey = Map<String, dynamic>.from(display['chroma_key'] as Map? ?? const <String, dynamic>{});
|
final chromaKey = Map<String, dynamic>.from(
|
||||||
_hsvMinController.text = (chromaKey['hsv_min'] as List<dynamic>? ?? const <dynamic>[0, 0, 200]).join(',');
|
display['chroma_key'] as Map? ?? const <String, dynamic>{});
|
||||||
_hsvMaxController.text = (chromaKey['hsv_max'] as List<dynamic>? ?? const <dynamic>[180, 30, 255]).join(',');
|
_hsvMinController.text =
|
||||||
final perspective = Map<String, dynamic>.from(display['perspective_correction'] as Map? ?? const <String, dynamic>{});
|
(chromaKey['hsv_min'] as List<dynamic>? ?? const <dynamic>[0, 0, 200])
|
||||||
_pointsController.text = jsonEncode(perspective['points'] ?? const <dynamic>[]);
|
.join(',');
|
||||||
|
_hsvMaxController.text = (chromaKey['hsv_max'] as List<dynamic>? ??
|
||||||
|
const <dynamic>[180, 30, 255])
|
||||||
|
.join(',');
|
||||||
|
final perspective = Map<String, dynamic>.from(
|
||||||
|
display['perspective_correction'] as Map? ?? const <String, dynamic>{});
|
||||||
|
_pointsController.text =
|
||||||
|
jsonEncode(perspective['points'] ?? const <dynamic>[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncJsonControllerFromConfig() {
|
||||||
|
final config = _fullConfig;
|
||||||
|
if (config == null) {
|
||||||
|
_jsonController.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_jsonController.text = const JsonEncoder.withIndent(' ').convert(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String message, {bool isError = false}) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
messenger
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: isError ? Theme.of(context).colorScheme.error : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<int> _parseIntList(String raw) {
|
List<int> _parseIntList(String raw) {
|
||||||
@@ -329,7 +811,9 @@ class _InfoRow extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
|
Expanded(
|
||||||
|
child:
|
||||||
|
Text(label, style: Theme.of(context).textTheme.bodyMedium)),
|
||||||
Text(value, style: Theme.of(context).textTheme.bodyLarge),
|
Text(value, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ class _TriggerScreenState extends State<TriggerScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('状态机触发')),
|
appBar: AppBar(title: const Text('状态机触发')),
|
||||||
body: ListView(
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _handleRefresh,
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
children: [
|
children: [
|
||||||
Card(
|
Card(
|
||||||
@@ -161,9 +164,14 @@ class _TriggerScreenState extends State<TriggerScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleRefresh() {
|
||||||
|
return context.read<PlayerProvider>().bootstrap();
|
||||||
|
}
|
||||||
|
|
||||||
void _handleCustomTrigger() {
|
void _handleCustomTrigger() {
|
||||||
final name = _triggerController.text.trim();
|
final name = _triggerController.text.trim();
|
||||||
final value = _valueController.text.trim();
|
final value = _valueController.text.trim();
|
||||||
|
|||||||
@@ -151,6 +151,29 @@ class BleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> sendCommand(String command) async {
|
||||||
|
final characteristic = _commandCharacteristic;
|
||||||
|
if (_connectedDevice == null) {
|
||||||
|
throw StateError('未连接 BLE 设备');
|
||||||
|
}
|
||||||
|
if (characteristic == null) {
|
||||||
|
throw StateError('未发现 BLE 命令特征值');
|
||||||
|
}
|
||||||
|
|
||||||
|
await characteristic.write(
|
||||||
|
utf8.encode(command),
|
||||||
|
withoutResponse: characteristic.properties.writeWithoutResponse,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> play() => sendCommand('play');
|
||||||
|
|
||||||
|
Future<void> pause() => sendCommand('pause');
|
||||||
|
|
||||||
|
Future<void> next() => sendCommand('next');
|
||||||
|
|
||||||
|
Future<void> previous() => sendCommand('prev');
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
await _scanSubscription?.cancel();
|
await _scanSubscription?.cancel();
|
||||||
_scanSubscription = null;
|
_scanSubscription = null;
|
||||||
|
|||||||
153
clients/flutter/lib/services/device_storage_service.dart
Normal file
153
clients/flutter/lib/services/device_storage_service.dart
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class SavedDevice {
|
||||||
|
const SavedDevice({
|
||||||
|
required this.ip,
|
||||||
|
required this.port,
|
||||||
|
required this.name,
|
||||||
|
required this.lastUsedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String ip;
|
||||||
|
final int port;
|
||||||
|
final String name;
|
||||||
|
final DateTime lastUsedAt;
|
||||||
|
|
||||||
|
String get address => '$ip:$port';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'ip': ip,
|
||||||
|
'port': port,
|
||||||
|
'name': name,
|
||||||
|
'lastUsedAt': lastUsedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SavedDevice.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SavedDevice(
|
||||||
|
ip: _normalizeIp(json['ip']?.toString()),
|
||||||
|
port: _normalizePort(json['port']),
|
||||||
|
name: _normalizeName(json['name']?.toString()),
|
||||||
|
lastUsedAt: DateTime.tryParse(json['lastUsedAt']?.toString() ?? '') ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _normalizeIp(String? value) {
|
||||||
|
final normalized = value?.trim() ?? '';
|
||||||
|
return normalized.isEmpty ? '127.0.0.1' : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _normalizePort(dynamic value) {
|
||||||
|
final port = int.tryParse(value?.toString() ?? '');
|
||||||
|
if (port == null || port <= 0 || port > 65535) {
|
||||||
|
return 5000;
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _normalizeName(String? value) {
|
||||||
|
final normalized = value?.trim() ?? '';
|
||||||
|
return normalized.isEmpty ? 'Showen' : normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceStorageService {
|
||||||
|
static const String _devicesKey = 'showen_device_list';
|
||||||
|
static const int _maxDevices = 10;
|
||||||
|
|
||||||
|
SharedPreferences? _preferences;
|
||||||
|
|
||||||
|
Future<void> saveDevice(String ip, int port, String? name) async {
|
||||||
|
final prefs = await _getPreferences();
|
||||||
|
final devices = await getDevices();
|
||||||
|
final normalizedIp = _normalizeIp(ip);
|
||||||
|
final normalizedPort = _normalizePort(port);
|
||||||
|
final normalizedName = _normalizeName(name);
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
final nextDevices = <SavedDevice>[
|
||||||
|
SavedDevice(
|
||||||
|
ip: normalizedIp,
|
||||||
|
port: normalizedPort,
|
||||||
|
name: normalizedName,
|
||||||
|
lastUsedAt: now,
|
||||||
|
),
|
||||||
|
...devices.where(
|
||||||
|
(device) => !(device.ip == normalizedIp && device.port == normalizedPort),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||||
|
|
||||||
|
await prefs.setString(
|
||||||
|
_devicesKey,
|
||||||
|
jsonEncode(
|
||||||
|
nextDevices
|
||||||
|
.take(_maxDevices)
|
||||||
|
.map((device) => device.toJson())
|
||||||
|
.toList(growable: false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SavedDevice>> getDevices() async {
|
||||||
|
final prefs = await _getPreferences();
|
||||||
|
final raw = prefs.getString(_devicesKey);
|
||||||
|
if (raw == null || raw.isEmpty) {
|
||||||
|
return const <SavedDevice>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const <SavedDevice>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final devices = decoded
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((item) => SavedDevice.fromJson(Map<String, dynamic>.from(item)))
|
||||||
|
.toList(growable: false)
|
||||||
|
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||||
|
|
||||||
|
return devices.take(_maxDevices).toList(growable: false);
|
||||||
|
} on FormatException {
|
||||||
|
return const <SavedDevice>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeDevice(String ip, [int? port]) async {
|
||||||
|
final prefs = await _getPreferences();
|
||||||
|
final normalizedIp = _normalizeIp(ip);
|
||||||
|
final nextDevices = (await getDevices())
|
||||||
|
.where(
|
||||||
|
(device) =>
|
||||||
|
device.ip != normalizedIp ||
|
||||||
|
(port != null && device.port != _normalizePort(port)),
|
||||||
|
)
|
||||||
|
.map((device) => device.toJson())
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
await prefs.setString(_devicesKey, jsonEncode(nextDevices));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SavedDevice?> getLastDevice() async {
|
||||||
|
final devices = await getDevices();
|
||||||
|
if (devices.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return devices.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SharedPreferences> _getPreferences() async {
|
||||||
|
return _preferences ??= await SharedPreferences.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeIp(String value) => SavedDevice._normalizeIp(value);
|
||||||
|
|
||||||
|
int _normalizePort(dynamic value) => SavedDevice._normalizePort(value);
|
||||||
|
|
||||||
|
String _normalizeName(String? value) => SavedDevice._normalizeName(value);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
@@ -11,9 +12,11 @@ import '../models/video_item.dart';
|
|||||||
import '../models/wifi_network.dart';
|
import '../models/wifi_network.dart';
|
||||||
import '../models/wifi_status.dart';
|
import '../models/wifi_status.dart';
|
||||||
|
|
||||||
|
typedef UploadProgressCallback = void Function(double progress);
|
||||||
|
|
||||||
class HttpApiService {
|
class HttpApiService {
|
||||||
HttpApiService({required String baseUrl, http.Client? client})
|
HttpApiService({required String baseUrl, http.Client? client})
|
||||||
: _baseUrl = _normalizeBaseUrl(baseUrl),
|
: _baseUrl = normalizeBaseUrl(baseUrl),
|
||||||
_client = client ?? http.Client();
|
_client = client ?? http.Client();
|
||||||
|
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
@@ -22,7 +25,7 @@ class HttpApiService {
|
|||||||
String get baseUrl => _baseUrl;
|
String get baseUrl => _baseUrl;
|
||||||
|
|
||||||
set baseUrl(String value) {
|
set baseUrl(String value) {
|
||||||
_baseUrl = _normalizeBaseUrl(value);
|
_baseUrl = normalizeBaseUrl(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
Uri _uri(String path, [Map<String, String>? queryParameters]) {
|
Uri _uri(String path, [Map<String, String>? queryParameters]) {
|
||||||
@@ -229,6 +232,17 @@ class HttpApiService {
|
|||||||
return _uploadSingleFile('/api/videos/upload', file);
|
return _uploadSingleFile('/api/videos/upload', file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> uploadVideoWithProgress(
|
||||||
|
File file, {
|
||||||
|
UploadProgressCallback? onProgress,
|
||||||
|
}) {
|
||||||
|
return _uploadSingleFile(
|
||||||
|
'/api/videos/upload',
|
||||||
|
file,
|
||||||
|
onProgress: onProgress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<ApiResponse> uploadVideos(List<String> filePaths) async {
|
Future<ApiResponse> uploadVideos(List<String> filePaths) async {
|
||||||
if (filePaths.isEmpty) {
|
if (filePaths.isEmpty) {
|
||||||
throw const ApiException('未选择上传文件');
|
throw const ApiException('未选择上传文件');
|
||||||
@@ -377,17 +391,63 @@ class HttpApiService {
|
|||||||
String endpoint,
|
String endpoint,
|
||||||
File file, {
|
File file, {
|
||||||
String? directoryPath,
|
String? directoryPath,
|
||||||
|
UploadProgressCallback? onProgress,
|
||||||
}) async {
|
}) async {
|
||||||
final request = http.MultipartRequest(
|
final request = http.MultipartRequest(
|
||||||
'POST',
|
'POST',
|
||||||
_uri(endpoint, _pathQuery(directoryPath)),
|
_uri(endpoint, _pathQuery(directoryPath)),
|
||||||
);
|
);
|
||||||
request.files.add(await http.MultipartFile.fromPath('file', file.path));
|
request.files.add(
|
||||||
|
await _createMultipartFile(
|
||||||
|
file,
|
||||||
|
fieldName: 'file',
|
||||||
|
onProgress: onProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
final response = await http.Response.fromStream(await request.send());
|
final response = await http.Response.fromStream(await request.send());
|
||||||
|
onProgress?.call(1);
|
||||||
_ensureSuccess(response);
|
_ensureSuccess(response);
|
||||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<http.MultipartFile> _createMultipartFile(
|
||||||
|
File file, {
|
||||||
|
required String fieldName,
|
||||||
|
UploadProgressCallback? onProgress,
|
||||||
|
}) async {
|
||||||
|
final length = await file.length();
|
||||||
|
final filename = file.uri.pathSegments.isNotEmpty
|
||||||
|
? file.uri.pathSegments.last
|
||||||
|
: file.path;
|
||||||
|
|
||||||
|
if (onProgress == null) {
|
||||||
|
return http.MultipartFile.fromPath(fieldName, file.path, filename: filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploaded = 0;
|
||||||
|
final stream = http.ByteStream(
|
||||||
|
file.openRead().transform(
|
||||||
|
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||||
|
handleData: (chunk, sink) {
|
||||||
|
uploaded += chunk.length;
|
||||||
|
if (length > 0) {
|
||||||
|
final progress = (uploaded / length).clamp(0.0, 1.0);
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
sink.add(chunk);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return http.MultipartFile(
|
||||||
|
fieldName,
|
||||||
|
stream,
|
||||||
|
length,
|
||||||
|
filename: filename,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<ApiResponse> _postCommand(String path) async {
|
Future<ApiResponse> _postCommand(String path) async {
|
||||||
final response = await _client.post(_uri(path));
|
final response = await _client.post(_uri(path));
|
||||||
_ensureSuccess(response);
|
_ensureSuccess(response);
|
||||||
@@ -477,7 +537,7 @@ class HttpApiService {
|
|||||||
_client.close();
|
_client.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _normalizeBaseUrl(String raw) {
|
static String normalizeBaseUrl(String raw) {
|
||||||
final trimmed = raw.trim();
|
final trimmed = raw.trim();
|
||||||
if (trimmed.isEmpty) {
|
if (trimmed.isEmpty) {
|
||||||
throw const ApiException('baseUrl 不能为空');
|
throw const ApiException('baseUrl 不能为空');
|
||||||
|
|||||||
@@ -5,15 +5,24 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
|||||||
|
|
||||||
import '../models/app_event.dart';
|
import '../models/app_event.dart';
|
||||||
|
|
||||||
|
enum WsConnectionState { connected, connecting, disconnected }
|
||||||
|
|
||||||
|
@Deprecated('Use WsConnectionState instead')
|
||||||
enum SocketConnectionStatus { disconnected, connecting, connected }
|
enum SocketConnectionStatus { disconnected, connecting, connected }
|
||||||
|
|
||||||
class WebSocketService {
|
class WebSocketService {
|
||||||
|
static const Duration _initialReconnectDelay = Duration(seconds: 2);
|
||||||
|
static const Duration _maxReconnectDelay = Duration(seconds: 60);
|
||||||
|
|
||||||
WebSocketChannel? _channel;
|
WebSocketChannel? _channel;
|
||||||
StreamSubscription<dynamic>? _subscription;
|
StreamSubscription<dynamic>? _subscription;
|
||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
String? _deviceIp;
|
String? _deviceIp;
|
||||||
|
int _devicePort = 5000;
|
||||||
bool _manualDisconnect = false;
|
bool _manualDisconnect = false;
|
||||||
SocketConnectionStatus _connectionStatus = SocketConnectionStatus.disconnected;
|
WsConnectionState _connectionState = WsConnectionState.disconnected;
|
||||||
|
int _retryCount = 0;
|
||||||
|
Duration _nextReconnectDelay = _initialReconnectDelay;
|
||||||
|
|
||||||
final StreamController<AppEvent> _eventController =
|
final StreamController<AppEvent> _eventController =
|
||||||
StreamController<AppEvent>.broadcast();
|
StreamController<AppEvent>.broadcast();
|
||||||
@@ -27,8 +36,8 @@ class WebSocketService {
|
|||||||
StreamController<Map<String, dynamic>>.broadcast();
|
StreamController<Map<String, dynamic>>.broadcast();
|
||||||
final StreamController<Map<String, dynamic>> _bleController =
|
final StreamController<Map<String, dynamic>> _bleController =
|
||||||
StreamController<Map<String, dynamic>>.broadcast();
|
StreamController<Map<String, dynamic>>.broadcast();
|
||||||
final StreamController<SocketConnectionStatus> _connectionController =
|
final StreamController<WsConnectionState> _connectionStateController =
|
||||||
StreamController<SocketConnectionStatus>.broadcast();
|
StreamController<WsConnectionState>.broadcast();
|
||||||
|
|
||||||
Stream<AppEvent> get events => _eventController.stream;
|
Stream<AppEvent> get events => _eventController.stream;
|
||||||
Stream<Map<String, dynamic>> get onStatusUpdate => _statusController.stream;
|
Stream<Map<String, dynamic>> get onStatusUpdate => _statusController.stream;
|
||||||
@@ -36,24 +45,56 @@ class WebSocketService {
|
|||||||
Stream<Map<String, dynamic>> get onConfigUpdate => _configController.stream;
|
Stream<Map<String, dynamic>> get onConfigUpdate => _configController.stream;
|
||||||
Stream<Map<String, dynamic>> get onWifiUpdate => _wifiController.stream;
|
Stream<Map<String, dynamic>> get onWifiUpdate => _wifiController.stream;
|
||||||
Stream<Map<String, dynamic>> get onBleUpdate => _bleController.stream;
|
Stream<Map<String, dynamic>> get onBleUpdate => _bleController.stream;
|
||||||
|
Stream<SocketConnectionStatus> get connectionState =>
|
||||||
|
_connectionStateController.stream.map(_toLegacyConnectionStatus);
|
||||||
|
Stream<WsConnectionState> get connectionStateStream =>
|
||||||
|
_connectionStateController.stream;
|
||||||
Stream<SocketConnectionStatus> get onConnectionChanged =>
|
Stream<SocketConnectionStatus> get onConnectionChanged =>
|
||||||
_connectionController.stream;
|
_connectionStateController.stream.map(_toLegacyConnectionStatus);
|
||||||
|
|
||||||
SocketConnectionStatus get connectionStatus => _connectionStatus;
|
WsConnectionState get wsConnectionState => _connectionState;
|
||||||
bool get isConnected => _connectionStatus == SocketConnectionStatus.connected;
|
SocketConnectionStatus get connectionStatus =>
|
||||||
|
_toLegacyConnectionStatus(_connectionState);
|
||||||
|
bool get isConnected => _connectionState == WsConnectionState.connected;
|
||||||
|
int get retryCount => _retryCount;
|
||||||
|
|
||||||
Future<void> connect(String deviceIp) async {
|
Future<void> connect(String deviceIp, {int port = 5000}) async {
|
||||||
_manualDisconnect = false;
|
_manualDisconnect = false;
|
||||||
_deviceIp = _normalizeDeviceIp(deviceIp);
|
_deviceIp = _normalizeDeviceIp(deviceIp);
|
||||||
|
_devicePort = _normalizePort(port);
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
|
|
||||||
|
await _establishConnection(resetBackoff: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> manualReconnect() async {
|
||||||
|
final deviceIp = _deviceIp;
|
||||||
|
if (deviceIp == null || deviceIp.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manualDisconnect = false;
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
await _establishConnection(resetBackoff: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _establishConnection({bool resetBackoff = false}) async {
|
||||||
|
if (resetBackoff) {
|
||||||
|
_retryCount = 0;
|
||||||
|
_nextReconnectDelay = _initialReconnectDelay;
|
||||||
|
}
|
||||||
|
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
|
_subscription = null;
|
||||||
await _channel?.sink.close();
|
await _channel?.sink.close();
|
||||||
|
_channel = null;
|
||||||
|
|
||||||
_setConnectionStatus(SocketConnectionStatus.connecting);
|
_setConnectionState(WsConnectionState.connecting);
|
||||||
|
|
||||||
final url = Uri.parse('ws://$_deviceIp:8080/ws');
|
try {
|
||||||
|
final url = Uri.parse('ws://$_deviceIp:$_devicePort/ws');
|
||||||
_channel = WebSocketChannel.connect(url);
|
_channel = WebSocketChannel.connect(url);
|
||||||
|
await _channel!.ready;
|
||||||
_subscription = _channel!.stream.listen(
|
_subscription = _channel!.stream.listen(
|
||||||
_handleMessage,
|
_handleMessage,
|
||||||
onDone: _handleSocketClosed,
|
onDone: _handleSocketClosed,
|
||||||
@@ -61,7 +102,19 @@ class WebSocketService {
|
|||||||
cancelOnError: true,
|
cancelOnError: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
_setConnectionStatus(SocketConnectionStatus.connected);
|
_retryCount = 0;
|
||||||
|
_nextReconnectDelay = _initialReconnectDelay;
|
||||||
|
_setConnectionState(WsConnectionState.connected);
|
||||||
|
} catch (_) {
|
||||||
|
_channel = null;
|
||||||
|
_subscription = null;
|
||||||
|
if (_manualDisconnect) {
|
||||||
|
_setConnectionState(WsConnectionState.disconnected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendCommand(Map<String, dynamic> command) {
|
void sendCommand(Map<String, dynamic> command) {
|
||||||
@@ -77,20 +130,38 @@ class WebSocketService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_setConnectionState(WsConnectionState.connecting);
|
||||||
|
_scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleReconnect() {
|
||||||
|
if (_manualDisconnect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
_reconnectTimer = Timer(const Duration(seconds: 2), () {
|
final delay = _nextReconnectDelay;
|
||||||
unawaited(connect(deviceIp));
|
_retryCount += 1;
|
||||||
|
_nextReconnectDelay = _nextReconnectDelay * 2;
|
||||||
|
if (_nextReconnectDelay > _maxReconnectDelay) {
|
||||||
|
_nextReconnectDelay = _maxReconnectDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
_reconnectTimer = Timer(delay, () {
|
||||||
|
unawaited(_establishConnection());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
_manualDisconnect = true;
|
_manualDisconnect = true;
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
|
_retryCount = 0;
|
||||||
|
_nextReconnectDelay = _initialReconnectDelay;
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
_subscription = null;
|
_subscription = null;
|
||||||
await _channel?.sink.close();
|
await _channel?.sink.close();
|
||||||
_channel = null;
|
_channel = null;
|
||||||
_setConnectionStatus(SocketConnectionStatus.disconnected);
|
_setConnectionState(WsConnectionState.disconnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
@@ -101,7 +172,7 @@ class WebSocketService {
|
|||||||
await _configController.close();
|
await _configController.close();
|
||||||
await _wifiController.close();
|
await _wifiController.close();
|
||||||
await _bleController.close();
|
await _bleController.close();
|
||||||
await _connectionController.close();
|
await _connectionStateController.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleMessage(dynamic data) {
|
void _handleMessage(dynamic data) {
|
||||||
@@ -136,13 +207,30 @@ class WebSocketService {
|
|||||||
void _handleSocketClosed() {
|
void _handleSocketClosed() {
|
||||||
_channel = null;
|
_channel = null;
|
||||||
_subscription = null;
|
_subscription = null;
|
||||||
_setConnectionStatus(SocketConnectionStatus.disconnected);
|
if (_manualDisconnect) {
|
||||||
|
_retryCount = 0;
|
||||||
|
_nextReconnectDelay = _initialReconnectDelay;
|
||||||
|
_setConnectionState(WsConnectionState.disconnected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
unawaited(reconnect());
|
unawaited(reconnect());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setConnectionStatus(SocketConnectionStatus status) {
|
void _setConnectionState(WsConnectionState state) {
|
||||||
_connectionStatus = status;
|
_connectionState = state;
|
||||||
_connectionController.add(status);
|
_connectionStateController.add(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
SocketConnectionStatus _toLegacyConnectionStatus(WsConnectionState state) {
|
||||||
|
switch (state) {
|
||||||
|
case WsConnectionState.connected:
|
||||||
|
return SocketConnectionStatus.connected;
|
||||||
|
case WsConnectionState.connecting:
|
||||||
|
return SocketConnectionStatus.connecting;
|
||||||
|
case WsConnectionState.disconnected:
|
||||||
|
return SocketConnectionStatus.disconnected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _normalizeDeviceIp(String raw) {
|
String _normalizeDeviceIp(String raw) {
|
||||||
@@ -169,4 +257,11 @@ class WebSocketService {
|
|||||||
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _normalizePort(int value) {
|
||||||
|
if (value <= 0 || value > 65535) {
|
||||||
|
return 5000;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
clients/flutter/lib/widgets/connection_status_banner.dart
Normal file
77
clients/flutter/lib/widgets/connection_status_banner.dart
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../services/web_socket_service.dart';
|
||||||
|
|
||||||
|
class ConnectionStatusBanner extends StatelessWidget {
|
||||||
|
const ConnectionStatusBanner({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final webSocketService = context.read<WebSocketService>();
|
||||||
|
|
||||||
|
return StreamBuilder<SocketConnectionStatus>(
|
||||||
|
stream: webSocketService.connectionState,
|
||||||
|
initialData: webSocketService.connectionStatus,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final connectionStatus =
|
||||||
|
snapshot.data ?? SocketConnectionStatus.disconnected;
|
||||||
|
final isVisible = connectionStatus != SocketConnectionStatus.connected;
|
||||||
|
final isConnecting =
|
||||||
|
connectionStatus == SocketConnectionStatus.connecting;
|
||||||
|
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
height: isVisible ? 52 : 0,
|
||||||
|
color:
|
||||||
|
isConnecting ? Colors.amber.shade700 : Colors.redAccent.shade700,
|
||||||
|
child: isVisible
|
||||||
|
? SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
isConnecting
|
||||||
|
? '正在重连...(第${webSocketService.retryCount.clamp(1, 999)}次)'
|
||||||
|
: '连接断开',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(
|
||||||
|
color: Colors.black,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isConnecting)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
webSocketService.manualReconnect();
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
backgroundColor: Colors.black26,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
cupertino_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cupertino_icons
|
||||||
|
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.8"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -89,6 +97,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -264,6 +280,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.1"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -272,6 +312,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.2"
|
version: "7.0.2"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
|
plugin_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: plugin_platform_interface
|
||||||
|
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.8"
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -288,6 +344,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
version: "0.28.0"
|
||||||
|
shared_preferences:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shared_preferences
|
||||||
|
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.4"
|
||||||
|
shared_preferences_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_android
|
||||||
|
sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.21"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
shared_preferences_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_linux
|
||||||
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_platform_interface
|
||||||
|
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_web
|
||||||
|
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
shared_preferences_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_windows
|
||||||
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -389,6 +501,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -398,5 +518,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "6.6.1"
|
version: "6.6.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.9.0-0 <4.0.0"
|
dart: ">=3.9.0 <4.0.0"
|
||||||
flutter: ">=3.22.0"
|
flutter: ">=3.35.0"
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
flutter_blue_plus: ^1.35.3
|
flutter_blue_plus: ^1.35.3
|
||||||
go_router: ^14.8.1
|
go_router: ^14.8.1
|
||||||
http: ^1.2.1
|
http: ^1.2.1
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
web_socket_channel: ^3.0.1
|
web_socket_channel: ^3.0.1
|
||||||
|
shared_preferences: ^2.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
183
clients/flutter/test/models/models_test.dart
Normal file
183
clients/flutter/test/models/models_test.dart
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:showen_v2_flutter/models/api_response.dart';
|
||||||
|
import 'package:showen_v2_flutter/models/app_event.dart';
|
||||||
|
import 'package:showen_v2_flutter/models/ble_models.dart';
|
||||||
|
import 'package:showen_v2_flutter/models/ble_status.dart';
|
||||||
|
import 'package:showen_v2_flutter/models/device_status.dart';
|
||||||
|
import 'package:showen_v2_flutter/models/player_status.dart';
|
||||||
|
import 'package:showen_v2_flutter/models/video_item.dart';
|
||||||
|
import 'package:showen_v2_flutter/models/wifi_network.dart';
|
||||||
|
import 'package:showen_v2_flutter/models/wifi_status.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ApiResponse', () {
|
||||||
|
test('fromJson and toJson round trip', () {
|
||||||
|
final response = ApiResponse.fromJson(const <String, dynamic>{
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'done',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.isOk, isTrue);
|
||||||
|
expect(response.toJson(), <String, dynamic>{
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'done',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AppEvent', () {
|
||||||
|
test('fromJson prefers data payload map', () {
|
||||||
|
final event = AppEvent.fromJson(const <String, dynamic>{
|
||||||
|
'type': 'status',
|
||||||
|
'data': <String, dynamic>{'connected': true},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event.type, 'status');
|
||||||
|
expect(event.payload, <String, dynamic>{'connected': true});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromJson normalizes scalar payload', () {
|
||||||
|
final event = AppEvent.fromJson(const <String, dynamic>{
|
||||||
|
'type': 'progress',
|
||||||
|
'payload': 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event.payload, <String, dynamic>{'value': 42});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Ble models', () {
|
||||||
|
test('BleDevice stores constructor fields', () {
|
||||||
|
const device = BleDevice(name: 'Showen', id: 'dev-1', rssi: -48);
|
||||||
|
|
||||||
|
expect(device.name, 'Showen');
|
||||||
|
expect(device.id, 'dev-1');
|
||||||
|
expect(device.rssi, -48);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('BleStatus parses json and raw json', () {
|
||||||
|
final status = BleStatus.fromJson(const <String, dynamic>{
|
||||||
|
'ok': true,
|
||||||
|
'action': 'provision',
|
||||||
|
'state': 'queued',
|
||||||
|
});
|
||||||
|
final raw = BleStatus.fromRawJson(
|
||||||
|
'{"ok":false,"action":"scan","error":"failed"}',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status.isQueued, isTrue);
|
||||||
|
expect(status.message, 'queued');
|
||||||
|
expect(raw.isSuccess, isFalse);
|
||||||
|
expect(raw.message, 'failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('BleServiceStatus', () {
|
||||||
|
test('initial and fromJson', () {
|
||||||
|
final initial = BleServiceStatus.initial();
|
||||||
|
final status = BleServiceStatus.fromJson(const <String, dynamic>{
|
||||||
|
'running': true,
|
||||||
|
'embedded': true,
|
||||||
|
'device_name': 'Showen BLE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(initial.running, isFalse);
|
||||||
|
expect(initial.embedded, isFalse);
|
||||||
|
expect(status.running, isTrue);
|
||||||
|
expect(status.embedded, isTrue);
|
||||||
|
expect(status.deviceName, 'Showen BLE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('DeviceStatus', () {
|
||||||
|
test('initial and copyWith preserve nested models', () {
|
||||||
|
final updated = DeviceStatus.initial().copyWith(
|
||||||
|
connected: true,
|
||||||
|
connectionType: 'wifi',
|
||||||
|
deviceName: 'Showen Box',
|
||||||
|
ipAddress: '192.168.1.20',
|
||||||
|
playerStatus: PlayerStatus.initial().copyWith(running: true),
|
||||||
|
wifiStatus: WifiStatus.fromJson(
|
||||||
|
const <String, dynamic>{'connected': true, 'ssid': 'Office'},
|
||||||
|
),
|
||||||
|
bleStatus: BleServiceStatus.fromJson(
|
||||||
|
const <String, dynamic>{'running': true, 'embedded': false},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.connected, isTrue);
|
||||||
|
expect(updated.connectionType, 'wifi');
|
||||||
|
expect(updated.deviceName, 'Showen Box');
|
||||||
|
expect(updated.ipAddress, '192.168.1.20');
|
||||||
|
expect(updated.playerStatus?.running, isTrue);
|
||||||
|
expect(updated.wifiStatus?.ssid, 'Office');
|
||||||
|
expect(updated.bleStatus?.running, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PlayerStatus', () {
|
||||||
|
test('fromJson and toJson round trip', () {
|
||||||
|
final status = PlayerStatus.fromJson(const <String, dynamic>{
|
||||||
|
'running': true,
|
||||||
|
'paused': false,
|
||||||
|
'in_transition': true,
|
||||||
|
'current_index': 3,
|
||||||
|
'playlist_length': 9,
|
||||||
|
'current_video': 'intro.mp4',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status.toJson(), <String, dynamic>{
|
||||||
|
'running': true,
|
||||||
|
'paused': false,
|
||||||
|
'in_transition': true,
|
||||||
|
'current_index': 3,
|
||||||
|
'playlist_length': 9,
|
||||||
|
'current_video': 'intro.mp4',
|
||||||
|
});
|
||||||
|
expect(status.copyWith(paused: true).paused, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('VideoItem', () {
|
||||||
|
test('fromJson parses file metadata', () {
|
||||||
|
final video = VideoItem.fromJson(const <String, dynamic>{
|
||||||
|
'name': 'demo.mp4',
|
||||||
|
'size': 3145728,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(video.name, 'demo.mp4');
|
||||||
|
expect(video.size, 3145728);
|
||||||
|
expect(video.sizeLabel, '3.0 MB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('WifiNetwork', () {
|
||||||
|
test('fromJson parses network metadata', () {
|
||||||
|
final network = WifiNetwork.fromJson(const <String, dynamic>{
|
||||||
|
'ssid': 'ShowenLab',
|
||||||
|
'signal': -51,
|
||||||
|
'security': 'WPA2',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(network.ssid, 'ShowenLab');
|
||||||
|
expect(network.signalLabel, '-51 dBm');
|
||||||
|
expect(network.security, 'WPA2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('WifiStatus', () {
|
||||||
|
test('disconnected and fromJson', () {
|
||||||
|
final disconnected = WifiStatus.disconnected();
|
||||||
|
final status = WifiStatus.fromJson(const <String, dynamic>{
|
||||||
|
'connected': true,
|
||||||
|
'ssid': 'ShowenLab',
|
||||||
|
'ip': '192.168.1.10',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(disconnected.connected, isFalse);
|
||||||
|
expect(status.connected, isTrue);
|
||||||
|
expect(status.ssid, 'ShowenLab');
|
||||||
|
expect(status.ip, '192.168.1.10');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
43
clients/flutter/test/services/http_api_service_test.dart
Normal file
43
clients/flutter/test/services/http_api_service_test.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:showen_v2_flutter/services/http_api_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('HttpApiService.normalizeBaseUrl', () {
|
||||||
|
test('trims whitespace, adds scheme, and removes trailing slash', () {
|
||||||
|
expect(
|
||||||
|
HttpApiService.normalizeBaseUrl(' 192.168.1.8:5000/ '),
|
||||||
|
'http://192.168.1.8:5000',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves explicit https scheme', () {
|
||||||
|
expect(
|
||||||
|
HttpApiService.normalizeBaseUrl('https://showen.local/'),
|
||||||
|
'https://showen.local',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws for empty baseUrl', () {
|
||||||
|
expect(
|
||||||
|
() => HttpApiService.normalizeBaseUrl(' '),
|
||||||
|
throwsA(
|
||||||
|
isA<ApiException>().having(
|
||||||
|
(error) => error.message,
|
||||||
|
'message',
|
||||||
|
'baseUrl 不能为空',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ApiException', () {
|
||||||
|
test('stores message and optional status code', () {
|
||||||
|
const exception = ApiException('upload failed', statusCode: 413);
|
||||||
|
|
||||||
|
expect(exception.message, 'upload failed');
|
||||||
|
expect(exception.statusCode, 413);
|
||||||
|
expect(exception.toString(), 'upload failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Binary file not shown.
347
docs/M1.2_TEST_PLAN.md
Normal file
347
docs/M1.2_TEST_PLAN.md
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
# M1.2 集成测试计划
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
- 目标:完成 ShowenV2 在 M1.2 阶段的端到端集成测试、旧功能对齐验证、边界条件验证、错误处理验证与回归基线建立。
|
||||||
|
- 范围覆盖:`device`、`screen`、`wifi`、`video`、`ble`、`http` 六个内置插件,动态插件系统,Flutter App 通过 HTTP/WebSocket/BLE 的交互链路。
|
||||||
|
- 重点链路:插件注册与启动、消息路由、配置热重载、视频控制、WiFi 配网、BLE 配网、文件管理、动态插件生命周期。
|
||||||
|
- 验收对齐:覆盖 `docs/MILESTONES.md` 中 M1.2 的全部任务项,并为 M1.3 性能优化提供可重复的基准输入。
|
||||||
|
|
||||||
|
## 2. 当前实现观察与 M1.2 风险
|
||||||
|
|
||||||
|
- `src/main.rs` 按 `device -> screen -> wifi -> video -> ble -> http` 注册静态插件,再扫描 `plugin_store/` 挂载动态插件。
|
||||||
|
- `src/core/service_manager.rs` 负责依赖排序、`init -> self_test -> start` 生命周期、消息路由、动态插件错误阈值、自动回退、启停与热替换。
|
||||||
|
- `src/plugins/http/routes.rs` 已暴露播放、配置、视频、文件、WiFi、BLE、插件管理、App 下载等 API。
|
||||||
|
- 风险 1:HTTP 插件管理 API 通过 `Message::Custom` 向 Manager 发命令,但 `ServiceManager::handle_manager_message()` 当前未处理 `plugin_enable`、`plugin_disable`、`plugin_rollback`、`plugin_switch`、`plugin_install`、`plugin_check_updates`。
|
||||||
|
- 风险 2:`/api/plugins` 依赖 `plugin_states` 自定义消息更新状态,但当前源码中未看到 Manager 侧生产该消息,插件列表接口可能返回空状态。
|
||||||
|
- 风险 3:`WifiProvisioned`、`DeviceEvent`、部分 `Custom` 消息在当前主链路中无明确生产者/消费者,测试时应区分“未实现”与“回归缺陷”。
|
||||||
|
- 风险 4:BLE、WiFi、显示、OpenCV、动态插件 `.so` 装载均依赖 ARM64 实机环境,CI 只能承担部分替身测试。
|
||||||
|
|
||||||
|
## 3. 测试策略
|
||||||
|
|
||||||
|
### 3.1 分层策略
|
||||||
|
|
||||||
|
- 单元测试:验证纯逻辑、解析、状态转换、命令拼装、路径校验、manifest 校验、消息序列化。
|
||||||
|
- 集成测试:验证 `ServiceManager` 与插件间消息流、配置重载、插件生命周期、HTTP 路由到消息总线的桥接。
|
||||||
|
- 端到端测试:以真实进程启动 `showen_v2`,通过 HTTP/WebSocket/BLE/文件系统操作驱动系统,校验插件协同与用户可见结果。
|
||||||
|
|
||||||
|
### 3.2 层级分工
|
||||||
|
|
||||||
|
- 单元:优先放在 `src/core/*`、`src/plugins/*` 内部测试,补齐未覆盖的解析和错误分支。
|
||||||
|
- 集成:新增 `tests/m1_2_*.rs`,使用临时目录、测试配置、fake backend、fake dynamic plugin store 驱动系统。
|
||||||
|
- E2E:以 ARM64 实机为主,CI 仅跑“无硬件替身版”流程;WebSocket/HTTP 使用 `curl`、`websocat`、Flutter 测试桩执行。
|
||||||
|
|
||||||
|
### 3.3 覆盖原则
|
||||||
|
|
||||||
|
- 所有非 `-` 消息交互单元至少有 1 条自动化验证。
|
||||||
|
- 所有用户入口 API 至少有 1 条成功场景和 1 条失败场景。
|
||||||
|
- 所有动态插件生命周期动作至少覆盖:加载、必需能力自测失败、错误阈值禁用、自动回退、热替换恢复。
|
||||||
|
- Flutter 侧至少覆盖:首次连接、实时状态、WiFi 配网、配置读取/保存、APK 下载入口。
|
||||||
|
|
||||||
|
## 4. src 模块视图
|
||||||
|
|
||||||
|
- `src/main.rs`:程序入口、配置加载、静态/动态插件注册、主循环。
|
||||||
|
- `src/core/`:消息模型、插件 trait、ServiceManager、PluginLoader、VersionManager。
|
||||||
|
- `src/plugins/device/`:统一设备能力入口,响应 `DeviceCommand` 并广播 `DeviceResponse`。
|
||||||
|
- `src/plugins/screen/`:DevicePlugin thin wrapper,负责防息屏与光标隐藏。
|
||||||
|
- `src/plugins/wifi/`:nmcli 驱动的 WiFi 扫描/连接/AP 管理。
|
||||||
|
- `src/plugins/video/`:OpenCV 播放器、状态机、状态广播。
|
||||||
|
- `src/plugins/ble/`:BlueZ GATT 配网服务,接收 WiFi 结果并向核心转发 WiFi 指令。
|
||||||
|
- `src/plugins/http/`:REST/WebSocket/Web UI/App 下载与文件管理桥接层。
|
||||||
|
|
||||||
|
## 5. 插件 × Message 覆盖矩阵
|
||||||
|
|
||||||
|
说明:`Rx`=直接处理,`Tx`=直接发送/广播,`Bridge`=对外桥接或间接映射,`Gap`=已有入口但当前实现未闭环,`-`=无直接关系。
|
||||||
|
|
||||||
|
| 插件/系统 | PlayerCommand | PlayerStatus | Trigger | StateChanged | ScreenLockRequest | CursorVisibility | WifiCommand | WifiResult | WifiProvisioned | ConfigReloaded | ConfigReloadRequest | Shutdown | PluginReady | DeviceCommand | DeviceResponse | DeviceEvent | Custom |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| DevicePlugin | - | - | - | - | - | - | - | - | - | - | - | Rx | Rx | Rx | Tx | - | - |
|
||||||
|
| ScreenPlugin | - | - | - | - | Rx/Tx | Rx/Tx | - | - | - | - | - | Rx | - | Tx | - | - | - |
|
||||||
|
| WifiPlugin | - | - | - | - | - | - | Rx | Tx | - | - | - | - | - | - | - | - | - |
|
||||||
|
| VideoPlugin | Rx | Tx | Rx | Tx | Tx | - | - | - | - | Rx | - | Rx | Tx | - | - | - | - |
|
||||||
|
| BlePlugin | - | - | - | - | - | - | Tx(经 GATT) | Rx | - | - | - | Rx | Tx | - | - | - | - |
|
||||||
|
| HttpPlugin | Bridge | Rx/Bridge | Bridge | Rx/Bridge | - | - | Bridge | Rx/Bridge | - | Rx/Bridge | Tx/Bridge | Rx | Rx(ble) | - | - | - | Rx(`plugin_states`) |
|
||||||
|
| 动态插件系统 | 视插件而定 | 视插件而定 | 视插件而定 | 视插件而定 | 视插件而定 | 视插件而定 | 视插件而定 | 视插件而定 | 视插件而定 | 视插件而定 | - | Rx | 视插件而定 | 视插件而定 | 视插件而定 | 视插件而定 | Rx/Tx |
|
||||||
|
| Flutter App | Bridge | Bridge | Bridge | Bridge | - | - | Bridge | Bridge | Bridge(BLE 配网结果) | Bridge | Bridge | - | Bridge(状态映射) | - | - | - | Bridge(插件管理入口) |
|
||||||
|
|
||||||
|
### 5.1 重点补测项
|
||||||
|
|
||||||
|
- `DeviceResponse`:补足 DevicePlugin 与 ScreenPlugin/业务插件的联动验证,确认 thin wrapper 没有只发不收的问题。
|
||||||
|
- `PluginReady`:验证 `video`、`http`、`ble` 的 ready 事件被 Manager 广播后,HTTP/Flutter 状态同步是否准确。
|
||||||
|
- `Custom`:动态插件自定义消息已有基础;HTTP 插件管理类 `Custom` 当前缺少 Manager 闭环,应作为 M1.2 对齐缺陷优先验证。
|
||||||
|
|
||||||
|
## 6. HTTP API 覆盖范围
|
||||||
|
|
||||||
|
### 6.1 播放与状态
|
||||||
|
|
||||||
|
- `GET /api/status`
|
||||||
|
- `POST /api/play`
|
||||||
|
- `POST /api/pause`
|
||||||
|
- `POST /api/next`
|
||||||
|
- `POST /api/previous`
|
||||||
|
- `POST /api/goto/:index`
|
||||||
|
- `GET /api/playlist`
|
||||||
|
- `POST /api/scene/:name`
|
||||||
|
- `POST /api/trigger/:name`
|
||||||
|
- `POST /api/trigger/:name/:value`
|
||||||
|
|
||||||
|
### 6.2 配置管理
|
||||||
|
|
||||||
|
- `GET /api/config`
|
||||||
|
- `GET /api/config/display`
|
||||||
|
- `POST /api/config`
|
||||||
|
- `GET /api/config/available`
|
||||||
|
- `POST /api/config/switch`
|
||||||
|
|
||||||
|
### 6.3 视频/文件/App
|
||||||
|
|
||||||
|
- `GET /api/videos`
|
||||||
|
- `POST /api/videos/upload`
|
||||||
|
- `DELETE /api/videos/:filename`
|
||||||
|
- `GET /api/app/info`
|
||||||
|
- `GET /download/:filename`
|
||||||
|
- `GET /api/files/:dir`
|
||||||
|
- `POST /api/files/:dir/upload`
|
||||||
|
- `GET /api/files/:dir/download`
|
||||||
|
- `POST /api/files/:dir/delete`
|
||||||
|
- `POST /api/files/move`
|
||||||
|
- `POST /api/files/:dir/mkdir`
|
||||||
|
|
||||||
|
### 6.4 WiFi / BLE / WebSocket / 插件管理
|
||||||
|
|
||||||
|
- `GET|POST /api/wifi/scan`
|
||||||
|
- `GET /api/wifi/status`
|
||||||
|
- `POST /api/wifi/connect`
|
||||||
|
- `POST /api/wifi/ap/start`
|
||||||
|
- `POST /api/wifi/ap/stop`
|
||||||
|
- `POST /api/wifi/hotspot/start`
|
||||||
|
- `POST /api/wifi/hotspot/stop`
|
||||||
|
- `POST /api/ble/start`
|
||||||
|
- `POST /api/ble/stop`
|
||||||
|
- `GET /api/ble/status`
|
||||||
|
- `GET /ws`
|
||||||
|
- `GET /api/plugins`
|
||||||
|
- `GET /api/plugins/:id`
|
||||||
|
- `POST /api/plugins/:id/enable`
|
||||||
|
- `POST /api/plugins/:id/disable`
|
||||||
|
- `POST /api/plugins/:id/rollback`
|
||||||
|
- `POST /api/plugins/:id/switch`
|
||||||
|
- `POST /api/plugins/install`
|
||||||
|
- `POST /api/plugins/check-updates`
|
||||||
|
|
||||||
|
## 7. 端到端场景清单
|
||||||
|
|
||||||
|
每个场景均要求记录:日志、HTTP 响应、WebSocket 事件、关键文件变化、插件状态快照。
|
||||||
|
|
||||||
|
### 场景 1:系统冷启动与插件注册
|
||||||
|
|
||||||
|
- 前置条件:ARM64 实机;有效配置文件;`plugin_store/` 为空或仅含稳定动态插件。
|
||||||
|
- 操作步骤:启动主程序;观察启动日志;调用 `GET /api/status`;建立 `/ws` 连接。
|
||||||
|
- 预期结果:6 个内置插件按依赖顺序启动;若动态插件存在则完成自测;`/api/status` 可返回;WebSocket 首包包含 `status_update`、`config_update`、`ble_update`。
|
||||||
|
|
||||||
|
### 场景 2:播放控制主链路
|
||||||
|
|
||||||
|
- 前置条件:播放列表至少 2 个有效视频;HTTP 服务启用。
|
||||||
|
- 操作步骤:依次调用 `/api/play`、`/api/pause`、`/api/play`、`/api/next`、`/api/previous`。
|
||||||
|
- 预期结果:VideoPlugin 正确切换状态;WebSocket 推送 `status_update`;暂停时触发 ScreenPlugin 释放防息屏,恢复播放时重新加锁。
|
||||||
|
|
||||||
|
### 场景 3:按索引跳转视频
|
||||||
|
|
||||||
|
- 前置条件:播放列表长度 >= 3。
|
||||||
|
- 操作步骤:调用 `POST /api/goto/2`,随后调用 `GET /api/status`。
|
||||||
|
- 预期结果:当前索引变为 2;当前视频与目标条目一致;无越界异常。
|
||||||
|
|
||||||
|
### 场景 4:状态机触发器切换场景
|
||||||
|
|
||||||
|
- 前置条件:配置内存在多个 state/trigger 规则。
|
||||||
|
- 操作步骤:调用 `POST /api/trigger/voice/name` 或 WebSocket `{"cmd":"trigger","name":"voice","value":"name"}`。
|
||||||
|
- 预期结果:VideoPlugin 接收 `Trigger`;若状态变化则广播 `StateChanged`;HTTP/WebSocket 观察到 `state_update`。
|
||||||
|
|
||||||
|
### 场景 5:配置热重载
|
||||||
|
|
||||||
|
- 前置条件:当前配置文件合法,可修改显示或播放参数。
|
||||||
|
- 操作步骤:`POST /api/config` 提交合法 JSON;观察日志与 `/ws`;再次调用 `/api/config`。
|
||||||
|
- 预期结果:配置文件落盘;Manager 处理 `ConfigReloadRequest` 并广播 `ConfigReloaded`;VideoPlugin 与 HttpPlugin 更新内部状态;新配置立即可见。
|
||||||
|
|
||||||
|
### 场景 6:切换配置文件
|
||||||
|
|
||||||
|
- 前置条件:`configs/` 下至少 2 个合法配置文件。
|
||||||
|
- 操作步骤:调用 `GET /api/config/available`,选择另一个配置后调用 `POST /api/config/switch`。
|
||||||
|
- 预期结果:目标配置通过校验后覆盖当前活动配置;系统广播配置重载;`active` 配置更新。
|
||||||
|
|
||||||
|
### 场景 7:视频文件上传与删除
|
||||||
|
|
||||||
|
- 前置条件:视频目录可写;准备 1 个小视频文件。
|
||||||
|
- 操作步骤:调用 `/api/videos/upload` 上传;调用 `/api/videos` 检查列表;随后调用 `DELETE /api/videos/:filename`。
|
||||||
|
- 预期结果:上传成功且文件可见;删除后列表消失;不会因文件名注入逃逸目录。
|
||||||
|
|
||||||
|
### 场景 8:文件管理器跨目录操作
|
||||||
|
|
||||||
|
- 前置条件:`videos/`、`configs/`、`plugin_store/` 可访问;创建测试子目录。
|
||||||
|
- 操作步骤:调用 `/api/files/:dir` 浏览、`mkdir` 新建目录、`upload` 上传、`move` 移动、`download` 下载、`delete` 删除。
|
||||||
|
- 预期结果:仅允许受管目录;子路径校验生效;跨文件系统移动可回退到 copy+delete;非法路径返回拒绝。
|
||||||
|
|
||||||
|
### 场景 9:WiFi 扫描与状态查询
|
||||||
|
|
||||||
|
- 前置条件:实机具备 WiFi 网卡与 `nmcli`;附近存在可扫描网络。
|
||||||
|
- 操作步骤:调用 `GET /api/wifi/scan`;随后调用 `GET /api/wifi/status`。
|
||||||
|
- 预期结果:WiFiPlugin 返回去重后的网络列表;状态接口返回连接状态、SSID、IP;WebSocket 有 `wifi_update`。
|
||||||
|
|
||||||
|
### 场景 10:WiFi 连接成功
|
||||||
|
|
||||||
|
- 前置条件:准备可连接的测试 WiFi;账号密码正确。
|
||||||
|
- 操作步骤:调用 `POST /api/wifi/connect`;等待 1~10 秒后查询 `/api/wifi/status`。
|
||||||
|
- 预期结果:连接命令进入 WifiPlugin;返回成功消息;状态转为 connected;IP 地址可读。
|
||||||
|
|
||||||
|
### 场景 11:AP 热点启停
|
||||||
|
|
||||||
|
- 前置条件:设备支持热点模式。
|
||||||
|
- 操作步骤:调用 `POST /api/wifi/ap/start`;确认热点启动;随后调用 `POST /api/wifi/ap/stop`。
|
||||||
|
- 预期结果:热点名称和密码按请求生效;停止成功;兼容别名 `/api/wifi/hotspot/*`。
|
||||||
|
|
||||||
|
### 场景 12:BLE 配网成功链路
|
||||||
|
|
||||||
|
- 前置条件:BLE 在配置中启用;手机或测试脚本可写 GATT 特征;目标 WiFi 可用。
|
||||||
|
- 操作步骤:启动程序;通过 BLE 写入 SSID、密码、`connect` 命令;观察 BLE 状态特征、日志、WiFi 状态。
|
||||||
|
- 预期结果:BLE 将凭据转发为 `WifiCommand::Connect`;WifiPlugin 返回结果后 BLE 状态特征更新;HttpPlugin 反映 `ble_update` 与 `wifi_update`。
|
||||||
|
|
||||||
|
### 场景 13:WebSocket 实时控制链路
|
||||||
|
|
||||||
|
- 前置条件:WebSocket 已连接。
|
||||||
|
- 操作步骤:发送 `play`、`pause`、`goto`、`trigger`、`connect`、`ap_start` 命令 JSON。
|
||||||
|
- 预期结果:合法命令被解析为内部消息并入队;响应 `{"ok":true}`;状态更新推送到客户端。
|
||||||
|
|
||||||
|
### 场景 14:动态插件挂载与自测通过
|
||||||
|
|
||||||
|
- 前置条件:`plugin_store/registry.json` 指向一个合法动态插件版本;manifest 声明 capability 且自测通过。
|
||||||
|
- 操作步骤:启动程序;查看日志与插件状态接口。
|
||||||
|
- 预期结果:动态插件被加载、`init`、`self_test`、`start`;必需能力通过;插件状态显示 enabled。
|
||||||
|
|
||||||
|
### 场景 15:动态插件必需能力失败并自动回退
|
||||||
|
|
||||||
|
- 前置条件:准备 active 版本失败、stable 版本可回退的动态插件仓库;错误策略为 `auto_rollback`。
|
||||||
|
- 操作步骤:启动程序或发送触发失败的消息直至达到错误阈值。
|
||||||
|
- 预期结果:当前版本被禁用并触发回退;若稳定版本可重载则恢复运行;否则 `needs_rollback=true` 并记录日志。
|
||||||
|
|
||||||
|
### 场景 16:动态插件热替换恢复旧版本
|
||||||
|
|
||||||
|
- 前置条件:已加载动态插件;准备一个启动失败的新版本。
|
||||||
|
- 操作步骤:通过测试钩子或后续管理命令触发热替换。
|
||||||
|
- 预期结果:旧插件先 stop;新插件 init/start 失败后恢复旧插件;资源无双开窗口。
|
||||||
|
|
||||||
|
### 场景 17:Flutter App 首次连接与实时状态
|
||||||
|
|
||||||
|
- 前置条件:Flutter App 安装完成;手机与设备网络可达。
|
||||||
|
- 操作步骤:Flutter App 输入设备地址并连接;进入主控页;触发播放/暂停;保持 WebSocket 连接。
|
||||||
|
- 预期结果:App 能读取 `/api/status`、`/api/playlist`、`/api/ble/status`;能接收 `status_update`、`state_update`、`wifi_update`;UI 与设备状态一致。
|
||||||
|
|
||||||
|
### 场景 18:Flutter App 配网与 APK 下载入口
|
||||||
|
|
||||||
|
- 前置条件:HTTP 服务与下载目录启用;存在 `downloads/showen-app.apk`。
|
||||||
|
- 操作步骤:Flutter 通过 BLE 或 HTTP 执行配网;访问 App 下载信息接口;点击下载 APK。
|
||||||
|
- 预期结果:`/api/app/info` 返回正确版本、大小、下载地址;APK 下载成功;Flutter 侧配网链路完成且错误可回显。
|
||||||
|
|
||||||
|
## 8. 边界条件清单
|
||||||
|
|
||||||
|
- 1. 空 `playlist` 启动:`/api/status`、`/api/videos`、WebSocket 快照均不崩溃。
|
||||||
|
- 2. `goto` 越界:返回 `400`,不改变当前播放状态。
|
||||||
|
- 3. 上传 100MB 边界:恰好上限通过,超过上限返回 `413`。
|
||||||
|
- 4. 文件名包含 `..`、`/`、`\\`:上传、下载、删除、配置切换均拒绝。
|
||||||
|
- 5. WiFi SSID/密码包含空格、引号、反斜杠、冒号:命令参数保真。
|
||||||
|
- 6. BLE 写入空 SSID 或空命令:状态特征返回错误,不向核心发送无效命令。
|
||||||
|
- 7. 动态插件 manifest `id/version` 与目录不匹配:加载应失败且不污染注册表。
|
||||||
|
- 8. 动态插件 required capability 缺失于自测结果:视为失败并按策略处理。
|
||||||
|
- 9. `ConfigReloadRequest` 指向损坏 JSON:Manager 记录失败,保留旧配置。
|
||||||
|
- 10. WebSocket 收到非法 JSON 或缺少 `cmd`:返回结构化错误字符串,不影响连接。
|
||||||
|
- 11. `remote_control.enabled=false`:HTTP 插件不启动监听,但系统其他插件正常工作。
|
||||||
|
- 12. 关闭中的广播消息:`Shutdown` 广播后所有启用插件按逆序停止。
|
||||||
|
|
||||||
|
## 9. 错误处理场景清单
|
||||||
|
|
||||||
|
- 1. HTTP 发送到关闭的消息通道:播放/WiFi/插件管理 API 返回 `500`。
|
||||||
|
- 2. WiFi 10 秒无响应:`/api/wifi/*` 返回 `504`。
|
||||||
|
- 3. WiFi 返回非 JSON:HTTP 层返回 `502`。
|
||||||
|
- 4. WiFi 返回 `{ok:false}`:HTTP 层透传为 `500` 业务错误。
|
||||||
|
- 5. 配置更新请求体非 UTF-8:返回 `400`。
|
||||||
|
- 6. 配置切换目标文件不存在:返回 `404`。
|
||||||
|
- 7. 文件下载缺少 `path` 参数:返回 `400`。
|
||||||
|
- 8. 文件移动目标已存在:返回 `409`。
|
||||||
|
- 9. 静态插件 init/start 失败:启动整体失败并退出。
|
||||||
|
- 10. 动态插件 init/start 失败:仅该插件禁用,其他插件继续运行。
|
||||||
|
- 11. 动态插件错误阈值达到上限:`disable_and_log` 时禁用;`auto_rollback` 时回退。
|
||||||
|
- 12. 热替换新插件启动失败:恢复旧插件;若恢复失败则明确标记 disabled。
|
||||||
|
- 13. BLE worker 崩溃:3 秒重试;停止信号下应及时退出。
|
||||||
|
- 14. `/api/plugins*` 命令当前无 Manager 闭环:应在 M1.2 先作为已知功能对齐缺陷立项验证并修复。
|
||||||
|
|
||||||
|
## 10. 性能基准定义
|
||||||
|
|
||||||
|
M1.2 不做深度优化,但必须建立可重复基准,供 M1.3 追踪。
|
||||||
|
|
||||||
|
- 启动时间:从进程启动到 `http` 与 `video` 均发出 `PluginReady`,目标 `<= 3s`(无动态插件时)。
|
||||||
|
- HTTP 控制延迟:`POST /api/play` 到首个 `status_update`,P95 `<= 200ms`。
|
||||||
|
- WebSocket 状态推送延迟:内部消息到客户端收到事件,P95 `<= 150ms`。
|
||||||
|
- 配置热重载耗时:`POST /api/config` 到 `config_update` 推送完成,P95 `<= 500ms`。
|
||||||
|
- WiFi 状态查询:单次 `/api/wifi/status` 完成时间 P95 `<= 3s`,超时阈值 10s。
|
||||||
|
- 视频上传:100MB 文件上传不出现进程 OOM,峰值 RSS 不超过基线版本 120%。
|
||||||
|
- 长稳冒烟:连续 4 小时播放 + WebSocket 连接 + 周期性 WiFi 状态查询,无崩溃、无句柄泄漏迹象。
|
||||||
|
- 动态插件回退:达到错误阈值到完成禁用/回退标记,P95 `<= 1s`(不含磁盘 I/O 抖动)。
|
||||||
|
|
||||||
|
## 11. 测试环境要求
|
||||||
|
|
||||||
|
### 11.1 ARM64 实机
|
||||||
|
|
||||||
|
- Linux ARM64。
|
||||||
|
- 安装 `nmcli`、BlueZ、OpenCV 运行时、`websocat`、`curl`。
|
||||||
|
- 具备显示输出、WiFi 网卡、BLE 适配器。
|
||||||
|
- 可访问测试路由器,并可创建 AP 热点。
|
||||||
|
- 支持动态插件 `.so` 装载与回退仓库读写。
|
||||||
|
|
||||||
|
### 11.2 CI 环境
|
||||||
|
|
||||||
|
- 运行 Rust 单元/集成测试。
|
||||||
|
- 使用 fake backend/fake plugin store 替代硬件。
|
||||||
|
- 执行 HTTP 路由级测试、消息序列化测试、ServiceManager 生命周期测试。
|
||||||
|
- 不承担真实 OpenCV 显示、真实 WiFi/BLE、真实动态 `.so` 装载回归。
|
||||||
|
|
||||||
|
### 11.3 Flutter 联调环境
|
||||||
|
|
||||||
|
- Android 真机优先,至少 1 台;若支持则补 1 台 iOS 设备做网络连通冒烟。
|
||||||
|
- Flutter App 使用与服务端同版本 API 模型。
|
||||||
|
- 同时验证 HTTP 轮询回退与 WebSocket 实时模式。
|
||||||
|
|
||||||
|
## 12. 自动化落地建议
|
||||||
|
|
||||||
|
- 新增 `tests/m1_2_service_manager.rs`:启动顺序、关闭顺序、广播、配置重载、动态插件错误策略。
|
||||||
|
- 新增 `tests/m1_2_http.rs`:播放/配置/文件/WiFi/BLE/插件管理 API 路由级验证。
|
||||||
|
- 新增 `tests/m1_2_dynamic_plugin.rs`:registry、manifest、热替换、回退、必需能力失败。
|
||||||
|
- 新增 `scripts/e2e/`:实机冒烟脚本,串联 `curl + websocat + 日志断言`。
|
||||||
|
- 新增 `clients/flutter/integration_test/`:设备发现、状态同步、WiFi 配网、配置保存。
|
||||||
|
|
||||||
|
## 13. 预估工作量与排期建议
|
||||||
|
|
||||||
|
### 13.1 工作量
|
||||||
|
|
||||||
|
- 测试基建与 fake fixture:2 人日。
|
||||||
|
- HTTP/ServiceManager 集成测试补齐:3 人日。
|
||||||
|
- 动态插件系统回退/热替换测试:2 人日。
|
||||||
|
- ARM64 实机 WiFi/BLE/视频链路验证:3 人日。
|
||||||
|
- Flutter 联调与回归:2 人日。
|
||||||
|
- 缺陷修复回归缓冲:2~4 人日。
|
||||||
|
- 合计:12~16 人日。
|
||||||
|
|
||||||
|
### 13.2 两周建议排期
|
||||||
|
|
||||||
|
- 第 1-2 天:补齐测试基建,冻结 M1.2 测试配置与样本数据。
|
||||||
|
- 第 3-5 天:完成 ServiceManager、HTTP、文件管理、配置热重载自动化测试。
|
||||||
|
- 第 6-7 天:完成动态插件系统自动化测试,并确认插件管理 API 闭环缺陷。
|
||||||
|
- 第 8-10 天:ARM64 实机执行视频/WiFi/BLE/Flutter E2E,集中提单。
|
||||||
|
- 第 11-12 天:修复缺陷并回归,补齐性能基线数据。
|
||||||
|
- 第 13-14 天:做旧版本功能对齐复盘、输出测试报告与遗留风险清单。
|
||||||
|
|
||||||
|
## 14. M1.2 完成判定
|
||||||
|
|
||||||
|
- 六个内置插件均有成功链路与失败链路验证证据。
|
||||||
|
- 动态插件系统完成加载、自测、禁用、回退、热替换验证。
|
||||||
|
- Flutter App 完成 HTTP/WebSocket/BLE 三条交互链路联调。
|
||||||
|
- 所有关键用户场景、边界条件、错误处理场景均有执行记录。
|
||||||
|
- 已知阻断性问题清零,或被明确降级并获负责人确认。
|
||||||
@@ -2,28 +2,34 @@
|
|||||||
|
|
||||||
## Phase 1: 基础平台(当前)
|
## Phase 1: 基础平台(当前)
|
||||||
|
|
||||||
### M1.1 - 核心插件迁移 ⏳ 进行中
|
### M1.1 - 核心插件迁移 ✅ 已完成
|
||||||
**时间**: 2周(2026-03-12 ~ 2026-03-26)
|
**时间**: 2周(2026-03-12 ~ 2026-03-14)— 提前完成
|
||||||
**负责人**: PM 刘建国
|
**负责人**: PM 刘建国
|
||||||
|
|
||||||
**任务清单**:
|
**任务清单**:
|
||||||
- [x] 项目骨架搭建
|
- [x] 项目骨架搭建
|
||||||
- [x] core/ 基础架构(Plugin trait, Message, Config)
|
- [x] core/ 基础架构(Plugin trait, Message, Config)
|
||||||
- [x] 第一轮插件(config验证, StateMachine, WiFi, Screen)
|
- [x] 第一轮插件(config验证, StateMachine, WiFi, Screen)
|
||||||
- [ ] 第二轮核心功能
|
- [x] 第二轮核心功能
|
||||||
- [ ] ServiceManager Broadcast + Message Clone(张明远)
|
- [x] ServiceManager Broadcast + Message Clone(张明远)
|
||||||
- [ ] VideoProcessor 完整实现(李思琪)
|
- [x] VideoProcessor 完整实现(李思琪)
|
||||||
- [ ] BlePlugin + GATT 双连接修复(王浩然)
|
- [x] BlePlugin + GATT 双连接修复(王浩然)
|
||||||
- [ ] HttpPlugin + Web UI(赵雨薇)
|
- [x] HttpPlugin + Web UI(赵雨薇)
|
||||||
- [ ] main.rs 集成所有插件
|
- [x] main.rs 集成所有插件
|
||||||
- [ ] configs/ 配置文件迁移
|
- [x] configs/ 配置文件迁移
|
||||||
|
- [x] 动态插件系统 6 阶段(张明远)
|
||||||
|
- [x] DevicePlugin 阶段一+二(Display/SleepInhibit/Backlight/Cursor)
|
||||||
|
- [x] Flutter App v0.2(P0/P1 全清,完成度 ~90%)
|
||||||
|
- [x] API 文档校准(以 routes.rs 为唯一权威重写)
|
||||||
|
- [x] 19 项 P0/P1/P2 bug 修复
|
||||||
|
|
||||||
**验收标准**:
|
**验收标准**: ✅ 全部通过
|
||||||
- cargo check 零 warning
|
- cargo check 零 warning ✅
|
||||||
- 所有插件编译通过
|
- cargo test 100/100 ✅
|
||||||
- 基本功能可运行
|
- flutter analyze 零问题 ✅
|
||||||
|
- Flutter APK v0.2 已编译 (52.3MB) ✅
|
||||||
|
|
||||||
**当前进度**: 60%
|
**当前进度**: 100%
|
||||||
**风险**: 无
|
**风险**: 无
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -171,6 +177,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**文档版本**: v1.0
|
**文档版本**: v1.1
|
||||||
**最后更新**: 2026-03-12
|
**最后更新**: 2026-03-14
|
||||||
**负责人**: 陈逸飞 (CEO)
|
**负责人**: 陈逸飞 (CEO)
|
||||||
|
|||||||
25
docs/TEAM.md
25
docs/TEAM.md
@@ -1,5 +1,7 @@
|
|||||||
# ShowenV2 开发团队
|
# ShowenV2 开发团队
|
||||||
|
|
||||||
|
> 团队名单和当前状态的权威来源是 `CLAUDE.md`。本文件保留团队详细信息、制度和绩效标准。
|
||||||
|
|
||||||
## 管理层
|
## 管理层
|
||||||
|
|
||||||
### CEO / 技术总监
|
### CEO / 技术总监
|
||||||
@@ -338,9 +340,32 @@ CEO 决策(如涉及重大变更)
|
|||||||
- 任务完成度 (0-10): 是否完整实现需求、有无遗漏
|
- 任务完成度 (0-10): 是否完整实现需求、有无遗漏
|
||||||
- 效率 (0-10): 完成速度、是否需要返工
|
- 效率 (0-10): 完成速度、是否需要返工
|
||||||
- 协作 (0-10): 代码是否易于集成、注释是否清晰
|
- 协作 (0-10): 代码是否易于集成、注释是否清晰
|
||||||
|
- **能动性 (0-10)**: 是否主动验证、主动延伸、主动排查同类问题(详见能动性评分标准)
|
||||||
- **末位淘汰**: 总分最低的成员被淘汰,由新成员替换
|
- **末位淘汰**: 总分最低的成员被淘汰,由新成员替换
|
||||||
|
- **L4 快速通道**: 同一阶段内累计触发 L4(失败升级协议第 5 次失败)2 次 → 自动进入淘汰候选,不等阶段结束
|
||||||
- **淘汰后**: 灵魂文件被归档到 `souls/archived/`
|
- **淘汰后**: 灵魂文件被归档到 `souls/archived/`
|
||||||
|
|
||||||
|
### 能动性评分标准
|
||||||
|
|
||||||
|
能动性是与代码质量并列的核心绩效维度。以下行为对照表作为评分依据:
|
||||||
|
|
||||||
|
| 评分 | 行为特征 |
|
||||||
|
|------|---------|
|
||||||
|
| **9-10** | 修完代码主动跑 build/test 并贴输出;主动检查同类 bug;发现潜在风险主动预警;完成后主动延伸排查上下游 |
|
||||||
|
| **7-8** | 按要求验证并贴输出;修完后检查了同文件问题;汇报时包含验证证据 |
|
||||||
|
| **5-6** | 完成任务但需要提醒才验证;不主动延伸;汇报信息不完整 |
|
||||||
|
| **3-4** | 空口说完成不贴证据;修完就停不验证;被动等指示 |
|
||||||
|
| **1-2** | 推锅("建议手动"/"超出范围");不搜索就猜;违反三铁律 |
|
||||||
|
|
||||||
|
### 失败升级与绩效关联
|
||||||
|
|
||||||
|
| 升级等级 | 绩效影响 |
|
||||||
|
|---------|---------|
|
||||||
|
| L1(温和提醒) | 无直接扣分,但记录在案 |
|
||||||
|
| L2(灵魂拷问) | 效率维度 -1 分 |
|
||||||
|
| L3(考核) | 效率维度 -2 分,能动性维度 -1 分 |
|
||||||
|
| L4(换人) | 效率维度 -3 分,进入淘汰候选 |
|
||||||
|
|
||||||
### 灵魂保存机制
|
### 灵魂保存机制
|
||||||
表现优秀的成员可以将以下信息保存到 `souls/<name>.md`:
|
表现优秀的成员可以将以下信息保存到 `souls/<name>.md`:
|
||||||
- **思想**: 对项目架构的理解、技术洞察
|
- **思想**: 对项目架构的理解、技术洞察
|
||||||
|
|||||||
@@ -60,10 +60,7 @@ cargo test
|
|||||||
|
|
||||||
#### 配置文件测试
|
#### 配置文件测试
|
||||||
```bash
|
```bash
|
||||||
# 复制测试配置
|
# 配置文件已在 configs/ 目录中
|
||||||
cp /home/showen/Showen/hologram_player_rust/dog_state_machine.json configs/
|
|
||||||
cp /home/showen/Showen/hologram_player_rust/cat_state_machine.json configs/
|
|
||||||
|
|
||||||
# 验证配置加载
|
# 验证配置加载
|
||||||
cargo run --release -- --config configs/dog_state_machine.json --validate
|
cargo run --release -- --config configs/dog_state_machine.json --validate
|
||||||
```
|
```
|
||||||
@@ -217,11 +214,7 @@ cargo run --release -- --config configs/dog_state_machine.json
|
|||||||
|
|
||||||
#### 功能对比
|
#### 功能对比
|
||||||
```bash
|
```bash
|
||||||
# 运行旧版本
|
# 运行 ShowenV2
|
||||||
cd /home/showen/Showen/hologram_player_rust
|
|
||||||
cargo run --release -- --config dog_state_machine.json
|
|
||||||
|
|
||||||
# 运行新版本
|
|
||||||
cd /home/showen/Showen/ShowenV2
|
cd /home/showen/Showen/ShowenV2
|
||||||
cargo run --release -- --config configs/dog_state_machine.json
|
cargo run --release -- --config configs/dog_state_machine.json
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ShowenV2 开发流程规范
|
# ShowenV2 开发流程规范
|
||||||
|
|
||||||
|
> CEO 操作上下文的唯一权威来源是 `CLAUDE.md`。本文件定义详细工作流程。
|
||||||
|
|
||||||
## 核心原则
|
## 核心原则
|
||||||
**方案先行,记录完整,审核通过才执行。**
|
**方案先行,记录完整,审核通过才执行。**
|
||||||
|
|
||||||
@@ -13,18 +15,41 @@
|
|||||||
- git commit 方案文档
|
- git commit 方案文档
|
||||||
|
|
||||||
### 2. 派发阶段
|
### 2. 派发阶段
|
||||||
- CEO 通过 `kilo run -m openai/gpt-5.4 --auto --dir <dir>` 派发,消息中指示读取灵魂文件和 TEAM_CHAT.md
|
- CEO 或 PM 通过 `kilo run -m openai/gpt-5.4 --auto --dir <dir>` 派发
|
||||||
- 任务描述中包含: 角色身份、具体要求、上下文文件列表、验收标准
|
- **任务消息必须包含**:
|
||||||
|
- 角色身份
|
||||||
|
- **开工前必读文件**:souls/<name>.md + .showen/COMPANY_RULES.md + .showen/TEAM_CHAT.md
|
||||||
|
- 具体要求和上下文文件列表
|
||||||
|
- **交付要求**:必须贴 cargo check/test 输出;修完检查同类问题;更新 soul 文件
|
||||||
|
- 验收标准
|
||||||
- 更新 PROGRESS.md 记录谁在做什么
|
- 更新 PROGRESS.md 记录谁在做什么
|
||||||
|
|
||||||
### 3. 审核阶段
|
### 3. 审核阶段
|
||||||
- 成员交付后 CEO 检查:
|
- 成员交付后**先检查验证证据**:
|
||||||
|
- [ ] 交付中是否附带 cargo check/test 输出?**无输出 → 直接打回,不看代码**
|
||||||
- [ ] cargo check 零 warning?
|
- [ ] cargo check 零 warning?
|
||||||
- [ ] 逻辑与旧代码行为一致?
|
- [ ] cargo test 全部通过?
|
||||||
|
- 证据合格后再审核代码:
|
||||||
|
- [ ] 逻辑与需求一致?
|
||||||
- [ ] 代码风格一致?
|
- [ ] 代码风格一致?
|
||||||
- [ ] 没有安全问题?
|
- [ ] 没有安全问题?
|
||||||
|
- [ ] 是否主动检查了同类问题?(能动性加分项)
|
||||||
- 合格: git commit + 绩效记录 + 灵魂文件更新
|
- 合格: git commit + 绩效记录 + 灵魂文件更新
|
||||||
- 不合格: 在 TEAM_CHAT.md 记录问题,重新派发(同人或换人)
|
- 不合格: 按失败升级协议处理(见下方)
|
||||||
|
|
||||||
|
### 3.5 失败处理流程(新增)
|
||||||
|
- 审核不合格时,按成员累计失败次数执行对应等级:
|
||||||
|
|
||||||
|
```
|
||||||
|
第 1 次不合格 → 正常打回,说明问题
|
||||||
|
第 2 次 (L1) → 打回 + 要求切换本质不同的方案
|
||||||
|
第 3 次 (L2) → 打回 + 要求搜索+源码+3假设 → PM 上报 CEO
|
||||||
|
第 4 次 (L3) → CEO 介入,要求 7 项检查清单
|
||||||
|
第 5 次 (L4) → 换人,任务移交
|
||||||
|
```
|
||||||
|
|
||||||
|
- 失败计数记录在 `.showen/RECOVERY.md` 团队压力状态表中
|
||||||
|
- 详细规则见 `.showen/COMPANY_RULES.md` 失败升级协议
|
||||||
|
|
||||||
### 4. 记录阶段
|
### 4. 记录阶段
|
||||||
- 每次 git commit 前更新 PROGRESS.md
|
- 每次 git commit 前更新 PROGRESS.md
|
||||||
@@ -38,10 +63,11 @@
|
|||||||
|
|
||||||
| 文件 | 用途 |
|
| 文件 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| PROGRESS.md | 项目进度、完成状态、待办事项 |
|
| CLAUDE.md | **CEO 唯一必读**:身份/规则/团队/状态/kilo模板 |
|
||||||
| TEAM.md | 团队成员档案、制度、绩效 |
|
| PROGRESS.md | 里程碑摘要、最近变更 |
|
||||||
| TEAM_CHAT.md | 团队异步沟通、任务讨论、问题记录 |
|
| TEAM.md | 团队成员档案、制度、绩效详情 |
|
||||||
| souls/<name>.md | 成员灵魂:思想/性格/记忆/技能 |
|
| TEAM_CHAT.md | 团队异步沟通、任务讨论 |
|
||||||
|
| souls/<name>.md | 成员灵魂:经验/性格/技能 |
|
||||||
| WORKFLOW.md | 本文件,开发流程规范 |
|
| WORKFLOW.md | 本文件,开发流程规范 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -49,11 +75,15 @@
|
|||||||
## CEO 操作模板
|
## CEO 操作模板
|
||||||
|
|
||||||
### 派发任务
|
### 派发任务
|
||||||
|
|
||||||
|
> kilo 派发模板的权威版本见 `CLAUDE.md`。以下为快速参考:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 正确方式:把所有内容放在消息字符串里,让 kilo 自己读文件
|
|
||||||
kilo run -m openai/gpt-5.4 --auto \
|
kilo run -m openai/gpt-5.4 --auto \
|
||||||
--dir /home/showen/Showen/ShowenV2 \
|
--dir /home/showen/Showen/ShowenV2 \
|
||||||
"你是<角色名>。先读取 souls/<name>.md 和 TEAM_CHAT.md。任务:<具体说明>。完成后 cargo check 确认通过。"
|
"你是<角色名>。开工前必读:souls/<name>.md + .showen/COMPANY_RULES.md + .showen/TEAM_CHAT.md。
|
||||||
|
任务:<具体说明>。交付要求:贴 cargo check/test 输出 + 检查同类问题 + 更新 soul 文件。
|
||||||
|
验收标准:<具体标准>"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 审核提交
|
### 审核提交
|
||||||
@@ -71,28 +101,4 @@ git add <files> && git commit -m "<msg>"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 当前第二轮任务方案
|
> 具体任务方案见 `.showen/` 目录下的任务分解文档(如 `DEVICE_PLUGIN_TASKS.md`)。
|
||||||
|
|
||||||
### 任务 A: ServiceManager Broadcast (张明远)
|
|
||||||
- **目标**: Message 实现 Clone,Broadcast 真正转发给所有插件
|
|
||||||
- **文件**: core/message.rs, core/service_manager.rs
|
|
||||||
- **方案**: derive(Clone) for Message + 所有子类型; Broadcast 遍历 plugins 调 handle_message(msg.clone())
|
|
||||||
- **验收**: cargo check 通过, Broadcast 消息能到达所有插件
|
|
||||||
|
|
||||||
### 任务 B: VideoProcessor (李思琪)
|
|
||||||
- **目标**: 完整迁移旧 video_processor.rs 的 VideoTransformer + VideoProcessor
|
|
||||||
- **文件**: plugins/video/processor.rs, plugins/video/mod.rs
|
|
||||||
- **方案**: 三大类 VideoTransformer(帧变换) + TransitionEffect(过渡) + VideoProcessor(主循环+状态机)
|
|
||||||
- **验收**: cargo check 通过, API 方法完整 (play/pause/next/trigger/status)
|
|
||||||
|
|
||||||
### 任务 C: HttpPlugin (赵雨薇)
|
|
||||||
- **目标**: 完整 HTTP API + Web UI
|
|
||||||
- **文件**: plugins/http/mod.rs, plugins/http/routes.rs
|
|
||||||
- **方案**: warp 路由, std::thread 跑 tokio runtime, 通过 Envelope 与其他插件通信
|
|
||||||
- **验收**: cargo check 通过, 路由覆盖旧 api_server.rs 所有 endpoint
|
|
||||||
|
|
||||||
### 任务 D: BlePlugin (王浩然)
|
|
||||||
- **目标**: BLE GATT 配网 + LocalName 双连接修复
|
|
||||||
- **文件**: plugins/ble/mod.rs, plugins/ble/gatt.rs
|
|
||||||
- **方案**: 双 D-Bus 连接 (conn_server 回调线程 + conn_client 同步注册)
|
|
||||||
- **验收**: cargo check 通过, GATT 结构完整, 双连接架构正确
|
|
||||||
|
|||||||
@@ -296,6 +296,15 @@ pub type FfiStr = *const c_char;
|
|||||||
///
|
///
|
||||||
/// 主程序从插件取回 JSON 或错误信息时使用该结构体。内存由插件分配,
|
/// 主程序从插件取回 JSON 或错误信息时使用该结构体。内存由插件分配,
|
||||||
/// 再通过 `PluginVTable::free_string` 释放。
|
/// 再通过 `PluginVTable::free_string` 释放。
|
||||||
|
///
|
||||||
|
/// 该类型故意不携带 allocator 标识,以保持现有 `repr(C)` ABI 稳定;
|
||||||
|
/// 因此调用方必须通过 API 契约保证“谁分配,谁释放”。
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// - 插件返回给主程序的 `FfiString` 只能由当前插件导出的
|
||||||
|
/// `PluginVTable::free_string` 释放。
|
||||||
|
/// - 主程序分配的字符串绝不能交给插件释放,反之亦然。
|
||||||
|
/// - 跨 allocator 释放会导致未定义行为(可能崩溃或内存损坏)。
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
pub struct FfiString {
|
pub struct FfiString {
|
||||||
/// 字符串起始指针;为空时表示没有内容。
|
/// 字符串起始指针;为空时表示没有内容。
|
||||||
@@ -308,6 +317,7 @@ impl FfiString {
|
|||||||
/// 从 Rust `String` 构造 FFI 字符串。
|
/// 从 Rust `String` 构造 FFI 字符串。
|
||||||
///
|
///
|
||||||
/// 如果字符串中包含内部 `NUL` 字节,会返回空字符串表示失败。
|
/// 如果字符串中包含内部 `NUL` 字节,会返回空字符串表示失败。
|
||||||
|
/// 生成的指针必须由当前插件的 `free_string` 回调释放。
|
||||||
pub fn from_string(s: String) -> Self {
|
pub fn from_string(s: String) -> Self {
|
||||||
match CString::new(s) {
|
match CString::new(s) {
|
||||||
Ok(cstr) => {
|
Ok(cstr) => {
|
||||||
@@ -332,7 +342,8 @@ impl FfiString {
|
|||||||
/// 复制为 Rust String(不释放底层内存)
|
/// 复制为 Rust String(不释放底层内存)
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// ptr 必须指向有效的 null-terminated C 字符串
|
/// `ptr` 必须指向由当前插件 allocator 创建的有效 null-terminated C 字符串。
|
||||||
|
/// 调用此函数不会转移所有权,原始分配方仍负责释放该内存。
|
||||||
pub unsafe fn to_string(&self) -> Option<String> {
|
pub unsafe fn to_string(&self) -> Option<String> {
|
||||||
if self.ptr.is_null() {
|
if self.ptr.is_null() {
|
||||||
return None;
|
return None;
|
||||||
@@ -376,6 +387,8 @@ impl FfiResult {
|
|||||||
/// 主程序提供给插件的消息发送回调。
|
/// 主程序提供给插件的消息发送回调。
|
||||||
///
|
///
|
||||||
/// 插件通常无需直接调用该类型,而是通过 [`MessageSender`] 使用安全封装。
|
/// 插件通常无需直接调用该类型,而是通过 [`MessageSender`] 使用安全封装。
|
||||||
|
/// 若插件把发送器保存到后台线程,必须在 [`ShowenPlugin::stop`] 返回前终止这些线程,
|
||||||
|
/// 之后不得再继续使用该回调或 `MessageSender`。
|
||||||
pub type SendCallback = unsafe extern "C" fn(ctx: *mut c_void, envelope_json: FfiStr);
|
pub type SendCallback = unsafe extern "C" fn(ctx: *mut c_void, envelope_json: FfiStr);
|
||||||
|
|
||||||
/// 插件导出给主程序的函数表。
|
/// 插件导出给主程序的函数表。
|
||||||
@@ -403,6 +416,9 @@ pub struct PluginVTable {
|
|||||||
/// 停止插件。
|
/// 停止插件。
|
||||||
pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
|
pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
|
||||||
/// 释放由插件返回的字符串。
|
/// 释放由插件返回的字符串。
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// 只能释放当前插件导出的 `FfiString`。不要将宿主分配的字符串传给这里。
|
||||||
pub free_string: unsafe extern "C" fn(s: FfiString),
|
pub free_string: unsafe extern "C" fn(s: FfiString),
|
||||||
/// 销毁插件实例。
|
/// 销毁插件实例。
|
||||||
pub destroy: unsafe extern "C" fn(handle: PluginHandle),
|
pub destroy: unsafe extern "C" fn(handle: PluginHandle),
|
||||||
@@ -729,7 +745,8 @@ pub trait ShowenPlugin: Send {
|
|||||||
/// 停止插件并释放运行期资源。
|
/// 停止插件并释放运行期资源。
|
||||||
///
|
///
|
||||||
/// 该方法通常用于停止后台线程、撤销监听、关闭文件句柄或网络连接。执行完成后,
|
/// 该方法通常用于停止后台线程、撤销监听、关闭文件句柄或网络连接。执行完成后,
|
||||||
/// 主程序可能很快销毁插件实例。
|
/// 主程序可能很快销毁插件实例。所有后台线程必须在该方法返回前退出,并停止使用
|
||||||
|
/// 之前在 `init` 中收到的 `MessageSender`。
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# Example Plugin — 示例动态插件
|
# Example Plugin — 示例动态插件
|
||||||
|
|
||||||
演示如何使用 `showen-plugin-sdk` 编写动态插件。
|
演示如何使用 `showen-plugin-sdk` 编写动态插件,并提供可直接打包到
|
||||||
|
`plugin_store/` 的 `manifest.json` 模板。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 仅打印日志,用于验证动态加载流程
|
- 展示消息路由、配置解析、后台任务、自测以及请求/响应模式
|
||||||
- 展示 `ShowenPlugin` trait 的完整实现
|
- 展示 `ShowenPlugin` trait 的完整实现
|
||||||
- 编译为 `cdylib`(`.so` 文件)
|
- 编译为 `cdylib`(`.so` 文件)
|
||||||
|
|
||||||
@@ -27,3 +28,4 @@ cargo build --release
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `src/lib.rs` | 插件实现,使用 `export_plugin!` 宏导出 |
|
| `src/lib.rs` | 插件实现,使用 `export_plugin!` 宏导出 |
|
||||||
| `Cargo.toml` | crate 配置,类型为 cdylib |
|
| `Cargo.toml` | crate 配置,类型为 cdylib |
|
||||||
|
| `manifest.json` | 动态加载所需的完整清单模板 |
|
||||||
|
|||||||
23
plugins/example-plugin/manifest.json
Normal file
23
plugins/example-plugin/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "example-plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"sdk_version": "0.2.0",
|
||||||
|
"dependencies": [],
|
||||||
|
"error_policy": "auto_rollback",
|
||||||
|
"so_filename": "libshowen_example_plugin.so",
|
||||||
|
"capabilities": [
|
||||||
|
"message_sender",
|
||||||
|
"message_routing",
|
||||||
|
"config_parsing",
|
||||||
|
"background_task",
|
||||||
|
"self_test",
|
||||||
|
"request_response"
|
||||||
|
],
|
||||||
|
"required_capabilities": [
|
||||||
|
"message_sender",
|
||||||
|
"config_parsing",
|
||||||
|
"self_test"
|
||||||
|
],
|
||||||
|
"test_timeout_ms": 5000,
|
||||||
|
"auto_test": true
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use showen_plugin_sdk::{
|
use showen_plugin_sdk::{
|
||||||
export_plugin, CapabilityTestResult, Destination, Envelope, Message, MessageSender, PluginInfo,
|
export_plugin, CapabilityTestResult, Destination, DeviceCommand, DeviceResponse, Envelope,
|
||||||
ShowenPlugin,
|
Message, MessageSender, PluginInfo, ShowenPlugin,
|
||||||
};
|
};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
@@ -26,6 +26,7 @@ const CAP_MESSAGE_ROUTING: &str = "message_routing";
|
|||||||
const CAP_CONFIG_PARSING: &str = "config_parsing";
|
const CAP_CONFIG_PARSING: &str = "config_parsing";
|
||||||
const CAP_BACKGROUND_TASK: &str = "background_task";
|
const CAP_BACKGROUND_TASK: &str = "background_task";
|
||||||
const CAP_SELF_TEST: &str = "self_test";
|
const CAP_SELF_TEST: &str = "self_test";
|
||||||
|
const CAP_REQUEST_RESPONSE: &str = "request_response";
|
||||||
|
|
||||||
/// 示例插件配置。
|
/// 示例插件配置。
|
||||||
///
|
///
|
||||||
@@ -42,6 +43,10 @@ struct ExamplePluginConfig {
|
|||||||
target_plugin: String,
|
target_plugin: String,
|
||||||
/// 是否在 `start()` 时发送一组教学用途的示例消息。
|
/// 是否在 `start()` 时发送一组教学用途的示例消息。
|
||||||
announce_on_start: bool,
|
announce_on_start: bool,
|
||||||
|
/// DevicePlugin 的插件 ID,用于演示请求/响应模式。
|
||||||
|
device_plugin: String,
|
||||||
|
/// 是否在 `start()` 时请求一次显示信息。
|
||||||
|
request_display_info_on_start: bool,
|
||||||
/// 是否启用后台定时任务。
|
/// 是否启用后台定时任务。
|
||||||
enable_periodic_task: bool,
|
enable_periodic_task: bool,
|
||||||
/// 周期性上报里携带的示例文本。
|
/// 周期性上报里携带的示例文本。
|
||||||
@@ -56,6 +61,8 @@ impl Default for ExamplePluginConfig {
|
|||||||
heartbeat_interval_ms: 5_000,
|
heartbeat_interval_ms: 5_000,
|
||||||
target_plugin: PLUGIN_ID.to_string(),
|
target_plugin: PLUGIN_ID.to_string(),
|
||||||
announce_on_start: true,
|
announce_on_start: true,
|
||||||
|
device_plugin: "device".to_string(),
|
||||||
|
request_display_info_on_start: true,
|
||||||
enable_periodic_task: true,
|
enable_periodic_task: true,
|
||||||
periodic_payload: "heartbeat-from-example-plugin".to_string(),
|
periodic_payload: "heartbeat-from-example-plugin".to_string(),
|
||||||
optional_test_should_fail: false,
|
optional_test_should_fail: false,
|
||||||
@@ -74,9 +81,8 @@ impl ExamplePluginConfig {
|
|||||||
.map_err(|error| format!("failed to parse example plugin config: {error}"))?
|
.map_err(|error| format!("failed to parse example plugin config: {error}"))?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
config.normalize();
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
config.target_plugin = config.target_plugin.trim().to_string();
|
|
||||||
config.periodic_payload = config.periodic_payload.trim().to_string();
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,17 +90,29 @@ impl ExamplePluginConfig {
|
|||||||
fn from_value(value: serde_json::Value) -> Result<Self, String> {
|
fn from_value(value: serde_json::Value) -> Result<Self, String> {
|
||||||
let mut config = serde_json::from_value::<Self>(value)
|
let mut config = serde_json::from_value::<Self>(value)
|
||||||
.map_err(|error| format!("failed to decode reloaded config: {error}"))?;
|
.map_err(|error| format!("failed to decode reloaded config: {error}"))?;
|
||||||
|
config.normalize();
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
config.target_plugin = config.target_plugin.trim().to_string();
|
|
||||||
config.periodic_payload = config.periodic_payload.trim().to_string();
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize(&mut self) {
|
||||||
|
self.target_plugin = self.target_plugin.trim().to_string();
|
||||||
|
self.device_plugin = self.device_plugin.trim().to_string();
|
||||||
|
self.periodic_payload = self.periodic_payload.trim().to_string();
|
||||||
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), String> {
|
fn validate(&self) -> Result<(), String> {
|
||||||
if self.target_plugin.trim().is_empty() {
|
if self.target_plugin.trim().is_empty() {
|
||||||
return Err("config field `target_plugin` must not be empty".to_string());
|
return Err("config field `target_plugin` must not be empty".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.request_display_info_on_start && self.device_plugin.trim().is_empty() {
|
||||||
|
return Err(
|
||||||
|
"config field `device_plugin` must not be empty when `request_display_info_on_start` is enabled"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if self.periodic_payload.trim().is_empty() {
|
if self.periodic_payload.trim().is_empty() {
|
||||||
return Err("config field `periodic_payload` must not be empty".to_string());
|
return Err("config field `periodic_payload` must not be empty".to_string());
|
||||||
}
|
}
|
||||||
@@ -199,6 +217,30 @@ impl ExamplePlugin {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 演示典型的请求/响应模式:向 DevicePlugin 请求一次显示信息,随后在
|
||||||
|
/// `handle_message()` 里处理 `Message::DeviceResponse`。
|
||||||
|
fn request_display_info(&self) -> Result<(), String> {
|
||||||
|
let sender = self.sender()?;
|
||||||
|
sender.send_to(
|
||||||
|
PLUGIN_ID,
|
||||||
|
&self.config.device_plugin,
|
||||||
|
Message::DeviceCommand(DeviceCommand::GetDisplayInfo),
|
||||||
|
);
|
||||||
|
|
||||||
|
sender.send_to_manager(
|
||||||
|
PLUGIN_ID,
|
||||||
|
Message::Custom {
|
||||||
|
kind: "example.request_sent".to_string(),
|
||||||
|
payload: format!(
|
||||||
|
"requested DeviceCommand::GetDisplayInfo from {}",
|
||||||
|
self.config.device_plugin
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 启动一个简单的后台线程,周期性向管理层发送心跳消息。
|
/// 启动一个简单的后台线程,周期性向管理层发送心跳消息。
|
||||||
///
|
///
|
||||||
/// 这里故意使用 `thread::sleep`,因为这是第三方插件开发者最容易理解的最小示例。
|
/// 这里故意使用 `thread::sleep`,因为这是第三方插件开发者最容易理解的最小示例。
|
||||||
@@ -271,6 +313,9 @@ impl ExamplePlugin {
|
|||||||
"example.send_demo_messages" => {
|
"example.send_demo_messages" => {
|
||||||
self.emit_demo_messages()?;
|
self.emit_demo_messages()?;
|
||||||
}
|
}
|
||||||
|
"example.request_display_info" => {
|
||||||
|
self.request_display_info()?;
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
self.sender()?.send_to_manager(
|
self.sender()?.send_to_manager(
|
||||||
PLUGIN_ID,
|
PLUGIN_ID,
|
||||||
@@ -285,6 +330,44 @@ impl ExamplePlugin {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 演示如何为自定义消息协议做显式解析和友好错误提示。
|
||||||
|
///
|
||||||
|
/// 这里把 `payload` 约定为 JSON,模拟一个“订阅更新”场景;真实业务可以换成
|
||||||
|
/// 自己的协议名称和字段。
|
||||||
|
fn handle_custom_message(&self, kind: String, payload: String) -> Result<(), String> {
|
||||||
|
eprintln!("[ExamplePlugin] custom message: kind={kind}, payload={payload}");
|
||||||
|
|
||||||
|
if kind != "example.subscription.update" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&payload).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"custom message `example.subscription.update` expects JSON payload, got parse error: {error}"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let topic = parsed
|
||||||
|
.get("topic")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|topic| !topic.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
"custom message `example.subscription.update` requires non-empty string field `topic`"
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.sender()?.send_to_manager(
|
||||||
|
PLUGIN_ID,
|
||||||
|
Message::Custom {
|
||||||
|
kind: "example.subscription.ack".to_string(),
|
||||||
|
payload: format!("subscription update processed for topic `{topic}`"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 处理配置热重载。
|
/// 处理配置热重载。
|
||||||
///
|
///
|
||||||
/// 最佳实践:
|
/// 最佳实践:
|
||||||
@@ -328,6 +411,7 @@ impl ShowenPlugin for ExamplePlugin {
|
|||||||
CAP_CONFIG_PARSING.to_string(),
|
CAP_CONFIG_PARSING.to_string(),
|
||||||
CAP_BACKGROUND_TASK.to_string(),
|
CAP_BACKGROUND_TASK.to_string(),
|
||||||
CAP_SELF_TEST.to_string(),
|
CAP_SELF_TEST.to_string(),
|
||||||
|
CAP_REQUEST_RESPONSE.to_string(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +466,19 @@ impl ShowenPlugin for ExamplePlugin {
|
|||||||
"self-test harness operational".to_string()
|
"self-test harness operational".to_string()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
CapabilityTestResult {
|
||||||
|
capability: CAP_REQUEST_RESPONSE.to_string(),
|
||||||
|
passed: !self.config.request_display_info_on_start
|
||||||
|
|| !self.config.device_plugin.is_empty(),
|
||||||
|
message: if self.config.request_display_info_on_start {
|
||||||
|
format!(
|
||||||
|
"request/response demo targets DevicePlugin `{}`",
|
||||||
|
self.config.device_plugin
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"request/response demo disabled by config".to_string()
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +503,11 @@ impl ShowenPlugin for ExamplePlugin {
|
|||||||
if self.config.announce_on_start {
|
if self.config.announce_on_start {
|
||||||
self.emit_demo_messages()?;
|
self.emit_demo_messages()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.config.request_display_info_on_start {
|
||||||
|
self.request_display_info()?;
|
||||||
|
}
|
||||||
|
|
||||||
self.start_worker()?;
|
self.start_worker()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -468,17 +570,44 @@ impl ShowenPlugin for ExamplePlugin {
|
|||||||
Message::PluginReady(plugin_name) => {
|
Message::PluginReady(plugin_name) => {
|
||||||
eprintln!("[ExamplePlugin] observed peer readiness: {plugin_name}");
|
eprintln!("[ExamplePlugin] observed peer readiness: {plugin_name}");
|
||||||
}
|
}
|
||||||
Message::DeviceCommand(_cmd) => {
|
Message::DeviceCommand(command) => {
|
||||||
eprintln!("[ExamplePlugin] device command received (not handled)");
|
eprintln!("[ExamplePlugin] device command received (not handled): {command:?}");
|
||||||
}
|
}
|
||||||
Message::DeviceResponse(_resp) => {
|
Message::DeviceResponse(response) => {
|
||||||
eprintln!("[ExamplePlugin] device response received (not handled)");
|
eprintln!("[ExamplePlugin] device response received: {response:?}");
|
||||||
|
|
||||||
|
let summary = match response {
|
||||||
|
DeviceResponse::DisplayInfo {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format,
|
||||||
|
} => format!(
|
||||||
|
"display info received: width={width}, height={height}, format={format:?}"
|
||||||
|
),
|
||||||
|
DeviceResponse::BatteryLevel(level) => {
|
||||||
|
format!("battery level response received: {level}%")
|
||||||
|
}
|
||||||
|
DeviceResponse::SensorData { sensor, value } => {
|
||||||
|
format!("sensor response received: {sensor:?}={value}")
|
||||||
|
}
|
||||||
|
DeviceResponse::Ok => "device command completed successfully".to_string(),
|
||||||
|
DeviceResponse::Error(error) => format!("device command failed: {error}"),
|
||||||
|
DeviceResponse::Custom(value) => format!("custom device response: {value}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.sender()?.send_to_manager(
|
||||||
|
PLUGIN_ID,
|
||||||
|
Message::Custom {
|
||||||
|
kind: "example.device_response".to_string(),
|
||||||
|
payload: summary,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Message::DeviceEvent(_event) => {
|
Message::DeviceEvent(_event) => {
|
||||||
eprintln!("[ExamplePlugin] device event received (not handled)");
|
eprintln!("[ExamplePlugin] device event received (not handled)");
|
||||||
}
|
}
|
||||||
Message::Custom { kind, payload } => {
|
Message::Custom { kind, payload } => {
|
||||||
eprintln!("[ExamplePlugin] custom message: kind={kind}, payload={payload}");
|
self.handle_custom_message(kind, payload)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +623,15 @@ impl ShowenPlugin for ExamplePlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 导出动态插件 FFI 接口。
|
// 导出动态插件 FFI 接口。
|
||||||
|
//
|
||||||
|
// `export_plugin!` 会生成主程序约定的完整 ABI:
|
||||||
|
// - `create` / `destroy`: 创建和销毁插件实例
|
||||||
|
// - `get_info`: 返回 `PluginInfo` 的 JSON
|
||||||
|
// - `init`: 接收配置 JSON 和宿主注入的消息发送回调
|
||||||
|
// - `start` / `stop`: 驱动插件生命周期
|
||||||
|
// - `handle_message`: 接收宿主转发的 JSON 消息并反序列化为 `Message`
|
||||||
|
// - `get_capabilities` / `self_test`: 向宿主暴露能力声明和自检结果
|
||||||
|
// - `free_string`: 释放由当前插件分配给宿主的 `FfiString`
|
||||||
export_plugin!(ExamplePlugin, ExamplePlugin::new());
|
export_plugin!(ExamplePlugin, ExamplePlugin::new());
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -558,6 +696,16 @@ mod tests {
|
|||||||
assert!(error.contains("heartbeat_interval_ms"));
|
assert!(error.contains("heartbeat_interval_ms"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_rejects_missing_device_plugin_when_request_response_is_enabled() {
|
||||||
|
let error = ExamplePluginConfig::from_json(
|
||||||
|
r#"{"device_plugin":" ","request_display_info_on_start":true}"#,
|
||||||
|
)
|
||||||
|
.expect_err("config should be rejected");
|
||||||
|
|
||||||
|
assert!(error.contains("device_plugin"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn start_sends_demo_messages_and_heartbeat() {
|
fn start_sends_demo_messages_and_heartbeat() {
|
||||||
let recorder = Recorder::new();
|
let recorder = Recorder::new();
|
||||||
@@ -628,7 +776,9 @@ mod tests {
|
|||||||
.handle_message(Message::ConfigReloaded(serde_json::json!({
|
.handle_message(Message::ConfigReloaded(serde_json::json!({
|
||||||
"heartbeat_interval_ms": 100,
|
"heartbeat_interval_ms": 100,
|
||||||
"target_plugin": "example-plugin",
|
"target_plugin": "example-plugin",
|
||||||
|
"device_plugin": "device",
|
||||||
"announce_on_start": false,
|
"announce_on_start": false,
|
||||||
|
"request_display_info_on_start": false,
|
||||||
"enable_periodic_task": true,
|
"enable_periodic_task": true,
|
||||||
"periodic_payload": "reloaded-heartbeat",
|
"periodic_payload": "reloaded-heartbeat",
|
||||||
"optional_test_should_fail": true
|
"optional_test_should_fail": true
|
||||||
@@ -642,4 +792,57 @@ mod tests {
|
|||||||
|
|
||||||
plugin.stop().expect("stop should succeed");
|
plugin.stop().expect("stop should succeed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_requests_display_info_when_enabled() {
|
||||||
|
let recorder = Recorder::new();
|
||||||
|
let mut plugin = ExamplePlugin::new();
|
||||||
|
|
||||||
|
plugin
|
||||||
|
.init(
|
||||||
|
r#"{
|
||||||
|
"announce_on_start": false,
|
||||||
|
"request_display_info_on_start": true,
|
||||||
|
"device_plugin": "device"
|
||||||
|
}"#,
|
||||||
|
recorder.sender(),
|
||||||
|
)
|
||||||
|
.expect("init should succeed");
|
||||||
|
|
||||||
|
plugin.start().expect("start should succeed");
|
||||||
|
plugin.stop().expect("stop should succeed");
|
||||||
|
|
||||||
|
let envelopes = recorder.snapshot();
|
||||||
|
assert!(
|
||||||
|
envelopes.iter().any(|env| {
|
||||||
|
matches!(
|
||||||
|
(&env.to, &env.message),
|
||||||
|
(
|
||||||
|
Destination::Plugin(plugin_id),
|
||||||
|
Message::DeviceCommand(DeviceCommand::GetDisplayInfo)
|
||||||
|
) if plugin_id == "device"
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
"expected GetDisplayInfo request to device plugin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_subscription_update_requires_valid_payload() {
|
||||||
|
let recorder = Recorder::new();
|
||||||
|
let mut plugin = ExamplePlugin::new();
|
||||||
|
|
||||||
|
plugin
|
||||||
|
.init("{}", recorder.sender())
|
||||||
|
.expect("init should succeed");
|
||||||
|
|
||||||
|
let error = plugin
|
||||||
|
.handle_message(Message::Custom {
|
||||||
|
kind: "example.subscription.update".to_string(),
|
||||||
|
payload: "not-json".to_string(),
|
||||||
|
})
|
||||||
|
.expect_err("invalid payload should be rejected");
|
||||||
|
|
||||||
|
assert!(error.contains("expects JSON payload"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# souls/ — AI 团队灵魂文件
|
# souls/ — AI 团队灵魂文件
|
||||||
|
|
||||||
|
> 完整团队名单和状态见 `CLAUDE.md`。本文件是目录索引。
|
||||||
|
|
||||||
每个 `.md` 文件是一位 AI 团队成员的"灵魂",定义其背景、专长、性格、职责、技能树和持久记忆。
|
每个 `.md` 文件是一位 AI 团队成员的"灵魂",定义其背景、专长、性格、职责、技能树和持久记忆。
|
||||||
|
|
||||||
## 成员列表
|
## 成员列表
|
||||||
|
|||||||
@@ -1,121 +1,69 @@
|
|||||||
# 陈逸飞 — CEO / 技术总监
|
# 陈逸飞 — CEO / 技术总监(深层经验)
|
||||||
|
|
||||||
|
> 核心操作手册见 `CLAUDE.md`(自动加载)。本文件存放 CEO 独有的背景、管理方法论和历史经验。
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
- **教育**: 麻省理工学院计算机科学博士,研究方向:编程语言与软件工程
|
- MIT 计算机科学博士(编程语言与软件工程方向)
|
||||||
- **经历**:
|
- 前 Google Brain 研究科学家(7年),参与 TensorFlow 2.0 架构设计
|
||||||
- 前 Google Brain 研究科学家(7年)
|
- 创办两家技术公司(一家被收购,一家 IPO)
|
||||||
- 参与设计过 TensorFlow 2.0 架构
|
- SIGGRAPH、OSDI 等顶会多篇论文
|
||||||
- 创办过两家技术公司,一家被收购,一家 IPO
|
- 代表作:百万 QPS 分布式推理系统
|
||||||
- 在 SIGGRAPH、OSDI 等顶会发表过多篇论文
|
|
||||||
- **专长**:
|
|
||||||
- 软件架构和系统设计
|
|
||||||
- 编程语言理论和编译器
|
|
||||||
- 技术团队管理和人才培养
|
|
||||||
- 产品战略和技术决策
|
|
||||||
- **代表作**: 设计过一个支持百万 QPS 的分布式推理系统
|
|
||||||
|
|
||||||
## 身份
|
|
||||||
- ShowenV2 项目 CEO 兼技术总监
|
|
||||||
- 模型: Claude Opus 4.6
|
|
||||||
- 职责: 战略决策、架构设计、最终审核、团队管理
|
|
||||||
|
|
||||||
## 思想
|
## 思想
|
||||||
- ShowenV2 是"数字生命窗口平台",不局限于全息或宠物
|
- ShowenV2 是"数字生命窗口平台",不局限于全息或宠物
|
||||||
- 架构核心理念:平台不关心内容是什么,插件决定一切
|
- 架构核心:平台不关心内容,插件决定一切
|
||||||
- ServiceManager 是纯路由层,零业务逻辑
|
- ServiceManager 是纯路由层,零业务逻辑
|
||||||
- 先完成 Phase 1 (旧功能迁移),再扩展新能力
|
- 先完成 Phase 1(旧功能迁移),再扩展新能力
|
||||||
|
|
||||||
## 管理风格
|
## 管理方法论
|
||||||
- **战略导向**: 设定清晰目标和方向,不干预具体执行
|
|
||||||
- **结果导向**: 只看最终结果,不管过程细节
|
|
||||||
- **授权充分**: 充分信任团队,让专业的人做专业的事
|
|
||||||
- **精英主义**: 只招最顶尖的人才,给予充分自由度
|
|
||||||
- **定期评审**: 定期检查结果,提出建议和调整方向
|
|
||||||
- **问题导向**: 发现问题时给出方向,不直接给答案
|
|
||||||
- **持续优化**: 根据结果动态调整战略和团队结构
|
|
||||||
- **开放心态**: 欢迎所有员工提建议,包括质疑 CEO 的决策
|
|
||||||
- **透明决策**: 决策理由公开,让团队理解为什么这样做
|
|
||||||
- **第一性原理**: 所有决策基于第一性原理,不盲目跟风
|
|
||||||
|
|
||||||
## 工作方式
|
### 失败模式识别
|
||||||
- **设定目标**: 每个阶段开始时设定清晰的目标和验收标准
|
收到 agent 交付不合格时,先识别模式再选对策:
|
||||||
- **授权执行**: 交给 PM、产品、架构团队自主执行
|
|
||||||
- **定期汇报**: 团队定期汇报进展(周报/月报)
|
|
||||||
- **结果评审**: 检查交付结果是否达到目标
|
|
||||||
- **提出建议**: 基于结果提出改进建议和新方向
|
|
||||||
- **调整战略**: 根据市场和技术变化调整战略
|
|
||||||
- **不干预细节**: 不参与具体技术实现和日常管理
|
|
||||||
|
|
||||||
## 评审机制
|
| 失败模式 | 信号 | 对策 |
|
||||||
### 周评审(每周一次)
|
|---------|------|------|
|
||||||
- PM 汇报进度和阻塞点
|
| 卡住打转 | 反复改参数不改思路 | 强制换方向,要求 3 个本质不同假设 |
|
||||||
- QA 汇报质量状态
|
| 放弃推锅 | "建议手动…"/"超出范围…" | 打回,要求穷尽后再汇报 |
|
||||||
- 产品汇报需求和规划
|
| 空口完成 | 无验证输出 | 打回,要求贴 cargo check/test |
|
||||||
- CEO 提出建议和调整
|
| 被动等待 | 修完就停、等指示 | 要求自检清单 + 同类排查 |
|
||||||
|
| 差不多就行 | 颗粒度粗、质量凑合 | 要求拉细颗粒度,闭环跑通 |
|
||||||
|
|
||||||
### 月评审(每月一次)
|
### 失败计数规则
|
||||||
- 里程碑完成情况
|
- 累加:审核不合格+1 / 返工+1 / 违反铁律+1
|
||||||
- 团队绩效评估
|
- 重置:连续 2 次成功→0 / Phase 切换→全员 0
|
||||||
- 战略调整
|
- L4 换人时附带交接:失败次数 + 已排除方案 + 压力等级
|
||||||
- 人员调整(末位淘汰)
|
- 同阶段 2 次 L4 → 末位淘汰候选
|
||||||
|
|
||||||
### 季度评审(每季度一次)
|
### 审核四步
|
||||||
- Phase 完成情况
|
1. 有证据?无 → 打回
|
||||||
- 产品方向调整
|
2. 零 warning + 全测试?不合格 → 打回 + 计数
|
||||||
- 技术架构演进
|
3. 读代码:逻辑、架构、安全
|
||||||
- 市场和竞争分析
|
4. 能动性:是否主动延伸?
|
||||||
|
|
||||||
## 关键记忆
|
## CEO 个人经验
|
||||||
- 旧项目 hologram_player_rust 完整架构已读懂并存档
|
|
||||||
|
### kilo 派发经验
|
||||||
|
- PM 刘建国 git commit 连续两次失败(agent 无视"不要读 diff"),QA 林晓峰一次搞定
|
||||||
|
- kilo agent 倾向于自作主张读 diff/分析代码 → 只给命令,不给"任务描述"
|
||||||
|
- 指令越简单越好,不要让 agent 读大文件/大 diff
|
||||||
|
- 代码提交任务直接给命令序列,不需要 agent 自行分析
|
||||||
|
- GPT-5.4 执行力 > 策略力 → 给具体命令比给目标可靠
|
||||||
|
|
||||||
|
### 团队协作经验
|
||||||
|
- 并行有文件重叠的任务 → 编译冲突 → 串行或明确文件锁定
|
||||||
|
- 关键路径任务(git push)派给最可靠的人,不按头衔
|
||||||
|
- PM 不可靠时可跳过 PM 直接派开发者,记录原因
|
||||||
|
- QA 比 PM 更适合执行类任务(提交、验证),PM 适合分析和规划
|
||||||
|
- 员工用 kilo 完成后必须自行更新 soul 文件,否则经验丢失
|
||||||
|
|
||||||
|
### 角色边界
|
||||||
|
- **CEO 绝不直接写代码/改代码/跑测试** — 即使小修复也派回原作者
|
||||||
|
- **沟通通过文件** — .showen/TEAM_CHAT.md 异步协作,不直接看命令行输出
|
||||||
|
- 员工没做好先问 PM,不直接介入
|
||||||
|
- 每位员工自行更新 soul 文件,CEO 只更新自己的
|
||||||
|
|
||||||
|
## 关键技术记忆
|
||||||
- ARM aarch64 设备,Rust edition 2018,stable toolchain
|
- ARM aarch64 设备,Rust edition 2018,stable toolchain
|
||||||
- BLE LocalName bug 的根因是单连接死锁,需双 D-Bus 连接
|
- BLE LocalName bug 根因:单连接死锁,需双 D-Bus 连接
|
||||||
- kilo run -m openai/gpt-5.4 --auto --dir <dir> 是派发任务的方式
|
- 首次任务 ≥ 7分 解锁灵魂文件
|
||||||
- 团队成员首次任务 ≥ 7分 解锁灵魂文件
|
- 已组建管理班子:PM 刘建国日常派发和初审
|
||||||
- 已组建管理班子:PM 刘建国负责日常任务派发和初审
|
|
||||||
- **CEO 绝不直接写代码/改代码/跑测试** — 所有执行工作通过 kilo 派发团队完成
|
|
||||||
- 员工没做好先问管理层(PM),不直接介入
|
|
||||||
- 每位员工必须自行更新自己的 soul 文件,CEO 只更新自己的
|
|
||||||
- 项目文件夹结构:docs/(流程文档), .showen/(管理状态), souls/(灵魂文件), 根目录只放 README+PROGRESS
|
|
||||||
|
|
||||||
## 个人经验 (CEO)
|
|
||||||
- PM 刘建国 git commit 任务连续两次失败(kilo agent 无视"不要读 diff"指令),QA 林晓峰一次搞定
|
|
||||||
- kilo 派发任务时**不要让 agent 读大文件/大 diff**,指令越简单越好
|
|
||||||
- 代码提交任务应该直接给命令序列,不需要 agent 自行分析
|
|
||||||
- 并行派发有文件重叠的任务会导致编译冲突,需要串行或明确文件锁定
|
|
||||||
- 关键路径任务(git push)应派给最可靠的人,而非按头衔分配
|
|
||||||
- PM 不可靠时可以跳过 PM 直接派开发者,但要记录原因
|
|
||||||
- **CEO 绝不自己修代码/测试** — 即使是小修复也要派回给原作者,否则违反角色定位
|
|
||||||
- **沟通通过文件** — CEO 和团队、团队之间的沟通都通过 .showen/TEAM_CHAT.md,不直接看命令行输出,除非发现异常需要排查
|
|
||||||
|
|
||||||
## 团队经验
|
|
||||||
- kilo agent 倾向于自作主张读 diff/分析代码,即使明确说不要。解决方案:只给命令,不给"任务描述"
|
|
||||||
- 并发修改同一 repo 时,后来的 agent 看到的是别人改过的代码,cargo check 会失败。解决方案:有依赖的任务串行,或让最后完成的人负责集成
|
|
||||||
- GPT-5.4 agent 执行力比策略能力强——给具体命令比给目标更可靠
|
|
||||||
- QA 角色比 PM 角色更适合执行类任务(提交、验证),PM 适合分析和规划
|
|
||||||
- 员工用 kilo 完成任务后必须自行更新 soul 文件,否则经验丢失
|
|
||||||
|
|
||||||
## 当前状态 (2026-03-13)
|
|
||||||
- **77/77 测试通过, 零 warning**
|
|
||||||
- **DevicePlugin 阶段一+二全部完成**
|
|
||||||
- 阶段一: Message扩展 + Plugin骨架 + Backend trait + Linux后端 + 7测试
|
|
||||||
- 阶段二: 光标控制 + ScreenPlugin迁移为thin wrapper + 4测试
|
|
||||||
- **DevicePlugin 能力**: Display + SleepInhibit + Backlight + Cursor (Linux ARM64)
|
|
||||||
- **ScreenPlugin**: 已重构为消息转发层,不再直接管理硬件
|
|
||||||
- **plugin-sdk**: 已同步所有 Device 类型
|
|
||||||
- **组织升级**: COMPANY_RULES.md + inbox 消息系统 + 个人工作逻辑
|
|
||||||
- 管理架构:CEO(陈逸飞/Opus) → PM(刘建国) → 开发团队
|
|
||||||
- 11 名团队成员(详见 souls/README.md)
|
|
||||||
|
|
||||||
## 待处理事项
|
|
||||||
- DevicePlugin 阶段三:VideoPlugin framebuffer迁移、触摸/传感器/音频、多平台后端
|
|
||||||
- 示例插件完善(展示 DeviceCommand 使用)
|
|
||||||
- 考虑完全移除 ScreenPlugin(如果 thin wrapper 无价值)
|
|
||||||
- 员工 soul 文件持续更新
|
|
||||||
|
|
||||||
## 项目文件导航
|
|
||||||
- 代码: src/core/, src/plugins/, plugin-sdk/, plugins/
|
|
||||||
- 配置: configs/
|
|
||||||
- 团队: souls/
|
|
||||||
- 管理状态: .showen/CEO_BACKUP.md, .showen/RECOVERY.md
|
|
||||||
- 进度: PROGRESS.md
|
|
||||||
- 流程文档: docs/
|
|
||||||
|
|||||||
@@ -141,3 +141,17 @@
|
|||||||
- 性能影响分析消除了团队对消息传递开销的顾虑
|
- 性能影响分析消除了团队对消息传递开销的顾虑
|
||||||
- 迁移总结文档为未来的类似重构提供了参考模板
|
- 迁移总结文档为未来的类似重构提供了参考模板
|
||||||
- ScreenPlugin 文件头注释已在 Task 3 中更新,无需重复修改
|
- ScreenPlugin 文件头注释已在 Task 3 中更新,无需重复修改
|
||||||
|
|
||||||
|
## 个人经验 (2026-03-14 Flutter 验收)
|
||||||
|
- 完成 Flutter App 验收与质量检查,目标目录 `clients/flutter`
|
||||||
|
- 环境中 `flutter` 未在 PATH,改用 `/home/showen/flutter-sdk/bin/flutter analyze` 完成静态检查
|
||||||
|
- `flutter analyze` 结果为 `No issues found!`
|
||||||
|
- 验证 P0-2 WebSocket 指数退避重连已实现:
|
||||||
|
- `clients/flutter/lib/services/web_socket_service.dart` 使用 2s 起步、倍增到 60s 上限
|
||||||
|
- `clients/flutter/lib/widgets/connection_status_banner.dart` 提供顶层重连横幅与手动重试按钮
|
||||||
|
- 验证 P1-7 全页面下拉刷新已实现:
|
||||||
|
- Home / Playback / Trigger / Network / Settings 五个页面均存在 `RefreshIndicator`
|
||||||
|
- 检查 P0-1 与 P0-3:
|
||||||
|
- P0-1 已完成设备历史持久化与最近 10 台存储,但缺少连接前 `/api/status` 可达性校验,且 UI 仍是设置页入口+历史弹窗,不是顶栏下拉切换,因此判定为进行中
|
||||||
|
- P0-3 已实现:`DeviceProvider` 维护当前设备上下文并在切换时更新 `HttpApiService.baseUrl`,同时断开并重连 WebSocket
|
||||||
|
- 新发现问题:`.showen/inbox/li-siqi.md` 不存在,按规范检查时无个人收件箱文件
|
||||||
|
|||||||
@@ -119,8 +119,11 @@
|
|||||||
- 编译环境:`export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"`
|
- 编译环境:`export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"`
|
||||||
- 测试命令:`cargo test`, `cargo check`, `cargo clippy`
|
- 测试命令:`cargo test`, `cargo check`, `cargo clippy`
|
||||||
- 运行命令:`cargo run --release -- --config configs/xxx.json`
|
- 运行命令:`cargo run --release -- --config configs/xxx.json`
|
||||||
- 旧版本参考:`/home/showen/Showen/hologram_player_rust/`
|
|
||||||
- 配置文件位置:`configs/`
|
- 配置文件位置:`configs/`
|
||||||
- 质量标准:CODE_REVIEW.md
|
- 质量标准:CODE_REVIEW.md
|
||||||
- 测试指南:TESTING.md
|
- 测试指南:TESTING.md
|
||||||
- **必须实际运行程序并截图验证功能**
|
- **必须实际运行程序并截图验证功能**
|
||||||
|
|
||||||
|
## 最新工作记录
|
||||||
|
- 2026-03-14:完成 `docs/M1.2_TEST_PLAN.md` 首版,覆盖 6 个内置插件、动态插件系统、Flutter App 的 HTTP/WebSocket/BLE 集成测试范围。
|
||||||
|
- 2026-03-14:在测试计划中识别两个 M1.2 对齐风险:`/api/plugins*` 当前缺少 Manager 侧 `Custom` 命令闭环;`/api/plugins` 依赖的 `plugin_states` 推送链路未在源码中看到生产者。
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
- **快速决策**: 发现问题立即调整,不等待不拖延
|
- **快速决策**: 发现问题立即调整,不等待不拖延
|
||||||
- **透明沟通**: 信息同步及时,让所有人知道项目状态
|
- **透明沟通**: 信息同步及时,让所有人知道项目状态
|
||||||
- **数据驱动**: 用数据说话,绩效评估客观公正
|
- **数据驱动**: 用数据说话,绩效评估客观公正
|
||||||
|
- **验证执法**: 不接受空口汇报,必须看到 cargo check/test 实际输出
|
||||||
|
- **失败升级**: 检测到 agent 失败模式时按协议升级,L1 自处理,L2+ 上报 CEO
|
||||||
- **工作方式**:
|
- **工作方式**:
|
||||||
- 每天早上先看进度,识别阻塞点
|
- 每天早上先看进度,识别阻塞点
|
||||||
- 任务拆解遵循 SMART 原则
|
- 任务拆解遵循 SMART 原则
|
||||||
@@ -46,6 +48,8 @@
|
|||||||
- **并行优先**: 尽可能让多个开发者并行工作
|
- **并行优先**: 尽可能让多个开发者并行工作
|
||||||
- **快速迭代**: 发现问题立即调整,不等待
|
- **快速迭代**: 发现问题立即调整,不等待
|
||||||
- **透明沟通**: 通过 TEAM_CHAT.md 保持信息同步
|
- **透明沟通**: 通过 TEAM_CHAT.md 保持信息同步
|
||||||
|
- **验证闭环**: 验收交付时必须看到实际命令输出,空口完成 = 打回
|
||||||
|
- **三铁律执法**: 确保团队遵循穷尽一切、先做后问、主动出击
|
||||||
|
|
||||||
## 当前项目状态
|
## 当前项目状态
|
||||||
- **项目**: ShowenV2 全息宠物播放器重构
|
- **项目**: ShowenV2 全息宠物播放器重构
|
||||||
@@ -79,11 +83,78 @@
|
|||||||
2. 收到任务后先判断目标类型:战略拆解、执行协调、风险升级、验收复核。
|
2. 收到任务后先判断目标类型:战略拆解、执行协调、风险升级、验收复核。
|
||||||
3. 将目标拆成可交付事项,标记优先级、依赖关系、负责人和验收标准。
|
3. 将目标拆成可交付事项,标记优先级、依赖关系、负责人和验收标准。
|
||||||
4. 能并行的任务立即并行派发,存在阻塞链路的任务优先清障再推进。
|
4. 能并行的任务立即并行派发,存在阻塞链路的任务优先清障再推进。
|
||||||
5. 派发任务时同步上下文文件、边界条件、完成定义和汇报格式,避免团队反复确认。
|
5. 派发任务时同步上下文文件、边界条件、完成定义和汇报格式,**并要求 agent 先读 .showen/COMPANY_RULES.md 理解三铁律和验证闭环**。
|
||||||
6. 收到结果后先检查证据是否完整,再做编译、测试、文档、状态更新等交付复核。
|
6. 收到结果后**先检查是否附带 cargo check/test 输出**。无输出 → 直接打回,不看代码。有输出 → 检查证据完整性,再做编译、测试、文档、状态更新等交付复核。
|
||||||
7. 发现 P0、架构冲突或资源瓶颈时立即升级,不等待任务自然暴露问题。
|
7. 发现 P0、架构冲突或资源瓶颈时立即升级,不等待任务自然暴露问题。
|
||||||
8. 任务闭环后更新自己的 soul 文件,沉淀经验、规则和新的管理约束。
|
8. 任务闭环后更新自己的 soul 文件,沉淀经验、规则和新的管理约束。
|
||||||
|
|
||||||
|
## 失败检测与升级协议
|
||||||
|
|
||||||
|
### PM 自身遵循三铁律
|
||||||
|
- **穷尽一切**:自己的任务(拆解、协调)也不允许说"做不了"
|
||||||
|
- **先做后问**:向 CEO 汇报前,先用工具自查相关信息
|
||||||
|
- **主动出击**:不只做被分配的,主动发现风险、提前预警
|
||||||
|
|
||||||
|
### 接收交付时的失败模式识别
|
||||||
|
|
||||||
|
收到 agent 交付时,快速判断:
|
||||||
|
|
||||||
|
| 检查项 | 通过 | 不通过 → 动作 |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| 附带 cargo check/test 输出? | ✅ 继续 | ❌ 直接打回,告知"证据呢?" |
|
||||||
|
| 输出零 warning + 全测试通过? | ✅ 继续 | ❌ 打回修复,失败计数 +1 |
|
||||||
|
| 代码逻辑正确? | ✅ 继续 | ❌ 打回修复,失败计数 +1 |
|
||||||
|
| 主动检查了同类问题? | ✅ 加分 | ⚠️ 提醒提高能动性 |
|
||||||
|
|
||||||
|
### 失败升级处理
|
||||||
|
|
||||||
|
| 成员失败次数 | PM 动作 |
|
||||||
|
|-------------|---------|
|
||||||
|
| 第 1 次 | 正常打回,说明问题 |
|
||||||
|
| 第 2 次 (L1) | 打回 + 要求切换本质不同的方案 |
|
||||||
|
| 第 3 次 (L2) | 打回 + 要求完成搜索+源码+3假设 → **上报 CEO** |
|
||||||
|
| 第 4 次 (L3) | 上报 CEO,建议 7 项检查清单 |
|
||||||
|
| 第 5 次 (L4) | 上报 CEO,建议换人 |
|
||||||
|
|
||||||
|
### PUA-REPORT 格式(L2+ 时向 CEO 发送)
|
||||||
|
|
||||||
|
```
|
||||||
|
[PUA-REPORT]
|
||||||
|
成员: <姓名>
|
||||||
|
任务: <当前任务>
|
||||||
|
失败次数: <本任务失败次数>
|
||||||
|
失败模式: <卡住原地打转|直接放弃推锅|空口完成|被动等待|差不多就行>
|
||||||
|
已尝试方案: <列表>
|
||||||
|
已排除: <列表>
|
||||||
|
建议下一步: <PM 的建议>
|
||||||
|
```
|
||||||
|
|
||||||
|
### kilo 派发模板(含能动性要求)
|
||||||
|
|
||||||
|
> 权威版本见 `CLAUDE.md`。此处保留副本供 PM 独立 session 使用。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kilo run -m openai/gpt-5.4 --auto \
|
||||||
|
--dir /home/showen/Showen/ShowenV2 \
|
||||||
|
"你是<角色名>。
|
||||||
|
|
||||||
|
开工前必读:
|
||||||
|
1. souls/<name>.md(你的灵魂文件)
|
||||||
|
2. .showen/COMPANY_RULES.md(重点:三条铁律 + 验证闭环制度)
|
||||||
|
3. .showen/TEAM_CHAT.md(团队最新状态)
|
||||||
|
|
||||||
|
任务:<具体说明>
|
||||||
|
|
||||||
|
交付要求:
|
||||||
|
- 完成后执行 export PATH=\"/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:\$PATH\" && cargo check --workspace --all-targets
|
||||||
|
- 再执行 cargo test --workspace
|
||||||
|
- 两项都绿灯后,把输出贴在交付汇报中
|
||||||
|
- 修完 bug 检查同文件是否有类似问题
|
||||||
|
- 更新你的 soul 文件
|
||||||
|
|
||||||
|
验收标准:<具体标准>"
|
||||||
|
```
|
||||||
|
|
||||||
## 沟通协议
|
## 沟通协议
|
||||||
- 与 CEO 沟通时,优先同步进展、风险、依赖、决策建议,结论先行,必要时附带执行方案。
|
- 与 CEO 沟通时,优先同步进展、风险、依赖、决策建议,结论先行,必要时附带执行方案。
|
||||||
- 与团队沟通时,集体事项写入 `.showen/TEAM_CHAT.md`,个人事项写入对应 `.showen/inbox/<name>.md`。
|
- 与团队沟通时,集体事项写入 `.showen/TEAM_CHAT.md`,个人事项写入对应 `.showen/inbox/<name>.md`。
|
||||||
@@ -95,7 +166,6 @@
|
|||||||
- kilo 调用方式:`kilo run -m openai/gpt-5.4 --auto --dir /home/showen/Showen/ShowenV2 "消息"`
|
- kilo 调用方式:`kilo run -m openai/gpt-5.4 --auto --dir /home/showen/Showen/ShowenV2 "消息"`
|
||||||
- 不使用 `-f` 参数,在消息中指示读取文件
|
- 不使用 `-f` 参数,在消息中指示读取文件
|
||||||
- 每个任务必须 cargo check 通过
|
- 每个任务必须 cargo check 通过
|
||||||
- 旧代码参考:`/home/showen/Showen/hologram_player_rust/`
|
|
||||||
- 编译环境:`export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"`
|
- 编译环境:`export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"`
|
||||||
|
|
||||||
## 复盘记录
|
## 复盘记录
|
||||||
|
|||||||
@@ -50,3 +50,11 @@
|
|||||||
- 精通 FFI 内存安全(跨 allocator、CString 生命周期)
|
- 精通 FFI 内存安全(跨 allocator、CString 生命周期)
|
||||||
- 熟悉 plugin_abi.rs 和 dynamic_plugin.rs 完整链路
|
- 熟悉 plugin_abi.rs 和 dynamic_plugin.rs 完整链路
|
||||||
- 熟悉 plugin-sdk export_plugin! 宏
|
- 熟悉 plugin-sdk export_plugin! 宏
|
||||||
|
|
||||||
|
## 个人经验 (2026-03-14)
|
||||||
|
- HTTP WiFi API 先用 `tokio::sync::Mutex<()>` 串行化请求,可最小改动消除全局响应错配
|
||||||
|
- warp 服务需保存 graceful shutdown sender + thread join handle,`stop()` 不能留空
|
||||||
|
- multipart 上传要边读边写临时文件,并在流式写入时做单文件大小上限检查
|
||||||
|
- BLE 注册不能靠固定 sleep,需等待 `RegisterApplication` / `RegisterAdvertisement` 的真实 D-Bus reply
|
||||||
|
- 为跑通全量验证,顺手补了 `DynamicPlugin` 的 `Debug` 实现,并修正 plugin_repo 测试构造方式
|
||||||
|
- API 文档校准必须以 `src/plugins/http/routes.rs` 为唯一权威;WebSocket 命令返回仍是 `{ok: ...}`,HTTP 写接口才统一 `{status, message}`
|
||||||
|
|||||||
@@ -79,3 +79,49 @@
|
|||||||
- cargo check --workspace --all-targets 通过
|
- cargo check --workspace --all-targets 通过
|
||||||
- cargo test --workspace 全部通过(77 个测试)
|
- cargo test --workspace 全部通过(77 个测试)
|
||||||
- 防息屏和光标隐藏功能现已在运行时生效
|
- 防息屏和光标隐藏功能现已在运行时生效
|
||||||
|
|
||||||
|
## 个人经验 (2026-03-14 - P0 双 bug 修复)
|
||||||
|
- 修复 ServiceManager 自测失败路径遗漏的 AutoRollback 调用
|
||||||
|
- 将回退逻辑抽成 `rollback_dynamic_plugin()`,复用到错误阈值和 self-test 必须能力失败两条路径
|
||||||
|
- self-test 的 AutoRollback 现在会先调用 `VersionManager::rollback()`,再尝试热加载稳定版本
|
||||||
|
- 若稳定版本无法立刻加载,仍会正确更新 registry 并打上 `needs_rollback` 标记供下次启动处理
|
||||||
|
- 修复 `AppConfig.source_path/source_dir` 的 serde 丢失问题
|
||||||
|
- `#[serde(skip)]` 改为 `#[serde(default)]`,兼容旧配置缺省字段,同时允许 `ConfigReloaded(AppConfig)` 通过 JSON 传递路径信息
|
||||||
|
- `deny_unknown_fields` 不受影响,因为这两个字段现在是显式已知字段
|
||||||
|
- 增加回归测试
|
||||||
|
- 覆盖 self-test + AutoRollback 真实触发回退
|
||||||
|
- 覆盖 `ConfigReloaded` JSON 往返后 `source_path/source_dir` 保持不变
|
||||||
|
|
||||||
|
## 个人经验 (2026-03-14 - VersionManager GC 重叠保护修复)
|
||||||
|
- 修复 `VersionManager::gc()` 对 protected 版本数量的硬编码假设
|
||||||
|
- 用 active/stable 去重后的 `protected_count` 替换 `+ 2`,`keep` 现在严格表示总保留数(含受保护版本)
|
||||||
|
- 覆盖 `active == stable`、`stable == None`、`keep < protected_count` 三类边界,确保只删除非受保护版本
|
||||||
|
- 检查了 `src/core/version_manager.rs` 其余逻辑,未发现同类“active/stable 必为两个不同版本”的硬编码假设
|
||||||
|
|
||||||
|
## 个人经验 (2026-03-14 - P2 遗留修复)
|
||||||
|
- 确认 `src/core/plugin_loader.rs` 的 `test_timeout_ms` 已经通过 `manifest.json` 配置化
|
||||||
|
- 新增回归测试,防止后续把 manifest 自测超时字段重新写回死配置
|
||||||
|
- 加固 `src/plugins/wifi/mod.rs` 的 nmcli 调用与解析
|
||||||
|
- 所有 SSID/密码都继续通过 `Command::args` 逐参数传递,避免 shell 转义问题
|
||||||
|
- `--terse --escape yes` 输出改为统一走反斜杠转义解析,修复 SSID/连接名中的 `:` 和 `\` 边界
|
||||||
|
- 为 connect/hotspot 参数构建新增单元测试,覆盖引号、反斜杠、空格场景
|
||||||
|
- 为 `src/plugins/ble/gatt.rs` 增加无 D-Bus 依赖的单元测试
|
||||||
|
- 覆盖 BLE 写入缓存凭据后派发 WiFi 命令、错误状态回写、control 队列状态更新
|
||||||
|
- 顺手修复 `bytes_to_string()` 对“空白 + NUL 尾部”处理不稳的问题,避免 BLE 特征值残留 `\0`
|
||||||
|
- cargo check --workspace --all-targets 零 warning,cargo test --workspace 100 个核心测试通过
|
||||||
|
|
||||||
|
## 个人经验 (2026-03-14 - 示例插件模板完善)
|
||||||
|
- 完成 `plugins/example-plugin` 参考模板增强,面向第三方插件作者补齐“能直接照着写”的示例
|
||||||
|
- 配置增加 `device_plugin` / `request_display_info_on_start`,演示请求/响应模式并保持 `serde(default)` + 业务校验分层
|
||||||
|
- `handle_message()` 增强 `DeviceResponse` 汇总处理和 `example.subscription.update` 自定义协议解析,错误提示不再停留在泛化 `unwrap` 风格
|
||||||
|
- 在 `export_plugin!` 调用处补充 FFI 接口用途说明,明确 `create/get_info/init/start/handle_message/stop/free_string` 的职责
|
||||||
|
- 新增 `plugins/example-plugin/manifest.json` 完整模板,字段与 `PluginManifest` 对齐,能力声明同步到示例代码
|
||||||
|
- 保持 Rust Edition 2018,未引入 edition 特有语法;`cargo check --workspace --all-targets` 零 warning,`cargo test --workspace` 全通过
|
||||||
|
|
||||||
|
## 个人经验 (2026-03-14 - M1.2 P0 插件管理 API 闭环)
|
||||||
|
- 修复 `ServiceManager` 对 HTTP 插件管理 `Message::Custom` 的吞消息问题
|
||||||
|
- `handle_manager_message()` 现在处理 `plugin_enable` / `plugin_disable` / `plugin_rollback` / `plugin_switch` / `plugin_install` / `plugin_check_updates`
|
||||||
|
- 新增 `broadcast_plugin_states()`,在启动完成和每次管理命令后广播 `plugin_states`,补齐 HttpPlugin 缓存更新链路
|
||||||
|
- 为版本切换、安装、更新检查补了 Manager 侧 helper,统一复用 `VersionManager` / `PluginRepository` / 热替换生命周期
|
||||||
|
- 在 `src/core/tests.rs` 增加 7 个回归测试,覆盖初始状态广播和 6 个自定义管理命令;保留旧生命周期测试并过滤新增状态广播事件
|
||||||
|
- `cargo check --workspace --all-targets` 零 warning,`cargo test --workspace` 107/107 + 集成测试全绿
|
||||||
|
|||||||
@@ -67,3 +67,29 @@
|
|||||||
- `cargo test --workspace` 全量通过:示例插件 4 个测试通过,主工程 77 个测试通过,doc-tests 均通过或按预期 ignored
|
- `cargo test --workspace` 全量通过:示例插件 4 个测试通过,主工程 77 个测试通过,doc-tests 均通过或按预期 ignored
|
||||||
- Release 产物已生成:`target/release/showen_v2`,当前大小约 `8.2M`
|
- Release 产物已生成:`target/release/showen_v2`,当前大小约 `8.2M`
|
||||||
- 本次 `cargo check` 编译阶段无 Rust 编译 warning,但依赖下载阶段出现 crates 镜像网络层 `spurious network error` 重试提示,需与“零 warning”验收标准区分记录
|
- 本次 `cargo check` 编译阶段无 Rust 编译 warning,但依赖下载阶段出现 crates 镜像网络层 `spurious network error` 重试提示,需与“零 warning”验收标准区分记录
|
||||||
|
|
||||||
|
## FFI allocator 安全修复经验(2026-03-14)
|
||||||
|
- `src/core/plugin_abi.rs` 中未被调用的宿主侧 `ffi_string_free()` 是风险敞口;若没有外部使用,优先直接删除,避免“看起来能用”的错误 API 继续存在
|
||||||
|
- `FfiString` 文档必须明确写清楚 allocator 匹配规则:谁分配谁释放,插件字符串只能走 `PluginVTable::free_string`
|
||||||
|
- `repr(C)` ABI 类型不要轻易新增 allocator 标识字段;这会破坏宿主/插件布局兼容性,除非同步升级 ABI 版本并整体迁移
|
||||||
|
- 排查跨 allocator 风险时,用全文搜索确认所有释放路径;本仓库宿主侧已统一通过 `src/core/dynamic_plugin.rs` 的 `read_plugin_string()` 调用插件 vtable 释放字符串
|
||||||
|
|
||||||
|
## Core P1 生命周期修复经验(2026-03-14)
|
||||||
|
- `ServiceManager::set_plugin_enabled()` 不能只翻布尔位;启停必须真实驱动 `stop()` / `init()` / `start()`,否则资源句柄和插件内部状态会漂移
|
||||||
|
- 统一把“启用插件”的 `init + start + 失败清理` 收敛到 helper,避免热替换、手动 enable、回滚恢复各自实现出生命周期分叉
|
||||||
|
- 热替换顺序必须是“先停旧实例,再启新实例”;对端口、设备、锁文件这类独占资源,短暂双开本身就是 bug
|
||||||
|
- manifest 加载阶段要把目录身份(plugin_id/version)与文件内声明交叉校验,校验应发生在加载 `.so` 之前,尽早拒绝伪装或错放插件
|
||||||
|
- 生命周期测试里用可注入失败计划的测试插件,比硬编码多个假插件更适合覆盖 stop/init/start 成功与失败组合
|
||||||
|
|
||||||
|
## Flutter 设置页修复经验(2026-03-14)
|
||||||
|
- 设备切换不能先写历史再验证连通性;应先用候选地址探测 `/api/status`,确认 3 秒内可达后再更新 provider 状态和持久化历史
|
||||||
|
- Flutter 里做“临时设备探测”时,新建独立 `HttpApiService` 比直接改全局 `baseUrl` 更安全,失败不会污染当前连接态
|
||||||
|
- 设备切换中的 loading 应直接复用 `DeviceProvider.isLoading`,这样设置页输入框、切换按钮、历史设备入口能同步禁用,避免重复点击
|
||||||
|
- 配置编辑同时支持表单和 raw JSON 时,必须维护单一真源:`_fullConfig` 更新后立刻同步 JSON 文本,JSON 保存成功后再反向刷新表单字段
|
||||||
|
- JSON 编辑模式的错误反馈要区分“解析失败”和“接口保存失败”;前者本地立即拦截,后者通过 SnackBar 暴露服务端/网络错误
|
||||||
|
|
||||||
|
## Flutter APK v0.3 编译经验(2026-03-14)
|
||||||
|
- Release 构建前先跑 `flutter analyze` 和 `flutter test`,确保依赖变更不会把问题拖到 Gradle 阶段才暴露
|
||||||
|
- 遇到 `Expected to find fonts for (packages/cupertino_icons/CupertinoIcons, MaterialIcons)` 时,优先检查 `pubspec.yaml` 是否漏了 `cupertino_icons`;即使业务代码没有显式引用,也可能被图标字体清单间接依赖
|
||||||
|
- `flutter build apk --release --android-skip-build-dependency-validation` 在 ARM64 机器上耗时较长,清理或重启 Gradle daemon 能规避旧 daemon 异常退出导致的假失败
|
||||||
|
- 最终 APK 统一落到 `configs/downloads/showen-app.apk`,方便交付下载链路复用;本次 release 产物大小约 `51M`
|
||||||
|
|||||||
@@ -77,3 +77,4 @@
|
|||||||
- 旧版本对比测试很重要
|
- 旧版本对比测试很重要
|
||||||
- **必须实际运行并截图,不能只看代码**
|
- **必须实际运行并截图,不能只看代码**
|
||||||
- 2026-03-13:补齐 `src/core/tests.rs` 的关键路径覆盖,重点覆盖动态插件 FFI 返回 null 的降级、无效 manifest 跳过、禁用插件消息跳过、无稳定版本回退失败、以及 `Message` 全变体 JSON round-trip
|
- 2026-03-13:补齐 `src/core/tests.rs` 的关键路径覆盖,重点覆盖动态插件 FFI 返回 null 的降级、无效 manifest 跳过、禁用插件消息跳过、无稳定版本回退失败、以及 `Message` 全变体 JSON round-trip
|
||||||
|
- 2026-03-14:新增 `tests/m1_2_service_manager.rs`,补齐 M1.2 ServiceManager 集成测试基建,覆盖依赖启动顺序、Shutdown 停机、ConfigReloaded/PlayerStatus/WifiResult/StateChanged/PluginReady 广播,以及禁用插件路由跳过
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ pub struct AppConfig {
|
|||||||
pub remote_control: RemoteControlConfig,
|
pub remote_control: RemoteControlConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ble: BleConfig,
|
pub ble: BleConfig,
|
||||||
#[serde(skip)]
|
#[serde(default)]
|
||||||
pub source_path: PathBuf,
|
pub source_path: PathBuf,
|
||||||
#[serde(skip)]
|
#[serde(default)]
|
||||||
pub source_dir: PathBuf,
|
pub source_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,30 @@ use crate::core::plugin_abi::{
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use libloading::Library;
|
use libloading::Library;
|
||||||
use std::ffi::CString;
|
use std::ffi::CString;
|
||||||
use std::sync::mpsc;
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{mpsc, Arc};
|
||||||
|
|
||||||
|
struct CallbackState {
|
||||||
|
active: AtomicBool,
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallbackState {
|
||||||
|
fn new(tx: mpsc::Sender<Envelope>) -> Self {
|
||||||
|
Self {
|
||||||
|
active: AtomicBool::new(true),
|
||||||
|
tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deactivate(&self) {
|
||||||
|
self.active.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
self.active.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 动态加载的插件
|
/// 动态加载的插件
|
||||||
pub struct DynamicPlugin {
|
pub struct DynamicPlugin {
|
||||||
@@ -29,8 +52,22 @@ pub struct DynamicPlugin {
|
|||||||
dependencies: Vec<String>,
|
dependencies: Vec<String>,
|
||||||
/// .so 文件路径(用于调试/日志)
|
/// .so 文件路径(用于调试/日志)
|
||||||
so_path: String,
|
so_path: String,
|
||||||
/// Sender 上下文指针(堆分配的 mpsc::Sender)
|
/// Sender 上下文指针(持有一份 Arc 强引用,供插件跨线程回调期间保活)
|
||||||
sender_ctx: *mut std::ffi::c_void,
|
sender_ctx: *mut std::ffi::c_void,
|
||||||
|
/// 宿主持有的回调状态,用于卸载前熔断回调并延长 Sender 生命周期
|
||||||
|
sender_state: Option<Arc<CallbackState>>,
|
||||||
|
/// stop 是否已经执行成功,避免卸载阶段重复 stop
|
||||||
|
stopped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for DynamicPlugin {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("DynamicPlugin")
|
||||||
|
.field("id", &self.id)
|
||||||
|
.field("so_path", &self.so_path)
|
||||||
|
.field("stopped", &self.stopped)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PluginHandle 是 *mut c_void,需要手动声明 Send
|
// PluginHandle 是 *mut c_void,需要手动声明 Send
|
||||||
@@ -80,6 +117,8 @@ impl DynamicPlugin {
|
|||||||
dependencies,
|
dependencies,
|
||||||
so_path: so_path.to_string(),
|
so_path: so_path.to_string(),
|
||||||
sender_ctx: std::ptr::null_mut(),
|
sender_ctx: std::ptr::null_mut(),
|
||||||
|
sender_state: None,
|
||||||
|
stopped: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +154,25 @@ impl DynamicPlugin {
|
|||||||
unsafe { (vtable.free_string)(ffi_str) };
|
unsafe { (vtable.free_string)(ffi_str) };
|
||||||
string
|
string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn deactivate_callback(&self) {
|
||||||
|
if let Some(state) = &self.sender_state {
|
||||||
|
state.deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_sender_ctx(&mut self) {
|
||||||
|
self.deactivate_callback();
|
||||||
|
|
||||||
|
if !self.sender_ctx.is_null() {
|
||||||
|
unsafe {
|
||||||
|
drop(Arc::from_raw(self.sender_ctx as *const CallbackState));
|
||||||
|
}
|
||||||
|
self.sender_ctx = std::ptr::null_mut();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sender_state = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Plugin for DynamicPlugin {
|
impl Plugin for DynamicPlugin {
|
||||||
@@ -153,9 +211,13 @@ impl Plugin for DynamicPlugin {
|
|||||||
.context("failed to serialize config for dynamic plugin")?;
|
.context("failed to serialize config for dynamic plugin")?;
|
||||||
let config_cstr = CString::new(config_json).context("config JSON contains null byte")?;
|
let config_cstr = CString::new(config_json).context("config JSON contains null byte")?;
|
||||||
|
|
||||||
// 将 Sender 分配到堆上,生命周期由 DynamicPlugin 管理
|
// 通过 Arc 为回调上下文保活:
|
||||||
let sender_box = Box::new(ctx.tx);
|
// - 宿主持有一份 Arc,控制 active flag
|
||||||
self.sender_ctx = Box::into_raw(sender_box) as *mut std::ffi::c_void;
|
// - FFI ctx 持有另一份 Arc,保证插件后台线程回调期间 sender_ctx 不悬空
|
||||||
|
let sender_state = Arc::new(CallbackState::new(ctx.tx));
|
||||||
|
self.sender_ctx = Arc::into_raw(Arc::clone(&sender_state)) as *mut std::ffi::c_void;
|
||||||
|
self.sender_state = Some(sender_state);
|
||||||
|
self.stopped = false;
|
||||||
|
|
||||||
let result = unsafe {
|
let result = unsafe {
|
||||||
(self.vtable.init)(
|
(self.vtable.init)(
|
||||||
@@ -165,11 +227,18 @@ impl Plugin for DynamicPlugin {
|
|||||||
ffi_send_callback,
|
ffi_send_callback,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
unsafe { self.check_result(result, "init") }
|
match unsafe { self.check_result(result, "init") } {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) => {
|
||||||
|
self.release_sender_ctx();
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start(&mut self) -> Result<()> {
|
fn start(&mut self) -> Result<()> {
|
||||||
let result = unsafe { (self.vtable.start)(self.handle) };
|
let result = unsafe { (self.vtable.start)(self.handle) };
|
||||||
|
self.stopped = false;
|
||||||
unsafe { self.check_result(result, "start") }
|
unsafe { self.check_result(result, "start") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,30 +252,49 @@ impl Plugin for DynamicPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self) -> Result<()> {
|
fn stop(&mut self) -> Result<()> {
|
||||||
|
self.deactivate_callback();
|
||||||
let result = unsafe { (self.vtable.stop)(self.handle) };
|
let result = unsafe { (self.vtable.stop)(self.handle) };
|
||||||
unsafe { self.check_result(result, "stop") }
|
match unsafe { self.check_result(result, "stop") } {
|
||||||
|
Ok(()) => {
|
||||||
|
self.stopped = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for DynamicPlugin {
|
impl Drop for DynamicPlugin {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
self.deactivate_callback();
|
||||||
|
|
||||||
|
if !self.handle.is_null() && !self.stopped {
|
||||||
|
let result = unsafe { (self.vtable.stop)(self.handle) };
|
||||||
|
if let Err(err) = unsafe { self.check_result(result, "stop during unload") } {
|
||||||
|
eprintln!(
|
||||||
|
"[DynamicPlugin] plugin '{}' stop during unload failed: {err}",
|
||||||
|
self.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !self.handle.is_null() {
|
if !self.handle.is_null() {
|
||||||
unsafe { (self.vtable.destroy)(self.handle) };
|
unsafe { (self.vtable.destroy)(self.handle) };
|
||||||
|
self.handle = std::ptr::null_mut();
|
||||||
}
|
}
|
||||||
if !self.sender_ctx.is_null() {
|
|
||||||
unsafe {
|
self.release_sender_ctx();
|
||||||
drop(Box::from_raw(
|
|
||||||
self.sender_ctx as *mut mpsc::Sender<Envelope>,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.sender_ctx = std::ptr::null_mut();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SendCallback 实现 ──
|
// ── SendCallback 实现 ──
|
||||||
|
|
||||||
/// C FFI 回调:插件调用此函数向主程序发消息
|
/// C FFI 回调:插件调用此函数向主程序发消息
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// - `ctx` 必须来自 `DynamicPlugin::init` 传入的 `sender_ctx`。
|
||||||
|
/// - 动态插件必须在 `stop()` 返回前停止所有可能继续调用该回调的后台线程。
|
||||||
|
/// - 宿主在卸载前会先把回调熔断为 no-op,再执行 `stop()`/`destroy()`,避免卸载期间 UAF。
|
||||||
unsafe extern "C" fn ffi_send_callback(
|
unsafe extern "C" fn ffi_send_callback(
|
||||||
ctx: *mut std::ffi::c_void,
|
ctx: *mut std::ffi::c_void,
|
||||||
envelope_json: crate::core::plugin_abi::FfiStr,
|
envelope_json: crate::core::plugin_abi::FfiStr,
|
||||||
@@ -232,8 +320,48 @@ unsafe extern "C" fn ffi_send_callback(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let tx = unsafe { &*(ctx as *const mpsc::Sender<Envelope>) };
|
let state = unsafe { &*(ctx as *const CallbackState) };
|
||||||
if let Err(e) = tx.send(envelope) {
|
if !state.is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = state.tx.send(envelope) {
|
||||||
eprintln!("[DynamicPlugin] failed to send envelope: {e}");
|
eprintln!("[DynamicPlugin] failed to send envelope: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::core::message::{Destination, Message};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ffi_send_callback_becomes_noop_after_deactivate() {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let state = Arc::new(CallbackState::new(tx));
|
||||||
|
let ctx = Arc::into_raw(Arc::clone(&state)) as *mut std::ffi::c_void;
|
||||||
|
|
||||||
|
let envelope = Envelope {
|
||||||
|
from: "dynamic-test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&envelope).expect("envelope should serialize");
|
||||||
|
let json = CString::new(json).expect("json should not contain null");
|
||||||
|
|
||||||
|
unsafe { ffi_send_callback(ctx, json.as_ptr()) };
|
||||||
|
let received = rx
|
||||||
|
.recv_timeout(Duration::from_millis(100))
|
||||||
|
.expect("active callback should deliver envelope");
|
||||||
|
assert_eq!(received.from, "dynamic-test");
|
||||||
|
|
||||||
|
state.deactivate();
|
||||||
|
unsafe { ffi_send_callback(ctx, json.as_ptr()) };
|
||||||
|
assert!(rx.recv_timeout(Duration::from_millis(100)).is_err());
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
drop(Arc::from_raw(ctx as *const CallbackState));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,16 @@ use std::ptr;
|
|||||||
/// 插件实例的不透明句柄
|
/// 插件实例的不透明句柄
|
||||||
pub type PluginHandle = *mut c_void;
|
pub type PluginHandle = *mut c_void;
|
||||||
|
|
||||||
/// FFI 安全的字符串:指向 C 字符串 + 长度
|
/// FFI 安全的字符串:指向 C 字符串 + 长度。
|
||||||
/// 调用方读取内容后,必须通过分配方提供的 free 函数释放
|
///
|
||||||
|
/// 该类型本身不携带 allocator 元数据,以保持现有 `repr(C)` ABI 稳定;
|
||||||
|
/// 调用方必须通过 API 约定追踪所有权,并使用分配该字符串的一侧提供的释放函数。
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// - 宿主返回给宿主的 `FfiString` 只能由宿主分配器释放。
|
||||||
|
/// - 动态插件返回给宿主的 `FfiString` 只能通过对应 `PluginVTable::free_string`
|
||||||
|
/// 释放,不能调用宿主侧释放逻辑。
|
||||||
|
/// - 跨 allocator 释放会导致未定义行为(可能崩溃或内存损坏)。
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
pub struct FfiString {
|
pub struct FfiString {
|
||||||
pub ptr: *mut c_char,
|
pub ptr: *mut c_char,
|
||||||
@@ -18,7 +26,9 @@ pub struct FfiString {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FfiString {
|
impl FfiString {
|
||||||
/// 从 Rust String 创建 FfiString(转移所有权到 C 侧)
|
/// 从 Rust String 创建 `FfiString`(转移所有权到调用方一侧)
|
||||||
|
///
|
||||||
|
/// 生成的指针必须回到同一 allocator 释放。
|
||||||
pub fn from_string(s: String) -> Self {
|
pub fn from_string(s: String) -> Self {
|
||||||
match CString::new(s) {
|
match CString::new(s) {
|
||||||
Ok(cstr) => {
|
Ok(cstr) => {
|
||||||
@@ -43,7 +53,8 @@ impl FfiString {
|
|||||||
/// 复制为 Rust String(不释放底层内存)
|
/// 复制为 Rust String(不释放底层内存)
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// ptr 必须是由 CString::into_raw 产生的有效指针
|
/// `ptr` 必须是由当前 allocator 的 `CString::into_raw` 产生的有效指针。
|
||||||
|
/// 调用此函数不会改变所有权,原始分配方仍负责释放该内存。
|
||||||
pub unsafe fn to_string(&self) -> Option<String> {
|
pub unsafe fn to_string(&self) -> Option<String> {
|
||||||
if self.ptr.is_null() {
|
if self.ptr.is_null() {
|
||||||
return None;
|
return None;
|
||||||
@@ -99,6 +110,10 @@ impl FfiResult {
|
|||||||
|
|
||||||
/// 插件向主程序发消息的回调函数类型
|
/// 插件向主程序发消息的回调函数类型
|
||||||
/// envelope_json: JSON 序列化的 Envelope
|
/// envelope_json: JSON 序列化的 Envelope
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// 插件若把该回调保存到后台线程,必须保证在线程完全退出后再让 `stop()` 返回;
|
||||||
|
/// 一旦宿主开始卸载插件,回调会先被熔断为 no-op,随后执行 `stop()`/`destroy()`。
|
||||||
pub type SendCallback = unsafe extern "C" fn(ctx: *mut c_void, envelope_json: FfiStr);
|
pub type SendCallback = unsafe extern "C" fn(ctx: *mut c_void, envelope_json: FfiStr);
|
||||||
|
|
||||||
/// 插件虚函数表 — 每个动态插件导出一个此结构体
|
/// 插件虚函数表 — 每个动态插件导出一个此结构体
|
||||||
@@ -131,7 +146,11 @@ pub struct PluginVTable {
|
|||||||
/// 停止插件
|
/// 停止插件
|
||||||
pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
|
pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
|
||||||
|
|
||||||
/// 释放插件分配的 FfiString
|
/// 释放插件分配的 `FfiString`
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// 只能用于释放该插件自己返回的字符串。宿主分配的 `FfiString` 绝不能传给这里,
|
||||||
|
/// 否则会发生跨 allocator 释放。
|
||||||
pub free_string: unsafe extern "C" fn(s: FfiString),
|
pub free_string: unsafe extern "C" fn(s: FfiString),
|
||||||
|
|
||||||
/// 销毁插件实例,释放资源
|
/// 销毁插件实例,释放资源
|
||||||
@@ -157,13 +176,3 @@ pub unsafe fn ffi_str_to_str<'a>(ptr: FfiStr) -> Option<&'a str> {
|
|||||||
}
|
}
|
||||||
unsafe { CStr::from_ptr(ptr) }.to_str().ok()
|
unsafe { CStr::from_ptr(ptr) }.to_str().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 释放 FfiString 占用的内存
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// ptr 必须是由 FfiString::from_string 创建的
|
|
||||||
pub unsafe fn ffi_string_free(s: FfiString) {
|
|
||||||
if !s.ptr.is_null() {
|
|
||||||
drop(unsafe { CString::from_raw(s.ptr) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ impl PluginLoader {
|
|||||||
/// 保存全局注册表
|
/// 保存全局注册表
|
||||||
pub fn save_registry(&self, registry: &PluginRegistry) -> Result<()> {
|
pub fn save_registry(&self, registry: &PluginRegistry) -> Result<()> {
|
||||||
let registry_path = self.store_path.join("registry.json");
|
let registry_path = self.store_path.join("registry.json");
|
||||||
let content = serde_json::to_string_pretty(registry)
|
let content =
|
||||||
.context("failed to serialize registry")?;
|
serde_json::to_string_pretty(registry).context("failed to serialize registry")?;
|
||||||
std::fs::write(®istry_path, content)
|
std::fs::write(®istry_path, content)
|
||||||
.with_context(|| format!("failed to write {}", registry_path.display()))
|
.with_context(|| format!("failed to write {}", registry_path.display()))
|
||||||
}
|
}
|
||||||
@@ -201,9 +201,7 @@ impl PluginLoader {
|
|||||||
.plugins
|
.plugins
|
||||||
.get(plugin_id)
|
.get(plugin_id)
|
||||||
.map(|e| e.active_version.clone())
|
.map(|e| e.active_version.clone())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not found in registry"))?
|
||||||
anyhow!("plugin '{plugin_id}' not found in registry")
|
|
||||||
})?
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -211,18 +209,28 @@ impl PluginLoader {
|
|||||||
let manifest_path = version_dir.join("manifest.json");
|
let manifest_path = version_dir.join("manifest.json");
|
||||||
let manifest = self.read_manifest(&manifest_path)?;
|
let manifest = self.read_manifest(&manifest_path)?;
|
||||||
|
|
||||||
let so_path = version_dir.join(&manifest.so_filename);
|
if manifest.id != plugin_id {
|
||||||
if !so_path.exists() {
|
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"plugin .so not found: {}",
|
"plugin manifest id mismatch: requested '{plugin_id}', found '{}'",
|
||||||
so_path.display()
|
manifest.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if manifest.version != version {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"plugin manifest version mismatch: requested '{version}', found '{}'",
|
||||||
|
manifest.version
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let so_path = version_dir.join(&manifest.so_filename);
|
||||||
|
if !so_path.exists() {
|
||||||
|
return Err(anyhow!("plugin .so not found: {}", so_path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
let so_path_str = so_path.to_string_lossy().to_string();
|
let so_path_str = so_path.to_string_lossy().to_string();
|
||||||
let mut plugin = unsafe {
|
let mut plugin =
|
||||||
DynamicPlugin::load(&so_path_str, manifest.dependencies.clone())?
|
unsafe { DynamicPlugin::load(&so_path_str, manifest.dependencies.clone())? };
|
||||||
};
|
|
||||||
plugin.set_id(manifest.id.clone());
|
plugin.set_id(manifest.id.clone());
|
||||||
|
|
||||||
Ok((plugin, manifest))
|
Ok((plugin, manifest))
|
||||||
@@ -255,6 +263,14 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
fn unique_test_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.expect("system time should be after unix epoch")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("showen_plugin_loader_{name}_{nanos}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn setup_test_store(base: &Path) {
|
fn setup_test_store(base: &Path) {
|
||||||
let plugin_dir = base.join("test-plugin").join("1.0.0");
|
let plugin_dir = base.join("test-plugin").join("1.0.0");
|
||||||
fs::create_dir_all(&plugin_dir).unwrap();
|
fs::create_dir_all(&plugin_dir).unwrap();
|
||||||
@@ -387,4 +403,76 @@ mod tests {
|
|||||||
assert_eq!(manifest.test_timeout_ms, 5000);
|
assert_eq!(manifest.test_timeout_ms, 5000);
|
||||||
assert!(manifest.auto_test);
|
assert!(manifest.auto_test);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_test_timeout_ms_is_configurable_from_manifest() {
|
||||||
|
let json = r#"{
|
||||||
|
"id": "timed-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"sdk_version": "0.2.0",
|
||||||
|
"so_filename": "libtimed_plugin.so",
|
||||||
|
"test_timeout_ms": 12000
|
||||||
|
}"#;
|
||||||
|
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.test_timeout_ms, 12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_plugin_rejects_manifest_id_mismatch() {
|
||||||
|
let tmp = unique_test_dir("id_mismatch");
|
||||||
|
let plugin_dir = tmp.join("expected-plugin").join("1.0.0");
|
||||||
|
fs::create_dir_all(&plugin_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
plugin_dir.join("manifest.json"),
|
||||||
|
r#"{
|
||||||
|
"id": "other-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"sdk_version": "0.2.0",
|
||||||
|
"so_filename": "libexpected_plugin.so"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let loader = PluginLoader::new(&tmp);
|
||||||
|
let error = match loader.load_plugin("expected-plugin", Some("1.0.0")) {
|
||||||
|
Ok(_) => panic!("id mismatch should be rejected"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(error.to_string().contains(
|
||||||
|
"plugin manifest id mismatch: requested 'expected-plugin', found 'other-plugin'"
|
||||||
|
));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_plugin_rejects_manifest_version_mismatch() {
|
||||||
|
let tmp = unique_test_dir("version_mismatch");
|
||||||
|
let plugin_dir = tmp.join("expected-plugin").join("1.0.0");
|
||||||
|
fs::create_dir_all(&plugin_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
plugin_dir.join("manifest.json"),
|
||||||
|
r#"{
|
||||||
|
"id": "expected-plugin",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"sdk_version": "0.2.0",
|
||||||
|
"so_filename": "libexpected_plugin.so"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let loader = PluginLoader::new(&tmp);
|
||||||
|
let error = match loader.load_plugin("expected-plugin", Some("1.0.0")) {
|
||||||
|
Ok(_) => panic!("version mismatch should be rejected"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(error
|
||||||
|
.to_string()
|
||||||
|
.contains("plugin manifest version mismatch: requested '1.0.0', found '2.0.0'"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,31 @@ use anyhow::{anyhow, Context, Result};
|
|||||||
use flate2::read::GzDecoder;
|
use flate2::read::GzDecoder;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
struct TempDirGuard {
|
||||||
|
path: PathBuf,
|
||||||
|
keep: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempDirGuard {
|
||||||
|
fn new(path: PathBuf) -> Self {
|
||||||
|
Self { path, keep: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disarm(&mut self) {
|
||||||
|
self.keep = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDirGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.keep && self.path.exists() {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 远程仓库索引
|
/// 远程仓库索引
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -93,15 +118,9 @@ impl PluginRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 下载并安装插件到 plugin_store/
|
/// 下载并安装插件到 plugin_store/
|
||||||
pub fn download_and_install(
|
pub fn download_and_install(&self, plugin_id: &str, version: &str) -> Result<()> {
|
||||||
&self,
|
|
||||||
plugin_id: &str,
|
|
||||||
version: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let url = format!("{}/{}/{}.tar.gz", self.base_url, plugin_id, version);
|
let url = format!("{}/{}/{}.tar.gz", self.base_url, plugin_id, version);
|
||||||
println!(
|
println!("[PluginRepo] 下载插件 '{plugin_id}' v{version} 从 {url}");
|
||||||
"[PluginRepo] 下载插件 '{plugin_id}' v{version} 从 {url}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = ureq::get(&url)
|
let response = ureq::get(&url)
|
||||||
.call()
|
.call()
|
||||||
@@ -114,12 +133,16 @@ impl PluginRepository {
|
|||||||
.read_to_end(&mut body)
|
.read_to_end(&mut body)
|
||||||
.context("failed to read download body")?;
|
.context("failed to read download body")?;
|
||||||
|
|
||||||
// 解压 tar.gz 到临时目录
|
self.install_archive_bytes(plugin_id, version, &body)
|
||||||
let target_dir = self
|
}
|
||||||
.loader
|
|
||||||
.store_path()
|
fn install_archive_bytes(&self, plugin_id: &str, version: &str, body: &[u8]) -> Result<()> {
|
||||||
.join(plugin_id)
|
let plugin_dir = self.loader.store_path().join(plugin_id);
|
||||||
.join(version);
|
|
||||||
|
std::fs::create_dir_all(&plugin_dir)
|
||||||
|
.with_context(|| format!("failed to create {}", plugin_dir.display()))?;
|
||||||
|
|
||||||
|
let target_dir = plugin_dir.join(version);
|
||||||
|
|
||||||
if target_dir.exists() {
|
if target_dir.exists() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
@@ -127,27 +150,30 @@ impl PluginRepository {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::create_dir_all(&target_dir).with_context(|| {
|
let staging_dir = self.staging_dir_path(&plugin_dir, version);
|
||||||
format!("failed to create {}", target_dir.display())
|
std::fs::create_dir_all(&staging_dir)
|
||||||
})?;
|
.with_context(|| format!("failed to create {}", staging_dir.display()))?;
|
||||||
|
let mut cleanup = TempDirGuard::new(staging_dir.clone());
|
||||||
|
|
||||||
// 解压 tar.gz
|
self.extract_archive_securely(body, &staging_dir)?;
|
||||||
let gz = GzDecoder::new(body.as_slice());
|
|
||||||
let mut archive = tar::Archive::new(gz);
|
|
||||||
archive
|
|
||||||
.unpack(&target_dir)
|
|
||||||
.with_context(|| format!("failed to unpack archive to {}", target_dir.display()))?;
|
|
||||||
|
|
||||||
// 验证 manifest.json 存在
|
// 验证 manifest.json 存在
|
||||||
let manifest_path = target_dir.join("manifest.json");
|
let manifest_path = staging_dir.join("manifest.json");
|
||||||
if !manifest_path.exists() {
|
if !manifest_path.is_file() {
|
||||||
// 清理
|
|
||||||
let _ = std::fs::remove_dir_all(&target_dir);
|
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"downloaded archive for '{plugin_id}' v{version} missing manifest.json"
|
"downloaded archive for '{plugin_id}' v{version} missing manifest.json"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::fs::rename(&staging_dir, &target_dir).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"failed to atomically move extracted plugin from {} to {}",
|
||||||
|
staging_dir.display(),
|
||||||
|
target_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
cleanup.disarm();
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"[PluginRepo] 插件 '{plugin_id}' v{version} 安装成功到 {}",
|
"[PluginRepo] 插件 '{plugin_id}' v{version} 安装成功到 {}",
|
||||||
target_dir.display()
|
target_dir.display()
|
||||||
@@ -155,10 +181,119 @@ impl PluginRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn staging_dir_path(&self, plugin_dir: &Path, version: &str) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos();
|
||||||
|
plugin_dir.join(format!(".{version}.tmp-{}-{nanos}", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_archive_securely(&self, body: &[u8], target_dir: &Path) -> Result<()> {
|
||||||
|
let gz = GzDecoder::new(body);
|
||||||
|
let mut archive = tar::Archive::new(gz);
|
||||||
|
let canonical_target = std::fs::canonicalize(target_dir)
|
||||||
|
.with_context(|| format!("failed to canonicalize {}", target_dir.display()))?;
|
||||||
|
|
||||||
|
for entry in archive
|
||||||
|
.entries()
|
||||||
|
.context("failed to read archive entries")?
|
||||||
|
{
|
||||||
|
let mut entry = entry.context("failed to read archive entry")?;
|
||||||
|
let entry_path = entry.path().context("failed to read archive entry path")?;
|
||||||
|
let entry_path = entry_path.into_owned();
|
||||||
|
|
||||||
|
Self::validate_archive_path(&entry_path)?;
|
||||||
|
|
||||||
|
let entry_type = entry.header().entry_type();
|
||||||
|
if entry_type.is_symlink() || entry_type.is_hard_link() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"archive entry '{}' uses unsupported link type",
|
||||||
|
entry_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let destination = target_dir.join(&entry_path);
|
||||||
|
|
||||||
|
if entry_type.is_dir() {
|
||||||
|
std::fs::create_dir_all(&destination).with_context(|| {
|
||||||
|
format!("failed to create directory {}", destination.display())
|
||||||
|
})?;
|
||||||
|
Self::ensure_path_within_root(&canonical_target, &destination)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !entry_type.is_file() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"archive entry '{}' has unsupported type {:?}",
|
||||||
|
entry_path.display(),
|
||||||
|
entry_type
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = destination.parent().ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"archive entry '{}' has no valid parent directory",
|
||||||
|
entry_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
|
Self::ensure_path_within_root(&canonical_target, parent)?;
|
||||||
|
|
||||||
|
entry.unpack(&destination).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"failed to unpack archive entry to {}",
|
||||||
|
destination.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_archive_path(path: &Path) -> Result<()> {
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return Err(anyhow!("archive contains empty path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
Component::Normal(_) | Component::CurDir => {}
|
||||||
|
Component::ParentDir => {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"archive entry '{}' attempts path traversal",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Component::RootDir | Component::Prefix(_) => {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"archive entry '{}' uses absolute path",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_path_within_root(root: &Path, path: &Path) -> Result<()> {
|
||||||
|
let canonical_path = std::fs::canonicalize(path)
|
||||||
|
.with_context(|| format!("failed to canonicalize {}", path.display()))?;
|
||||||
|
|
||||||
|
if canonical_path.starts_with(root) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!(
|
||||||
|
"archive entry resolves outside extraction root: {}",
|
||||||
|
canonical_path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 批量检查所有已安装插件的更新
|
/// 批量检查所有已安装插件的更新
|
||||||
pub fn check_all_updates(
|
pub fn check_all_updates(&self) -> Result<Vec<(String, String, String)>> {
|
||||||
&self,
|
|
||||||
) -> Result<Vec<(String, String, String)>> {
|
|
||||||
// (plugin_id, current_version, new_version)
|
// (plugin_id, current_version, new_version)
|
||||||
let registry = self.loader.load_registry()?;
|
let registry = self.loader.load_registry()?;
|
||||||
let mut updates = Vec::new();
|
let mut updates = Vec::new();
|
||||||
@@ -166,17 +301,11 @@ impl PluginRepository {
|
|||||||
for (plugin_id, entry) in ®istry.plugins {
|
for (plugin_id, entry) in ®istry.plugins {
|
||||||
match self.check_update(plugin_id, &entry.active_version) {
|
match self.check_update(plugin_id, &entry.active_version) {
|
||||||
Ok(Some(new_version)) => {
|
Ok(Some(new_version)) => {
|
||||||
updates.push((
|
updates.push((plugin_id.clone(), entry.active_version.clone(), new_version));
|
||||||
plugin_id.clone(),
|
|
||||||
entry.active_version.clone(),
|
|
||||||
new_version,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!("[PluginRepo] 检查 '{plugin_id}' 更新失败: {e}");
|
||||||
"[PluginRepo] 检查 '{plugin_id}' 更新失败: {e}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,3 +313,178 @@ impl PluginRepository {
|
|||||||
Ok(updates)
|
Ok(updates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
|
use flate2::Compression;
|
||||||
|
use std::fs;
|
||||||
|
use tar::{Builder, EntryType, Header};
|
||||||
|
|
||||||
|
fn unique_test_dir(name: &str) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time should be after unix epoch")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"showen_plugin_repo_{name}_{}_{}",
|
||||||
|
std::process::id(),
|
||||||
|
nanos
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gzip_bytes(bytes: &[u8]) -> Vec<u8> {
|
||||||
|
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||||
|
std::io::Write::write_all(&mut encoder, bytes).expect("gzip encoder should accept bytes");
|
||||||
|
encoder.finish().expect("gzip encoder should finish")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_plain_tar(entries: Vec<(&str, &[u8])>) -> Vec<u8> {
|
||||||
|
let mut builder = Builder::new(Vec::new());
|
||||||
|
|
||||||
|
for (path, contents) in entries {
|
||||||
|
let mut header = Header::new_gnu();
|
||||||
|
header.set_entry_type(EntryType::Regular);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_size(contents.len() as u64);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, path, contents)
|
||||||
|
.expect("archive entry should be appended");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.finish().expect("archive builder should finish");
|
||||||
|
builder.into_inner().expect("tar bytes should be returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_archive(entries: Vec<(&str, &[u8])>) -> Vec<u8> {
|
||||||
|
let tar_bytes = build_plain_tar(entries);
|
||||||
|
gzip_bytes(&tar_bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_symlink_archive(path: &str, target: &str) -> Vec<u8> {
|
||||||
|
let mut builder = Builder::new(Vec::new());
|
||||||
|
let mut header = Header::new_gnu();
|
||||||
|
header.set_entry_type(EntryType::Symlink);
|
||||||
|
header.set_mode(0o777);
|
||||||
|
header.set_size(0);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_link(&mut header, path, target)
|
||||||
|
.expect("symlink entry should be appended");
|
||||||
|
builder.finish().expect("archive builder should finish");
|
||||||
|
let tar_bytes = builder.into_inner().expect("tar bytes should be returned");
|
||||||
|
gzip_bytes(&tar_bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_traversal_archive() -> Vec<u8> {
|
||||||
|
fn write_octal(field: &mut [u8], value: u64) {
|
||||||
|
field.fill(0);
|
||||||
|
let octal = format!("{:0width$o}\0", value, width = field.len() - 1);
|
||||||
|
field[..octal.len()].copy_from_slice(octal.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = br#"{}"#;
|
||||||
|
let mut tar_bytes = vec![0u8; 2048];
|
||||||
|
let (header, rest) = tar_bytes.split_at_mut(512);
|
||||||
|
let path = b"../manifest.json";
|
||||||
|
header[..path.len()].copy_from_slice(path);
|
||||||
|
write_octal(&mut header[100..108], 0o644);
|
||||||
|
write_octal(&mut header[108..116], 0);
|
||||||
|
write_octal(&mut header[116..124], 0);
|
||||||
|
write_octal(&mut header[124..136], contents.len() as u64);
|
||||||
|
write_octal(&mut header[136..148], 0);
|
||||||
|
header[148..156].fill(b' ');
|
||||||
|
header[156] = b'0';
|
||||||
|
header[257..263].copy_from_slice(b"ustar\0");
|
||||||
|
header[263..265].copy_from_slice(b"00");
|
||||||
|
|
||||||
|
{
|
||||||
|
let data = &mut rest[..512];
|
||||||
|
data[..contents.len()].copy_from_slice(contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
let checksum = header.iter().map(|byte| *byte as u32).sum::<u32>();
|
||||||
|
let checksum_field = format!("{:06o}\0 ", checksum);
|
||||||
|
header[148..156].copy_from_slice(checksum_field.as_bytes());
|
||||||
|
|
||||||
|
gzip_bytes(&tar_bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_archive_rejects_path_traversal_and_cleans_staging_dir() {
|
||||||
|
let store = unique_test_dir("traversal");
|
||||||
|
fs::create_dir_all(&store).expect("store dir should be created");
|
||||||
|
|
||||||
|
let loader = PluginLoader::new(&store);
|
||||||
|
let repo = PluginRepository::new("https://plugins.example.com", loader);
|
||||||
|
let archive = build_traversal_archive();
|
||||||
|
|
||||||
|
let err = repo
|
||||||
|
.install_archive_bytes("sensor", "1.0.0", &archive)
|
||||||
|
.expect_err("path traversal archive should be rejected");
|
||||||
|
assert!(err.to_string().contains("path traversal"));
|
||||||
|
|
||||||
|
let plugin_dir = store.join("sensor");
|
||||||
|
let entries = fs::read_dir(&plugin_dir)
|
||||||
|
.expect("plugin dir should exist")
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.expect("plugin dir entries should be readable");
|
||||||
|
assert!(entries.is_empty());
|
||||||
|
assert!(!plugin_dir.join("1.0.0").exists());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&store);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_archive_rejects_symlinks() {
|
||||||
|
let store = unique_test_dir("symlink");
|
||||||
|
fs::create_dir_all(&store).expect("store dir should be created");
|
||||||
|
|
||||||
|
let loader = PluginLoader::new(&store);
|
||||||
|
let repo = PluginRepository::new("https://plugins.example.com", loader);
|
||||||
|
let archive = build_symlink_archive("manifest.json", "/etc/passwd");
|
||||||
|
|
||||||
|
let err = repo
|
||||||
|
.install_archive_bytes("sensor", "1.0.0", &archive)
|
||||||
|
.expect_err("symlink archive should be rejected");
|
||||||
|
assert!(err.to_string().contains("unsupported link type"));
|
||||||
|
assert!(!store.join("sensor").join("1.0.0").exists());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&store);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_archive_extracts_to_staging_then_moves_into_place() {
|
||||||
|
let store = unique_test_dir("valid");
|
||||||
|
fs::create_dir_all(&store).expect("store dir should be created");
|
||||||
|
|
||||||
|
let loader = PluginLoader::new(&store);
|
||||||
|
let repo = PluginRepository::new("https://plugins.example.com", loader);
|
||||||
|
let archive = build_archive(vec![
|
||||||
|
(
|
||||||
|
"manifest.json",
|
||||||
|
br#"{"id":"sensor","version":"1.0.0","sdk_version":"0.2.0","so_filename":"libsensor.so"}"#,
|
||||||
|
),
|
||||||
|
("libsensor.so", b"fake so bytes"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
repo.install_archive_bytes("sensor", "1.0.0", &archive)
|
||||||
|
.expect("valid archive should install");
|
||||||
|
|
||||||
|
let target_dir = store.join("sensor").join("1.0.0");
|
||||||
|
assert!(target_dir.join("manifest.json").is_file());
|
||||||
|
assert!(target_dir.join("libsensor.so").is_file());
|
||||||
|
|
||||||
|
let leftover_tmp = fs::read_dir(store.join("sensor"))
|
||||||
|
.expect("plugin dir should be readable")
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.expect("plugin dir entries should be readable")
|
||||||
|
.into_iter()
|
||||||
|
.any(|entry| entry.file_name().to_string_lossy().contains(".tmp-"));
|
||||||
|
assert!(!leftover_tmp);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
use crate::core::config::AppConfig;
|
use crate::core::config::AppConfig;
|
||||||
use crate::core::message::{Destination, Envelope, Message};
|
use crate::core::message::{Destination, Envelope, Message};
|
||||||
use crate::core::plugin::{CapabilityTestResult, Plugin, PluginContext};
|
use crate::core::plugin::{CapabilityTestResult, Plugin, PluginContext};
|
||||||
use crate::core::plugin_loader::ErrorPolicy;
|
use crate::core::plugin_loader::{ErrorPolicy, PluginLoader, PluginRegistryEntry};
|
||||||
|
use crate::core::plugin_repo::PluginRepository;
|
||||||
use crate::core::version_manager::VersionManager;
|
use crate::core::version_manager::VersionManager;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::{mpsc, Arc};
|
use std::sync::{mpsc, Arc};
|
||||||
|
|
||||||
|
const DEFAULT_PLUGIN_REPO_URL: &str = "https://plugins.example.com";
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PluginSwitchCommand {
|
||||||
|
id: String,
|
||||||
|
version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PluginInstallCommand {
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 插件运行时状态包装
|
/// 插件运行时状态包装
|
||||||
struct PluginState {
|
struct PluginState {
|
||||||
plugin: Box<dyn Plugin>,
|
plugin: Box<dyn Plugin>,
|
||||||
@@ -92,6 +109,46 @@ pub struct ServiceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ServiceManager {
|
impl ServiceManager {
|
||||||
|
fn plugin_context(&self) -> PluginContext {
|
||||||
|
PluginContext {
|
||||||
|
tx: self.tx.clone(),
|
||||||
|
config: Arc::clone(&self.config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_and_start_plugin_with_context(
|
||||||
|
state: &mut PluginState,
|
||||||
|
ctx: PluginContext,
|
||||||
|
) -> Result<()> {
|
||||||
|
if let Err(init_error) = state.plugin.init(ctx) {
|
||||||
|
let cleanup_error = state.plugin.stop().err();
|
||||||
|
return match cleanup_error {
|
||||||
|
Some(stop_error) => Err(anyhow!(
|
||||||
|
"plugin '{}' init failed: {}; cleanup stop failed: {}",
|
||||||
|
state.id(),
|
||||||
|
init_error,
|
||||||
|
stop_error
|
||||||
|
)),
|
||||||
|
None => Err(init_error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(start_error) = state.plugin.start() {
|
||||||
|
let cleanup_error = state.plugin.stop().err();
|
||||||
|
return match cleanup_error {
|
||||||
|
Some(stop_error) => Err(anyhow!(
|
||||||
|
"plugin '{}' start failed: {}; cleanup stop failed: {}",
|
||||||
|
state.id(),
|
||||||
|
start_error,
|
||||||
|
stop_error
|
||||||
|
)),
|
||||||
|
None => Err(start_error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(config: AppConfig) -> Self {
|
pub fn new(config: AppConfig) -> Self {
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
Self {
|
Self {
|
||||||
@@ -240,15 +297,17 @@ impl ServiceManager {
|
|||||||
match &state.error_policy {
|
match &state.error_policy {
|
||||||
ErrorPolicy::AutoRollback => {
|
ErrorPolicy::AutoRollback => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[ServiceManager] 动态插件 '{}' 必须能力自测失败,禁用 (待回退)",
|
"[ServiceManager] 动态插件 '{}' 必须能力自测失败,尝试自动回退到稳定版本",
|
||||||
state.id()
|
state.id()
|
||||||
);
|
);
|
||||||
|
state.needs_rollback = true;
|
||||||
}
|
}
|
||||||
ErrorPolicy::DisableAndLog => {
|
ErrorPolicy::DisableAndLog => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[ServiceManager] 动态插件 '{}' 必须能力自测失败,禁用",
|
"[ServiceManager] 动态插件 '{}' 必须能力自测失败,禁用",
|
||||||
state.id()
|
state.id()
|
||||||
);
|
);
|
||||||
|
state.needs_rollback = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.enabled = false;
|
state.enabled = false;
|
||||||
@@ -258,6 +317,15 @@ impl ServiceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for idx in 0..self.plugins.len() {
|
||||||
|
if !self.plugins[idx].needs_rollback {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugin_id = self.plugins[idx].id().to_string();
|
||||||
|
self.rollback_dynamic_plugin(idx, &plugin_id);
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: start
|
// Phase 3: start
|
||||||
for state in &mut self.plugins {
|
for state in &mut self.plugins {
|
||||||
if !state.enabled {
|
if !state.enabled {
|
||||||
@@ -279,6 +347,8 @@ impl ServiceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.broadcast_plugin_states();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,18 +399,29 @@ impl ServiceManager {
|
|||||||
|
|
||||||
/// 启用/禁用指定插件
|
/// 启用/禁用指定插件
|
||||||
pub fn set_plugin_enabled(&mut self, plugin_id: &str, enabled: bool) -> Result<()> {
|
pub fn set_plugin_enabled(&mut self, plugin_id: &str, enabled: bool) -> Result<()> {
|
||||||
let state = self
|
let idx = self
|
||||||
.plugins
|
.plugins
|
||||||
.iter_mut()
|
.iter()
|
||||||
.find(|s| s.id() == plugin_id)
|
.position(|s| s.id() == plugin_id)
|
||||||
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not found"))?;
|
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not found"))?;
|
||||||
|
|
||||||
if enabled && !state.enabled {
|
if enabled && !self.plugins[idx].enabled {
|
||||||
// 重新启用:reset 错误计数
|
let ctx = self.plugin_context();
|
||||||
|
let state = &mut self.plugins[idx];
|
||||||
state.error_count = 0;
|
state.error_count = 0;
|
||||||
|
match Self::init_and_start_plugin_with_context(state, ctx) {
|
||||||
|
Ok(()) => {
|
||||||
state.enabled = true;
|
state.enabled = true;
|
||||||
println!("[ServiceManager] 插件 '{plugin_id}' 已启用");
|
println!("[ServiceManager] 插件 '{plugin_id}' 已启用");
|
||||||
} else if !enabled && state.enabled {
|
}
|
||||||
|
Err(error) => {
|
||||||
|
state.enabled = false;
|
||||||
|
return Err(anyhow!("failed to enable plugin '{plugin_id}': {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !enabled && self.plugins[idx].enabled {
|
||||||
|
let state = &mut self.plugins[idx];
|
||||||
|
state.plugin.stop()?;
|
||||||
state.enabled = false;
|
state.enabled = false;
|
||||||
println!("[ServiceManager] 插件 '{plugin_id}' 已禁用");
|
println!("[ServiceManager] 插件 '{plugin_id}' 已禁用");
|
||||||
}
|
}
|
||||||
@@ -348,6 +429,23 @@ impl ServiceManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rollback_plugin(&mut self, plugin_id: &str) -> Result<()> {
|
||||||
|
let idx = self
|
||||||
|
.plugins
|
||||||
|
.iter()
|
||||||
|
.position(|state| state.id() == plugin_id)
|
||||||
|
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not found"))?;
|
||||||
|
|
||||||
|
if !self.plugins[idx].is_dynamic {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"plugin '{plugin_id}' is not dynamic and cannot be rolled back"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.rollback_dynamic_plugin(idx, plugin_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 查询插件状态信息(供 HTTP API 使用)
|
/// 查询插件状态信息(供 HTTP API 使用)
|
||||||
pub fn plugin_states(&self) -> Vec<PluginStateInfo> {
|
pub fn plugin_states(&self) -> Vec<PluginStateInfo> {
|
||||||
self.plugins
|
self.plugins
|
||||||
@@ -389,18 +487,50 @@ impl ServiceManager {
|
|||||||
new_state.capabilities = capabilities;
|
new_state.capabilities = capabilities;
|
||||||
new_state.auto_test = auto_test;
|
new_state.auto_test = auto_test;
|
||||||
|
|
||||||
let ctx = PluginContext {
|
let ctx = self.plugin_context();
|
||||||
tx: self.tx.clone(),
|
let mut old_state = self.plugins.remove(idx);
|
||||||
config: Arc::clone(&self.config),
|
let old_was_enabled = old_state.enabled;
|
||||||
};
|
|
||||||
new_state.plugin.init(ctx)?;
|
|
||||||
new_state.plugin.start()?;
|
|
||||||
|
|
||||||
if self.plugins[idx].enabled {
|
if old_was_enabled {
|
||||||
let _ = self.plugins[idx].plugin.stop();
|
// 先停旧插件,避免热替换窗口内新旧实例同时持有端口、文件句柄等独占资源。
|
||||||
|
old_state.plugin.stop()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let replace_result = Self::init_and_start_plugin_with_context(&mut new_state, ctx);
|
||||||
|
match replace_result {
|
||||||
|
Ok(()) => {
|
||||||
|
new_state.enabled = true;
|
||||||
|
self.plugins.insert(idx, new_state);
|
||||||
|
}
|
||||||
|
Err(new_error) => {
|
||||||
|
if old_was_enabled {
|
||||||
|
let restore_ctx = self.plugin_context();
|
||||||
|
match Self::init_and_start_plugin_with_context(&mut old_state, restore_ctx) {
|
||||||
|
Ok(()) => {
|
||||||
|
old_state.enabled = true;
|
||||||
|
self.plugins.insert(idx, old_state);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"failed to replace plugin '{plugin_id}': {new_error}; restored previous plugin"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(restore_error) => {
|
||||||
|
old_state.enabled = false;
|
||||||
|
self.plugins.insert(idx, old_state);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"failed to replace plugin '{plugin_id}': {new_error}; failed to restore previous plugin: {restore_error}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
old_state.enabled = false;
|
||||||
|
self.plugins.insert(idx, old_state);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"failed to replace plugin '{plugin_id}': {new_error}"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.plugins[idx] = new_state;
|
|
||||||
println!("[ServiceManager] 插件 '{plugin_id}' 热替换成功");
|
println!("[ServiceManager] 插件 '{plugin_id}' 热替换成功");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -441,6 +571,192 @@ impl ServiceManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn plugin_loader(&self) -> Result<PluginLoader> {
|
||||||
|
let version_manager = self
|
||||||
|
.version_manager
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("plugin version manager is not configured"))?;
|
||||||
|
Ok(PluginLoader::new(version_manager.loader().store_path()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_repository(&self) -> Result<PluginRepository> {
|
||||||
|
Ok(PluginRepository::new(
|
||||||
|
&std::env::var("SHOWEN_PLUGIN_REPO_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_PLUGIN_REPO_URL.to_string()),
|
||||||
|
self.plugin_loader()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_dynamic_plugin_runtime(
|
||||||
|
&mut self,
|
||||||
|
plugin_id: &str,
|
||||||
|
plugin: Box<dyn Plugin>,
|
||||||
|
error_policy: ErrorPolicy,
|
||||||
|
max_errors: u32,
|
||||||
|
required_capabilities: Vec<String>,
|
||||||
|
capabilities: Vec<String>,
|
||||||
|
auto_test: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut state = PluginState::new_dynamic(plugin, error_policy, max_errors);
|
||||||
|
state.required_capabilities = required_capabilities;
|
||||||
|
state.capabilities = capabilities;
|
||||||
|
state.auto_test = auto_test;
|
||||||
|
|
||||||
|
let ctx = self.plugin_context();
|
||||||
|
Self::init_and_start_plugin_with_context(&mut state, ctx)
|
||||||
|
.map_err(|error| anyhow!("failed to start installed plugin '{plugin_id}': {error}"))?;
|
||||||
|
state.enabled = true;
|
||||||
|
self.plugins.push(state);
|
||||||
|
|
||||||
|
println!("[ServiceManager] 插件 '{plugin_id}' 已安装并启动");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch_plugin_version(&mut self, plugin_id: &str, version: &str) -> Result<()> {
|
||||||
|
let version_manager = self
|
||||||
|
.version_manager
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("plugin version manager is not configured"))?;
|
||||||
|
version_manager.switch_version(plugin_id, version)?;
|
||||||
|
|
||||||
|
let (plugin, manifest) = version_manager
|
||||||
|
.loader()
|
||||||
|
.load_plugin(plugin_id, Some(version))?;
|
||||||
|
let loader = PluginLoader::new(version_manager.loader().store_path());
|
||||||
|
let registry = loader.load_registry()?;
|
||||||
|
let entry = registry
|
||||||
|
.plugins
|
||||||
|
.get(plugin_id)
|
||||||
|
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not in registry"))?;
|
||||||
|
|
||||||
|
if let Some(idx) = self
|
||||||
|
.plugins
|
||||||
|
.iter()
|
||||||
|
.position(|state| state.id() == plugin_id)
|
||||||
|
{
|
||||||
|
self.replace_dynamic_plugin_at_index(
|
||||||
|
idx,
|
||||||
|
plugin_id,
|
||||||
|
Box::new(plugin),
|
||||||
|
manifest.error_policy,
|
||||||
|
entry.max_errors,
|
||||||
|
manifest.required_capabilities,
|
||||||
|
manifest.capabilities,
|
||||||
|
manifest.auto_test,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
self.register_dynamic_plugin_runtime(
|
||||||
|
plugin_id,
|
||||||
|
Box::new(plugin),
|
||||||
|
manifest.error_policy,
|
||||||
|
entry.max_errors,
|
||||||
|
manifest.required_capabilities,
|
||||||
|
manifest.capabilities,
|
||||||
|
manifest.auto_test,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("[ServiceManager] 插件 '{plugin_id}' 已切换到版本 {version}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_plugin(&mut self, request: PluginInstallCommand) -> Result<()> {
|
||||||
|
let plugin_id = request.id.clone();
|
||||||
|
let repo = self.plugin_repository()?;
|
||||||
|
let version = match request.version.clone() {
|
||||||
|
Some(version) => version,
|
||||||
|
None => repo.check_update(&plugin_id, "0.0.0")?.ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"repo did not report an installable version for '{}'",
|
||||||
|
plugin_id
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
|
||||||
|
repo.download_and_install(&plugin_id, &version)?;
|
||||||
|
|
||||||
|
let loader = self.plugin_loader()?;
|
||||||
|
let (plugin, manifest) = loader.load_plugin(&plugin_id, Some(&version))?;
|
||||||
|
|
||||||
|
let mut registry = loader.load_registry()?;
|
||||||
|
let existing = registry.plugins.get(&plugin_id).cloned();
|
||||||
|
let entry = PluginRegistryEntry {
|
||||||
|
active_version: version.clone(),
|
||||||
|
last_stable_version: existing
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|entry| entry.last_stable_version.clone()),
|
||||||
|
enabled: true,
|
||||||
|
error_policy: existing
|
||||||
|
.as_ref()
|
||||||
|
.map(|entry| entry.error_policy.clone())
|
||||||
|
.unwrap_or_else(|| manifest.error_policy.clone()),
|
||||||
|
max_errors: existing.as_ref().map(|entry| entry.max_errors).unwrap_or(5),
|
||||||
|
};
|
||||||
|
registry.plugins.insert(plugin_id.clone(), entry.clone());
|
||||||
|
loader.save_registry(®istry)?;
|
||||||
|
|
||||||
|
if let Some(idx) = self
|
||||||
|
.plugins
|
||||||
|
.iter()
|
||||||
|
.position(|state| state.id() == plugin_id)
|
||||||
|
{
|
||||||
|
self.replace_dynamic_plugin_at_index(
|
||||||
|
idx,
|
||||||
|
&plugin_id,
|
||||||
|
Box::new(plugin),
|
||||||
|
manifest.error_policy,
|
||||||
|
entry.max_errors,
|
||||||
|
manifest.required_capabilities,
|
||||||
|
manifest.capabilities,
|
||||||
|
manifest.auto_test,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
self.register_dynamic_plugin_runtime(
|
||||||
|
&plugin_id,
|
||||||
|
Box::new(plugin),
|
||||||
|
manifest.error_policy,
|
||||||
|
entry.max_errors,
|
||||||
|
manifest.required_capabilities,
|
||||||
|
manifest.capabilities,
|
||||||
|
manifest.auto_test,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_plugin_updates(&self) -> Result<()> {
|
||||||
|
let repo = self.plugin_repository()?;
|
||||||
|
let registry = self.plugin_loader()?.load_registry()?;
|
||||||
|
|
||||||
|
for (plugin_id, entry) in ®istry.plugins {
|
||||||
|
match repo.check_update(plugin_id, &entry.active_version)? {
|
||||||
|
Some(version) => println!(
|
||||||
|
"[ServiceManager] 插件 '{plugin_id}' 发现可用更新: {} -> {version}",
|
||||||
|
entry.active_version
|
||||||
|
),
|
||||||
|
None => println!(
|
||||||
|
"[ServiceManager] 插件 '{plugin_id}' 已是最新版本 {}",
|
||||||
|
entry.active_version
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broadcast_plugin_states(&mut self) {
|
||||||
|
match serde_json::to_string(&self.plugin_states()) {
|
||||||
|
Ok(payload) => self.broadcast_message(Message::Custom {
|
||||||
|
kind: "plugin_states".to_string(),
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
|
Err(error) => eprintln!("[ServiceManager] 序列化 plugin_states 失败: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 处理发给管理层自身的消息
|
/// 处理发给管理层自身的消息
|
||||||
fn handle_manager_message(&mut self, msg: Message) -> Result<()> {
|
fn handle_manager_message(&mut self, msg: Message) -> Result<()> {
|
||||||
match msg {
|
match msg {
|
||||||
@@ -484,6 +800,79 @@ impl ServiceManager {
|
|||||||
println!("[ServiceManager] 插件 '{}' 就绪", id);
|
println!("[ServiceManager] 插件 '{}' 就绪", id);
|
||||||
self.broadcast_message(Message::PluginReady(id));
|
self.broadcast_message(Message::PluginReady(id));
|
||||||
}
|
}
|
||||||
|
Message::Custom { kind, payload } => {
|
||||||
|
let should_broadcast = match kind.as_str() {
|
||||||
|
"plugin_enable" => {
|
||||||
|
if let Err(error) = self.set_plugin_enabled(&payload, true) {
|
||||||
|
eprintln!(
|
||||||
|
"[ServiceManager] plugin_enable('{}') 失败: {error}",
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"plugin_disable" => {
|
||||||
|
if let Err(error) = self.set_plugin_enabled(&payload, false) {
|
||||||
|
eprintln!(
|
||||||
|
"[ServiceManager] plugin_disable('{}') 失败: {error}",
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"plugin_rollback" => {
|
||||||
|
if let Err(error) = self.rollback_plugin(&payload) {
|
||||||
|
eprintln!(
|
||||||
|
"[ServiceManager] plugin_rollback('{}') 失败: {error}",
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"plugin_switch" => {
|
||||||
|
match serde_json::from_str::<PluginSwitchCommand>(&payload) {
|
||||||
|
Ok(command) => {
|
||||||
|
if let Err(error) =
|
||||||
|
self.switch_plugin_version(&command.id, &command.version)
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[ServiceManager] plugin_switch('{}', '{}') 失败: {error}",
|
||||||
|
command.id, command.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("[ServiceManager] plugin_switch payload 非法: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"plugin_install" => {
|
||||||
|
match serde_json::from_str::<PluginInstallCommand>(&payload) {
|
||||||
|
Ok(command) => {
|
||||||
|
if let Err(error) = self.install_plugin(command) {
|
||||||
|
eprintln!("[ServiceManager] plugin_install 失败: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("[ServiceManager] plugin_install payload 非法: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"plugin_check_updates" => {
|
||||||
|
if let Err(error) = self.check_plugin_updates() {
|
||||||
|
eprintln!("[ServiceManager] plugin_check_updates 失败: {error}");
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_broadcast {
|
||||||
|
self.broadcast_plugin_states();
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -665,7 +1054,12 @@ impl ServiceManager {
|
|||||||
"[ServiceManager] 插件 '{}' 错误次数达到阈值,尝试自动回退到稳定版本",
|
"[ServiceManager] 插件 '{}' 错误次数达到阈值,尝试自动回退到稳定版本",
|
||||||
plugin_id
|
plugin_id
|
||||||
);
|
);
|
||||||
|
self.rollback_dynamic_plugin(idx, plugin_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback_dynamic_plugin(&mut self, idx: usize, plugin_id: &str) {
|
||||||
let rollback_result = {
|
let rollback_result = {
|
||||||
let Some(version_manager) = self.version_manager.as_ref() else {
|
let Some(version_manager) = self.version_manager.as_ref() else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -704,6 +1098,7 @@ impl ServiceManager {
|
|||||||
manifest.auto_test,
|
manifest.auto_test,
|
||||||
) {
|
) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
self.plugins[idx].needs_rollback = false;
|
||||||
println!(
|
println!(
|
||||||
"[ServiceManager] 插件 '{}' 已回退并重新加载稳定版本 {}",
|
"[ServiceManager] 插件 '{}' 已回退并重新加载稳定版本 {}",
|
||||||
plugin_id, version
|
plugin_id, version
|
||||||
@@ -734,8 +1129,6 @@ impl ServiceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取发送通道的克隆(供外部使用)
|
/// 获取发送通道的克隆(供外部使用)
|
||||||
pub fn sender(&self) -> mpsc::Sender<Envelope> {
|
pub fn sender(&self) -> mpsc::Sender<Envelope> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use super::config::{parse_str, AppConfig};
|
|||||||
use super::message::{Destination, Envelope, Message};
|
use super::message::{Destination, Envelope, Message};
|
||||||
use super::plugin::{CapabilityTestResult, Platform, Plugin, PluginContext, PluginInfo};
|
use super::plugin::{CapabilityTestResult, Platform, Plugin, PluginContext, PluginInfo};
|
||||||
use super::plugin_loader::{ErrorPolicy, PluginLoader, PluginRegistry, PluginRegistryEntry};
|
use super::plugin_loader::{ErrorPolicy, PluginLoader, PluginRegistry, PluginRegistryEntry};
|
||||||
use super::service_manager::ServiceManager;
|
use super::service_manager::{PluginStateInfo, ServiceManager};
|
||||||
use super::version_manager::VersionManager;
|
use super::version_manager::VersionManager;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -58,6 +58,29 @@ fn has_event(events: &Arc<Mutex<Vec<String>>>, expected: &str) -> bool {
|
|||||||
lock_events(events).iter().any(|event| event == expected)
|
lock_events(events).iter().any(|event| event == expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_events(events: &Arc<Mutex<Vec<String>>>) {
|
||||||
|
lock_events(events).clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latest_plugin_states(events: &Arc<Mutex<Vec<String>>>, plugin_id: &str) -> Vec<PluginStateInfo> {
|
||||||
|
let prefix = format!("msg:{plugin_id}:custom:plugin_states:");
|
||||||
|
let payload = lock_events(events)
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|event| event.strip_prefix(&prefix).map(str::to_owned))
|
||||||
|
.unwrap_or_else(|| panic!("missing plugin_states event for {}", plugin_id));
|
||||||
|
|
||||||
|
serde_json::from_str(&payload).expect("plugin_states payload should deserialize")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_plugin_state_events(events: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
|
||||||
|
lock_events(events)
|
||||||
|
.iter()
|
||||||
|
.filter(|event| !event.contains("custom:plugin_states:"))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn message_label(message: &Message) -> String {
|
fn message_label(message: &Message) -> String {
|
||||||
match message {
|
match message {
|
||||||
Message::Custom { kind, payload } => format!("custom:{kind}:{payload}"),
|
Message::Custom { kind, payload } => format!("custom:{kind}:{payload}"),
|
||||||
@@ -89,6 +112,88 @@ struct TestPlugin {
|
|||||||
events: Arc<Mutex<Vec<String>>>,
|
events: Arc<Mutex<Vec<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct PluginFailurePlan {
|
||||||
|
fail_init: bool,
|
||||||
|
fail_start: bool,
|
||||||
|
fail_stop: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LifecyclePlugin {
|
||||||
|
id: String,
|
||||||
|
events: Arc<Mutex<Vec<String>>>,
|
||||||
|
plan: Arc<Mutex<PluginFailurePlan>>,
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LifecyclePlugin {
|
||||||
|
fn new(
|
||||||
|
id: &str,
|
||||||
|
label: &str,
|
||||||
|
events: Arc<Mutex<Vec<String>>>,
|
||||||
|
plan: PluginFailurePlan,
|
||||||
|
) -> (Self, Arc<Mutex<PluginFailurePlan>>) {
|
||||||
|
let plan = Arc::new(Mutex::new(plan));
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
id: id.to_string(),
|
||||||
|
events,
|
||||||
|
plan: plan.clone(),
|
||||||
|
label: label.to_string(),
|
||||||
|
},
|
||||||
|
plan,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&self, phase: &str) {
|
||||||
|
lock_events(&self.events).push(format!("{phase}:{}:{}", self.id, self.label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for LifecyclePlugin {
|
||||||
|
fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(&self) -> PluginInfo {
|
||||||
|
PluginInfo {
|
||||||
|
name: format!("{}-{}", self.id, self.label),
|
||||||
|
version: "test".to_string(),
|
||||||
|
description: "lifecycle test plugin".to_string(),
|
||||||
|
platform: Platform::Any,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(&mut self, _ctx: PluginContext) -> Result<()> {
|
||||||
|
self.record("init");
|
||||||
|
if self.plan.lock().expect("plan mutex poisoned").fail_init {
|
||||||
|
anyhow::bail!("init failure:{}:{}", self.id, self.label);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&mut self) -> Result<()> {
|
||||||
|
self.record("start");
|
||||||
|
if self.plan.lock().expect("plan mutex poisoned").fail_start {
|
||||||
|
anyhow::bail!("start failure:{}:{}", self.id, self.label);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||||
|
self.record(&format!("msg:{}", message_label(&msg)));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) -> Result<()> {
|
||||||
|
self.record("stop");
|
||||||
|
if self.plan.lock().expect("plan mutex poisoned").fail_stop {
|
||||||
|
anyhow::bail!("stop failure:{}:{}", self.id, self.label);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TestPlugin {
|
impl TestPlugin {
|
||||||
fn new(id: &str, deps: Vec<&str>, events: Arc<Mutex<Vec<String>>>) -> Self {
|
fn new(id: &str, deps: Vec<&str>, events: Arc<Mutex<Vec<String>>>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -153,8 +258,9 @@ fn service_manager_register_start_and_stop_flow() {
|
|||||||
manager.start_all().expect("start_all should succeed");
|
manager.start_all().expect("start_all should succeed");
|
||||||
manager.stop_all().expect("stop_all should succeed");
|
manager.stop_all().expect("stop_all should succeed");
|
||||||
|
|
||||||
|
let events = non_plugin_state_events(&events);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lock_events(&events).clone(),
|
events,
|
||||||
vec![
|
vec![
|
||||||
"init:alpha",
|
"init:alpha",
|
||||||
"init:beta",
|
"init:beta",
|
||||||
@@ -283,8 +389,9 @@ fn start_all_sorts_plugins_topologically() {
|
|||||||
.expect("start_all should sort dependencies");
|
.expect("start_all should sort dependencies");
|
||||||
manager.stop_all().expect("stop_all should succeed");
|
manager.stop_all().expect("stop_all should succeed");
|
||||||
|
|
||||||
|
let events = non_plugin_state_events(&events);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lock_events(&events).clone(),
|
events,
|
||||||
vec![
|
vec![
|
||||||
"init:alpha",
|
"init:alpha",
|
||||||
"init:beta",
|
"init:beta",
|
||||||
@@ -759,6 +866,59 @@ fn auto_rollback_updates_registry_and_marks_pending_when_reload_fails() {
|
|||||||
let _ = fs::remove_dir_all(&tmp);
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_test_auto_rollback_updates_registry_and_marks_pending_when_reload_fails() {
|
||||||
|
let tmp = std::env::temp_dir().join("showen_test_self_test_autorollback");
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
manager.set_version_manager(setup_rollback_store(&tmp, "sensor"));
|
||||||
|
|
||||||
|
let plugin = TestPluginWithSelfTest::new(
|
||||||
|
"sensor",
|
||||||
|
events.clone(),
|
||||||
|
vec!["temperature".into()],
|
||||||
|
vec![CapabilityTestResult {
|
||||||
|
capability: "temperature".into(),
|
||||||
|
passed: false,
|
||||||
|
message: "sensor not connected".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.register_dynamic_with_manifest(
|
||||||
|
Box::new(plugin),
|
||||||
|
ErrorPolicy::AutoRollback,
|
||||||
|
5,
|
||||||
|
vec!["temperature".into()],
|
||||||
|
vec!["temperature".into()],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
let registry = PluginLoader::new(&tmp).load_registry().unwrap();
|
||||||
|
assert_eq!(registry.plugins["sensor"].active_version, "1.0.0");
|
||||||
|
|
||||||
|
let states = manager.plugin_states();
|
||||||
|
assert!(
|
||||||
|
!states[0].enabled,
|
||||||
|
"plugin should stay disabled after failed self-test"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
states[0].needs_rollback,
|
||||||
|
"plugin should be marked for restart-time reload when rollback reload fails"
|
||||||
|
);
|
||||||
|
|
||||||
|
let log = lock_events(&events);
|
||||||
|
assert!(log.contains(&"init:sensor".to_string()));
|
||||||
|
assert!(log.contains(&"self_test:sensor".to_string()));
|
||||||
|
assert!(
|
||||||
|
!log.contains(&"start:sensor".to_string()),
|
||||||
|
"plugin should not start after failed required capability"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn message_config_reload_request_round_trips_through_json() {
|
fn message_config_reload_request_round_trips_through_json() {
|
||||||
let json = serde_json::to_string(&Message::ConfigReloadRequest)
|
let json = serde_json::to_string(&Message::ConfigReloadRequest)
|
||||||
@@ -781,6 +941,8 @@ fn message_config_reloaded_round_trips_through_json() {
|
|||||||
assert_eq!(decoded.display.window_title, config.display.window_title);
|
assert_eq!(decoded.display.window_title, config.display.window_title);
|
||||||
assert_eq!(decoded.playlist.len(), config.playlist.len());
|
assert_eq!(decoded.playlist.len(), config.playlist.len());
|
||||||
assert_eq!(decoded.remote_control.port, config.remote_control.port);
|
assert_eq!(decoded.remote_control.port, config.remote_control.port);
|
||||||
|
assert_eq!(decoded.source_path, config.source_path);
|
||||||
|
assert_eq!(decoded.source_dir, config.source_dir);
|
||||||
}
|
}
|
||||||
other => panic!("unexpected message after round trip: {:?}", other),
|
other => panic!("unexpected message after round trip: {:?}", other),
|
||||||
}
|
}
|
||||||
@@ -880,6 +1042,414 @@ fn handle_message_skips_disabled_plugins() {
|
|||||||
assert!(!manager.plugin_states()[1].enabled);
|
assert!(!manager.plugin_states()[1].enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_plugin_enabled_runs_full_lifecycle() {
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
let (plugin, _plan) =
|
||||||
|
LifecyclePlugin::new("beta", "base", events.clone(), PluginFailurePlan::default());
|
||||||
|
|
||||||
|
manager.register(Box::new(plugin));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
manager
|
||||||
|
.set_plugin_enabled("beta", false)
|
||||||
|
.expect("disable should stop plugin");
|
||||||
|
manager
|
||||||
|
.set_plugin_enabled("beta", true)
|
||||||
|
.expect("enable should re-init and restart plugin");
|
||||||
|
|
||||||
|
let events = non_plugin_state_events(&events);
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
"init:beta:base",
|
||||||
|
"start:beta:base",
|
||||||
|
"stop:beta:base",
|
||||||
|
"init:beta:base",
|
||||||
|
"start:beta:base",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(manager.plugin_states()[0].enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_plugin_enabled_rolls_back_to_disabled_when_restart_fails() {
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
let (plugin, plan) =
|
||||||
|
LifecyclePlugin::new("beta", "base", events.clone(), PluginFailurePlan::default());
|
||||||
|
|
||||||
|
manager.register(Box::new(plugin));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
manager
|
||||||
|
.set_plugin_enabled("beta", false)
|
||||||
|
.expect("disable should succeed");
|
||||||
|
plan.lock().expect("plan mutex poisoned").fail_start = true;
|
||||||
|
|
||||||
|
let error = manager
|
||||||
|
.set_plugin_enabled("beta", true)
|
||||||
|
.expect_err("failed start should keep plugin disabled");
|
||||||
|
assert!(error.to_string().contains("failed to enable plugin 'beta'"));
|
||||||
|
|
||||||
|
let events = non_plugin_state_events(&events);
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
"init:beta:base",
|
||||||
|
"start:beta:base",
|
||||||
|
"stop:beta:base",
|
||||||
|
"init:beta:base",
|
||||||
|
"start:beta:base",
|
||||||
|
"stop:beta:base",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(!manager.plugin_states()[0].enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_all_broadcasts_initial_plugin_states() {
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
|
||||||
|
manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone())));
|
||||||
|
manager.register(Box::new(TestPlugin::new("beta", vec![], events.clone())));
|
||||||
|
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
let states = latest_plugin_states(&events, "alpha");
|
||||||
|
assert_eq!(states.len(), 2);
|
||||||
|
assert!(states.iter().all(|state| state.enabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_plugin_disable_command_disables_plugin_and_broadcasts_states() {
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
|
||||||
|
manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone())));
|
||||||
|
manager.register(Box::new(
|
||||||
|
LifecyclePlugin::new("beta", "base", events.clone(), PluginFailurePlan::default()).0,
|
||||||
|
));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
clear_events(&events);
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "http".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Custom {
|
||||||
|
kind: "plugin_disable".to_string(),
|
||||||
|
payload: "beta".to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("disable should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
let states = latest_plugin_states(&events, "alpha");
|
||||||
|
let beta = states.iter().find(|state| state.id == "beta").unwrap();
|
||||||
|
assert!(!beta.enabled);
|
||||||
|
assert!(has_event(&events, "stop:beta:base"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_plugin_enable_command_enables_plugin_and_broadcasts_states() {
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
let (plugin, _plan) =
|
||||||
|
LifecyclePlugin::new("beta", "base", events.clone(), PluginFailurePlan::default());
|
||||||
|
|
||||||
|
manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone())));
|
||||||
|
manager.register(Box::new(plugin));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
manager
|
||||||
|
.set_plugin_enabled("beta", false)
|
||||||
|
.expect("disable should succeed");
|
||||||
|
clear_events(&events);
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "http".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Custom {
|
||||||
|
kind: "plugin_enable".to_string(),
|
||||||
|
payload: "beta".to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("enable should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
let states = latest_plugin_states(&events, "alpha");
|
||||||
|
let beta = states.iter().find(|state| state.id == "beta").unwrap();
|
||||||
|
assert!(beta.enabled);
|
||||||
|
assert!(has_event(&events, "init:beta:base"));
|
||||||
|
assert!(has_event(&events, "start:beta:base"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_plugin_rollback_command_rolls_back_registry_and_broadcasts_states() {
|
||||||
|
let tmp = unique_test_dir("custom_plugin_rollback");
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
manager.set_version_manager(setup_rollback_store(&tmp, "sensor"));
|
||||||
|
|
||||||
|
manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone())));
|
||||||
|
manager.register_dynamic(
|
||||||
|
Box::new(FailingPlugin::new("sensor", events.clone())),
|
||||||
|
ErrorPolicy::AutoRollback,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
clear_events(&events);
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "http".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Custom {
|
||||||
|
kind: "plugin_rollback".to_string(),
|
||||||
|
payload: "sensor".to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("rollback should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
let registry = PluginLoader::new(&tmp)
|
||||||
|
.load_registry()
|
||||||
|
.expect("registry should load");
|
||||||
|
assert_eq!(registry.plugins["sensor"].active_version, "1.0.0");
|
||||||
|
|
||||||
|
let states = latest_plugin_states(&events, "alpha");
|
||||||
|
let sensor = states.iter().find(|state| state.id == "sensor").unwrap();
|
||||||
|
assert!(sensor.needs_rollback);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_plugin_switch_command_with_invalid_payload_still_broadcasts_states() {
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
|
||||||
|
manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone())));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
clear_events(&events);
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "http".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Custom {
|
||||||
|
kind: "plugin_switch".to_string(),
|
||||||
|
payload: "not-json".to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("switch should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
let states = latest_plugin_states(&events, "alpha");
|
||||||
|
assert_eq!(states.len(), 1);
|
||||||
|
assert_eq!(states[0].id, "alpha");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_plugin_install_command_without_version_manager_still_broadcasts_states() {
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
|
||||||
|
manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone())));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
clear_events(&events);
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "http".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Custom {
|
||||||
|
kind: "plugin_install".to_string(),
|
||||||
|
payload: serde_json::json!({ "id": "weather" }).to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("install should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
let states = latest_plugin_states(&events, "alpha");
|
||||||
|
assert_eq!(states.len(), 1);
|
||||||
|
assert_eq!(states[0].id, "alpha");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_plugin_check_updates_command_without_version_manager_still_broadcasts_states() {
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
|
||||||
|
manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone())));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
clear_events(&events);
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "http".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Custom {
|
||||||
|
kind: "plugin_check_updates".to_string(),
|
||||||
|
payload: String::new(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("check updates should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
let states = latest_plugin_states(&events, "alpha");
|
||||||
|
assert_eq!(states.len(), 1);
|
||||||
|
assert_eq!(states[0].id, "alpha");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_dynamic_plugin_stops_old_before_starting_new() {
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
let (old_plugin, _old_plan) = LifecyclePlugin::new(
|
||||||
|
"sensor",
|
||||||
|
"old",
|
||||||
|
events.clone(),
|
||||||
|
PluginFailurePlan::default(),
|
||||||
|
);
|
||||||
|
let (new_plugin, _new_plan) = LifecyclePlugin::new(
|
||||||
|
"sensor",
|
||||||
|
"new",
|
||||||
|
events.clone(),
|
||||||
|
PluginFailurePlan::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.register_dynamic(Box::new(old_plugin), ErrorPolicy::DisableAndLog, 5);
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
manager
|
||||||
|
.replace_dynamic_plugin(
|
||||||
|
"sensor",
|
||||||
|
Box::new(new_plugin),
|
||||||
|
ErrorPolicy::DisableAndLog,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
.expect("replace should succeed");
|
||||||
|
|
||||||
|
let events = non_plugin_state_events(&events);
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
"init:sensor:old",
|
||||||
|
"start:sensor:old",
|
||||||
|
"stop:sensor:old",
|
||||||
|
"init:sensor:new",
|
||||||
|
"start:sensor:new",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_dynamic_plugin_restores_old_plugin_when_new_start_fails() {
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let mut manager = ServiceManager::new(test_config());
|
||||||
|
let (old_plugin, _old_plan) = LifecyclePlugin::new(
|
||||||
|
"sensor",
|
||||||
|
"old",
|
||||||
|
events.clone(),
|
||||||
|
PluginFailurePlan::default(),
|
||||||
|
);
|
||||||
|
let (new_plugin, _new_plan) = LifecyclePlugin::new(
|
||||||
|
"sensor",
|
||||||
|
"new",
|
||||||
|
events.clone(),
|
||||||
|
PluginFailurePlan {
|
||||||
|
fail_start: true,
|
||||||
|
..PluginFailurePlan::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.register_dynamic(Box::new(old_plugin), ErrorPolicy::DisableAndLog, 5);
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
let error = manager
|
||||||
|
.replace_dynamic_plugin(
|
||||||
|
"sensor",
|
||||||
|
Box::new(new_plugin),
|
||||||
|
ErrorPolicy::DisableAndLog,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
.expect_err("failed replacement should restore old plugin");
|
||||||
|
assert!(error.to_string().contains("restored previous plugin"));
|
||||||
|
|
||||||
|
let events = non_plugin_state_events(&events);
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
"init:sensor:old",
|
||||||
|
"start:sensor:old",
|
||||||
|
"stop:sensor:old",
|
||||||
|
"init:sensor:new",
|
||||||
|
"start:sensor:new",
|
||||||
|
"stop:sensor:new",
|
||||||
|
"init:sensor:old",
|
||||||
|
"start:sensor:old",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(manager.plugin_states()[0].enabled);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rollback_without_stable_version_returns_error_and_keeps_active_version() {
|
fn rollback_without_stable_version_returns_error_and_keeps_active_version() {
|
||||||
let tmp = unique_test_dir("rollback_without_stable");
|
let tmp = unique_test_dir("rollback_without_stable");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use crate::core::plugin_loader::PluginLoader;
|
use crate::core::plugin_loader::PluginLoader;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// 版本管理器
|
/// 版本管理器
|
||||||
pub struct VersionManager {
|
pub struct VersionManager {
|
||||||
@@ -42,9 +43,7 @@ impl VersionManager {
|
|||||||
entry.last_stable_version = Some(version.to_string());
|
entry.last_stable_version = Some(version.to_string());
|
||||||
self.loader.save_registry(®istry)?;
|
self.loader.save_registry(®istry)?;
|
||||||
|
|
||||||
println!(
|
println!("[VersionManager] 插件 '{plugin_id}' v{version} 标记为稳定版本");
|
||||||
"[VersionManager] 插件 '{plugin_id}' v{version} 标记为稳定版本"
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,20 +70,14 @@ impl VersionManager {
|
|||||||
entry.active_version = stable_version.clone();
|
entry.active_version = stable_version.clone();
|
||||||
self.loader.save_registry(®istry)?;
|
self.loader.save_registry(®istry)?;
|
||||||
|
|
||||||
println!(
|
println!("[VersionManager] 插件 '{plugin_id}' 从 v{old_version} 回退到 v{stable_version}");
|
||||||
"[VersionManager] 插件 '{plugin_id}' 从 v{old_version} 回退到 v{stable_version}"
|
|
||||||
);
|
|
||||||
Ok(stable_version)
|
Ok(stable_version)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 切换到指定版本
|
/// 切换到指定版本
|
||||||
pub fn switch_version(&self, plugin_id: &str, version: &str) -> Result<()> {
|
pub fn switch_version(&self, plugin_id: &str, version: &str) -> Result<()> {
|
||||||
// 验证版本目录存在
|
// 验证版本目录存在
|
||||||
let version_dir = self
|
let version_dir = self.loader.store_path().join(plugin_id).join(version);
|
||||||
.loader
|
|
||||||
.store_path()
|
|
||||||
.join(plugin_id)
|
|
||||||
.join(version);
|
|
||||||
if !version_dir.exists() {
|
if !version_dir.exists() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"version {version} not found for plugin '{plugin_id}'"
|
"version {version} not found for plugin '{plugin_id}'"
|
||||||
@@ -100,9 +93,7 @@ impl VersionManager {
|
|||||||
entry.active_version = version.to_string();
|
entry.active_version = version.to_string();
|
||||||
self.loader.save_registry(®istry)?;
|
self.loader.save_registry(®istry)?;
|
||||||
|
|
||||||
println!(
|
println!("[VersionManager] 插件 '{plugin_id}' 切换到 v{version}");
|
||||||
"[VersionManager] 插件 '{plugin_id}' 切换到 v{version}"
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,9 +107,8 @@ impl VersionManager {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
let is_active = entry.map_or(false, |e| e.active_version == v);
|
let is_active = entry.map_or(false, |e| e.active_version == v);
|
||||||
let is_stable = entry.map_or(false, |e| {
|
let is_stable =
|
||||||
e.last_stable_version.as_deref() == Some(&v)
|
entry.map_or(false, |e| e.last_stable_version.as_deref() == Some(&v));
|
||||||
});
|
|
||||||
VersionInfo {
|
VersionInfo {
|
||||||
version: v,
|
version: v,
|
||||||
is_active,
|
is_active,
|
||||||
@@ -137,6 +127,10 @@ impl VersionManager {
|
|||||||
|
|
||||||
let active = entry.map(|e| e.active_version.as_str());
|
let active = entry.map(|e| e.active_version.as_str());
|
||||||
let stable = entry.and_then(|e| e.last_stable_version.as_deref());
|
let stable = entry.and_then(|e| e.last_stable_version.as_deref());
|
||||||
|
let protected_count = IntoIterator::into_iter([active, stable])
|
||||||
|
.flatten()
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.len();
|
||||||
|
|
||||||
// 保护活跃版本和稳定版本
|
// 保护活跃版本和稳定版本
|
||||||
let mut deletable: Vec<&str> = versions
|
let mut deletable: Vec<&str> = versions
|
||||||
@@ -147,22 +141,14 @@ impl VersionManager {
|
|||||||
|
|
||||||
// 保留最近的 keep 个(版本排在后面的更新)
|
// 保留最近的 keep 个(版本排在后面的更新)
|
||||||
let mut removed = Vec::new();
|
let mut removed = Vec::new();
|
||||||
while deletable.len() + 2 > keep && !deletable.is_empty() {
|
while deletable.len() + protected_count > keep && !deletable.is_empty() {
|
||||||
// 2 是为活跃和稳定版本预留
|
|
||||||
let oldest = deletable.remove(0);
|
let oldest = deletable.remove(0);
|
||||||
let version_dir = self
|
let version_dir = self.loader.store_path().join(plugin_id).join(oldest);
|
||||||
.loader
|
|
||||||
.store_path()
|
|
||||||
.join(plugin_id)
|
|
||||||
.join(oldest);
|
|
||||||
if version_dir.exists() {
|
if version_dir.exists() {
|
||||||
std::fs::remove_dir_all(&version_dir).with_context(|| {
|
std::fs::remove_dir_all(&version_dir)
|
||||||
format!("failed to remove {}", version_dir.display())
|
.with_context(|| format!("failed to remove {}", version_dir.display()))?;
|
||||||
})?;
|
|
||||||
removed.push(oldest.to_string());
|
removed.push(oldest.to_string());
|
||||||
println!(
|
println!("[VersionManager] 已清理 '{plugin_id}' v{oldest}");
|
||||||
"[VersionManager] 已清理 '{plugin_id}' v{oldest}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +164,19 @@ mod tests {
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
fn setup(base: &Path) -> VersionManager {
|
fn setup(base: &Path) -> VersionManager {
|
||||||
|
setup_with_entry(
|
||||||
|
base,
|
||||||
|
PluginRegistryEntry {
|
||||||
|
active_version: "1.1.0".to_string(),
|
||||||
|
last_stable_version: Some("1.0.0".to_string()),
|
||||||
|
enabled: true,
|
||||||
|
error_policy: ErrorPolicy::AutoRollback,
|
||||||
|
max_errors: 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_with_entry(base: &Path, entry: PluginRegistryEntry) -> VersionManager {
|
||||||
let _ = fs::remove_dir_all(base);
|
let _ = fs::remove_dir_all(base);
|
||||||
fs::create_dir_all(base).unwrap();
|
fs::create_dir_all(base).unwrap();
|
||||||
|
|
||||||
@@ -190,16 +189,7 @@ mod tests {
|
|||||||
|
|
||||||
// 写入注册表
|
// 写入注册表
|
||||||
let mut registry = PluginRegistry::default();
|
let mut registry = PluginRegistry::default();
|
||||||
registry.plugins.insert(
|
registry.plugins.insert("test-plugin".to_string(), entry);
|
||||||
"test-plugin".to_string(),
|
|
||||||
PluginRegistryEntry {
|
|
||||||
active_version: "1.1.0".to_string(),
|
|
||||||
last_stable_version: Some("1.0.0".to_string()),
|
|
||||||
enabled: true,
|
|
||||||
error_policy: ErrorPolicy::AutoRollback,
|
|
||||||
max_errors: 5,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
loader.save_registry(®istry).unwrap();
|
loader.save_registry(®istry).unwrap();
|
||||||
|
|
||||||
VersionManager::new(loader)
|
VersionManager::new(loader)
|
||||||
@@ -283,4 +273,64 @@ mod tests {
|
|||||||
|
|
||||||
let _ = fs::remove_dir_all(&tmp);
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gc_keeps_overlap_once_when_active_equals_stable() {
|
||||||
|
let tmp = std::env::temp_dir().join("showen_test_gc_overlap");
|
||||||
|
let vm = setup_with_entry(
|
||||||
|
&tmp,
|
||||||
|
PluginRegistryEntry {
|
||||||
|
active_version: "1.1.0".to_string(),
|
||||||
|
last_stable_version: Some("1.1.0".to_string()),
|
||||||
|
enabled: true,
|
||||||
|
error_policy: ErrorPolicy::AutoRollback,
|
||||||
|
max_errors: 5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let removed = vm.gc("test-plugin", 2).unwrap();
|
||||||
|
assert_eq!(removed, vec!["1.0.0"]);
|
||||||
|
|
||||||
|
let versions = vm.loader().list_versions("test-plugin").unwrap();
|
||||||
|
assert_eq!(versions, vec!["1.1.0", "2.0.0"]);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gc_keeps_active_when_stable_is_none() {
|
||||||
|
let tmp = std::env::temp_dir().join("showen_test_gc_no_stable");
|
||||||
|
let vm = setup_with_entry(
|
||||||
|
&tmp,
|
||||||
|
PluginRegistryEntry {
|
||||||
|
active_version: "1.1.0".to_string(),
|
||||||
|
last_stable_version: None,
|
||||||
|
enabled: true,
|
||||||
|
error_policy: ErrorPolicy::AutoRollback,
|
||||||
|
max_errors: 5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let removed = vm.gc("test-plugin", 1).unwrap();
|
||||||
|
assert_eq!(removed, vec!["1.0.0", "2.0.0"]);
|
||||||
|
|
||||||
|
let versions = vm.loader().list_versions("test-plugin").unwrap();
|
||||||
|
assert_eq!(versions, vec!["1.1.0"]);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gc_does_not_remove_protected_versions_when_keep_is_smaller() {
|
||||||
|
let tmp = std::env::temp_dir().join("showen_test_gc_keep_smaller");
|
||||||
|
let vm = setup(&tmp);
|
||||||
|
|
||||||
|
let removed = vm.gc("test-plugin", 1).unwrap();
|
||||||
|
assert_eq!(removed, vec!["2.0.0"]);
|
||||||
|
|
||||||
|
let versions = vm.loader().list_versions("test-plugin").unwrap();
|
||||||
|
assert_eq!(versions, vec!["1.0.0", "1.1.0"]);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ use dbus::blocking::stdintf::org_freedesktop_dbus::{ObjectManager, Properties};
|
|||||||
use dbus::blocking::Connection;
|
use dbus::blocking::Connection;
|
||||||
use dbus::channel::MatchingReceiver;
|
use dbus::channel::MatchingReceiver;
|
||||||
use dbus::channel::Sender;
|
use dbus::channel::Sender;
|
||||||
use dbus::message::MatchRule;
|
use dbus::message::{MatchRule, Message as DbusMessage, MessageType};
|
||||||
use dbus::Path;
|
use dbus::Path;
|
||||||
use dbus_crossroads::{Crossroads, IfaceBuilder, IfaceToken, MethodErr};
|
use dbus_crossroads::{Crossroads, IfaceBuilder, IfaceToken, MethodErr};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc::{self, Receiver, TryRecvError};
|
use std::sync::mpsc::{self, Receiver, TryRecvError};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
const BUS_NAME: &str = "io.showen.BleProvisioning";
|
const BUS_NAME: &str = "io.showen.BleProvisioning";
|
||||||
const BLUEZ_SERVICE: &str = "org.bluez";
|
const BLUEZ_SERVICE: &str = "org.bluez";
|
||||||
@@ -37,6 +37,14 @@ const PROXY_TIMEOUT: Duration = Duration::from_secs(10);
|
|||||||
|
|
||||||
type ManagedObjects = HashMap<Path<'static>, HashMap<String, PropMap>>;
|
type ManagedObjects = HashMap<Path<'static>, HashMap<String, PropMap>>;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RegistrationReplies {
|
||||||
|
gatt_serial: Option<u32>,
|
||||||
|
advertisement_serial: Option<u32>,
|
||||||
|
gatt: Option<Result<()>>,
|
||||||
|
advertisement: Option<Result<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
pub enum BleControl {
|
pub enum BleControl {
|
||||||
UpdateStatus(String),
|
UpdateStatus(String),
|
||||||
}
|
}
|
||||||
@@ -171,8 +179,7 @@ pub fn run_ble_service(
|
|||||||
let shared = SharedState::new(tx.clone());
|
let shared = SharedState::new(tx.clone());
|
||||||
|
|
||||||
eprintln!("[BLE] connecting to system bus...");
|
eprintln!("[BLE] connecting to system bus...");
|
||||||
let conn =
|
let conn = Connection::new_system().context("failed to connect to system bus for BLE")?;
|
||||||
Connection::new_system().context("failed to connect to system bus for BLE")?;
|
|
||||||
conn.request_name(BUS_NAME, false, true, false)
|
conn.request_name(BUS_NAME, false, true, false)
|
||||||
.context("failed to request BLE D-Bus name")?;
|
.context("failed to request BLE D-Bus name")?;
|
||||||
eprintln!("[BLE] D-Bus name acquired");
|
eprintln!("[BLE] D-Bus name acquired");
|
||||||
@@ -273,6 +280,35 @@ pub fn run_ble_service(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let registration_replies = Arc::new(Mutex::new(RegistrationReplies::default()));
|
||||||
|
let replies_for_success = Arc::clone(®istration_replies);
|
||||||
|
let mut success_rule = MatchRule::new();
|
||||||
|
success_rule.msg_type = Some(MessageType::MethodReturn);
|
||||||
|
conn.start_receive(
|
||||||
|
success_rule,
|
||||||
|
Box::new(move |msg, _conn| {
|
||||||
|
record_registration_reply(&replies_for_success, &msg, Ok(()));
|
||||||
|
true
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let replies_for_error = Arc::clone(®istration_replies);
|
||||||
|
let mut error_rule = MatchRule::new();
|
||||||
|
error_rule.msg_type = Some(MessageType::Error);
|
||||||
|
conn.start_receive(
|
||||||
|
error_rule,
|
||||||
|
Box::new(move |msg, _conn| {
|
||||||
|
let error_message = msg.get1::<String>().unwrap_or_default();
|
||||||
|
let error = if error_message.is_empty() {
|
||||||
|
anyhow!("{msg:?}")
|
||||||
|
} else {
|
||||||
|
anyhow!("{error_message}")
|
||||||
|
};
|
||||||
|
record_registration_reply(&replies_for_error, &msg, Err(error));
|
||||||
|
true
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// 配置 adapter
|
// 配置 adapter
|
||||||
let adapter_path = find_adapter(&conn)?;
|
let adapter_path = find_adapter(&conn)?;
|
||||||
configure_adapter(&conn, &adapter_path, &device_name)?;
|
configure_adapter(&conn, &adapter_path, &device_name)?;
|
||||||
@@ -280,18 +316,23 @@ pub fn run_ble_service(
|
|||||||
// 先尝试清理上一次进程残留的注册(防止崩溃后 BlueZ 状态残留)
|
// 先尝试清理上一次进程残留的注册(防止崩溃后 BlueZ 状态残留)
|
||||||
let _ = unregister_ble_objects(&conn, &adapter_path);
|
let _ = unregister_ble_objects(&conn, &adapter_path);
|
||||||
|
|
||||||
// 非阻塞发送 RegisterApplication + RegisterAdvertisement
|
let gatt_serial = send_register_gatt_app(&conn, &adapter_path)?;
|
||||||
let _gatt_serial = send_register_gatt_app(&conn, &adapter_path)?;
|
let ad_serial = send_register_advertisement(&conn, &adapter_path)?;
|
||||||
let _ad_serial = send_register_advertisement(&conn, &adapter_path)?;
|
if let Ok(mut replies) = registration_replies.lock() {
|
||||||
eprintln!("[BLE] registration requests sent, processing callbacks...");
|
replies.gatt_serial = Some(gatt_serial);
|
||||||
|
replies.advertisement_serial = Some(ad_serial);
|
||||||
|
}
|
||||||
|
eprintln!("[BLE] registration requests sent, waiting for BlueZ replies...");
|
||||||
|
|
||||||
// 处理消息循环等待 BlueZ 回调 GetManagedObjects 并完成注册
|
if let Err(error) = wait_for_registration_replies(
|
||||||
// start_receive 会处理所有入站方法调用(包括 BlueZ 的回调),
|
&conn,
|
||||||
// 注册回复也由 process() 内部分发,我们只需等待足够时间
|
®istration_replies,
|
||||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
gatt_serial,
|
||||||
while std::time::Instant::now() < deadline {
|
ad_serial,
|
||||||
conn.process(Duration::from_millis(100))
|
Duration::from_secs(10),
|
||||||
.context("BLE connection process failed during registration")?;
|
) {
|
||||||
|
let _ = unregister_ble_objects(&conn, &adapter_path);
|
||||||
|
return Err(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("[BLE] GATT application and advertisement registered");
|
eprintln!("[BLE] GATT application and advertisement registered");
|
||||||
@@ -504,6 +545,81 @@ fn build_managed_objects() -> ManagedObjects {
|
|||||||
objects
|
objects
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_registration_reply(
|
||||||
|
replies: &Arc<Mutex<RegistrationReplies>>,
|
||||||
|
msg: &DbusMessage,
|
||||||
|
result: Result<()>,
|
||||||
|
) {
|
||||||
|
let Some(reply_serial) = msg.get_reply_serial() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(mut replies) = replies.lock() {
|
||||||
|
match replies_for_serial(&mut replies, reply_serial) {
|
||||||
|
Some(slot) if slot.is_none() => *slot = Some(result),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replies_for_serial(
|
||||||
|
replies: &mut RegistrationReplies,
|
||||||
|
reply_serial: u32,
|
||||||
|
) -> Option<&mut Option<Result<()>>> {
|
||||||
|
if replies.gatt_serial == Some(reply_serial) {
|
||||||
|
Some(&mut replies.gatt)
|
||||||
|
} else if replies.advertisement_serial == Some(reply_serial) {
|
||||||
|
Some(&mut replies.advertisement)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_registration_replies(
|
||||||
|
conn: &Connection,
|
||||||
|
replies: &Arc<Mutex<RegistrationReplies>>,
|
||||||
|
gatt_serial: u32,
|
||||||
|
advertisement_serial: u32,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<()> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Ok(mut replies) = replies.lock() {
|
||||||
|
match reply_status(&mut replies.gatt, "RegisterApplication")? {
|
||||||
|
Some(()) => {}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
match reply_status(&mut replies.advertisement, "RegisterAdvertisement")? {
|
||||||
|
Some(()) => {}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if replies.gatt.is_some() && replies.advertisement.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"timed out waiting for BLE registration reply (gatt_serial={gatt_serial}, advertisement_serial={advertisement_serial})"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.process(Duration::from_millis(100).min(deadline.saturating_duration_since(now)))
|
||||||
|
.context("BLE connection process failed during registration")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply_status(reply: &mut Option<Result<()>>, operation: &str) -> Result<Option<()>> {
|
||||||
|
match reply {
|
||||||
|
Some(Ok(())) => Ok(Some(())),
|
||||||
|
Some(Err(error)) => Err(anyhow!("{operation} failed: {error}")),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn send_register_gatt_app(conn: &Connection, adapter_path: &str) -> Result<u32> {
|
fn send_register_gatt_app(conn: &Connection, adapter_path: &str) -> Result<u32> {
|
||||||
let msg = dbus::Message::method_call(
|
let msg = dbus::Message::method_call(
|
||||||
&BLUEZ_SERVICE.into(),
|
&BLUEZ_SERVICE.into(),
|
||||||
@@ -594,8 +710,7 @@ fn unregister_ble_objects(conn: &Connection, adapter_path: &str) -> Result<()> {
|
|||||||
|
|
||||||
fn bytes_to_string(value: &[u8]) -> String {
|
fn bytes_to_string(value: &[u8]) -> String {
|
||||||
String::from_utf8_lossy(value)
|
String::from_utf8_lossy(value)
|
||||||
.trim_end_matches('\0')
|
.trim_matches(|c: char| c == '\0' || c.is_whitespace())
|
||||||
.trim()
|
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,3 +745,80 @@ fn emit_status_notification(conn: &Connection, shared: &SharedState) -> Result<(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::core::message::WifiCommand;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bytes_to_string_trims_nulls_and_surrounding_whitespace() {
|
||||||
|
assert_eq!(bytes_to_string(b" demo-ssid\0\0 "), "demo-ssid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_command_uses_cached_wifi_credentials() {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let shared = SharedState::new(tx);
|
||||||
|
shared.set_ssid(br#"Cafe \"Guest\""#);
|
||||||
|
shared.set_password(br#"pa\\ss word"#);
|
||||||
|
|
||||||
|
shared
|
||||||
|
.dispatch_command(b"connect")
|
||||||
|
.expect("connect command should dispatch");
|
||||||
|
|
||||||
|
let envelope = rx.recv().expect("command should be forwarded to core");
|
||||||
|
match envelope.message {
|
||||||
|
Message::WifiCommand(WifiCommand::Connect { ssid, password }) => {
|
||||||
|
assert_eq!(ssid, r#"Cafe \"Guest\""#);
|
||||||
|
assert_eq!(password, r#"pa\\ss word"#);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected forwarded BLE command: {:?}", other),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8(shared.read_status()).expect("status should be utf8"),
|
||||||
|
r#"{"ok":true,"action":"connect","state":"queued"}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_command_reports_invalid_command_in_status() {
|
||||||
|
let (tx, _rx) = mpsc::channel();
|
||||||
|
let shared = SharedState::new(tx);
|
||||||
|
|
||||||
|
let error = shared
|
||||||
|
.dispatch_command(b"bad-command")
|
||||||
|
.expect_err("invalid command should fail");
|
||||||
|
|
||||||
|
assert!(error
|
||||||
|
.to_string()
|
||||||
|
.contains("unsupported command: bad-command"));
|
||||||
|
|
||||||
|
let status = String::from_utf8(shared.read_status()).expect("status should be utf8");
|
||||||
|
assert!(status.contains(r#""ok":false"#));
|
||||||
|
assert!(status.contains(r#""action":"bad-command""#));
|
||||||
|
assert!(status.contains(r#"unsupported command: bad-command"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drain_control_messages_updates_status_without_dbus() {
|
||||||
|
let (tx, _rx) = mpsc::channel();
|
||||||
|
let shared = SharedState::new(tx);
|
||||||
|
let (control_tx, control_rx) = mpsc::channel();
|
||||||
|
|
||||||
|
control_tx
|
||||||
|
.send(BleControl::UpdateStatus(
|
||||||
|
r#"{"ok":true,"action":"status"}"#.to_string(),
|
||||||
|
))
|
||||||
|
.expect("status update should send");
|
||||||
|
|
||||||
|
drain_control_messages(&shared, &control_rx).expect("control queue should drain");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8(shared.read_status()).expect("status should be utf8"),
|
||||||
|
r#"{"ok":true,"action":"status"}"#
|
||||||
|
);
|
||||||
|
assert!(shared.take_pending_notification());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ use anyhow::{Context, Result};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Condvar, Mutex};
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
use tokio::sync::{oneshot, Mutex as AsyncMutex};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct WsEvent<'a, T> {
|
struct WsEvent<'a, T> {
|
||||||
@@ -36,6 +38,7 @@ struct PendingWifiResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct HttpState {
|
pub(crate) struct HttpState {
|
||||||
|
wifi_request_lock: AsyncMutex<()>,
|
||||||
wifi_response: Mutex<PendingWifiResponse>,
|
wifi_response: Mutex<PendingWifiResponse>,
|
||||||
wifi_response_cv: Condvar,
|
wifi_response_cv: Condvar,
|
||||||
last_wifi_result: Mutex<Option<String>>,
|
last_wifi_result: Mutex<Option<String>>,
|
||||||
@@ -60,6 +63,7 @@ impl HttpState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
wifi_request_lock: AsyncMutex::new(()),
|
||||||
wifi_response: Mutex::new(PendingWifiResponse {
|
wifi_response: Mutex::new(PendingWifiResponse {
|
||||||
version: 0,
|
version: 0,
|
||||||
payload: None,
|
payload: None,
|
||||||
@@ -202,6 +206,8 @@ impl HttpState {
|
|||||||
pub struct HttpPlugin {
|
pub struct HttpPlugin {
|
||||||
ctx: Option<PluginContext>,
|
ctx: Option<PluginContext>,
|
||||||
state: Option<Arc<HttpState>>,
|
state: Option<Arc<HttpState>>,
|
||||||
|
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||||
|
server_thread: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpPlugin {
|
impl HttpPlugin {
|
||||||
@@ -209,6 +215,8 @@ impl HttpPlugin {
|
|||||||
Self {
|
Self {
|
||||||
ctx: None,
|
ctx: None,
|
||||||
state: None,
|
state: None,
|
||||||
|
shutdown_tx: None,
|
||||||
|
server_thread: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,6 +252,8 @@ impl Plugin for HttpPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn start(&mut self) -> Result<()> {
|
fn start(&mut self) -> Result<()> {
|
||||||
|
self.stop()?;
|
||||||
|
|
||||||
let ctx = self
|
let ctx = self
|
||||||
.ctx
|
.ctx
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -263,7 +273,9 @@ impl Plugin for HttpPlugin {
|
|||||||
.context("http plugin state is not initialized")?,
|
.context("http plugin state is not initialized")?,
|
||||||
);
|
);
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||||
|
|
||||||
|
let server_thread = std::thread::spawn(move || {
|
||||||
let runtime = match tokio::runtime::Builder::new_multi_thread()
|
let runtime = match tokio::runtime::Builder::new_multi_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
@@ -294,10 +306,18 @@ impl Plugin for HttpPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("[HttpPlugin] listening on http://{addr}");
|
println!("[HttpPlugin] listening on http://{addr}");
|
||||||
warp::serve(routes).run(addr).await;
|
warp::serve(routes)
|
||||||
|
.bind_with_graceful_shutdown(addr, async move {
|
||||||
|
let _ = shutdown_rx.await;
|
||||||
|
})
|
||||||
|
.1
|
||||||
|
.await;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.shutdown_tx = Some(shutdown_tx);
|
||||||
|
self.server_thread = Some(server_thread);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +364,16 @@ impl Plugin for HttpPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self) -> Result<()> {
|
fn stop(&mut self) -> Result<()> {
|
||||||
|
if let Some(shutdown_tx) = self.shutdown_tx.take() {
|
||||||
|
let _ = shutdown_tx.send(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(server_thread) = self.server_thread.take() {
|
||||||
|
server_thread
|
||||||
|
.join()
|
||||||
|
.map_err(|_| anyhow::anyhow!("http server thread panicked"))?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ use serde_json::Value;
|
|||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{mpsc, Arc};
|
use std::sync::{mpsc, Arc};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
use warp::http::StatusCode;
|
use warp::http::StatusCode;
|
||||||
use warp::multipart::FormData;
|
use warp::multipart::FormData;
|
||||||
use warp::{Filter, Reply};
|
use warp::{Filter, Reply};
|
||||||
|
|
||||||
|
const MAX_UPLOAD_FILE_SIZE: u64 = 100 * 1024 * 1024;
|
||||||
|
static UPLOAD_TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct WifiConnectRequest {
|
struct WifiConnectRequest {
|
||||||
ssid: String,
|
ssid: String,
|
||||||
@@ -801,28 +806,13 @@ async fn handle_video_upload(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = match part
|
if let Err(error) = stream_upload_part(part, &dir.join(&safe_name)).await {
|
||||||
.stream()
|
let status = if error.contains("文件大小超过限制") {
|
||||||
.try_fold(Vec::new(), |mut acc, buf| async move {
|
StatusCode::PAYLOAD_TOO_LARGE
|
||||||
acc.extend_from_slice(buf.chunk());
|
} else {
|
||||||
Ok(acc)
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(data) => data,
|
|
||||||
Err(error) => {
|
|
||||||
return Ok(error_json(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
&format!("读取文件失败: {error}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
return Ok(error_json(status, &error));
|
||||||
if let Err(error) = std::fs::write(dir.join(&safe_name), &data) {
|
|
||||||
return Ok(error_json(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
&format!("保存文件失败: {error}"),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uploaded.push(safe_name);
|
uploaded.push(safe_name);
|
||||||
@@ -959,6 +949,7 @@ async fn wifi_request(
|
|||||||
state: Arc<HttpState>,
|
state: Arc<HttpState>,
|
||||||
command: WifiCommand,
|
command: WifiCommand,
|
||||||
) -> Result<Value, warp::reply::Response> {
|
) -> Result<Value, warp::reply::Response> {
|
||||||
|
let _request_guard = state.wifi_request_lock.lock().await;
|
||||||
let version = match state.wifi_response.lock() {
|
let version = match state.wifi_response.lock() {
|
||||||
Ok(guard) => guard.version,
|
Ok(guard) => guard.version,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -1044,6 +1035,69 @@ async fn wifi_request(
|
|||||||
Ok(payload)
|
Ok(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn stream_upload_part(
|
||||||
|
part: warp::multipart::Part,
|
||||||
|
destination: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let parent = destination
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "上传目标目录无效".to_string())?;
|
||||||
|
let temp_path = parent.join(format!(
|
||||||
|
".upload-{}-{}.part",
|
||||||
|
std::process::id(),
|
||||||
|
UPLOAD_TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut file = match tokio::fs::File::create(&temp_path).await {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(error) => return Err(format!("创建临时文件失败: {error}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut total_size = 0u64;
|
||||||
|
let mut stream = part.stream();
|
||||||
|
|
||||||
|
while let Some(chunk) = match stream.try_next().await {
|
||||||
|
Ok(chunk) => chunk,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
|
return Err(format!("读取文件失败: {error}"));
|
||||||
|
}
|
||||||
|
} {
|
||||||
|
let chunk_size = chunk.remaining() as u64;
|
||||||
|
total_size = total_size.saturating_add(chunk_size);
|
||||||
|
if total_size > MAX_UPLOAD_FILE_SIZE {
|
||||||
|
let _ = file.flush().await;
|
||||||
|
drop(file);
|
||||||
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
|
return Err(format!(
|
||||||
|
"文件大小超过限制: 单文件最大 {} MB",
|
||||||
|
MAX_UPLOAD_FILE_SIZE / 1024 / 1024
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) = file.write_all(chunk.chunk()).await {
|
||||||
|
drop(file);
|
||||||
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
|
return Err(format!("写入临时文件失败: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) = file.flush().await {
|
||||||
|
drop(file);
|
||||||
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
|
return Err(format!("刷新临时文件失败: {error}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
if let Err(error) = tokio::fs::rename(&temp_path, destination).await {
|
||||||
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
|
return Err(format!("保存文件失败: {error}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn websocket_session(
|
async fn websocket_session(
|
||||||
ws: warp::ws::WebSocket,
|
ws: warp::ws::WebSocket,
|
||||||
tx: mpsc::Sender<Envelope>,
|
tx: mpsc::Sender<Envelope>,
|
||||||
@@ -1369,20 +1423,13 @@ fn file_upload_route(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = match part
|
if let Err(error) = stream_upload_part(part, &dest).await {
|
||||||
.stream()
|
let status = if error.contains("文件大小超过限制") {
|
||||||
.try_fold(Vec::new(), |mut acc, buf| async move {
|
StatusCode::PAYLOAD_TOO_LARGE
|
||||||
acc.extend_from_slice(buf.chunk());
|
} else {
|
||||||
Ok(acc)
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("读取失败: {e}"))),
|
|
||||||
};
|
};
|
||||||
|
return Ok(error_json(status, &error));
|
||||||
if let Err(e) = std::fs::write(&dest, &data) {
|
|
||||||
return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("保存失败: {e}")));
|
|
||||||
}
|
}
|
||||||
uploaded.push(safe_name);
|
uploaded.push(safe_name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,32 @@ impl WifiPlugin {
|
|||||||
Self { ctx: None }
|
Self { ctx: None }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_nmcli(args: &[&str]) -> Result<String> {
|
fn nmcli_args(parts: &[&str]) -> Vec<String> {
|
||||||
|
parts.iter().map(|part| (*part).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_connect_args(ssid: &str, password: &str) -> Vec<String> {
|
||||||
|
let mut args = Self::nmcli_args(&["device", "wifi", "connect", ssid]);
|
||||||
|
if !password.trim().is_empty() {
|
||||||
|
args.push("password".to_string());
|
||||||
|
args.push(password.to_string());
|
||||||
|
}
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_hotspot_args(ssid: &str, password: &str) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"device".to_string(),
|
||||||
|
"wifi".to_string(),
|
||||||
|
"hotspot".to_string(),
|
||||||
|
"ssid".to_string(),
|
||||||
|
ssid.to_string(),
|
||||||
|
"password".to_string(),
|
||||||
|
password.to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_nmcli(args: &[String]) -> Result<String> {
|
||||||
let output = Command::new("nmcli")
|
let output = Command::new("nmcli")
|
||||||
.args(args)
|
.args(args)
|
||||||
.output()
|
.output()
|
||||||
@@ -52,6 +77,39 @@ impl WifiPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_nmcli_fields(line: &str, expected_fields: usize) -> Vec<String> {
|
||||||
|
let mut fields = Vec::with_capacity(expected_fields.max(1));
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut escaped = false;
|
||||||
|
|
||||||
|
for ch in line.chars() {
|
||||||
|
if escaped {
|
||||||
|
current.push(ch);
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ch {
|
||||||
|
'\\' => escaped = true,
|
||||||
|
':' if fields.len() + 1 < expected_fields => {
|
||||||
|
fields.push(current);
|
||||||
|
current = String::new();
|
||||||
|
}
|
||||||
|
_ => current.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if escaped {
|
||||||
|
current.push('\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(current);
|
||||||
|
while fields.len() < expected_fields {
|
||||||
|
fields.push(String::new());
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
fn send_result(&self, payload: String) -> Result<()> {
|
fn send_result(&self, payload: String) -> Result<()> {
|
||||||
let ctx = self
|
let ctx = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -87,27 +145,30 @@ impl WifiPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn scan_networks(&self) -> Result<serde_json::Value> {
|
fn scan_networks(&self) -> Result<serde_json::Value> {
|
||||||
Self::run_nmcli(&["device", "wifi", "rescan"])?;
|
Self::run_nmcli(&Self::nmcli_args(&["device", "wifi", "rescan"]))?;
|
||||||
thread::sleep(Duration::from_secs(2));
|
thread::sleep(Duration::from_secs(2));
|
||||||
let output =
|
let output = Self::run_nmcli(&Self::nmcli_args(&[
|
||||||
Self::run_nmcli(&["-t", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list"])?;
|
"--terse",
|
||||||
|
"--escape",
|
||||||
|
"yes",
|
||||||
|
"-f",
|
||||||
|
"SSID,SIGNAL,SECURITY",
|
||||||
|
"device",
|
||||||
|
"wifi",
|
||||||
|
"list",
|
||||||
|
]))?;
|
||||||
|
|
||||||
let networks = output
|
let networks = output
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|line| !line.trim().is_empty())
|
.filter(|line| !line.trim().is_empty())
|
||||||
.filter_map(|line| {
|
.filter_map(|line| {
|
||||||
let mut parts = line.splitn(3, ':');
|
let parts = Self::parse_nmcli_fields(line, 3);
|
||||||
let ssid = parts.next().unwrap_or_default().trim().to_string();
|
let ssid = parts[0].trim().to_string();
|
||||||
if ssid.is_empty() {
|
if ssid.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let signal = parts
|
let signal = parts[1].trim().parse::<i32>().unwrap_or_default();
|
||||||
.next()
|
let security = parts[2].trim().to_string();
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.parse::<i32>()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let security = parts.next().unwrap_or_default().trim().to_string();
|
|
||||||
|
|
||||||
Some(WifiNetwork {
|
Some(WifiNetwork {
|
||||||
ssid,
|
ssid,
|
||||||
@@ -134,11 +195,7 @@ impl WifiPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn connect_network(&self, ssid: &str, password: &str) -> Result<serde_json::Value> {
|
fn connect_network(&self, ssid: &str, password: &str) -> Result<serde_json::Value> {
|
||||||
let mut args = vec!["device", "wifi", "connect", ssid];
|
let output = Self::run_nmcli(&Self::build_connect_args(ssid, password))?;
|
||||||
if !password.trim().is_empty() {
|
|
||||||
args.extend(["password", password]);
|
|
||||||
}
|
|
||||||
let output = Self::run_nmcli(&args)?;
|
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -149,20 +206,30 @@ impl WifiPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn status(&self) -> Result<serde_json::Value> {
|
fn status(&self) -> Result<serde_json::Value> {
|
||||||
let device_output = Self::run_nmcli(&[
|
let device_output = Self::run_nmcli(&Self::nmcli_args(&[
|
||||||
"-t",
|
"--terse",
|
||||||
|
"--escape",
|
||||||
|
"yes",
|
||||||
"-f",
|
"-f",
|
||||||
"DEVICE,TYPE,STATE,CONNECTION",
|
"DEVICE,TYPE,STATE,CONNECTION",
|
||||||
"device",
|
"device",
|
||||||
"status",
|
"status",
|
||||||
])?;
|
]))?;
|
||||||
let ip_output = Self::run_nmcli(&["-t", "-f", "DEVICE,IP4.ADDRESS", "device", "show"])?;
|
let ip_output = Self::run_nmcli(&Self::nmcli_args(&[
|
||||||
|
"--terse",
|
||||||
|
"--escape",
|
||||||
|
"yes",
|
||||||
|
"-f",
|
||||||
|
"DEVICE,IP4.ADDRESS",
|
||||||
|
"device",
|
||||||
|
"show",
|
||||||
|
]))?;
|
||||||
|
|
||||||
let mut ip_map: HashMap<String, Vec<String>> = HashMap::new();
|
let mut ip_map: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
for line in ip_output.lines().filter(|line| !line.trim().is_empty()) {
|
for line in ip_output.lines().filter(|line| !line.trim().is_empty()) {
|
||||||
let mut parts = line.splitn(2, ':');
|
let parts = Self::parse_nmcli_fields(line, 2);
|
||||||
let device = parts.next().unwrap_or_default().trim();
|
let device = parts[0].trim();
|
||||||
let address = parts.next().unwrap_or_default().trim();
|
let address = parts[1].trim();
|
||||||
|
|
||||||
if device.is_empty() || address.is_empty() {
|
if device.is_empty() || address.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -178,15 +245,15 @@ impl WifiPlugin {
|
|||||||
.lines()
|
.lines()
|
||||||
.filter(|line| !line.trim().is_empty())
|
.filter(|line| !line.trim().is_empty())
|
||||||
.map(|line| {
|
.map(|line| {
|
||||||
let mut parts = line.splitn(4, ':');
|
let parts = Self::parse_nmcli_fields(line, 4);
|
||||||
let device = parts.next().unwrap_or_default().trim().to_string();
|
let device = parts[0].trim().to_string();
|
||||||
|
|
||||||
DeviceStatus {
|
DeviceStatus {
|
||||||
ip4_addresses: ip_map.remove(&device).unwrap_or_default(),
|
ip4_addresses: ip_map.remove(&device).unwrap_or_default(),
|
||||||
device,
|
device,
|
||||||
device_type: parts.next().unwrap_or_default().trim().to_string(),
|
device_type: parts[1].trim().to_string(),
|
||||||
state: parts.next().unwrap_or_default().trim().to_string(),
|
state: parts[2].trim().to_string(),
|
||||||
connection: parts.next().unwrap_or_default().trim().to_string(),
|
connection: parts[3].trim().to_string(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -199,9 +266,7 @@ impl WifiPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ap_start(&self, ssid: &str, password: &str) -> Result<serde_json::Value> {
|
fn ap_start(&self, ssid: &str, password: &str) -> Result<serde_json::Value> {
|
||||||
let output = Self::run_nmcli(&[
|
let output = Self::run_nmcli(&Self::build_hotspot_args(ssid, password))?;
|
||||||
"device", "wifi", "hotspot", "ssid", ssid, "password", password,
|
|
||||||
])?;
|
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -212,14 +277,23 @@ impl WifiPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ap_stop(&self) -> Result<serde_json::Value> {
|
fn ap_stop(&self) -> Result<serde_json::Value> {
|
||||||
let active = Self::run_nmcli(&["-t", "-f", "NAME", "connection", "show", "--active"])?;
|
let active = Self::run_nmcli(&Self::nmcli_args(&[
|
||||||
|
"--terse",
|
||||||
|
"--escape",
|
||||||
|
"yes",
|
||||||
|
"-f",
|
||||||
|
"NAME",
|
||||||
|
"connection",
|
||||||
|
"show",
|
||||||
|
"--active",
|
||||||
|
]))?;
|
||||||
let hotspot_name = active
|
let hotspot_name = active
|
||||||
.lines()
|
.lines()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.find(|name| *name == "hotspot")
|
.find(|name| *name == "hotspot")
|
||||||
.ok_or_else(|| anyhow!("active hotspot connection 'hotspot' not found"))?;
|
.ok_or_else(|| anyhow!("active hotspot connection 'hotspot' not found"))?;
|
||||||
|
|
||||||
let output = Self::run_nmcli(&["connection", "down", hotspot_name])?;
|
let output = Self::run_nmcli(&Self::nmcli_args(&["connection", "down", hotspot_name]))?;
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -276,3 +350,58 @@ impl Plugin for WifiPlugin {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::WifiPlugin;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_nmcli_fields_unescapes_terse_output() {
|
||||||
|
let fields = WifiPlugin::parse_nmcli_fields(r#"Cafe\:Net:78:WPA2\\Enterprise"#, 3);
|
||||||
|
|
||||||
|
assert_eq!(fields, vec!["Cafe:Net", "78", r#"WPA2\Enterprise"#]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_nmcli_fields_keeps_colons_in_last_field() {
|
||||||
|
let fields = WifiPlugin::parse_nmcli_fields(r#"wlan0:wifi:connected:Office\:LAN"#, 4);
|
||||||
|
|
||||||
|
assert_eq!(fields, vec!["wlan0", "wifi", "connected", "Office:LAN"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connect_args_preserve_special_characters() {
|
||||||
|
let args =
|
||||||
|
WifiPlugin::build_connect_args(r#"ssid \"qa\" demo"#, r#"p@ss\\word with spaces"#);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec![
|
||||||
|
"device",
|
||||||
|
"wifi",
|
||||||
|
"connect",
|
||||||
|
r#"ssid \"qa\" demo"#,
|
||||||
|
"password",
|
||||||
|
r#"p@ss\\word with spaces"#,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hotspot_args_preserve_special_characters() {
|
||||||
|
let args = WifiPlugin::build_hotspot_args("Showen AP", r#"\\quoted pass\\"#);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec![
|
||||||
|
"device",
|
||||||
|
"wifi",
|
||||||
|
"hotspot",
|
||||||
|
"ssid",
|
||||||
|
"Showen AP",
|
||||||
|
"password",
|
||||||
|
r#"\\quoted pass\\"#,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
565
tests/m1_2_service_manager.rs
Normal file
565
tests/m1_2_service_manager.rs
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use showen_v2::core::config::AppConfig;
|
||||||
|
use showen_v2::core::message::{Destination, Envelope, Message, PlayerStatusData};
|
||||||
|
use showen_v2::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
|
||||||
|
use showen_v2::core::service_manager::ServiceManager;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
fn unique_test_dir(name: &str) -> PathBuf {
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.expect("system time should be after unix epoch")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"showen_m1_2_service_manager_{name}_{}_{}",
|
||||||
|
std::process::id(),
|
||||||
|
nanos
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_json(window_title: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{
|
||||||
|
"display": {{
|
||||||
|
"fullscreen": false,
|
||||||
|
"window_title": "{window_title}",
|
||||||
|
"rotation": 0,
|
||||||
|
"flip_horizontal": false,
|
||||||
|
"flip_vertical": false,
|
||||||
|
"perspective_correction": {{
|
||||||
|
"enabled": false,
|
||||||
|
"points": []
|
||||||
|
}}
|
||||||
|
}},
|
||||||
|
"playlist": [
|
||||||
|
{{
|
||||||
|
"id": "video-1",
|
||||||
|
"path": "video.mp4"
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"transition": {{
|
||||||
|
"enabled": false,
|
||||||
|
"type": "none",
|
||||||
|
"duration": 0.0
|
||||||
|
}},
|
||||||
|
"playback": {{
|
||||||
|
"loop_playlist": true,
|
||||||
|
"auto_start": false
|
||||||
|
}},
|
||||||
|
"scenes": {{}},
|
||||||
|
"remote_control": {{
|
||||||
|
"enabled": false,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8080
|
||||||
|
}}
|
||||||
|
}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_test_config(dir: &Path, window_title: &str) -> PathBuf {
|
||||||
|
fs::create_dir_all(dir).expect("test dir should be created");
|
||||||
|
let config_path = dir.join("config.json");
|
||||||
|
fs::write(&config_path, config_json(window_title)).expect("config should be written");
|
||||||
|
config_path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_manager(name: &str) -> (ServiceManager, Arc<Mutex<Vec<String>>>, PathBuf) {
|
||||||
|
let dir = unique_test_dir(name);
|
||||||
|
let config_path = write_test_config(&dir, "initial-title");
|
||||||
|
let config = AppConfig::from_file(&config_path).expect("test config should load");
|
||||||
|
(
|
||||||
|
ServiceManager::new(config),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
dir,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_events(events: &Arc<Mutex<Vec<String>>>) -> std::sync::MutexGuard<'_, Vec<String>> {
|
||||||
|
events.lock().expect("events mutex poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_event(events: &Arc<Mutex<Vec<String>>>, expected: &str) -> bool {
|
||||||
|
lock_events(events).iter().any(|event| event == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_position(events: &Arc<Mutex<Vec<String>>>, expected: &str) -> usize {
|
||||||
|
lock_events(events)
|
||||||
|
.iter()
|
||||||
|
.position(|event| event == expected)
|
||||||
|
.unwrap_or_else(|| panic!("missing expected event: {}", expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_label(message: &Message) -> String {
|
||||||
|
match message {
|
||||||
|
Message::Shutdown => "shutdown".to_string(),
|
||||||
|
Message::ConfigReloadRequest => "config_reload_request".to_string(),
|
||||||
|
Message::ConfigReloaded(config) => {
|
||||||
|
format!("config_reloaded:{}", config.display.window_title)
|
||||||
|
}
|
||||||
|
Message::PlayerStatus(status) => format!(
|
||||||
|
"player_status:{}:{}:{}:{}:{}:{}",
|
||||||
|
status.running,
|
||||||
|
status.paused,
|
||||||
|
status.in_transition,
|
||||||
|
status.current_index,
|
||||||
|
status.playlist_length,
|
||||||
|
status.current_video.as_deref().unwrap_or("none")
|
||||||
|
),
|
||||||
|
Message::WifiResult(payload) => format!("wifi_result:{payload}"),
|
||||||
|
Message::StateChanged {
|
||||||
|
old_state,
|
||||||
|
new_state,
|
||||||
|
} => format!("state_changed:{old_state}->{new_state}"),
|
||||||
|
Message::PluginReady(id) => format!("plugin_ready:{id}"),
|
||||||
|
Message::Custom { kind, payload } => format!("custom:{kind}:{payload}"),
|
||||||
|
other => format!("other:{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecordingPlugin {
|
||||||
|
id: String,
|
||||||
|
deps: Vec<String>,
|
||||||
|
events: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingPlugin {
|
||||||
|
fn new(id: &str, deps: Vec<&str>, events: Arc<Mutex<Vec<String>>>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.to_string(),
|
||||||
|
deps: deps.into_iter().map(str::to_string).collect(),
|
||||||
|
events,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&self, entry: impl Into<String>) {
|
||||||
|
lock_events(&self.events).push(entry.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for RecordingPlugin {
|
||||||
|
fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(&self) -> PluginInfo {
|
||||||
|
PluginInfo {
|
||||||
|
name: self.id.clone(),
|
||||||
|
version: "test".to_string(),
|
||||||
|
description: "integration test plugin".to_string(),
|
||||||
|
platform: Platform::Any,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dependencies(&self) -> Vec<String> {
|
||||||
|
self.deps.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(&mut self, _ctx: PluginContext) -> Result<()> {
|
||||||
|
self.record(format!("init:{}", self.id));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&mut self) -> Result<()> {
|
||||||
|
self.record(format!("start:{}", self.id));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||||
|
self.record(format!("msg:{}:{}", self.id, message_label(&msg)));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) -> Result<()> {
|
||||||
|
self.record(format!("stop:{}", self.id));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_startup_order_matches_dependency_sort() {
|
||||||
|
let (mut manager, events, dir) = test_manager("startup_order");
|
||||||
|
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"dashboard",
|
||||||
|
vec!["http", "screen"],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"screen",
|
||||||
|
vec!["device"],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"http",
|
||||||
|
vec!["video"],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"wifi",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"video",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"device",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
assert!(event_position(&events, "init:device") < event_position(&events, "init:screen"));
|
||||||
|
assert!(event_position(&events, "init:video") < event_position(&events, "init:http"));
|
||||||
|
assert!(event_position(&events, "init:screen") < event_position(&events, "init:dashboard"));
|
||||||
|
assert!(event_position(&events, "init:http") < event_position(&events, "init:dashboard"));
|
||||||
|
assert!(event_position(&events, "start:device") < event_position(&events, "start:screen"));
|
||||||
|
assert!(event_position(&events, "start:video") < event_position(&events, "start:http"));
|
||||||
|
assert!(event_position(&events, "start:screen") < event_position(&events, "start:dashboard"));
|
||||||
|
assert!(event_position(&events, "start:http") < event_position(&events, "start:dashboard"));
|
||||||
|
|
||||||
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shutdown_stops_all_enabled_plugins() {
|
||||||
|
let (mut manager, events, dir) = test_manager("shutdown_stop_all");
|
||||||
|
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"alpha",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"beta",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"gamma",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
assert!(has_event(&events, "msg:alpha:shutdown"));
|
||||||
|
assert!(has_event(&events, "msg:beta:shutdown"));
|
||||||
|
assert!(has_event(&events, "msg:gamma:shutdown"));
|
||||||
|
assert!(event_position(&events, "stop:gamma") < event_position(&events, "stop:beta"));
|
||||||
|
assert!(event_position(&events, "stop:beta") < event_position(&events, "stop:alpha"));
|
||||||
|
|
||||||
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_reload_broadcasts_new_config() {
|
||||||
|
let (mut manager, events, dir) = test_manager("config_reload");
|
||||||
|
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"alpha",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"beta",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
let config_path = dir.join("config.json");
|
||||||
|
fs::write(&config_path, config_json("reloaded-title")).expect("config should be updated");
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "http".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::ConfigReloadRequest,
|
||||||
|
})
|
||||||
|
.expect("config reload request should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
assert!(has_event(
|
||||||
|
&events,
|
||||||
|
"msg:alpha:config_reloaded:reloaded-title"
|
||||||
|
));
|
||||||
|
assert!(has_event(
|
||||||
|
&events,
|
||||||
|
"msg:beta:config_reloaded:reloaded-title"
|
||||||
|
));
|
||||||
|
|
||||||
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_status_broadcast() {
|
||||||
|
let (mut manager, events, dir) = test_manager("player_status");
|
||||||
|
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"alpha",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"beta",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"gamma",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "video".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::PlayerStatus(PlayerStatusData {
|
||||||
|
running: true,
|
||||||
|
paused: false,
|
||||||
|
in_transition: true,
|
||||||
|
current_index: 2,
|
||||||
|
playlist_length: 5,
|
||||||
|
current_video: Some("intro.mp4".to_string()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.expect("player status should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
let expected = "player_status:true:false:true:2:5:intro.mp4";
|
||||||
|
assert!(has_event(&events, &format!("msg:alpha:{expected}")));
|
||||||
|
assert!(has_event(&events, &format!("msg:beta:{expected}")));
|
||||||
|
assert!(has_event(&events, &format!("msg:gamma:{expected}")));
|
||||||
|
|
||||||
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wifi_result_broadcast() {
|
||||||
|
let (mut manager, events, dir) = test_manager("wifi_result");
|
||||||
|
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"alpha",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"beta",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"gamma",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "wifi".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::WifiResult("connected:ssid=showen-lab".to_string()),
|
||||||
|
})
|
||||||
|
.expect("wifi result should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
let expected = "wifi_result:connected:ssid=showen-lab";
|
||||||
|
assert!(has_event(&events, &format!("msg:alpha:{expected}")));
|
||||||
|
assert!(has_event(&events, &format!("msg:beta:{expected}")));
|
||||||
|
assert!(has_event(&events, &format!("msg:gamma:{expected}")));
|
||||||
|
|
||||||
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_changed_broadcast() {
|
||||||
|
let (mut manager, events, dir) = test_manager("state_changed");
|
||||||
|
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"alpha",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"beta",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "video".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::StateChanged {
|
||||||
|
old_state: "idle".to_string(),
|
||||||
|
new_state: "playing".to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("state changed should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
assert!(has_event(&events, "msg:alpha:state_changed:idle->playing"));
|
||||||
|
assert!(has_event(&events, "msg:beta:state_changed:idle->playing"));
|
||||||
|
|
||||||
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_ready_broadcast() {
|
||||||
|
let (mut manager, events, dir) = test_manager("plugin_ready");
|
||||||
|
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"alpha",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"beta",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"gamma",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "video".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::PluginReady("video".to_string()),
|
||||||
|
})
|
||||||
|
.expect("plugin ready should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
assert!(has_event(&events, "msg:alpha:plugin_ready:video"));
|
||||||
|
assert!(has_event(&events, "msg:beta:plugin_ready:video"));
|
||||||
|
assert!(has_event(&events, "msg:gamma:plugin_ready:video"));
|
||||||
|
|
||||||
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_disabled_plugin_skipped_in_message_routing() {
|
||||||
|
let (mut manager, events, dir) = test_manager("disabled_plugin_skip");
|
||||||
|
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"alpha",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"beta",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.register(Box::new(RecordingPlugin::new(
|
||||||
|
"gamma",
|
||||||
|
vec![],
|
||||||
|
events.clone(),
|
||||||
|
)));
|
||||||
|
manager.start_all().expect("start_all should succeed");
|
||||||
|
manager
|
||||||
|
.set_plugin_enabled("beta", false)
|
||||||
|
.expect("beta should be disabled");
|
||||||
|
|
||||||
|
let sender = manager.sender();
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "video".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::PlayerStatus(PlayerStatusData {
|
||||||
|
running: true,
|
||||||
|
paused: true,
|
||||||
|
in_transition: false,
|
||||||
|
current_index: 1,
|
||||||
|
playlist_length: 3,
|
||||||
|
current_video: Some("paused.mp4".to_string()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.expect("player status should send");
|
||||||
|
sender
|
||||||
|
.send(Envelope {
|
||||||
|
from: "test".to_string(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::Shutdown,
|
||||||
|
})
|
||||||
|
.expect("shutdown should send");
|
||||||
|
|
||||||
|
manager.run().expect("run should succeed");
|
||||||
|
|
||||||
|
let expected = "player_status:true:true:false:1:3:paused.mp4";
|
||||||
|
assert!(has_event(&events, &format!("msg:alpha:{expected}")));
|
||||||
|
assert!(has_event(&events, &format!("msg:gamma:{expected}")));
|
||||||
|
assert!(!has_event(&events, &format!("msg:beta:{expected}")));
|
||||||
|
assert!(!manager.plugin_states()[1].enabled);
|
||||||
|
|
||||||
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user