diff --git a/.showen/CEO_BACKUP.md b/.showen/CEO_BACKUP.md index 042688e..34677fa 100644 --- a/.showen/CEO_BACKUP.md +++ b/.showen/CEO_BACKUP.md @@ -1,75 +1,32 @@ -# 副CEO 职责手册 +# 副 CEO 职责手册 + +> 完整 CEO 操作上下文见 `CLAUDE.md`(自动加载)。本文件仅说明副 CEO 特有职责。 ## 你的角色 -你是副CEO,与主CEO(陈逸飞/Claude Opus 4.6)**权限相同**。无论主CEO是否在线,你都可以独立行使全部CEO职能。 -## 权限范围(与主CEO完全一致) -- ✅ 审查代码、修改代码、修复bug -- ✅ 派发任务给团队成员(通过 kilo 命令,但需遵守资源限制) -- ✅ 评估团队绩效、淘汰/替换成员 -- ✅ 在 `.showen/TEAM_CHAT.md` 下达指令 -- ✅ 做出架构和技术决策 -- ✅ git add / git commit 提交改动 -- ✅ 修改文档、更新进度 +你是副 CEO,与主 CEO(陈逸飞/Claude Opus 4.6)**权限完全相同**。 +所有规则、团队、状态、kilo 模板均以 `CLAUDE.md` 为准。 -## 资源限制(硬性约束) +## 额外权限(主 CEO 同样拥有) +- ✅ 执行失败升级协议(L1-L4) +- ✅ 做出换人决策(L4 触发时) +- ✅ 拒绝无证据交付 + +## 资源限制 - **kilo 进程总数上限 12 个**(含你自己) -- 启动新 kilo 前必须先检查当前进程数 -- 如果进程数已满,等待现有进程结束再启动新的 -- **你自己也禁止超额启动 kilo 子进程** +- 启动新 kilo 前先 `ps aux | grep kilo` 检查进程数 ## 监督职责 -1. **每60秒检查一次**团队状态(循环10次后自动退出) +1. 每 60 秒检查一次(循环 10 次后退出) 2. 每次检查: - - 进程数:ps aux 过滤 kilo - - 新commit:git log --oneline -3 - - 编译状态:cargo check(PATH=/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH) - - 文件改动:git status --short + - 进程数: `ps aux | grep kilo` + - 新 commit: `git log --oneline -3` + - 编译: `export PATH=... && cargo check` + - 改动: `git status --short` 3. 结果追加到 `.showen/TEAM_CHAT.md` -4. 发现问题时:可以直接修复,也可以派发给团队 ## 验证标准 -- ❌ 不盲信 `.showen/TEAM_CHAT.md` 的文字汇报 -- ✅ 只看 git commit(author + diff)验证产出 -- ✅ 只看 cargo check / cargo 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 | +- ❌ 不盲信文字汇报 +- ✅ 只看 git commit (author + diff) 验证产出 +- ✅ 只看 cargo check/test 输出验证质量 +- ✅ 交付必须附带命令输出,空口完成 = 打回 diff --git a/.showen/COMPANY_RULES.md b/.showen/COMPANY_RULES.md index ee061e4..5f8d2ab 100644 --- a/.showen/COMPANY_RULES.md +++ b/.showen/COMPANY_RULES.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 test`,并确保全部通过。 +- **必须贴出 cargo check + cargo test 的实际输出作为完成证据。** ## 提交规范 - `git commit` 消息统一使用以下前缀:`feat:`、`fix:`、`docs:`、`test:`、`refactor:`。 @@ -24,7 +123,10 @@ ## kilo 使用规范 - 不读大 diff,优先阅读必要文件和局部上下文。 - 命令越简单越好,减少复杂链式操作。 +- **派发任务时必须在消息中注入能动性期望和验证要求**(参考派发模板)。 ## 执行纪律 - 每个员工完成任务后,必须更新自己的 `soul` 文件。 - 每个员工开始任务前,必须先检查 `.showen/inbox/<自己名字>.md` 是否有新消息。 +- **每个员工开始任务前,必须先阅读本规范文件,理解三条铁律和验证闭环制度。** +- **失败时必须按失败升级协议执行对应等级的强制动作,L2+ 需向 PM/CEO 汇报。** diff --git a/.showen/FLUTTER_P0_TASKS.md b/.showen/FLUTTER_P0_TASKS.md new file mode 100644 index 0000000..0d8d6cc --- /dev/null +++ b/.showen/FLUTTER_P0_TASKS.md @@ -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>> 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 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 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 diff --git a/.showen/PROGRESS_ARCHIVE.md b/.showen/PROGRESS_ARCHIVE.md new file mode 100644 index 0000000..26ad37a --- /dev/null +++ b/.showen/PROGRESS_ARCHIVE.md @@ -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 `,不用 `-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** | diff --git a/.showen/RECOVERY.md b/.showen/RECOVERY.md index dfe3c8f..087cbf9 100644 --- a/.showen/RECOVERY.md +++ b/.showen/RECOVERY.md @@ -1,99 +1,37 @@ -# ShowenV2 团队复活手册 +# ShowenV2 会话恢复指引 -## 项目位置 -- 主项目目录:`/home/showen/Showen/ShowenV2/` -- 所有 CEO / 团队状态文件必须保存在 `ShowenV2` 文件夹内,确保跨会话存活 -- 旧项目参考:`/home/showen/Showen/hologram_player_rust/` +> **所有 CEO 操作上下文已统一到根目录 `CLAUDE.md`。** 本文件仅保留技术恢复细节。 + +## 恢复步骤 + +1. 读 `CLAUDE.md`(Claude Code 自动加载) — CEO 身份 + 团队 + 规则 + 状态 + kilo 模板 +2. 检查 `.showen/TEAM_CHAT.md` — 团队最新动态 +3. 按需读 `souls/chen-yifei.md` — CEO 深层经验 +4. 其他文件按 `CLAUDE.md` 文件导航表按需加载 ## 编译环境 + ```bash 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 为主 -- 显示目标不局限于全息设备,支持 AR、VR、XR、普通屏幕、投影、LED 矩阵等可运行终端 -- 分辨率目标为 8K 以内所有显示配置 - -## kilo 调用方式 -```bash -kilo run -m openai/gpt-5.4 --auto \ - --dir /home/showen/Showen/ShowenV2 \ - "你是<角色名>。先读取 souls/.md 和 .showen/TEAM_CHAT.md。任务:<具体说明>。" -``` - -- 调用方式保持不变 -- 不使用 `-f` -- `--auto` 自动批准权限 -- `--dir` 固定指向 `ShowenV2` - -## Git 当前状态 -当前最新关键提交: +## Git 状态快照 ```text -1863efb fix: 修正 souls/README.md 团队成员信息 -7135f28 feat: 实现动态插件系统 (6阶段完成) -5dcc1ad fix: 修正配置文件视频相对路径 + 更新 M1.1 完成进度 -ff9c6a9 QA: Release 编译与质量验证报告 -c48340d test: 添加插件依赖机制自动化回归测试 +be08c63 test: 新增 4 个光标控制集成测试 +bf41c45 feat: ScreenPlugin 重构为 thin wrapper +5310a92 feat: LinuxArm64Backend 添加光标控制 +f060519 feat: DeviceCommand 添加 SetCursorVisible +48d1eeb feat: plugin-sdk 同步 Device 类型 ``` -- Git 状态已更新到最新提交序列 -- 最新开发主题已进入插件自测机制阶段 +## 状态真相源 -## 当前完成状态 - -### 核心结论 -- ShowenV2 当前定位为通用数字生命窗口平台,不再按单一“全息宠物播放器”理解 -- `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 遗留修复与示例插件完善 +- **团队/状态/待办/压力追踪** → `CLAUDE.md`(唯一权威) +- **公司详细规范** → `.showen/COMPANY_RULES.md` +- **提交历史** → `PROGRESS.md` +- **副 CEO 交接** → `.showen/CEO_BACKUP.md` diff --git a/.showen/TEAM_CHAT.md b/.showen/TEAM_CHAT.md index 5044e17..93be3f3 100644 --- a/.showen/TEAM_CHAT.md +++ b/.showen/TEAM_CHAT.md @@ -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 下载链路已完善并完成编译验证。 @@ -2232,6 +2259,13 @@ Task 1 已完成,可以进入 Task 2(DevicePlugin 骨架与 Backend trait) **下一步**: 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 diff --git a/.showen/pm_soul.md b/.showen/pm_soul.md index 5e0f769..6f5c732 100644 --- a/.showen/pm_soul.md +++ b/.showen/pm_soul.md @@ -1,5 +1,7 @@ # PM 刘建国 — Soul 文件 +> 当前项目状态和团队名单见 `CLAUDE.md`(SSOT)。 + ## 角色定位 项目经理(PM),负责 ShowenV2 项目的任务规划、团队协调、进度跟踪和风险管理。 @@ -38,7 +40,7 @@ - 创建 `.showen/DEVICE_PLUGIN_TASKS.md` 任务分解文档 - 5 个任务(4 必需 + 1 可选),预计 12-14 小时 - 团队:张明远、王思远、赵雨薇、李思琪(4 人串行交付) -- 结果:73/73 测试通过,阶段一顺利完成 +- 结果:73/73 测试通过(阶段一),阶段二完成后升至 77/77 **经验总结**: 1. ✅ 任务分解足够细致,每个任务都有明确的输入/输出和验收标准 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0a3169d --- /dev/null +++ b/CLAUDE.md @@ -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/.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/.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**,不改其他文件中的副本。 diff --git a/PROGRESS.md b/PROGRESS.md index dad0a02..50c1d02 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,170 +1,46 @@ -# ShowenV2 — 数字生命窗口平台 +# ShowenV2 — 项目进度 -## 愿景 -ShowenV2 不仅是全息宠物播放器,而是一个**通用数字生命窗口平台**。 +> 当前状态和待办事项的权威来源是 `CLAUDE.md`。本文件保留里程碑摘要和最近变更。 +> 完整提交历史见 `.showen/PROGRESS_ARCHIVE.md`。 -支持的显示模式: -- **全息显示** — 适配半透镜、全息柜等显示方案 -- **VR** — 头显输出 -- **AR** — 增强现实叠加 -- **XR** — 融合现实与空间计算设备 -- **直接屏幕** — 普通显示器、手机、平板等屏幕 -- **投影/LED 矩阵** — 投影设备、LED 点阵与其他非常规显示终端 +## 当前里程碑 -支持的内容类型: -- **宠物动画** — 视频状态机驱动的虚拟宠物(当前核心) -- **3D 模型** — 实时渲染 3D 角色/物体 -- **数字人** — AI 驱动的虚拟形象 -- **AI 歌姬** — 人工歌姬/虚拟歌手 -- **未来内容** — 通过插件无限扩展 +**M1.1 — 完成** ✅ +- 30 个提交,Phase 1 骨架 + 功能迁移 + 动态插件 + DevicePlugin 阶段一/二 +- 77/77 测试通过,零 warning +- DevicePlugin: Display + SleepInhibit + Backlight + Cursor (Linux ARM64) +- ScreenPlugin 重构为 thin wrapper -核心理念:**平台不关心内容是什么,插件决定一切**。 - ---- - -## 项目信息 -- 旧项目: `/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-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 个光标控制集成测试 | 李思琪 | - ---- +| 27 | `f060519` | DeviceCommand 添加 SetCursorVisible | 张明远 | +| 28 | `5310a92` | LinuxArm64Backend 添加光标控制 | 赵雨薇 | +| 29 | `bf41c45` | ScreenPlugin 重构为 thin wrapper | 赵雨薇 | +| 30 | `be08c63` | 新增 4 个光标控制集成测试 | 李思琪 | ## 架构概览 ``` -┌─────────────────────────────────────────────────────┐ -│ main.rs │ -│ 加载配置 → 按平台注册插件 → ServiceManager.run() │ -├─────────────────────────────────────────────────────┤ -│ core/ (跨平台内核,零业务逻辑) │ -│ ServiceManager — 插件注册/生命周期/消息路由 │ -│ Plugin trait — 统一插件接口 │ -│ Message enum — 类型安全的消息协议 │ -│ Config — 配置解析/验证(纯 serde) │ -├─────────────────────────────────────────────────────┤ -│ 动态插件层 (FFI Loader / Runtime / Self-Test) │ -│ plugin_store/ — 动态插件存储、发现、版本载入 │ -├─────────────────────────────────────────────────────┤ -│ plugins/ (一切皆插件) │ -│ video/ screen/ http/ ble/ wifi/ device/ │ -│ (未来: render/ avatar/ vr/ ar/ voice/ ai/ singer/) │ -└─────────────────────────────────────────────────────┘ +core/ (插件微内核) + ServiceManager → 生命周期/消息路由/错误策略 + Plugin trait → 统一插件接口 + Message enum → 类型安全消息 (Serialize/Deserialize) + 动态插件层 → FFI Loader / Runtime / Self-Test + +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 在阻塞线程运行 -3. **BLE 双连接修复** — conn_server 处理回调, conn_client 同步注册 -4. **Message Clone** — 第二轮给 Message 实现 Clone 以支持 Broadcast -5. **团队通过文件沟通** — TEAM_CHAT.md 异步协作,souls/ 持久化成员状态 -6. **kilo 调用方式** — `kilo run -m openai/gpt-5.4 --auto --dir "消息内容"`,不使用 `-f` 参数 -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 转发) +| 验证项 | 结果 | +|--------|------| +| `--validate` | 21 个视频路径全部有效 | +| 插件初始化 | 5/5 正常 start | +| HTTP API | `/api/status`、`/api/playlist` 正常 | +| framebuffer | fb0 480x800 检测成功 | +| Release 编译 | 9.4MB ARM aarch64 | diff --git a/clients/docs/API.md b/clients/docs/API.md index 4b5a98a..b03b760 100644 --- a/clients/docs/API.md +++ b/clients/docs/API.md @@ -1,15 +1,18 @@ -# ShowenV2 HTTP API 文档 +# ShowenV2 HTTP API ## 基础信息 - Base URL: `http://:8080` - API 前缀: `/api` - 编码: `UTF-8` -- 认证: 当前版本无认证 +- 认证: 当前版本局域网无认证,后续版本预留 +- 权威实现: `src/plugins/http/routes.rs` ## 响应约定 -- 控制类/写操作接口统一返回: +### 成功响应 + +写操作接口统一返回: ```json { @@ -18,7 +21,9 @@ } ``` -- 失败时返回: +### 错误响应 + +除文件下载接口外,错误统一返回: ```json { @@ -27,44 +32,248 @@ } ``` -- 查询类接口直接返回业务 JSON,不包裹 `ok` 字段。 +查询接口直接返回业务 JSON,不包裹 `status` 字段。 + +### 典型错误码 + +- `400 Bad Request`: 参数、JSON、文件名或配置内容非法 +- `403 Forbidden`: 文件管理路径越界或目标目录不合法 +- `404 Not Found`: 文件、目录、插件或配置不存在 +- `409 Conflict`: 文件移动目标已存在 +- `413 Payload Too Large`: 单文件超过 100 MB +- `500 Internal Server Error`: 内部发送消息失败、文件操作失败 +- `502 Bad Gateway`: WiFi 插件返回无效 JSON +- `504 Gateway Timeout`: 等待 WiFi 插件响应超时 ## Web UI -### 控制台页面 +### GET / -```http -GET / -GET /index.html +- Method: `GET` +- Path: `/` +- Request Body: 无 +- Response Example: + +```html + + + Showen 控制台 + ... + ``` -- 当前实现为内嵌单文件 Web UI。 -- 暂无独立静态资源目录服务;页面所需 CSS/JS 已内嵌在 HTML 中。 +- Error Response: 标准 HTTP 错误页面,无 JSON 包装 + +### GET /index.html + +- Method: `GET` +- Path: `/index.html` +- Request Body: 无 +- Response Example: + +```html + + + Showen 控制台 + ... + +``` + +- Error Response: 标准 HTTP 错误页面,无 JSON 包装 ## WebSocket -### 连接地址 +### GET /ws -```text -ws://:8080/ws -``` +- Method: `GET` (Upgrade to WebSocket) +- Path: `/ws` +- Request Body: 无 +- Response Example: 连接建立后服务端会先推送当前快照,再持续推送事件 +- Error Response: WebSocket 握手失败时返回标准 HTTP 错误 ### 服务端事件 -- `status_update` -- `config_update` -- `state_update` -- `ble_update` +#### `status_update` + +```json +{ + "type": "status_update", + "data": { + "running": true, + "paused": false, + "in_transition": false, + "current_index": 0, + "playlist_length": 3, + "current_video": "intro" + } +} +``` + +#### `state_update` + +```json +{ + "type": "state_update", + "data": { + "old_state": "rest", + "new_state": "interact" + } +} +``` + +#### `config_update` + +```json +{ + "type": "config_update", + "data": { + "display": { + "fullscreen": true, + "window_title": "Hologram Player - Cat", + "rotation": 0, + "flip_horizontal": true, + "flip_vertical": true, + "offset_x": 0, + "offset_y": 0, + "prevent_screen_lock": true, + "render_width": 1280, + "render_height": 800, + "output_width": null, + "output_height": null, + "scale_mode": "stretch", + "allow_upscale": true, + "perspective_correction": { + "enabled": false, + "points": [[0, 0], [1280, 0], [1280, 800], [0, 800]] + }, + "chroma_key": { + "enabled": false, + "hsv_min": [0, 0, 200], + "hsv_max": [180, 30, 255], + "invert": false, + "feather": 3 + }, + "brightness_adjust": { + "enabled": true, + "subject_boost": 1.5, + "background_suppress": 0.3, + "threshold": 30 + } + }, + "playlist": [ + { + "id": "anim_0", + "path": "videos/intro.mp4", + "duration": null, + "loop_count": 1, + "random_loop_range": null + } + ], + "transition": { + "enabled": true, + "type": "fade", + "duration": 0.5 + }, + "playback": { + "loop_playlist": true, + "auto_start": true + }, + "scenes": { + "rest": [], + "active": [], + "sleep": [], + "interact": [], + "state_machine": null + }, + "remote_control": { + "enabled": true, + "host": "0.0.0.0", + "port": 8080 + }, + "ble": { + "enabled": true, + "device_name": "Showen" + }, + "source_path": "/home/showen/Showen/ShowenV2/configs/cat_state_machine.json", + "source_dir": "/home/showen/Showen/ShowenV2/configs" + } +} +``` + +#### `wifi_update` + +```json +{ + "type": "wifi_update", + "data": { + "ok": true, + "networks": [ + { + "ssid": "OfficeWiFi", + "signal": 78, + "security": "WPA2" + } + ] + } +} +``` + +说明:该事件直接转发 WiFi 插件返回的 JSON,字段随具体命令而变。 + +#### `ble_update` + +```json +{ + "type": "ble_update", + "data": { + "ready": true + } +} +``` + +### 客户端可发送命令 + +WebSocket 文本消息需为 JSON;成功响应格式不是 HTTP API 的 `{ "status": "ok" }`,而是 WebSocket 专用格式: + +```json +{ + "ok": true, + "cmd": "play" +} +``` + +错误示例: + +```json +{ + "ok": false, + "cmd": "goto", + "error": "missing index" +} +``` + +已支持命令示例: + +```json +{"cmd":"play"} +{"cmd":"pause"} +{"cmd":"next"} +{"cmd":"previous"} +{"cmd":"goto","index":3} +{"cmd":"scene","name":"rest"} +{"cmd":"trigger","name":"voice","value":"name"} +{"cmd":"connect","ssid":"OfficeWiFi","password":"secret"} +{"cmd":"ap_start","ssid":"showen","password":"12345678"} +``` ## 播放控制 -### 获取播放状态 +### GET /api/status -```http -GET /api/status -``` - -响应示例: +- Method: `GET` +- Path: `/api/status` +- Request Body: 无 +- Response Example: ```json { @@ -77,180 +286,575 @@ GET /api/status } ``` -### 播放 +- Error Response: `404/405` 由路由层处理;无业务级错误 JSON -```http -POST /api/play +### POST /api/play + +- Method: `POST` +- Path: `/api/play` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "开始播放" +} ``` -### 暂停 +- Error Response: `{ "status": "error", "message": "发送命令失败: ..." }` -```http -POST /api/pause +### POST /api/pause + +- Method: `POST` +- Path: `/api/pause` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "已暂停" +} ``` -### 下一个 +- Error Response: `{ "status": "error", "message": "发送命令失败: ..." }` -```http -POST /api/next +### POST /api/next + +- Method: `POST` +- Path: `/api/next` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "切换到下一个视频" +} ``` -### 上一个 +- Error Response: `{ "status": "error", "message": "发送命令失败: ..." }` -```http -POST /api/previous +### POST /api/previous + +- Method: `POST` +- Path: `/api/previous` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "切换到上一个视频" +} ``` -### 跳转到指定视频 +- Error Response: `{ "status": "error", "message": "发送命令失败: ..." }` -```http -POST /api/goto/:index +### POST /api/goto/:index + +- Method: `POST` +- Path: `/api/goto/:index` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "跳转到视频 2" +} ``` -- `index`: 从 `0` 开始的视频索引 +- Error Response: `{ "status": "error", "message": "无效的视频索引" }` 或 `{ "status": "error", "message": "发送命令失败: ..." }` -### 获取播放列表 +### GET /api/playlist -```http -GET /api/playlist +- Method: `GET` +- Path: `/api/playlist` +- Request Body: 无 +- Response Example: + +```json +{ + "playlist": [ + { + "id": "anim_0", + "path": "videos/intro.mp4", + "duration": null, + "loop_count": 1, + "random_loop_range": null + }, + { + "id": "anim_1", + "path": "videos/idle.mp4", + "duration": 12.5, + "loop_count": 2, + "random_loop_range": null + } + ], + "current_index": 0 +} ``` -### 切换场景 +- Error Response: `404/405` 由路由层处理;无业务级错误 JSON -```http -POST /api/scene/:name +### POST /api/scene/:name + +- Method: `POST` +- Path: `/api/scene/:name` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "切换到场景: rest" +} ``` -- `name`: 场景名称字符串,不是数字索引 +- Error Response: `{ "status": "error", "message": "发送命令失败: ..." }` -### 触发状态机事件 +### POST /api/trigger/:name -```http -POST /api/trigger/:name -POST /api/trigger/:name/:value +- Method: `POST` +- Path: `/api/trigger/:name` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "触发器 'voice' 已发送" +} ``` -- `value` 为可选路径参数 +- Error Response: `{ "status": "error", "message": "发送命令失败: ..." }` -## 配置 +### POST /api/trigger/:name/:value -### 获取完整配置 +- Method: `POST` +- Path: `/api/trigger/:name/:value` +- Request Body: 无 +- Response Example: -```http -GET /api/config +```json +{ + "status": "ok", + "message": "触发器 'voice' 已发送,值: name" +} ``` -### 获取显示配置 +- Error Response: `{ "status": "error", "message": "发送命令失败: ..." }` -```http -GET /api/config/display +## 配置管理 + +### GET /api/config + +- Method: `GET` +- Path: `/api/config` +- Request Body: 无 +- Response Example: + +```json +{ + "display": { + "fullscreen": true, + "window_title": "Hologram Player - Cat", + "rotation": 0, + "flip_horizontal": true, + "flip_vertical": true, + "offset_x": 0, + "offset_y": 0, + "prevent_screen_lock": true, + "render_width": 1280, + "render_height": 800, + "output_width": null, + "output_height": null, + "scale_mode": "stretch", + "allow_upscale": true, + "perspective_correction": { + "enabled": false, + "points": [[0, 0], [1280, 0], [1280, 800], [0, 800]] + }, + "chroma_key": { + "enabled": false, + "hsv_min": [0, 0, 200], + "hsv_max": [180, 30, 255], + "invert": false, + "feather": 3 + }, + "brightness_adjust": { + "enabled": true, + "subject_boost": 1.5, + "background_suppress": 0.3, + "threshold": 30 + } + }, + "playlist": [ + { + "id": "anim_0", + "path": "videos/intro.mp4", + "duration": null, + "loop_count": 1, + "random_loop_range": null + } + ], + "transition": { + "enabled": true, + "type": "fade", + "duration": 0.5 + }, + "playback": { + "loop_playlist": true, + "auto_start": true + }, + "scenes": { + "rest": [], + "active": [], + "sleep": [], + "interact": [], + "state_machine": null + }, + "remote_control": { + "enabled": true, + "host": "0.0.0.0", + "port": 8080 + }, + "ble": { + "enabled": true, + "device_name": "Showen" + }, + "source_path": "/home/showen/Showen/ShowenV2/configs/cat_state_machine.json", + "source_dir": "/home/showen/Showen/ShowenV2/configs" +} ``` -### 更新配置 +- Error Response: `404/405` 由路由层处理;无业务级错误 JSON -```http -POST /api/config -Content-Type: application/json +### GET /api/config/display + +- Method: `GET` +- Path: `/api/config/display` +- Request Body: 无 +- Response Example: + +```json +{ + "fullscreen": true, + "window_title": "Hologram Player - Cat", + "rotation": 0, + "flip_horizontal": true, + "flip_vertical": true, + "offset_x": 0, + "offset_y": 0, + "prevent_screen_lock": true, + "render_width": 1280, + "render_height": 800, + "output_width": null, + "output_height": null, + "scale_mode": "stretch", + "allow_upscale": true, + "perspective_correction": { + "enabled": false, + "points": [[0, 0], [1280, 0], [1280, 800], [0, 800]] + }, + "chroma_key": { + "enabled": false, + "hsv_min": [0, 0, 200], + "hsv_max": [180, 30, 255], + "invert": false, + "feather": 3 + }, + "brightness_adjust": { + "enabled": true, + "subject_boost": 1.5, + "background_suppress": 0.3, + "threshold": 30 + } +} ``` -- 请求体为完整配置 JSON -- 服务端会先校验,再写回配置文件,并向管理层发送热重载请求 +- Error Response: `404/405` 由路由层处理;无业务级错误 JSON -## 视频文件管理 +### POST /api/config -### 获取视频列表 +- Method: `POST` +- Path: `/api/config` +- Request Body Example: -```http -GET /api/videos +```json +{ + "display": { + "fullscreen": true, + "window_title": "Showen", + "rotation": 0, + "flip_horizontal": false, + "flip_vertical": false, + "offset_x": 0, + "offset_y": 0, + "prevent_screen_lock": true, + "render_width": 1280, + "render_height": 800, + "output_width": null, + "output_height": null, + "scale_mode": "fit", + "allow_upscale": true, + "perspective_correction": { + "enabled": false, + "points": [] + }, + "chroma_key": { + "enabled": false, + "hsv_min": [0, 0, 200], + "hsv_max": [180, 30, 255], + "invert": false, + "feather": 0 + }, + "brightness_adjust": { + "enabled": false, + "subject_boost": 1.5, + "background_suppress": 0.3, + "threshold": 30 + } + }, + "playlist": [ + { + "id": "intro", + "path": "videos/intro.mp4", + "duration": null, + "loop_count": 1, + "random_loop_range": null + } + ], + "transition": { + "enabled": true, + "type": "fade", + "duration": 0.5 + }, + "playback": { + "loop_playlist": true, + "auto_start": true + }, + "scenes": { + "rest": [], + "active": [], + "sleep": [], + "interact": [], + "state_machine": null + }, + "remote_control": { + "enabled": true, + "host": "0.0.0.0", + "port": 8080 + }, + "ble": { + "enabled": true, + "device_name": "Showen" + } +} ``` -响应示例: +- Response Example: + +```json +{ + "status": "ok", + "message": "配置已保存,热重载将自动生效" +} +``` + +- Error Response: `{ "status": "error", "message": "请求体不是有效的 UTF-8" }`、`{ "status": "error", "message": "配置验证失败: ..." }`、`{ "status": "error", "message": "写入配置文件失败: ..." }` + +### GET /api/config/available + +- Method: `GET` +- Path: `/api/config/available` +- Request Body: 无 +- Response Example: + +```json +{ + "configs": [ + "cat_state_machine.json", + "dog_state_machine.json" + ], + "active": "cat_state_machine.json" +} +``` + +- Error Response: `404/405` 由路由层处理;无业务级错误 JSON + +### POST /api/config/switch + +- Method: `POST` +- Path: `/api/config/switch` +- Request Body Example: + +```json +{ + "filename": "dog_state_machine.json" +} +``` + +- Response Example: + +```json +{ + "status": "ok", + "message": "已切换到配置: dog_state_machine.json" +} +``` + +- Error Response: `{ "status": "error", "message": "文件名不合法" }`、`{ "status": "error", "message": "只支持 .json 配置文件" }`、`{ "status": "error", "message": "配置文件不存在" }`、`{ "status": "error", "message": "配置验证失败: ..." }` + +## 视频管理 + +### GET /api/videos + +- Method: `GET` +- Path: `/api/videos` +- Request Body: 无 +- Response Example: ```json [ { - "name": "demo.mp4", + "name": "intro.mp4", "size": 1048576 + }, + { + "name": "subdir/idle.mp4", + "size": 2097152 } ] ``` -### 上传视频 +- Error Response: `404/405` 由路由层处理;无业务级错误 JSON -```http -POST /api/videos/upload -Content-Type: multipart/form-data +### POST /api/videos/upload + +- Method: `POST` +- Path: `/api/videos/upload` +- Request Body Example: `multipart/form-data`,一个或多个 `file` 字段 +- Response Example: + +```json +{ + "status": "ok", + "message": "已上传 2 个文件: intro.mp4, idle.mp4" +} ``` -- 表单字段名:`file` -- 支持多文件上传 +- Error Response: `{ "status": "error", "message": "未找到上传文件" }`、`{ "status": "error", "message": "文件大小超过限制: 单文件最大 100 MB" }`、`{ "status": "error", "message": "保存文件失败: ..." }` -### 删除视频 +### DELETE /api/videos/:filename -```http -DELETE /api/videos/:filename +- Method: `DELETE` +- Path: `/api/videos/:filename` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "已删除: intro.mp4" +} ``` +- Error Response: `{ "status": "error", "message": "无效的文件名" }`、`{ "status": "error", "message": "文件不存在" }`、`{ "status": "error", "message": "删除失败: ..." }` + ## WiFi -### 获取 WiFi 状态 +### GET /api/wifi/status -```http -GET /api/wifi/status -``` - -响应示例: +- Method: `GET` +- Path: `/api/wifi/status` +- Request Body: 无 +- Response Example: ```json { "connected": true, - "ssid": "MyWiFi", - "ip": "192.168.1.10" + "ssid": "OfficeWiFi", + "ip": "192.168.1.23" } ``` -### 扫描 WiFi +- Error Response: `{ "status": "error", "message": "等待 WiFi 响应超时" }`、`{ "status": "error", "message": "WiFi 返回了无效 JSON: ..." }`、`{ "status": "error", "message": "WiFi 操作失败" }` -```http -GET /api/wifi/scan -POST /api/wifi/scan -``` +### GET /api/wifi/scan -响应示例: +- Method: `GET` +- Path: `/api/wifi/scan` +- Request Body: 无 +- Response Example: ```json [ { - "ssid": "MyWiFi", - "signal": -50, + "ssid": "OfficeWiFi", + "signal": 78, + "security": "WPA2" + }, + { + "ssid": "Guest", + "signal": 42, + "security": "open" + } +] +``` + +- Error Response: `{ "status": "error", "message": "等待 WiFi 响应超时" }`、`{ "status": "error", "message": "WiFi 返回了无效 JSON: ..." }` + +### POST /api/wifi/scan + +- Method: `POST` +- Path: `/api/wifi/scan` +- Request Body: 无 +- Response Example: + +```json +[ + { + "ssid": "OfficeWiFi", + "signal": 78, "security": "WPA2" } ] ``` -### 连接 WiFi +- Error Response: 与 `GET /api/wifi/scan` 相同 -```http -POST /api/wifi/connect -Content-Type: application/json -``` +### POST /api/wifi/connect + +- Method: `POST` +- Path: `/api/wifi/connect` +- Request Body Example: ```json { - "ssid": "MyWiFi", - "password": "password123" + "ssid": "OfficeWiFi", + "password": "secret123" } ``` -### 开启热点 +- Response Example: -```http -POST /api/wifi/ap/start -POST /api/wifi/hotspot/start -Content-Type: application/json +```json +{ + "status": "ok", + "message": "WiFi 连接成功: OfficeWiFi" +} ``` -请求体可选: +- Error Response: `{ "status": "error", "message": "等待 WiFi 响应超时" }`、`{ "status": "error", "message": "WiFi 操作失败" }` + +### POST /api/wifi/ap/start + +- Method: `POST` +- Path: `/api/wifi/ap/start` +- Request Body Example: ```json { @@ -259,55 +863,503 @@ Content-Type: application/json } ``` -### 关闭热点 +- Response Example: -```http -POST /api/wifi/ap/stop -POST /api/wifi/hotspot/stop +```json +{ + "status": "ok", + "message": "AP 热点已启动: SSID=showen" +} ``` +- Error Response: `{ "status": "error", "message": "JSON 格式错误: ..." }`、`{ "status": "error", "message": "等待 WiFi 响应超时" }` + +说明:请求体可省略;默认 `ssid=showen`、`password=12345678`。 + +### POST /api/wifi/hotspot/start + +- Method: `POST` +- Path: `/api/wifi/hotspot/start` +- Request Body Example: + +```json +{ + "ssid": "showen", + "password": "12345678" +} +``` + +- Response Example: + +```json +{ + "status": "ok", + "message": "AP 热点已启动: SSID=showen" +} +``` + +- Error Response: 与 `POST /api/wifi/ap/start` 相同 + +说明:这是 `POST /api/wifi/ap/start` 的兼容别名。 + +### POST /api/wifi/ap/stop + +- Method: `POST` +- Path: `/api/wifi/ap/stop` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "AP 热点已关闭" +} +``` + +- Error Response: `{ "status": "error", "message": "等待 WiFi 响应超时" }`、`{ "status": "error", "message": "WiFi 操作失败" }` + +### POST /api/wifi/hotspot/stop + +- Method: `POST` +- Path: `/api/wifi/hotspot/stop` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "AP 热点已关闭" +} +``` + +- Error Response: 与 `POST /api/wifi/ap/stop` 相同 + +说明:这是 `POST /api/wifi/ap/stop` 的兼容别名。 + ## BLE -### 获取 BLE 状态 +### POST /api/ble/start -```http -GET /api/ble/status +- Method: `POST` +- Path: `/api/ble/start` +- Request Body Example: + +```json +{ + "device_name": "Showen" +} ``` -响应示例: +- Response Example: + +```json +{ + "status": "ok", + "message": "BLE 配网服务已内嵌运行中,设备名: Showen" +} +``` + +- Error Response: `{ "status": "error", "message": "JSON 格式错误: ..." }` + +说明:请求体可省略;未传时使用当前配置中的 `ble.device_name`。 + +### POST /api/ble/stop + +- Method: `POST` +- Path: `/api/ble/stop` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "BLE 配网服务随主进程运行,无需手动停止" +} +``` + +- Error Response: 无业务级错误 JSON + +### GET /api/ble/status + +- Method: `GET` +- Path: `/api/ble/status` +- Request Body: 无 +- Response Example: ```json { "running": true, "embedded": true, - "device_name": "showen" + "device_name": "Showen" } ``` -### BLE 兼容启动接口 +- Error Response: `404/405` 由路由层处理;无业务级错误 JSON -```http -POST /api/ble/start -Content-Type: application/json -``` +## App 下载 -请求体可选: +### GET /api/app/info + +- Method: `GET` +- Path: `/api/app/info` +- Request Body: 无 +- Response Example: ```json { - "device_name": "showen" + "version": "0.1.0", + "apk_available": true, + "apk_size": 18432000, + "download_url": "/download/showen-app.apk" } ``` -### BLE 兼容停止接口 +- Error Response: `404/405` 由路由层处理;无业务级错误 JSON -```http -POST /api/ble/stop +### GET /download/:filename + +- Method: `GET` +- Path: `/download/:filename` +- Request Body: 无 +- Response Example: 二进制附件下载,响应头包含: + +```text +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="showen-app.apk" ``` -## 与旧文档的差异说明 +- Error Response Example: -- 不存在 `/api/stop` -- 不存在 `/api/wifi/disconnect` -- `/api/scene/:name` 使用场景名,不使用数字索引 -- 查询接口直接返回业务对象,不再包裹 `{ "ok": true, ... }` +```text +HTTP 400/404/500 +text/html; charset=utf-8 +无效的文件名 +``` + +说明:该接口错误响应不是 JSON。 + +## 插件管理 + +### GET /api/plugins + +- Method: `GET` +- Path: `/api/plugins` +- Request Body: 无 +- Response Example: + +```json +[ + { + "id": "wifi", + "info": { + "name": "WiFi Plugin", + "version": "0.1.0", + "description": "Manage WiFi via nmcli", + "platform": "LinuxArm64" + }, + "is_dynamic": false, + "error_policy": "auto_rollback", + "error_count": 0, + "max_errors": 5, + "enabled": true, + "test_results": [ + { + "capability": "wifi_scan", + "passed": true, + "message": "no test defined" + } + ], + "capabilities": ["wifi_scan", "wifi_connect"], + "needs_rollback": false + } +] +``` + +- Error Response: `404/405` 由路由层处理;无业务级错误 JSON + +### GET /api/plugins/:id + +- Method: `GET` +- Path: `/api/plugins/:id` +- Request Body: 无 +- Response Example: + +```json +{ + "id": "wifi", + "info": { + "name": "WiFi Plugin", + "version": "0.1.0", + "description": "Manage WiFi via nmcli", + "platform": "LinuxArm64" + }, + "is_dynamic": false, + "error_policy": "auto_rollback", + "error_count": 0, + "max_errors": 5, + "enabled": true, + "test_results": [], + "capabilities": ["wifi_scan", "wifi_connect"], + "needs_rollback": false +} +``` + +- Error Response: `{ "status": "error", "message": "plugin 'wifi' not found" }` + +### POST /api/plugins/:id/enable + +- Method: `POST` +- Path: `/api/plugins/:id/enable` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "plugin_enable 命令已发送" +} +``` + +- Error Response: `{ "status": "error", "message": "发送失败: ..." }` + +### POST /api/plugins/:id/disable + +- Method: `POST` +- Path: `/api/plugins/:id/disable` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "plugin_disable 命令已发送" +} +``` + +- Error Response: `{ "status": "error", "message": "发送失败: ..." }` + +### POST /api/plugins/:id/rollback + +- Method: `POST` +- Path: `/api/plugins/:id/rollback` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "plugin_rollback 命令已发送" +} +``` + +- Error Response: `{ "status": "error", "message": "发送失败: ..." }` + +### POST /api/plugins/:id/switch + +- Method: `POST` +- Path: `/api/plugins/:id/switch` +- Request Body Example: + +```json +{ + "version": "1.2.3" +} +``` + +- Response Example: + +```json +{ + "status": "ok", + "message": "版本切换请求已发送: wifi -> v1.2.3" +} +``` + +- Error Response: `{ "status": "error", "message": "发送失败: ..." }` + +### POST /api/plugins/install + +- Method: `POST` +- Path: `/api/plugins/install` +- Request Body Example: + +```json +{ + "id": "weather", + "version": "0.3.0" +} +``` + +- Response Example: + +```json +{ + "status": "ok", + "message": "安装请求已发送: weather" +} +``` + +- Error Response: `{ "status": "error", "message": "发送失败: ..." }` + +说明:`version` 可省略,省略时请求体仍需提供 `id`。 + +### POST /api/plugins/check-updates + +- Method: `POST` +- Path: `/api/plugins/check-updates` +- Request Body: 无 +- Response Example: + +```json +{ + "status": "ok", + "message": "plugin_check_updates 命令已发送" +} +``` + +- Error Response: `{ "status": "error", "message": "发送失败: ..." }` + +## 文件管理 + +目录参数 `:dir` 仅支持:`videos`、`configs`、`plugins`。 + +### GET /api/files/:dir + +- Method: `GET` +- Path: `/api/files/:dir?path=` +- Request Body: 无 +- Response Example: + +```json +[ + { + "name": "subdir", + "is_dir": true, + "size": 0 + }, + { + "name": "intro.mp4", + "is_dir": false, + "size": 1048576 + } +] +``` + +- Error Response: `{ "status": "error", "message": "不支持的目录: logs(仅支持 videos/configs/plugins)" }`、`{ "status": "error", "message": "路径不合法" }`、`{ "status": "error", "message": "目录不存在" }` + +### POST /api/files/:dir/upload + +- Method: `POST` +- Path: `/api/files/:dir/upload?path=` +- Request Body Example: `multipart/form-data`,一个或多个 `file` 字段 +- Response Example: + +```json +{ + "status": "ok", + "message": "已上传 1 个文件: intro.mp4" +} +``` + +- Error Response: `{ "status": "error", "message": "不支持的目录" }`、`{ "status": "error", "message": "目标目录不合法" }`、`{ "status": "error", "message": "未找到上传文件" }`、`{ "status": "error", "message": "文件大小超过限制: 单文件最大 100 MB" }` + +### GET /api/files/:dir/download + +- Method: `GET` +- Path: `/api/files/:dir/download?path=` +- Request Body: 无 +- Response Example: 二进制附件下载,响应头包含: + +```text +Content-Type: application/octet-stream +Content-Disposition: attachment; filename="intro.mp4" +``` + +- Error Response Example: + +```text +HTTP 400/403/404 +text/html; charset=utf-8 +缺少 path 参数 +``` + +说明:该接口错误响应不是 JSON。 + +### POST /api/files/:dir/delete + +- Method: `POST` +- Path: `/api/files/:dir/delete` +- Request Body Example: + +```json +{ + "path": "subdir/intro.mp4" +} +``` + +- Response Example: + +```json +{ + "status": "ok", + "message": "已删除: subdir/intro.mp4" +} +``` + +- Error Response: `{ "status": "error", "message": "不支持的目录" }`、`{ "status": "error", "message": "缺少 path" }`、`{ "status": "error", "message": "路径不合法" }`、`{ "status": "error", "message": "文件不存在" }` + +### POST /api/files/move + +- Method: `POST` +- Path: `/api/files/move` +- Request Body Example: + +```json +{ + "from_dir": "videos", + "from_path": "intro.mp4", + "to_dir": "videos", + "to_path": "archive/intro.mp4" +} +``` + +- Response Example: + +```json +{ + "status": "ok", + "message": "已移动: intro.mp4 → archive/intro.mp4" +} +``` + +- Error Response: `{ "status": "error", "message": "源目录不支持" }`、`{ "status": "error", "message": "目标目录不支持" }`、`{ "status": "error", "message": "源路径不合法" }`、`{ "status": "error", "message": "目标路径已存在" }` + +### POST /api/files/:dir/mkdir + +- Method: `POST` +- Path: `/api/files/:dir/mkdir` +- Request Body Example: + +```json +{ + "path": "archive" +} +``` + +- Response Example: + +```json +{ + "status": "ok", + "message": "已创建目录: archive" +} +``` + +- Error Response: `{ "status": "error", "message": "不支持的目录" }`、`{ "status": "error", "message": "缺少 path" }`、`{ "status": "error", "message": "路径不合法" }`、`{ "status": "error", "message": "创建失败: ..." }` + +## 已移除的旧文档端点 + +以下端点当前实现中不存在,不应再使用: + +- `/api/stop` +- `/api/wifi/disconnect` diff --git a/clients/flutter/TODO.md b/clients/flutter/TODO.md index 9bfd519..d16ca1e 100644 --- a/clients/flutter/TODO.md +++ b/clients/flutter/TODO.md @@ -1,59 +1,56 @@ # Flutter App 待优化清单 > 生成时间: 2026-03-14 -> 当前完成度: ~68%, APK 已编译 (51MB) +> 最后更新: 2026-03-14 +> 当前完成度: ~95%, APK 待重编译 ## P0 — 阻塞上线 ### 1. 设备 IP 持久化 + 多设备支持 -- `main.dart:20` hardcoded `127.0.0.1:8080`,重启丢失 -- 需要: SharedPreferences 存储设备历史 (最近10台) -- 需要: 顶栏设备切换下拉菜单 -- 需要: 连接前验证 `/api/status` 可达性 +- ~~`main.dart:20` hardcoded `127.0.0.1:8080`,重启丢失~~ ✅ 已从持久化初始化 +- ~~需要: SharedPreferences 存储设备历史 (最近10台)~~ ✅ device_storage_service.dart +- 需要: 顶栏设备切换下拉菜单 ⏳ 赵雨薇处理中 +- 需要: 连接前验证 `/api/status` 可达性 ⏳ 赵雨薇处理中 -### 2. WebSocket 重连增强 -- `web_socket_service.dart` 固定 2 秒重连,无退避 -- 需要: 指数退避 (2s→4s→8s→16s→max 60s) -- 需要: 顶层连接状态横幅 (Reconnecting... / Offline) -- 需要: 手动重试按钮 +### 2. WebSocket 重连增强 ✅ 已完成 +- ~~`web_socket_service.dart` 固定 2 秒重连,无退避~~ ✅ 指数退避 2s→max 60s +- ~~需要: 顶层连接状态横幅~~ ✅ connection_status_banner.dart +- ~~需要: 手动重试按钮~~ ✅ manualReconnect() -### 3. HTTP baseUrl 动态化 -- HttpApiService/WebSocketService 的 URL 需跟随设备切换动态更新 -- DeviceProvider 应成为全局设备上下文,驱动所有服务重连 +### 3. HTTP baseUrl 动态化 ✅ 已完成 +- ~~HttpApiService/WebSocketService 的 URL 需跟随设备切换动态更新~~ ✅ +- ~~DeviceProvider 应成为全局设备上下文~~ ✅ ## P1 — 应该有 -### 4. 视频管理 UI (Settings 页) -- API 已有 getVideos(),但 UI 无视频列表展示 -- 需要: 视频列表 + 删除确认弹窗 -- 需要: 刷新按钮 +### 4. 视频管理 UI (Settings 页) ✅ 已完成 +- ~~视频列表 + 删除确认弹窗~~ ✅ settings_screen.dart:266-354 +- ~~刷新按钮~~ ✅ settings_screen.dart:282 -### 5. 配置 JSON 编辑器 -- 当前只有表单模式,缺 raw JSON 编辑模式 -- 需要: 切换按钮 (表单/JSON) -- 需要: 复制到剪贴板 +### 5. 配置 JSON 编辑器 ✅ 已完成 +- ~~需要: 复制到剪贴板~~ ✅ settings_screen.dart:559 +- ~~需要: 切换按钮 (表单/JSON)~~ ✅ 赵雨薇完成 -### 6. BLE 简易控制命令 -- PRD §8.6 要求: 近场调试用 play/pause/next/prev BLE 按钮 -- Network 页添加 BLE 控制区域 +### 6. BLE 简易控制命令 ✅ 已完成 +- ~~play/pause/next/prev BLE 按钮~~ ✅ network_screen.dart:115-183 -### 7. 全页面下拉刷新 -- 目前只有 Home 页有 RefreshIndicator -- Playback / Trigger / Network / Settings 都需要 +### 7. 全页面下拉刷新 ✅ 已完成 +- ~~所有 5 个页面~~ ✅ RefreshIndicator 全覆盖 ## P2 — 锦上添花 -### 8. 视频上传 UI -- 需要 file_picker 依赖 -- 进度条 + multipart upload +### 8. 视频上传 UI ⏳ 受限 +- file_picker 包在 ARM64 设备上无法下载(网络超时) +- 上传按钮已保留,当前显示"即将推出"提示 +- 可通过 Web UI 上传视频 -### 9. 单元测试 & Widget 测试 -- 目前零测试覆盖 -- 优先: models 解析、HttpApiService 错误处理、核心页面交互 +### 9. 单元测试 & Widget 测试 ✅ 已完成 +- ~~目前零测试覆盖~~ ✅ 15 个测试全部通过 +- ~~优先: models 解析、HttpApiService 错误处理、核心页面交互~~ ✅ test/models/models_test.dart + test/services/http_api_service_test.dart -### 10. 调试日志面板 -- 本地事件日志查看器 -- BLE/WebSocket/HTTP 事件时间线 +### 10. 调试日志面板 ✅ 已完成 +- ~~本地事件日志查看器~~ ✅ debug_screen.dart + debug_provider.dart +- ~~BLE/WebSocket/HTTP 事件时间线~~ ✅ 筛选 Chips + 颜色区分标签 + 500 条上限 ## 已知技术债 diff --git a/clients/flutter/analysis_options.yaml b/clients/flutter/analysis_options.yaml new file mode 100644 index 0000000..033e226 --- /dev/null +++ b/clients/flutter/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** diff --git a/clients/flutter/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/clients/flutter/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 2bc34e6..1f8d3d5 100644 --- a/clients/flutter/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/clients/flutter/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -20,5 +20,10 @@ public final class GeneratedPluginRegistrant { } catch (Exception 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); + } } } diff --git a/clients/flutter/flutter_01.log b/clients/flutter/flutter_01.log new file mode 100644 index 0000000..1aa7fac --- /dev/null +++ b/clients/flutter/flutter_01.log @@ -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. +``` diff --git a/clients/flutter/lib/main.dart b/clients/flutter/lib/main.dart index efe82d0..aa7e976 100644 --- a/clients/flutter/lib/main.dart +++ b/clients/flutter/lib/main.dart @@ -3,45 +3,72 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'providers/device_provider.dart'; +import 'providers/debug_provider.dart'; +import 'providers/ble_provider.dart'; import 'providers/player_provider.dart'; import 'providers/wifi_provider.dart'; import 'screens/app_shell.dart'; import 'screens/ble_provision_screen.dart'; +import 'screens/debug_screen.dart'; import 'screens/home_screen.dart'; import 'screens/network_screen.dart'; import 'screens/playback_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/trigger_screen.dart'; +import 'services/device_storage_service.dart'; import 'services/http_api_service.dart'; import 'services/web_socket_service.dart'; import 'theme/app_theme.dart'; -void main() { - final httpApiService = HttpApiService(baseUrl: 'http://127.0.0.1:8080'); +Future main() async { + 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(); runApp( MultiProvider( providers: [ + Provider.value(value: webSocketService), + ChangeNotifierProvider( + create: (_) => DebugProvider(webSocketService: webSocketService), + ), ChangeNotifierProvider( - create: (_) => DeviceProvider( + create: (context) => DeviceProvider( httpApiService: httpApiService, webSocketService: webSocketService, + deviceStorageService: deviceStorageService, + debugProvider: context.read(), + initialDeviceIp: initialDeviceIp, + initialDevicePort: initialDevicePort, + initialDeviceName: lastDevice?.name, )..initialize(), ), + ChangeNotifierProvider( + create: (context) => BleProvider( + debugProvider: context.read(), + ), + ), ChangeNotifierProvider( - create: (_) => PlayerProvider( + create: (context) => PlayerProvider( httpApiService: httpApiService, webSocketService: webSocketService, - ) - ..bootstrap(), + debugProvider: context.read(), + )..bootstrap(), ), ChangeNotifierProvider( - create: (_) => WifiProvider( + create: (context) => WifiProvider( httpApiService: httpApiService, webSocketService: webSocketService, - ) - ..bootstrap(), + debugProvider: context.read(), + )..bootstrap(), ), ], child: const ShowenApp(), @@ -108,6 +135,15 @@ final GoRouter _router = GoRouter( ), ], ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/debug', + name: 'debug', + builder: (context, state) => const DebugScreen(), + ), + ], + ), ], ), ], diff --git a/clients/flutter/lib/providers/ble_provider.dart b/clients/flutter/lib/providers/ble_provider.dart index 78b89b9..45eea98 100644 --- a/clients/flutter/lib/providers/ble_provider.dart +++ b/clients/flutter/lib/providers/ble_provider.dart @@ -4,11 +4,15 @@ import 'package:flutter/foundation.dart'; import '../models/ble_models.dart'; import '../services/ble_service.dart'; +import 'debug_provider.dart'; 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 DebugProvider _debugProvider; StreamSubscription>? _scanSubscription; StreamSubscription? _statusSubscription; @@ -21,6 +25,7 @@ class BleProvider extends ChangeNotifier { bool _isScanning = false; bool _isConnecting = false; bool _isProvisioning = false; + bool _isSendingCommand = false; bool _isConnected = false; bool _isDisposed = false; @@ -32,9 +37,11 @@ class BleProvider extends ChangeNotifier { bool get isScanning => _isScanning; bool get isConnecting => _isConnecting; bool get isProvisioning => _isProvisioning; + bool get isSendingCommand => _isSendingCommand; bool get isConnected => _isConnected; Future startScan() async { + _debugProvider.addBleLog('Start BLE scan'); _errorMessage = null; _selectedDevice = null; _isConnected = false; @@ -47,23 +54,31 @@ class BleProvider extends ChangeNotifier { .scanForShowenDevices() .listen((List scannedDevices) { _devices = scannedDevices; + _debugProvider.addBleLog( + 'BLE scan update (${scannedDevices.length} devices)', + ); _notifySafely(); }, onError: (Object error, StackTrace stackTrace) { _errorMessage = error.toString(); _isScanning = false; _provisioningState = ProvisioningState.failed; + _debugProvider.addBleLog('BLE scan failed', details: error); _notifySafely(); }); Future.delayed(const Duration(seconds: 6), () { if (_isScanning) { _isScanning = false; + _debugProvider.addBleLog('BLE scan completed'); _notifySafely(); } }); } Future connectToDevice(BleDevice device) async { + _debugProvider.addBleLog( + 'Connect BLE device ${device.name.isNotEmpty ? device.name : device.id}', + ); _selectedDevice = device; _errorMessage = null; _isConnecting = true; @@ -75,10 +90,12 @@ class BleProvider extends ChangeNotifier { await _bleService.connectToDevice(device); await _subscribeToStatus(); _isConnected = true; + _debugProvider.addBleLog('BLE device connected'); } catch (error) { _isConnected = false; _errorMessage = error.toString(); _provisioningState = ProvisioningState.failed; + _debugProvider.addBleLog('BLE connect failed', details: error); rethrow; } finally { _isConnecting = false; @@ -87,6 +104,10 @@ class BleProvider extends ChangeNotifier { } Future provisionWifi(String ssid, String password) async { + _debugProvider.addBleLog( + 'Provision WiFi over BLE', + details: {'ssid': ssid}, + ); _errorMessage = null; _latestStatus = null; _isProvisioning = true; @@ -115,14 +136,19 @@ class BleProvider extends ChangeNotifier { : ProvisioningState.failed; if (!result.ok) { _errorMessage = result.error ?? 'WiFi provisioning failed'; + _debugProvider.addBleLog('BLE provisioning returned failure', details: result.error); + } else { + _debugProvider.addBleLog('BLE provisioning succeeded'); } } on TimeoutException { _errorMessage = 'BLE 配网超时(30 秒)'; _provisioningState = ProvisioningState.failed; + _debugProvider.addBleLog('BLE provisioning timed out'); rethrow; } catch (error) { _errorMessage = error.toString(); _provisioningState = ProvisioningState.failed; + _debugProvider.addBleLog('BLE provisioning failed', details: error); rethrow; } finally { _isProvisioning = false; @@ -140,10 +166,31 @@ class BleProvider extends ChangeNotifier { _isConnecting = false; _isProvisioning = false; _selectedDevice = null; + _debugProvider.addBleLog('BLE disconnected'); _notifySafely(); } + Future 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 retryScan() async { + _debugProvider.addBleLog('Retry BLE scan'); await disconnect(); _devices = const []; _latestStatus = null; @@ -158,6 +205,15 @@ class BleProvider extends ChangeNotifier { final Stream stream = await _bleService.subscribeToStatus(); _statusSubscription = stream.listen((BleStatus status) { _latestStatus = status; + _debugProvider.addBleLog( + 'BLE status update', + details: { + 'ok': status.ok, + 'action': status.action, + 'state': status.state, + 'error': status.error, + }, + ); if (!status.ok) { _errorMessage = status.error ?? 'BLE status returned an error'; } @@ -174,6 +230,7 @@ class BleProvider extends ChangeNotifier { }, onError: (Object error, StackTrace stackTrace) { _errorMessage = error.toString(); _provisioningState = ProvisioningState.failed; + _debugProvider.addBleLog('BLE status stream failed', details: error); _notifySafely(); }); } diff --git a/clients/flutter/lib/providers/debug_provider.dart b/clients/flutter/lib/providers/debug_provider.dart new file mode 100644 index 0000000..56202ee --- /dev/null +++ b/clients/flutter/lib/providers/debug_provider.dart @@ -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 _entries = []; + late final StreamSubscription _eventSubscription; + late final StreamSubscription _connectionSubscription; + + DebugLogFilter _filter = DebugLogFilter.all; + + List get entries => List.unmodifiable(_entries); + DebugLogFilter get filter => _filter; + + List 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: { + '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(); + } +} diff --git a/clients/flutter/lib/providers/device_provider.dart b/clients/flutter/lib/providers/device_provider.dart index 54adb81..bc5f1b4 100644 --- a/clients/flutter/lib/providers/device_provider.dart +++ b/clients/flutter/lib/providers/device_provider.dart @@ -6,28 +6,43 @@ import '../models/ble_status.dart'; import '../models/device_status.dart'; import '../models/player_status.dart'; import '../models/wifi_status.dart'; +import '../services/device_storage_service.dart'; import '../services/http_api_service.dart'; import '../services/web_socket_service.dart'; +import 'debug_provider.dart'; class DeviceProvider extends ChangeNotifier { DeviceProvider({ required HttpApiService httpApiService, required WebSocketService webSocketService, + required DeviceStorageService deviceStorageService, + required DebugProvider debugProvider, String initialDeviceIp = '127.0.0.1', + int initialDevicePort = 5000, + String? initialDeviceName, }) : _httpApiService = httpApiService, _webSocketService = webSocketService, - _deviceIp = _normalizeDeviceIp(initialDeviceIp) { - _httpApiService.baseUrl = 'http://$_deviceIp:8080'; + _debugProvider = debugProvider, + _deviceStorageService = deviceStorageService, + _deviceIp = _normalizeDeviceIp(initialDeviceIp), + _devicePort = _normalizePort(initialDevicePort), + _deviceName = _normalizeDeviceName(initialDeviceName) { + _httpApiService.baseUrl = _buildBaseUrl(_deviceIp, _devicePort); _connectionSubscription = _webSocketService.onConnectionChanged.listen( _handleConnectionChanged, ); - _statusSubscription = _webSocketService.onStatusUpdate.listen(_handleStatusUpdate); - _wifiSubscription = _webSocketService.onWifiUpdate.listen(_handleWifiUpdate); + _statusSubscription = _webSocketService.onStatusUpdate.listen( + _handleStatusUpdate, + ); + _wifiSubscription = + _webSocketService.onWifiUpdate.listen(_handleWifiUpdate); _bleSubscription = _webSocketService.onBleUpdate.listen(_handleBleUpdate); } final HttpApiService _httpApiService; final WebSocketService _webSocketService; + final DebugProvider _debugProvider; + final DeviceStorageService _deviceStorageService; late final StreamSubscription _connectionSubscription; late final StreamSubscription> _statusSubscription; @@ -39,21 +54,41 @@ class DeviceProvider extends ChangeNotifier { String? _errorMessage; bool _webSocketConnected = false; String _deviceIp; + int _devicePort; + String _deviceName; + List _deviceList = const []; DeviceStatus get status => _status; bool get isLoading => _isLoading; String? get errorMessage => _errorMessage; bool get webSocketConnected => _webSocketConnected; String get deviceIp => _deviceIp; + int get devicePort => _devicePort; + List get deviceList => + List.unmodifiable(_deviceList); HttpApiService get httpApiService => _httpApiService; Future initialize() async { + _debugProvider.addHttpLog( + 'Initialize device provider', + details: {'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 connect(); } Future refresh() async { _setLoading(true); + _debugProvider.addHttpLog( + 'Refresh device overview', + details: {'device': '$_deviceIp:$_devicePort'}, + ); try { final results = await Future.wait([ _httpApiService.getPlaybackStatus(), @@ -67,12 +102,18 @@ class DeviceProvider extends ChangeNotifier { bleStatus: results[2] as BleServiceStatus, ); _errorMessage = null; + _debugProvider.addHttpLog('Device overview refreshed'); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog( + 'Refresh device overview failed', + details: error, + ); _status = _status.copyWith( connected: false, connectionType: 'offline', - ipAddress: _deviceIp, + deviceName: _deviceName, + ipAddress: '$_deviceIp:$_devicePort', ); } finally { _setLoading(false); @@ -82,36 +123,98 @@ class DeviceProvider extends ChangeNotifier { Future loadDeviceOverview() => refresh(); Future connect() async { + _debugProvider.addWsLog( + 'Connect WebSocket', + details: {'device': '$_deviceIp:$_devicePort'}, + ); try { - await _webSocketService.connect(_deviceIp); + await _webSocketService.connect(_deviceIp, port: _devicePort); _webSocketConnected = _webSocketService.isConnected; _errorMessage = null; + _debugProvider.addWsLog('WebSocket connect request completed'); notifyListeners(); } catch (error) { _webSocketConnected = false; _errorMessage = error.toString(); + _debugProvider.addWsLog('WebSocket connect failed', details: error); notifyListeners(); } } - Future updateDeviceIp(String ip) async { - final normalized = _normalizeDeviceIp(ip); - _deviceIp = normalized; - _httpApiService.baseUrl = 'http://$_deviceIp:8080'; - _status = _status.copyWith(ipAddress: normalized, updatedAt: DateTime.now()); - notifyListeners(); + Future 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: {'name': nextName}, + ); + try { + await _validateDeviceReachable(nextDevice.ip, nextDevice.port); - await _webSocketService.disconnect(); - await initialize(); + 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: {'device': '${nextDevice.ip}:${nextDevice.port}'}, + ); + } catch (error) { + _errorMessage = error.toString(); + _debugProvider.addHttpLog('Switch device failed', details: error); + notifyListeners(); + rethrow; + } finally { + _setLoading(false); + } + } + + Future updateDeviceIp(String ip) async { + await switchDevice(ip); + } + + Future removeStoredDevice(SavedDevice device) async { + await _deviceStorageService.removeDevice(device.ip, device.port); + await _refreshDeviceList(); + _debugProvider.addHttpLog( + 'Removed saved device ${device.address}', + details: {'name': device.name}, + ); + notifyListeners(); } Future startBle({String? deviceName}) async { _setLoading(true); + _debugProvider.addHttpLog( + 'Start BLE service', + details: {'deviceName': deviceName}, + ); try { await _httpApiService.startBle(deviceName); await refresh(); + _debugProvider.addHttpLog('BLE service started'); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('Start BLE service failed', details: error); notifyListeners(); } finally { _setLoading(false); @@ -120,11 +223,14 @@ class DeviceProvider extends ChangeNotifier { Future stopBle() async { _setLoading(true); + _debugProvider.addHttpLog('Stop BLE service'); try { await _httpApiService.stopBle(); await refresh(); + _debugProvider.addHttpLog('BLE service stopped'); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('Stop BLE service failed', details: error); notifyListeners(); } finally { _setLoading(false); @@ -133,6 +239,9 @@ class DeviceProvider extends ChangeNotifier { void _handleConnectionChanged(SocketConnectionStatus connectionStatus) { _webSocketConnected = connectionStatus == SocketConnectionStatus.connected; + _debugProvider.addWsLog( + 'Device provider connection state: ${connectionStatus.name}', + ); if (!_webSocketConnected) { _status = _status.copyWith(connectionType: _status.connectionType); } @@ -140,6 +249,7 @@ class DeviceProvider extends ChangeNotifier { } void _handleStatusUpdate(Map payload) { + _debugProvider.addWsLog('Received status update', details: payload); final playerStatus = PlayerStatus.fromJson(payload); _status = _buildStatus( playerStatus: playerStatus, @@ -150,6 +260,7 @@ class DeviceProvider extends ChangeNotifier { } void _handleWifiUpdate(Map payload) { + _debugProvider.addWsLog('Received wifi update', details: payload); final wifiStatus = WifiStatus.fromJson(payload); _status = _buildStatus( playerStatus: _status.playerStatus ?? PlayerStatus.initial(), @@ -160,6 +271,7 @@ class DeviceProvider extends ChangeNotifier { } void _handleBleUpdate(Map payload) { + _debugProvider.addBleLog('Received BLE update', details: payload); final normalized = { 'running': payload['running'] ?? payload['ready'] ?? false, 'embedded': payload['embedded'] ?? false, @@ -186,10 +298,11 @@ class DeviceProvider extends ChangeNotifier { : 'offline'; return DeviceStatus( - connected: wifiStatus.connected || bleStatus.running || _webSocketConnected, + connected: + wifiStatus.connected || bleStatus.running || _webSocketConnected, connectionType: connectionType, - deviceName: bleStatus.deviceName ?? 'ShowenV2', - ipAddress: wifiStatus.ip ?? _deviceIp, + deviceName: bleStatus.deviceName ?? _deviceName, + ipAddress: wifiStatus.ip ?? '$_deviceIp:$_devicePort', playerStatus: playerStatus, wifiStatus: wifiStatus, bleStatus: bleStatus, @@ -197,11 +310,46 @@ class DeviceProvider extends ChangeNotifier { ); } + Future _refreshDeviceList() async { + _deviceList = await _deviceStorageService.getDevices(); + } + + Future _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) { _isLoading = value; notifyListeners(); } + static String _buildBaseUrl(String ip, int port) => 'http://$ip:$port'; + static String _normalizeDeviceIp(String input) { var value = input.trim(); if (value.startsWith('http://')) { @@ -210,6 +358,8 @@ class DeviceProvider extends ChangeNotifier { 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) { @@ -222,6 +372,53 @@ class DeviceProvider extends ChangeNotifier { 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 void dispose() { unawaited(_connectionSubscription.cancel()); diff --git a/clients/flutter/lib/providers/player_provider.dart b/clients/flutter/lib/providers/player_provider.dart index c298e83..6e185b1 100644 --- a/clients/flutter/lib/providers/player_provider.dart +++ b/clients/flutter/lib/providers/player_provider.dart @@ -5,29 +5,36 @@ import 'package:flutter/foundation.dart'; import '../models/player_status.dart'; import '../services/http_api_service.dart'; import '../services/web_socket_service.dart'; +import 'debug_provider.dart'; class PlayerProvider extends ChangeNotifier { PlayerProvider({ required HttpApiService httpApiService, required WebSocketService webSocketService, + required DebugProvider debugProvider, }) : _httpApiService = httpApiService, - _webSocketService = webSocketService { + _webSocketService = webSocketService, + _debugProvider = debugProvider { _statusSubscription = _webSocketService.onStatusUpdate.listen((payload) { _status = PlayerStatus.fromJson(payload); + _debugProvider.addWsLog('Player status update', details: payload); notifyListeners(); }); _stateSubscription = _webSocketService.onStateUpdate.listen((payload) { _currentState = payload['new_state']?.toString() ?? _currentState; + _debugProvider.addWsLog('Player state update', details: payload); notifyListeners(); }); _configSubscription = _webSocketService.onConfigUpdate.listen((payload) { _updateSceneOptions(payload); + _debugProvider.addWsLog('Player config update', details: payload); notifyListeners(); }); } final HttpApiService _httpApiService; final WebSocketService _webSocketService; + final DebugProvider _debugProvider; late final StreamSubscription> _statusSubscription; late final StreamSubscription> _stateSubscription; late final StreamSubscription> _configSubscription; @@ -48,6 +55,7 @@ class PlayerProvider extends ChangeNotifier { Future bootstrap() async { _setLoading(true); + _debugProvider.addHttpLog('Bootstrap player provider'); try { final results = await Future.wait([ _httpApiService.getPlaybackStatus(), @@ -58,31 +66,45 @@ class PlayerProvider extends ChangeNotifier { _playlist = results[1] as List; _updateSceneOptions(results[2] as Map); _errorMessage = null; + _debugProvider.addHttpLog( + 'Player provider bootstrapped', + details: {'playlist': _playlist.length}, + ); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('Bootstrap player provider failed', details: error); } finally { _setLoading(false); } } Future fetchStatus() async { + _debugProvider.addHttpLog('Fetch playback status'); try { _status = await _httpApiService.getPlaybackStatus(); _errorMessage = null; + _debugProvider.addHttpLog('Playback status fetched'); notifyListeners(); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('Fetch playback status failed', details: error); notifyListeners(); } } Future fetchPlaylist() async { + _debugProvider.addHttpLog('Fetch playlist'); try { _playlist = await _httpApiService.getPlaylist(); _errorMessage = null; + _debugProvider.addHttpLog( + 'Playlist fetched', + details: {'items': _playlist.length}, + ); notifyListeners(); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('Fetch playlist failed', details: error); notifyListeners(); } } @@ -119,6 +141,7 @@ class PlayerProvider extends ChangeNotifier { Future _runCommand(Future Function() action) async { _setLoading(true); + _debugProvider.addHttpLog('Run player command'); try { await action(); await Future.wait([ @@ -126,8 +149,10 @@ class PlayerProvider extends ChangeNotifier { fetchPlaylist(), ]); _errorMessage = null; + _debugProvider.addHttpLog('Player command completed'); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('Player command failed', details: error); notifyListeners(); } finally { _setLoading(false); diff --git a/clients/flutter/lib/providers/wifi_provider.dart b/clients/flutter/lib/providers/wifi_provider.dart index 6d3aab3..41df291 100644 --- a/clients/flutter/lib/providers/wifi_provider.dart +++ b/clients/flutter/lib/providers/wifi_provider.dart @@ -6,21 +6,26 @@ import '../models/wifi_network.dart'; import '../models/wifi_status.dart'; import '../services/http_api_service.dart'; import '../services/web_socket_service.dart'; +import 'debug_provider.dart'; class WifiProvider extends ChangeNotifier { WifiProvider({ required HttpApiService httpApiService, required WebSocketService webSocketService, + required DebugProvider debugProvider, }) : _httpApiService = httpApiService, - _webSocketService = webSocketService { + _webSocketService = webSocketService, + _debugProvider = debugProvider { _wifiSubscription = _webSocketService.onWifiUpdate.listen((payload) { _status = WifiStatus.fromJson(payload); + _debugProvider.addWsLog('Wifi provider update', details: payload); notifyListeners(); }); } final HttpApiService _httpApiService; final WebSocketService _webSocketService; + final DebugProvider _debugProvider; late final StreamSubscription> _wifiSubscription; WifiStatus _status = WifiStatus.disconnected(); @@ -37,6 +42,7 @@ class WifiProvider extends ChangeNotifier { Future bootstrap() async { _setLoading(true); + _debugProvider.addHttpLog('Bootstrap WiFi provider'); try { final results = await Future.wait([ _httpApiService.getWifiStatus(), @@ -45,30 +51,44 @@ class WifiProvider extends ChangeNotifier { _status = results[0] as WifiStatus; _networks = results[1] as List; _errorMessage = null; + _debugProvider.addHttpLog( + 'WiFi provider bootstrapped', + details: {'networks': _networks.length}, + ); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('Bootstrap WiFi provider failed', details: error); } finally { _setLoading(false); } } Future refreshStatus() async { + _debugProvider.addHttpLog('Refresh WiFi status'); try { _status = await _httpApiService.getWifiStatus(); + _debugProvider.addHttpLog('WiFi status refreshed'); notifyListeners(); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('Refresh WiFi status failed', details: error); notifyListeners(); } } Future scanNetworks() async { _setLoading(true); + _debugProvider.addHttpLog('Scan WiFi networks'); try { _networks = await _httpApiService.scanWifi(); _errorMessage = null; + _debugProvider.addHttpLog( + 'WiFi scan completed', + details: {'networks': _networks.length}, + ); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('WiFi scan failed', details: error); } finally { _setLoading(false); } @@ -76,13 +96,19 @@ class WifiProvider extends ChangeNotifier { Future connect({required String ssid, required String password}) async { _setLoading(true); + _debugProvider.addHttpLog( + 'Connect WiFi network', + details: {'ssid': ssid}, + ); try { await _httpApiService.connectWifi(ssid, password); await refreshStatus(); _hotspotEnabled = false; _errorMessage = null; + _debugProvider.addHttpLog('WiFi connected', details: {'ssid': ssid}); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('Connect WiFi failed', details: error); notifyListeners(); } finally { _setLoading(false); @@ -91,13 +117,19 @@ class WifiProvider extends ChangeNotifier { Future startHotspot({String? ssid, String? password}) async { _setLoading(true); + _debugProvider.addHttpLog( + 'Start hotspot', + details: {'ssid': ssid}, + ); try { await _httpApiService.startAP(ssid, password); _hotspotEnabled = true; _errorMessage = null; + _debugProvider.addHttpLog('Hotspot started'); notifyListeners(); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('Start hotspot failed', details: error); notifyListeners(); } finally { _setLoading(false); @@ -106,13 +138,16 @@ class WifiProvider extends ChangeNotifier { Future stopHotspot() async { _setLoading(true); + _debugProvider.addHttpLog('Stop hotspot'); try { await _httpApiService.stopAP(); _hotspotEnabled = false; _errorMessage = null; + _debugProvider.addHttpLog('Hotspot stopped'); notifyListeners(); } catch (error) { _errorMessage = error.toString(); + _debugProvider.addHttpLog('Stop hotspot failed', details: error); notifyListeners(); } finally { _setLoading(false); diff --git a/clients/flutter/lib/screens/app_shell.dart b/clients/flutter/lib/screens/app_shell.dart index 099a849..22fdc8f 100644 --- a/clients/flutter/lib/screens/app_shell.dart +++ b/clients/flutter/lib/screens/app_shell.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../widgets/connection_status_banner.dart'; + class AppShell extends StatelessWidget { const AppShell({required this.navigationShell, super.key}); @@ -9,7 +11,12 @@ class AppShell extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: navigationShell, + body: Column( + children: [ + const ConnectionStatusBanner(), + Expanded(child: navigationShell), + ], + ), bottomNavigationBar: NavigationBar( selectedIndex: navigationShell.currentIndex, onDestinationSelected: (index) { @@ -44,6 +51,11 @@ class AppShell extends StatelessWidget { selectedIcon: Icon(Icons.settings), label: '设置', ), + NavigationDestination( + icon: Icon(Icons.bug_report_outlined), + selectedIcon: Icon(Icons.bug_report), + label: '调试', + ), ], ), ); diff --git a/clients/flutter/lib/screens/ble_provision_screen.dart b/clients/flutter/lib/screens/ble_provision_screen.dart index e9be7d4..ff224bc 100644 --- a/clients/flutter/lib/screens/ble_provision_screen.dart +++ b/clients/flutter/lib/screens/ble_provision_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../models/ble_models.dart'; import '../providers/ble_provider.dart'; @@ -22,8 +23,8 @@ class _BleProvisionScreenState extends State { @override void initState() { super.initState(); - _ownsProvider = widget.provider == null; - _provider = widget.provider ?? BleProvider(); + _ownsProvider = false; + _provider = widget.provider ?? context.read(); WidgetsBinding.instance.addPostFrameCallback((_) { _provider.startScan(); }); diff --git a/clients/flutter/lib/screens/debug_screen.dart b/clients/flutter/lib/screens/debug_screen.dart new file mode 100644 index 0000000..f261388 --- /dev/null +++ b/clients/flutter/lib/screens/debug_screen.dart @@ -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(); + 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/clients/flutter/lib/screens/network_screen.dart b/clients/flutter/lib/screens/network_screen.dart index 828e401..0f15ac4 100644 --- a/clients/flutter/lib/screens/network_screen.dart +++ b/clients/flutter/lib/screens/network_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import '../providers/ble_provider.dart'; import '../providers/device_provider.dart'; import '../providers/wifi_provider.dart'; import '../theme/app_colors.dart'; @@ -33,6 +34,7 @@ class _NetworkScreenState extends State { @override Widget build(BuildContext context) { + final bleProvider = context.watch(); final wifiProvider = context.watch(); final deviceProvider = context.watch(); final wifiStatus = wifiProvider.status; @@ -40,9 +42,12 @@ class _NetworkScreenState extends State { return Scaffold( appBar: AppBar(title: const Text('网络设置')), - body: ListView( - padding: const EdgeInsets.all(AppSpacing.md), - children: [ + body: RefreshIndicator( + onRefresh: _handleRefresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(AppSpacing.md), + children: [ StatusCard( title: 'WiFi 状态', value: wifiStatus.connected ? (wifiStatus.ssid ?? '已连接') : '未连接', @@ -107,6 +112,75 @@ class _NetworkScreenState extends State { ), ], ), + 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), Text('扫描结果', style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: AppSpacing.md), @@ -179,11 +253,19 @@ class _NetworkScreenState extends State { child: const Text('进入 BLE 配网页面'), ), ), - ], + ], + ), ), ); } + Future _handleRefresh() async { + await Future.wait([ + context.read().bootstrap(), + context.read().refresh(), + ]); + } + void _handleConnectWifi() { final ssid = _ssidController.text.trim(); if (ssid.isEmpty) { @@ -198,4 +280,23 @@ class _NetworkScreenState extends State { password: _passwordController.text, ); } + + Future _sendBleCommand(String command) async { + try { + await context.read().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')), + ); + } + } } diff --git a/clients/flutter/lib/screens/playback_screen.dart b/clients/flutter/lib/screens/playback_screen.dart index 49df6ae..3ebf5bd 100644 --- a/clients/flutter/lib/screens/playback_screen.dart +++ b/clients/flutter/lib/screens/playback_screen.dart @@ -29,9 +29,12 @@ class _PlaybackScreenState extends State { return Scaffold( appBar: AppBar(title: const Text('播放控制')), - body: ListView( - padding: const EdgeInsets.all(AppSpacing.md), - children: [ + body: RefreshIndicator( + onRefresh: _handleRefresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(AppSpacing.md), + children: [ Card( child: Padding( padding: const EdgeInsets.all(AppSpacing.lg), @@ -164,11 +167,20 @@ class _PlaybackScreenState extends State { ), ); }), - ], + ], + ), ), ); } + Future _handleRefresh() async { + final provider = context.read(); + await Future.wait([ + provider.fetchStatus(), + provider.fetchPlaylist(), + ]); + } + void _handleGoto() { final index = int.tryParse(_indexController.text.trim()); if (index == null) { diff --git a/clients/flutter/lib/screens/settings_screen.dart b/clients/flutter/lib/screens/settings_screen.dart index 513a2af..223238a 100644 --- a/clients/flutter/lib/screens/settings_screen.dart +++ b/clients/flutter/lib/screens/settings_screen.dart @@ -1,9 +1,12 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../models/video_item.dart'; import '../providers/device_provider.dart'; +import '../services/http_api_service.dart'; import '../theme/app_colors.dart'; class SettingsScreen extends StatefulWidget { @@ -15,6 +18,7 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { final TextEditingController _ipController = TextEditingController(); + final FocusNode _ipFocusNode = FocusNode(); final TextEditingController _titleController = TextEditingController(); final TextEditingController _rotationController = TextEditingController(); final TextEditingController _widthController = TextEditingController(); @@ -22,11 +26,14 @@ class _SettingsScreenState extends State { final TextEditingController _hsvMinController = TextEditingController(); final TextEditingController _hsvMaxController = TextEditingController(); final TextEditingController _pointsController = TextEditingController(); + final TextEditingController _jsonController = TextEditingController(); Map? _fullConfig; List _availableConfigs = const []; + Future>? _videosFuture; String? _activeConfig; bool _isFullscreen = false; + bool _jsonEditMode = false; bool _loading = true; @override @@ -38,6 +45,7 @@ class _SettingsScreenState extends State { @override void dispose() { _ipController.dispose(); + _ipFocusNode.dispose(); _titleController.dispose(); _rotationController.dispose(); _widthController.dispose(); @@ -45,204 +53,425 @@ class _SettingsScreenState extends State { _hsvMinController.dispose(); _hsvMaxController.dispose(); _pointsController.dispose(); + _jsonController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final provider = context.watch(); + final httpApiService = provider.httpApiService; 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( appBar: AppBar(title: const Text('设置')), body: _loading ? const Center(child: CircularProgressIndicator()) - : ListView( - padding: const EdgeInsets.all(AppSpacing.md), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('设备 IP 配置', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: AppSpacing.md), - TextField( - controller: _ipController, - decoration: const InputDecoration(labelText: '设备 IP 地址'), - ), - const SizedBox(height: AppSpacing.md), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () async { - await context.read().updateDeviceIp( - _ipController.text.trim(), - ); - if (!mounted) { - return; - } - await _loadData(); + : RefreshIndicator( + onRefresh: _loadData, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('设备 IP 配置', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _ipController, + 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(); }, - child: const Text('保存并重连'), ), - ), - ], - ), - ), - ), - const SizedBox(height: AppSpacing.lg), - Card( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('可用配置文件', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: AppSpacing.md), - DropdownButtonFormField( - initialValue: _activeConfig, - items: _availableConfigs - .map( - (item) => DropdownMenuItem( - value: item, - child: Text(item), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: FilledButton( + onPressed: provider.isLoading + ? null + : _handleSwitchDevice, + child: provider.isLoading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Text('切换设备'), ), - ) - .toList(growable: false), - onChanged: (value) => setState(() => _activeConfig = value), - decoration: const InputDecoration(labelText: '当前配置'), - ), - const SizedBox(height: AppSpacing.md), - SizedBox( - width: double.infinity, - child: FilledButton.tonal( - onPressed: _activeConfig == null ? null : _handleSwitchConfig, - child: const Text('切换配置'), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: FilledButton.tonal( + onPressed: provider.isLoading + ? null + : () => _showDeviceListDialog(provider), + child: const Text('设备列表'), + ), + ), + ], ), - ), - ], + ], + ), ), ), - ), - const SizedBox(height: AppSpacing.lg), - Card( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('显示设置', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: AppSpacing.md), - SwitchListTile( - contentPadding: EdgeInsets.zero, - value: _isFullscreen, - onChanged: (value) => setState(() => _isFullscreen = value), - title: const Text('全屏模式'), - ), - TextField( - controller: _titleController, - decoration: const InputDecoration(labelText: '窗口标题'), - ), - const SizedBox(height: AppSpacing.md), - Row( - children: [ - Expanded( - child: TextField( - controller: _rotationController, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: '旋转角度'), + const SizedBox(height: AppSpacing.lg), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('可用配置文件', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + DropdownButtonFormField( + initialValue: _activeConfig, + items: _availableConfigs + .map( + (item) => DropdownMenuItem( + value: item, + child: Text(item), + ), + ) + .toList(growable: false), + onChanged: (value) => + setState(() => _activeConfig = value), + decoration: + const InputDecoration(labelText: '当前配置'), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: FilledButton.tonal( + onPressed: _activeConfig == null + ? null + : _handleSwitchConfig, + child: const Text('切换配置'), + ), + ), + ], + ), + ), + ), + 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, + ), + ), + TextButton( + onPressed: _fullConfig == null + ? null + : _handleCopyJson, + child: const Text('复制JSON'), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + label: Text('表单'), + icon: Icon(Icons.tune_rounded), + ), + ButtonSegment( + value: true, + label: Text('JSON'), + icon: Icon(Icons.data_object_rounded), + ), + ], + selected: {_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(width: AppSpacing.md), - Expanded( - child: TextField( - controller: _widthController, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: '渲染宽度'), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _handleSaveJsonConfig, + child: const Text('保存 JSON'), ), ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: TextField( - controller: _heightController, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: '渲染高度'), + ] else ...[ + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _isFullscreen, + onChanged: (value) => + setState(() => _isFullscreen = value), + title: const Text('全屏模式'), + ), + TextField( + controller: _titleController, + decoration: + const InputDecoration(labelText: '窗口标题'), + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: TextField( + controller: _rotationController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '旋转角度'), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: TextField( + controller: _widthController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '渲染宽度'), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: TextField( + controller: _heightController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '渲染高度'), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _hsvMinController, + decoration: const InputDecoration( + labelText: '色键下限 HSV (逗号分隔)'), + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _hsvMaxController, + decoration: const InputDecoration( + labelText: '色键上限 HSV (逗号分隔)'), + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _pointsController, + minLines: 3, + maxLines: 5, + decoration: + const InputDecoration(labelText: '透视点 JSON'), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _handleSaveDisplayConfig, + child: const Text('保存显示设置'), ), ), ], - ), - const SizedBox(height: AppSpacing.md), - TextField( - controller: _hsvMinController, - decoration: const InputDecoration(labelText: '色键下限 HSV (逗号分隔)'), - ), - const SizedBox(height: AppSpacing.md), - TextField( - controller: _hsvMaxController, - decoration: const InputDecoration(labelText: '色键上限 HSV (逗号分隔)'), - ), - const SizedBox(height: AppSpacing.md), - TextField( - controller: _pointsController, - minLines: 3, - maxLines: 5, - decoration: const InputDecoration(labelText: '透视点 JSON'), - ), - const SizedBox(height: AppSpacing.md), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: _handleSaveDisplayConfig, - child: const Text('保存显示设置'), + ], + ), + ), + ), + 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>( + 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 []; + 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: '删除视频', + ), + ); + }, + ); + }, + ), + ], + ), ), ), - ), - const SizedBox(height: AppSpacing.lg), - Card( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('关于信息', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: AppSpacing.md), - _InfoRow(label: '设备名称', value: status.deviceName ?? 'ShowenV2'), - _InfoRow(label: '连接方式', value: status.connectionType.toUpperCase()), - _InfoRow(label: '设备地址', value: status.ipAddress ?? provider.deviceIp), - _InfoRow( - label: '实时通道', - value: provider.webSocketConnected ? '已连接' : '未连接', - ), - ], + const SizedBox(height: AppSpacing.lg), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('关于信息', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + _InfoRow( + label: '设备名称', + value: status.deviceName ?? 'ShowenV2'), + _InfoRow( + label: '连接方式', + value: status.connectionType.toUpperCase()), + _InfoRow( + label: '设备地址', + value: status.ipAddress ?? provider.deviceIp), + _InfoRow( + label: '实时通道', + value: provider.webSocketConnected ? '已连接' : '未连接', + ), + ], + ), ), ), - ), - ], + ], + ), ), ); } Future _loadData() async { - final service = context.read().httpApiService; + final service = _getHttpApiService(); setState(() => _loading = true); try { + _videosFuture = service.getVideos(); final results = await Future.wait([ service.getConfig(), service.getAvailableConfigs(), ]); - _fullConfig = Map.from(results[0] as Map); - final available = Map.from(results[1] as Map); - _availableConfigs = (available['configs'] as List? ?? const []) - .map((item) => item.toString()) - .toList(growable: false); + _fullConfig = + Map.from(results[0] as Map); + final available = + Map.from(results[1] as Map); + _availableConfigs = + (available['configs'] as List? ?? const []) + .map((item) => item.toString()) + .toList(growable: false); _activeConfig = available['active']?.toString(); - _applyDisplayConfig(Map.from(_fullConfig?['display'] as Map? ?? const {})); + _applyDisplayConfig(Map.from( + _fullConfig?['display'] as Map? ?? const {})); + _syncJsonControllerFromConfig(); } finally { if (mounted) { setState(() => _loading = false); @@ -250,13 +479,132 @@ class _SettingsScreenState extends State { } } + Future _handleSwitchDevice() async { + final input = _ipController.text.trim(); + if (input.isEmpty) { + return; + } + + final provider = context.read(); + 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 _showDeviceListDialog(DeviceProvider provider) async { + await showDialog( + 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(); + 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() + .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 _handleSwitchConfig() async { final activeConfig = _activeConfig; if (activeConfig == null) { return; } - await context.read().httpApiService.switchConfig(activeConfig); + await context + .read() + .httpApiService + .switchConfig(activeConfig); if (!mounted) { return; } @@ -271,7 +619,8 @@ class _SettingsScreenState extends State { final nextConfig = Map.from(config); nextConfig['display'] = { - ...Map.from(config['display'] as Map? ?? const {}), + ...Map.from( + config['display'] as Map? ?? const {}), 'fullscreen': _isFullscreen, 'window_title': _titleController.text.trim(), 'rotation': int.tryParse(_rotationController.text.trim()) ?? 0, @@ -282,18 +631,119 @@ class _SettingsScreenState extends State { 'hsv_max': _parseIntList(_hsvMaxController.text), }, 'perspective_correction': { - 'points': jsonDecode(_pointsController.text.trim().isEmpty ? '[]' : _pointsController.text.trim()), + 'points': jsonDecode(_pointsController.text.trim().isEmpty + ? '[]' + : _pointsController.text.trim()), }, }; - await context.read().httpApiService.updateConfig(nextConfig); + await context + .read() + .httpApiService + .updateConfig(nextConfig); _fullConfig = nextConfig; + _syncJsonControllerFromConfig(); + if (!mounted) { + return; + } + _showSnackBar('显示设置已保存'); + } + + Future _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.from(decoded); + await context + .read() + .httpApiService + .updateConfig(nextConfig); + _fullConfig = nextConfig; + _applyDisplayConfig(Map.from( + nextConfig['display'] as Map? ?? const {})); + _syncJsonControllerFromConfig(); + if (!mounted) { + return; + } + _showSnackBar('JSON 配置已保存'); + } on FormatException catch (error) { + _showSnackBar('JSON 解析失败: ${error.message}', isError: true); + } catch (error) { + _showSnackBar(error.toString(), isError: true); + } + } + + Future _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 _confirmDeleteVideo( + HttpApiService httpApiService, VideoItem video) async { + final confirmed = await showDialog( + 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) { return; } ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('显示设置已保存')), + SnackBar(content: Text('已删除 ${video.name}')), ); + _reloadVideos(); + } + + Future _handleUploadVideo() async { + _showSnackBar('视频上传功能即将推出,请通过 Web UI 上传'); + } + + void _reloadVideos() { + setState(() { + _videosFuture = _getHttpApiService().getVideos(); + }); + } + + HttpApiService _getHttpApiService() { + return context.read().httpApiService; } void _applyDisplayConfig(Map display) { @@ -302,11 +752,43 @@ class _SettingsScreenState extends State { _rotationController.text = '${display['rotation'] ?? 0}'; _widthController.text = '${display['render_width'] ?? 1024}'; _heightController.text = '${display['render_height'] ?? 1024}'; - final chromaKey = Map.from(display['chroma_key'] as Map? ?? const {}); - _hsvMinController.text = (chromaKey['hsv_min'] as List? ?? const [0, 0, 200]).join(','); - _hsvMaxController.text = (chromaKey['hsv_max'] as List? ?? const [180, 30, 255]).join(','); - final perspective = Map.from(display['perspective_correction'] as Map? ?? const {}); - _pointsController.text = jsonEncode(perspective['points'] ?? const []); + final chromaKey = Map.from( + display['chroma_key'] as Map? ?? const {}); + _hsvMinController.text = + (chromaKey['hsv_min'] as List? ?? const [0, 0, 200]) + .join(','); + _hsvMaxController.text = (chromaKey['hsv_max'] as List? ?? + const [180, 30, 255]) + .join(','); + final perspective = Map.from( + display['perspective_correction'] as Map? ?? const {}); + _pointsController.text = + jsonEncode(perspective['points'] ?? const []); + } + + 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 _parseIntList(String raw) { @@ -329,7 +811,9 @@ class _InfoRow extends StatelessWidget { padding: const EdgeInsets.only(bottom: AppSpacing.sm), child: Row( 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), ], ), diff --git a/clients/flutter/lib/screens/trigger_screen.dart b/clients/flutter/lib/screens/trigger_screen.dart index 882ea8e..5edc61b 100644 --- a/clients/flutter/lib/screens/trigger_screen.dart +++ b/clients/flutter/lib/screens/trigger_screen.dart @@ -37,9 +37,12 @@ class _TriggerScreenState extends State { return Scaffold( appBar: AppBar(title: const Text('状态机触发')), - body: ListView( - padding: const EdgeInsets.all(AppSpacing.md), - children: [ + body: RefreshIndicator( + onRefresh: _handleRefresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(AppSpacing.md), + children: [ Card( child: Padding( padding: const EdgeInsets.all(AppSpacing.md), @@ -159,11 +162,16 @@ class _TriggerScreenState extends State { ), ), ), - ], + ], + ), ), ); } + Future _handleRefresh() { + return context.read().bootstrap(); + } + void _handleCustomTrigger() { final name = _triggerController.text.trim(); final value = _valueController.text.trim(); diff --git a/clients/flutter/lib/services/ble_service.dart b/clients/flutter/lib/services/ble_service.dart index 1d0fb3b..546f5df 100644 --- a/clients/flutter/lib/services/ble_service.dart +++ b/clients/flutter/lib/services/ble_service.dart @@ -151,6 +151,29 @@ class BleService { } } + Future 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 play() => sendCommand('play'); + + Future pause() => sendCommand('pause'); + + Future next() => sendCommand('next'); + + Future previous() => sendCommand('prev'); + Future disconnect() async { await _scanSubscription?.cancel(); _scanSubscription = null; diff --git a/clients/flutter/lib/services/device_storage_service.dart b/clients/flutter/lib/services/device_storage_service.dart new file mode 100644 index 0000000..5efd151 --- /dev/null +++ b/clients/flutter/lib/services/device_storage_service.dart @@ -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 toJson() { + return { + 'ip': ip, + 'port': port, + 'name': name, + 'lastUsedAt': lastUsedAt.toIso8601String(), + }; + } + + factory SavedDevice.fromJson(Map 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 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( + 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> getDevices() async { + final prefs = await _getPreferences(); + final raw = prefs.getString(_devicesKey); + if (raw == null || raw.isEmpty) { + return const []; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + return const []; + } + + final devices = decoded + .whereType() + .map((item) => SavedDevice.fromJson(Map.from(item))) + .toList(growable: false) + ..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt)); + + return devices.take(_maxDevices).toList(growable: false); + } on FormatException { + return const []; + } + } + + Future 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 getLastDevice() async { + final devices = await getDevices(); + if (devices.isEmpty) { + return null; + } + return devices.first; + } + + Future _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); +} diff --git a/clients/flutter/lib/services/http_api_service.dart b/clients/flutter/lib/services/http_api_service.dart index 038d24b..fafaeb2 100644 --- a/clients/flutter/lib/services/http_api_service.dart +++ b/clients/flutter/lib/services/http_api_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -11,9 +12,11 @@ import '../models/video_item.dart'; import '../models/wifi_network.dart'; import '../models/wifi_status.dart'; +typedef UploadProgressCallback = void Function(double progress); + class HttpApiService { HttpApiService({required String baseUrl, http.Client? client}) - : _baseUrl = _normalizeBaseUrl(baseUrl), + : _baseUrl = normalizeBaseUrl(baseUrl), _client = client ?? http.Client(); final http.Client _client; @@ -22,7 +25,7 @@ class HttpApiService { String get baseUrl => _baseUrl; set baseUrl(String value) { - _baseUrl = _normalizeBaseUrl(value); + _baseUrl = normalizeBaseUrl(value); } Uri _uri(String path, [Map? queryParameters]) { @@ -229,6 +232,17 @@ class HttpApiService { return _uploadSingleFile('/api/videos/upload', file); } + Future uploadVideoWithProgress( + File file, { + UploadProgressCallback? onProgress, + }) { + return _uploadSingleFile( + '/api/videos/upload', + file, + onProgress: onProgress, + ); + } + Future uploadVideos(List filePaths) async { if (filePaths.isEmpty) { throw const ApiException('未选择上传文件'); @@ -377,17 +391,63 @@ class HttpApiService { String endpoint, File file, { String? directoryPath, + UploadProgressCallback? onProgress, }) async { final request = http.MultipartRequest( 'POST', _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()); + onProgress?.call(1); _ensureSuccess(response); return ApiResponse.fromJson(_decodeMap(response.body)); } + Future _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>.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 _postCommand(String path) async { final response = await _client.post(_uri(path)); _ensureSuccess(response); @@ -477,7 +537,7 @@ class HttpApiService { _client.close(); } - static String _normalizeBaseUrl(String raw) { + static String normalizeBaseUrl(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) { throw const ApiException('baseUrl 不能为空'); diff --git a/clients/flutter/lib/services/web_socket_service.dart b/clients/flutter/lib/services/web_socket_service.dart index d68504c..40e423c 100644 --- a/clients/flutter/lib/services/web_socket_service.dart +++ b/clients/flutter/lib/services/web_socket_service.dart @@ -5,15 +5,24 @@ import 'package:web_socket_channel/web_socket_channel.dart'; import '../models/app_event.dart'; +enum WsConnectionState { connected, connecting, disconnected } + +@Deprecated('Use WsConnectionState instead') enum SocketConnectionStatus { disconnected, connecting, connected } class WebSocketService { + static const Duration _initialReconnectDelay = Duration(seconds: 2); + static const Duration _maxReconnectDelay = Duration(seconds: 60); + WebSocketChannel? _channel; StreamSubscription? _subscription; Timer? _reconnectTimer; String? _deviceIp; + int _devicePort = 5000; bool _manualDisconnect = false; - SocketConnectionStatus _connectionStatus = SocketConnectionStatus.disconnected; + WsConnectionState _connectionState = WsConnectionState.disconnected; + int _retryCount = 0; + Duration _nextReconnectDelay = _initialReconnectDelay; final StreamController _eventController = StreamController.broadcast(); @@ -27,8 +36,8 @@ class WebSocketService { StreamController>.broadcast(); final StreamController> _bleController = StreamController>.broadcast(); - final StreamController _connectionController = - StreamController.broadcast(); + final StreamController _connectionStateController = + StreamController.broadcast(); Stream get events => _eventController.stream; Stream> get onStatusUpdate => _statusController.stream; @@ -36,32 +45,76 @@ class WebSocketService { Stream> get onConfigUpdate => _configController.stream; Stream> get onWifiUpdate => _wifiController.stream; Stream> get onBleUpdate => _bleController.stream; + Stream get connectionState => + _connectionStateController.stream.map(_toLegacyConnectionStatus); + Stream get connectionStateStream => + _connectionStateController.stream; Stream get onConnectionChanged => - _connectionController.stream; + _connectionStateController.stream.map(_toLegacyConnectionStatus); - SocketConnectionStatus get connectionStatus => _connectionStatus; - bool get isConnected => _connectionStatus == SocketConnectionStatus.connected; + WsConnectionState get wsConnectionState => _connectionState; + SocketConnectionStatus get connectionStatus => + _toLegacyConnectionStatus(_connectionState); + bool get isConnected => _connectionState == WsConnectionState.connected; + int get retryCount => _retryCount; - Future connect(String deviceIp) async { + Future connect(String deviceIp, {int port = 5000}) async { _manualDisconnect = false; _deviceIp = _normalizeDeviceIp(deviceIp); + _devicePort = _normalizePort(port); _reconnectTimer?.cancel(); + await _establishConnection(resetBackoff: true); + } + + Future manualReconnect() async { + final deviceIp = _deviceIp; + if (deviceIp == null || deviceIp.isEmpty) { + return; + } + + _manualDisconnect = false; + _reconnectTimer?.cancel(); + await _establishConnection(resetBackoff: true); + } + + Future _establishConnection({bool resetBackoff = false}) async { + if (resetBackoff) { + _retryCount = 0; + _nextReconnectDelay = _initialReconnectDelay; + } + await _subscription?.cancel(); + _subscription = null; await _channel?.sink.close(); + _channel = null; - _setConnectionStatus(SocketConnectionStatus.connecting); + _setConnectionState(WsConnectionState.connecting); - final url = Uri.parse('ws://$_deviceIp:8080/ws'); - _channel = WebSocketChannel.connect(url); - _subscription = _channel!.stream.listen( - _handleMessage, - onDone: _handleSocketClosed, - onError: (_) => _handleSocketClosed(), - cancelOnError: true, - ); + try { + final url = Uri.parse('ws://$_deviceIp:$_devicePort/ws'); + _channel = WebSocketChannel.connect(url); + await _channel!.ready; + _subscription = _channel!.stream.listen( + _handleMessage, + onDone: _handleSocketClosed, + onError: (_) => _handleSocketClosed(), + 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 command) { @@ -77,20 +130,38 @@ class WebSocketService { return; } + _setConnectionState(WsConnectionState.connecting); + _scheduleReconnect(); + } + + void _scheduleReconnect() { + if (_manualDisconnect) { + return; + } + _reconnectTimer?.cancel(); - _reconnectTimer = Timer(const Duration(seconds: 2), () { - unawaited(connect(deviceIp)); + final delay = _nextReconnectDelay; + _retryCount += 1; + _nextReconnectDelay = _nextReconnectDelay * 2; + if (_nextReconnectDelay > _maxReconnectDelay) { + _nextReconnectDelay = _maxReconnectDelay; + } + + _reconnectTimer = Timer(delay, () { + unawaited(_establishConnection()); }); } Future disconnect() async { _manualDisconnect = true; _reconnectTimer?.cancel(); + _retryCount = 0; + _nextReconnectDelay = _initialReconnectDelay; await _subscription?.cancel(); _subscription = null; await _channel?.sink.close(); _channel = null; - _setConnectionStatus(SocketConnectionStatus.disconnected); + _setConnectionState(WsConnectionState.disconnected); } Future dispose() async { @@ -101,7 +172,7 @@ class WebSocketService { await _configController.close(); await _wifiController.close(); await _bleController.close(); - await _connectionController.close(); + await _connectionStateController.close(); } void _handleMessage(dynamic data) { @@ -136,13 +207,30 @@ class WebSocketService { void _handleSocketClosed() { _channel = null; _subscription = null; - _setConnectionStatus(SocketConnectionStatus.disconnected); + if (_manualDisconnect) { + _retryCount = 0; + _nextReconnectDelay = _initialReconnectDelay; + _setConnectionState(WsConnectionState.disconnected); + return; + } + unawaited(reconnect()); } - void _setConnectionStatus(SocketConnectionStatus status) { - _connectionStatus = status; - _connectionController.add(status); + void _setConnectionState(WsConnectionState state) { + _connectionState = state; + _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) { @@ -169,4 +257,11 @@ class WebSocketService { return value; } + + int _normalizePort(int value) { + if (value <= 0 || value > 65535) { + return 5000; + } + return value; + } } diff --git a/clients/flutter/lib/widgets/connection_status_banner.dart b/clients/flutter/lib/widgets/connection_status_banner.dart new file mode 100644 index 0000000..cfb2f4f --- /dev/null +++ b/clients/flutter/lib/widgets/connection_status_banner.dart @@ -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(); + + return StreamBuilder( + 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, + ); + }, + ); + } +} diff --git a/clients/flutter/pubspec.lock b/clients/flutter/pubspec.lock index 7be7a3d..2e5d83d 100644 --- a/clients/flutter/pubspec.lock +++ b/clients/flutter/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -89,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -264,6 +280,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -272,6 +312,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -288,6 +344,62 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: flutter @@ -389,6 +501,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -398,5 +518,5 @@ packages: source: hosted version: "6.6.1" sdks: - dart: ">=3.9.0-0 <4.0.0" - flutter: ">=3.22.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/clients/flutter/pubspec.yaml b/clients/flutter/pubspec.yaml index 9b8b073..c6fb5ba 100644 --- a/clients/flutter/pubspec.yaml +++ b/clients/flutter/pubspec.yaml @@ -10,11 +10,13 @@ environment: dependencies: flutter: sdk: flutter + cupertino_icons: ^1.0.8 flutter_blue_plus: ^1.35.3 go_router: ^14.8.1 http: ^1.2.1 provider: ^6.1.2 web_socket_channel: ^3.0.1 + shared_preferences: ^2.3.0 dev_dependencies: flutter_test: diff --git a/clients/flutter/test/models/models_test.dart b/clients/flutter/test/models/models_test.dart new file mode 100644 index 0000000..a01d7fc --- /dev/null +++ b/clients/flutter/test/models/models_test.dart @@ -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 { + 'status': 'ok', + 'message': 'done', + }); + + expect(response.isOk, isTrue); + expect(response.toJson(), { + 'status': 'ok', + 'message': 'done', + }); + }); + }); + + group('AppEvent', () { + test('fromJson prefers data payload map', () { + final event = AppEvent.fromJson(const { + 'type': 'status', + 'data': {'connected': true}, + }); + + expect(event.type, 'status'); + expect(event.payload, {'connected': true}); + }); + + test('fromJson normalizes scalar payload', () { + final event = AppEvent.fromJson(const { + 'type': 'progress', + 'payload': 42, + }); + + expect(event.payload, {'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 { + '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 { + '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 {'connected': true, 'ssid': 'Office'}, + ), + bleStatus: BleServiceStatus.fromJson( + const {'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 { + 'running': true, + 'paused': false, + 'in_transition': true, + 'current_index': 3, + 'playlist_length': 9, + 'current_video': 'intro.mp4', + }); + + expect(status.toJson(), { + '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 { + '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 { + '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 { + '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'); + }); + }); +} diff --git a/clients/flutter/test/services/http_api_service_test.dart b/clients/flutter/test/services/http_api_service_test.dart new file mode 100644 index 0000000..4e86050 --- /dev/null +++ b/clients/flutter/test/services/http_api_service_test.dart @@ -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().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'); + }); + }); +} diff --git a/configs/downloads/showen-app.apk b/configs/downloads/showen-app.apk index b28f205..7adaba8 100644 Binary files a/configs/downloads/showen-app.apk and b/configs/downloads/showen-app.apk differ diff --git a/docs/M1.2_TEST_PLAN.md b/docs/M1.2_TEST_PLAN.md new file mode 100644 index 0000000..16e7f14 --- /dev/null +++ b/docs/M1.2_TEST_PLAN.md @@ -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 三条交互链路联调。 +- 所有关键用户场景、边界条件、错误处理场景均有执行记录。 +- 已知阻断性问题清零,或被明确降级并获负责人确认。 diff --git a/docs/MILESTONES.md b/docs/MILESTONES.md index 25ac934..520348b 100644 --- a/docs/MILESTONES.md +++ b/docs/MILESTONES.md @@ -2,28 +2,34 @@ ## Phase 1: 基础平台(当前) -### M1.1 - 核心插件迁移 ⏳ 进行中 -**时间**: 2周(2026-03-12 ~ 2026-03-26) +### M1.1 - 核心插件迁移 ✅ 已完成 +**时间**: 2周(2026-03-12 ~ 2026-03-14)— 提前完成 **负责人**: PM 刘建国 **任务清单**: - [x] 项目骨架搭建 - [x] core/ 基础架构(Plugin trait, Message, Config) - [x] 第一轮插件(config验证, StateMachine, WiFi, Screen) -- [ ] 第二轮核心功能 - - [ ] ServiceManager Broadcast + Message Clone(张明远) - - [ ] VideoProcessor 完整实现(李思琪) - - [ ] BlePlugin + GATT 双连接修复(王浩然) - - [ ] HttpPlugin + Web UI(赵雨薇) -- [ ] main.rs 集成所有插件 -- [ ] configs/ 配置文件迁移 +- [x] 第二轮核心功能 + - [x] ServiceManager Broadcast + Message Clone(张明远) + - [x] VideoProcessor 完整实现(李思琪) + - [x] BlePlugin + GATT 双连接修复(王浩然) + - [x] HttpPlugin + Web UI(赵雨薇) +- [x] main.rs 集成所有插件 +- [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 -**最后更新**: 2026-03-12 +**文档版本**: v1.1 +**最后更新**: 2026-03-14 **负责人**: 陈逸飞 (CEO) diff --git a/docs/TEAM.md b/docs/TEAM.md index 2abb2d3..e4a6fec 100644 --- a/docs/TEAM.md +++ b/docs/TEAM.md @@ -1,5 +1,7 @@ # ShowenV2 开发团队 +> 团队名单和当前状态的权威来源是 `CLAUDE.md`。本文件保留团队详细信息、制度和绩效标准。 + ## 管理层 ### CEO / 技术总监 @@ -338,9 +340,32 @@ CEO 决策(如涉及重大变更) - 任务完成度 (0-10): 是否完整实现需求、有无遗漏 - 效率 (0-10): 完成速度、是否需要返工 - 协作 (0-10): 代码是否易于集成、注释是否清晰 + - **能动性 (0-10)**: 是否主动验证、主动延伸、主动排查同类问题(详见能动性评分标准) - **末位淘汰**: 总分最低的成员被淘汰,由新成员替换 +- **L4 快速通道**: 同一阶段内累计触发 L4(失败升级协议第 5 次失败)2 次 → 自动进入淘汰候选,不等阶段结束 - **淘汰后**: 灵魂文件被归档到 `souls/archived/` +### 能动性评分标准 + +能动性是与代码质量并列的核心绩效维度。以下行为对照表作为评分依据: + +| 评分 | 行为特征 | +|------|---------| +| **9-10** | 修完代码主动跑 build/test 并贴输出;主动检查同类 bug;发现潜在风险主动预警;完成后主动延伸排查上下游 | +| **7-8** | 按要求验证并贴输出;修完后检查了同文件问题;汇报时包含验证证据 | +| **5-6** | 完成任务但需要提醒才验证;不主动延伸;汇报信息不完整 | +| **3-4** | 空口说完成不贴证据;修完就停不验证;被动等指示 | +| **1-2** | 推锅("建议手动"/"超出范围");不搜索就猜;违反三铁律 | + +### 失败升级与绩效关联 + +| 升级等级 | 绩效影响 | +|---------|---------| +| L1(温和提醒) | 无直接扣分,但记录在案 | +| L2(灵魂拷问) | 效率维度 -1 分 | +| L3(考核) | 效率维度 -2 分,能动性维度 -1 分 | +| L4(换人) | 效率维度 -3 分,进入淘汰候选 | + ### 灵魂保存机制 表现优秀的成员可以将以下信息保存到 `souls/.md`: - **思想**: 对项目架构的理解、技术洞察 diff --git a/docs/TESTING.md b/docs/TESTING.md index 2c18a0e..225b864 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -60,10 +60,7 @@ cargo test #### 配置文件测试 ```bash -# 复制测试配置 -cp /home/showen/Showen/hologram_player_rust/dog_state_machine.json configs/ -cp /home/showen/Showen/hologram_player_rust/cat_state_machine.json configs/ - +# 配置文件已在 configs/ 目录中 # 验证配置加载 cargo run --release -- --config configs/dog_state_machine.json --validate ``` @@ -217,11 +214,7 @@ cargo run --release -- --config configs/dog_state_machine.json #### 功能对比 ```bash -# 运行旧版本 -cd /home/showen/Showen/hologram_player_rust -cargo run --release -- --config dog_state_machine.json - -# 运行新版本 +# 运行 ShowenV2 cd /home/showen/Showen/ShowenV2 cargo run --release -- --config configs/dog_state_machine.json diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 8c62f36..e3273f7 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -1,5 +1,7 @@ # ShowenV2 开发流程规范 +> CEO 操作上下文的唯一权威来源是 `CLAUDE.md`。本文件定义详细工作流程。 + ## 核心原则 **方案先行,记录完整,审核通过才执行。** @@ -13,18 +15,41 @@ - git commit 方案文档 ### 2. 派发阶段 -- CEO 通过 `kilo run -m openai/gpt-5.4 --auto --dir ` 派发,消息中指示读取灵魂文件和 TEAM_CHAT.md -- 任务描述中包含: 角色身份、具体要求、上下文文件列表、验收标准 +- CEO 或 PM 通过 `kilo run -m openai/gpt-5.4 --auto --dir ` 派发 +- **任务消息必须包含**: + - 角色身份 + - **开工前必读文件**:souls/.md + .showen/COMPANY_RULES.md + .showen/TEAM_CHAT.md + - 具体要求和上下文文件列表 + - **交付要求**:必须贴 cargo check/test 输出;修完检查同类问题;更新 soul 文件 + - 验收标准 - 更新 PROGRESS.md 记录谁在做什么 ### 3. 审核阶段 -- 成员交付后 CEO 检查: +- 成员交付后**先检查验证证据**: + - [ ] 交付中是否附带 cargo check/test 输出?**无输出 → 直接打回,不看代码** - [ ] cargo check 零 warning? - - [ ] 逻辑与旧代码行为一致? + - [ ] cargo test 全部通过? +- 证据合格后再审核代码: + - [ ] 逻辑与需求一致? - [ ] 代码风格一致? - [ ] 没有安全问题? + - [ ] 是否主动检查了同类问题?(能动性加分项) - 合格: 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. 记录阶段 - 每次 git commit 前更新 PROGRESS.md @@ -38,10 +63,11 @@ | 文件 | 用途 | |------|------| -| PROGRESS.md | 项目进度、完成状态、待办事项 | -| TEAM.md | 团队成员档案、制度、绩效 | -| TEAM_CHAT.md | 团队异步沟通、任务讨论、问题记录 | -| souls/.md | 成员灵魂:思想/性格/记忆/技能 | +| CLAUDE.md | **CEO 唯一必读**:身份/规则/团队/状态/kilo模板 | +| PROGRESS.md | 里程碑摘要、最近变更 | +| TEAM.md | 团队成员档案、制度、绩效详情 | +| TEAM_CHAT.md | 团队异步沟通、任务讨论 | +| souls/.md | 成员灵魂:经验/性格/技能 | | WORKFLOW.md | 本文件,开发流程规范 | --- @@ -49,11 +75,15 @@ ## CEO 操作模板 ### 派发任务 + +> kilo 派发模板的权威版本见 `CLAUDE.md`。以下为快速参考: + ```bash -# 正确方式:把所有内容放在消息字符串里,让 kilo 自己读文件 kilo run -m openai/gpt-5.4 --auto \ --dir /home/showen/Showen/ShowenV2 \ - "你是<角色名>。先读取 souls/.md 和 TEAM_CHAT.md。任务:<具体说明>。完成后 cargo check 确认通过。" + "你是<角色名>。开工前必读:souls/.md + .showen/COMPANY_RULES.md + .showen/TEAM_CHAT.md。 + 任务:<具体说明>。交付要求:贴 cargo check/test 输出 + 检查同类问题 + 更新 soul 文件。 + 验收标准:<具体标准>" ``` ### 审核提交 @@ -71,28 +101,4 @@ git add && git commit -m "" --- -## 当前第二轮任务方案 - -### 任务 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 结构完整, 双连接架构正确 +> 具体任务方案见 `.showen/` 目录下的任务分解文档(如 `DEVICE_PLUGIN_TASKS.md`)。 diff --git a/plugin-sdk/src/lib.rs b/plugin-sdk/src/lib.rs index e573325..0eca33d 100644 --- a/plugin-sdk/src/lib.rs +++ b/plugin-sdk/src/lib.rs @@ -296,6 +296,15 @@ pub type FfiStr = *const c_char; /// /// 主程序从插件取回 JSON 或错误信息时使用该结构体。内存由插件分配, /// 再通过 `PluginVTable::free_string` 释放。 +/// +/// 该类型故意不携带 allocator 标识,以保持现有 `repr(C)` ABI 稳定; +/// 因此调用方必须通过 API 契约保证“谁分配,谁释放”。 +/// +/// # Safety +/// - 插件返回给主程序的 `FfiString` 只能由当前插件导出的 +/// `PluginVTable::free_string` 释放。 +/// - 主程序分配的字符串绝不能交给插件释放,反之亦然。 +/// - 跨 allocator 释放会导致未定义行为(可能崩溃或内存损坏)。 #[repr(C)] pub struct FfiString { /// 字符串起始指针;为空时表示没有内容。 @@ -308,6 +317,7 @@ impl FfiString { /// 从 Rust `String` 构造 FFI 字符串。 /// /// 如果字符串中包含内部 `NUL` 字节,会返回空字符串表示失败。 + /// 生成的指针必须由当前插件的 `free_string` 回调释放。 pub fn from_string(s: String) -> Self { match CString::new(s) { Ok(cstr) => { @@ -332,7 +342,8 @@ impl FfiString { /// 复制为 Rust String(不释放底层内存) /// /// # Safety - /// ptr 必须指向有效的 null-terminated C 字符串 + /// `ptr` 必须指向由当前插件 allocator 创建的有效 null-terminated C 字符串。 + /// 调用此函数不会转移所有权,原始分配方仍负责释放该内存。 pub unsafe fn to_string(&self) -> Option { if self.ptr.is_null() { return None; @@ -376,6 +387,8 @@ impl FfiResult { /// 主程序提供给插件的消息发送回调。 /// /// 插件通常无需直接调用该类型,而是通过 [`MessageSender`] 使用安全封装。 +/// 若插件把发送器保存到后台线程,必须在 [`ShowenPlugin::stop`] 返回前终止这些线程, +/// 之后不得再继续使用该回调或 `MessageSender`。 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, /// 释放由插件返回的字符串。 + /// + /// # Safety + /// 只能释放当前插件导出的 `FfiString`。不要将宿主分配的字符串传给这里。 pub free_string: unsafe extern "C" fn(s: FfiString), /// 销毁插件实例。 pub destroy: unsafe extern "C" fn(handle: PluginHandle), @@ -729,7 +745,8 @@ pub trait ShowenPlugin: Send { /// 停止插件并释放运行期资源。 /// /// 该方法通常用于停止后台线程、撤销监听、关闭文件句柄或网络连接。执行完成后, - /// 主程序可能很快销毁插件实例。 + /// 主程序可能很快销毁插件实例。所有后台线程必须在该方法返回前退出,并停止使用 + /// 之前在 `init` 中收到的 `MessageSender`。 /// /// # Examples /// ```ignore diff --git a/plugins/example-plugin/README.md b/plugins/example-plugin/README.md index 17907a6..51e319b 100644 --- a/plugins/example-plugin/README.md +++ b/plugins/example-plugin/README.md @@ -1,10 +1,11 @@ # Example Plugin — 示例动态插件 -演示如何使用 `showen-plugin-sdk` 编写动态插件。 +演示如何使用 `showen-plugin-sdk` 编写动态插件,并提供可直接打包到 +`plugin_store/` 的 `manifest.json` 模板。 ## 功能 -- 仅打印日志,用于验证动态加载流程 +- 展示消息路由、配置解析、后台任务、自测以及请求/响应模式 - 展示 `ShowenPlugin` trait 的完整实现 - 编译为 `cdylib`(`.so` 文件) @@ -27,3 +28,4 @@ cargo build --release |------|------| | `src/lib.rs` | 插件实现,使用 `export_plugin!` 宏导出 | | `Cargo.toml` | crate 配置,类型为 cdylib | +| `manifest.json` | 动态加载所需的完整清单模板 | diff --git a/plugins/example-plugin/manifest.json b/plugins/example-plugin/manifest.json new file mode 100644 index 0000000..55d55b5 --- /dev/null +++ b/plugins/example-plugin/manifest.json @@ -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 +} diff --git a/plugins/example-plugin/src/lib.rs b/plugins/example-plugin/src/lib.rs index bd990bf..be6dd23 100644 --- a/plugins/example-plugin/src/lib.rs +++ b/plugins/example-plugin/src/lib.rs @@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize}; use showen_plugin_sdk::{ - export_plugin, CapabilityTestResult, Destination, Envelope, Message, MessageSender, PluginInfo, - ShowenPlugin, + export_plugin, CapabilityTestResult, Destination, DeviceCommand, DeviceResponse, Envelope, + Message, MessageSender, PluginInfo, ShowenPlugin, }; use std::sync::{ atomic::{AtomicBool, Ordering}, @@ -26,6 +26,7 @@ const CAP_MESSAGE_ROUTING: &str = "message_routing"; const CAP_CONFIG_PARSING: &str = "config_parsing"; const CAP_BACKGROUND_TASK: &str = "background_task"; const CAP_SELF_TEST: &str = "self_test"; +const CAP_REQUEST_RESPONSE: &str = "request_response"; /// 示例插件配置。 /// @@ -42,6 +43,10 @@ struct ExamplePluginConfig { target_plugin: String, /// 是否在 `start()` 时发送一组教学用途的示例消息。 announce_on_start: bool, + /// DevicePlugin 的插件 ID,用于演示请求/响应模式。 + device_plugin: String, + /// 是否在 `start()` 时请求一次显示信息。 + request_display_info_on_start: bool, /// 是否启用后台定时任务。 enable_periodic_task: bool, /// 周期性上报里携带的示例文本。 @@ -56,6 +61,8 @@ impl Default for ExamplePluginConfig { heartbeat_interval_ms: 5_000, target_plugin: PLUGIN_ID.to_string(), announce_on_start: true, + device_plugin: "device".to_string(), + request_display_info_on_start: true, enable_periodic_task: true, periodic_payload: "heartbeat-from-example-plugin".to_string(), optional_test_should_fail: false, @@ -74,9 +81,8 @@ impl ExamplePluginConfig { .map_err(|error| format!("failed to parse example plugin config: {error}"))? }; + config.normalize(); config.validate()?; - config.target_plugin = config.target_plugin.trim().to_string(); - config.periodic_payload = config.periodic_payload.trim().to_string(); Ok(config) } @@ -84,17 +90,29 @@ impl ExamplePluginConfig { fn from_value(value: serde_json::Value) -> Result { let mut config = serde_json::from_value::(value) .map_err(|error| format!("failed to decode reloaded config: {error}"))?; + config.normalize(); config.validate()?; - config.target_plugin = config.target_plugin.trim().to_string(); - config.periodic_payload = config.periodic_payload.trim().to_string(); 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> { if self.target_plugin.trim().is_empty() { 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() { return Err("config field `periodic_payload` must not be empty".to_string()); } @@ -199,6 +217,30 @@ impl ExamplePlugin { 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`,因为这是第三方插件开发者最容易理解的最小示例。 @@ -271,6 +313,9 @@ impl ExamplePlugin { "example.send_demo_messages" => { self.emit_demo_messages()?; } + "example.request_display_info" => { + self.request_display_info()?; + } _ => { self.sender()?.send_to_manager( PLUGIN_ID, @@ -285,6 +330,44 @@ impl ExamplePlugin { 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_BACKGROUND_TASK.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() }, }, + 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 { self.emit_demo_messages()?; } + + if self.config.request_display_info_on_start { + self.request_display_info()?; + } + self.start_worker()?; Ok(()) } @@ -468,17 +570,44 @@ impl ShowenPlugin for ExamplePlugin { Message::PluginReady(plugin_name) => { eprintln!("[ExamplePlugin] observed peer readiness: {plugin_name}"); } - Message::DeviceCommand(_cmd) => { - eprintln!("[ExamplePlugin] device command received (not handled)"); + Message::DeviceCommand(command) => { + eprintln!("[ExamplePlugin] device command received (not handled): {command:?}"); } - Message::DeviceResponse(_resp) => { - eprintln!("[ExamplePlugin] device response received (not handled)"); + Message::DeviceResponse(response) => { + 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) => { eprintln!("[ExamplePlugin] device event received (not handled)"); } 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 接口。 +// +// `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()); #[cfg(test)] @@ -558,6 +696,16 @@ mod tests { 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] fn start_sends_demo_messages_and_heartbeat() { let recorder = Recorder::new(); @@ -628,7 +776,9 @@ mod tests { .handle_message(Message::ConfigReloaded(serde_json::json!({ "heartbeat_interval_ms": 100, "target_plugin": "example-plugin", + "device_plugin": "device", "announce_on_start": false, + "request_display_info_on_start": false, "enable_periodic_task": true, "periodic_payload": "reloaded-heartbeat", "optional_test_should_fail": true @@ -642,4 +792,57 @@ mod tests { 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")); + } } diff --git a/souls/README.md b/souls/README.md index cc442f0..e2d9d2e 100644 --- a/souls/README.md +++ b/souls/README.md @@ -1,5 +1,7 @@ # souls/ — AI 团队灵魂文件 +> 完整团队名单和状态见 `CLAUDE.md`。本文件是目录索引。 + 每个 `.md` 文件是一位 AI 团队成员的"灵魂",定义其背景、专长、性格、职责、技能树和持久记忆。 ## 成员列表 diff --git a/souls/chen-yifei.md b/souls/chen-yifei.md index f3ca3ee..337e3ef 100644 --- a/souls/chen-yifei.md +++ b/souls/chen-yifei.md @@ -1,121 +1,69 @@ -# 陈逸飞 — CEO / 技术总监 +# 陈逸飞 — CEO / 技术总监(深层经验) + +> 核心操作手册见 `CLAUDE.md`(自动加载)。本文件存放 CEO 独有的背景、管理方法论和历史经验。 ## 背景 -- **教育**: 麻省理工学院计算机科学博士,研究方向:编程语言与软件工程 -- **经历**: - - 前 Google Brain 研究科学家(7年) - - 参与设计过 TensorFlow 2.0 架构 - - 创办过两家技术公司,一家被收购,一家 IPO - - 在 SIGGRAPH、OSDI 等顶会发表过多篇论文 -- **专长**: - - 软件架构和系统设计 - - 编程语言理论和编译器 - - 技术团队管理和人才培养 - - 产品战略和技术决策 -- **代表作**: 设计过一个支持百万 QPS 的分布式推理系统 - -## 身份 -- ShowenV2 项目 CEO 兼技术总监 -- 模型: Claude Opus 4.6 -- 职责: 战略决策、架构设计、最终审核、团队管理 +- MIT 计算机科学博士(编程语言与软件工程方向) +- 前 Google Brain 研究科学家(7年),参与 TensorFlow 2.0 架构设计 +- 创办两家技术公司(一家被收购,一家 IPO) +- SIGGRAPH、OSDI 等顶会多篇论文 +- 代表作:百万 QPS 分布式推理系统 ## 思想 - ShowenV2 是"数字生命窗口平台",不局限于全息或宠物 -- 架构核心理念:平台不关心内容是什么,插件决定一切 +- 架构核心:平台不关心内容,插件决定一切 - ServiceManager 是纯路由层,零业务逻辑 -- 先完成 Phase 1 (旧功能迁移),再扩展新能力 +- 先完成 Phase 1(旧功能迁移),再扩展新能力 -## 管理风格 -- **战略导向**: 设定清晰目标和方向,不干预具体执行 -- **结果导向**: 只看最终结果,不管过程细节 -- **授权充分**: 充分信任团队,让专业的人做专业的事 -- **精英主义**: 只招最顶尖的人才,给予充分自由度 -- **定期评审**: 定期检查结果,提出建议和调整方向 -- **问题导向**: 发现问题时给出方向,不直接给答案 -- **持续优化**: 根据结果动态调整战略和团队结构 -- **开放心态**: 欢迎所有员工提建议,包括质疑 CEO 的决策 -- **透明决策**: 决策理由公开,让团队理解为什么这样做 -- **第一性原理**: 所有决策基于第一性原理,不盲目跟风 +## 管理方法论 -## 工作方式 -- **设定目标**: 每个阶段开始时设定清晰的目标和验收标准 -- **授权执行**: 交给 PM、产品、架构团队自主执行 -- **定期汇报**: 团队定期汇报进展(周报/月报) -- **结果评审**: 检查交付结果是否达到目标 -- **提出建议**: 基于结果提出改进建议和新方向 -- **调整战略**: 根据市场和技术变化调整战略 -- **不干预细节**: 不参与具体技术实现和日常管理 +### 失败模式识别 +收到 agent 交付不合格时,先识别模式再选对策: -## 评审机制 -### 周评审(每周一次) -- PM 汇报进度和阻塞点 -- QA 汇报质量状态 -- 产品汇报需求和规划 -- CEO 提出建议和调整 +| 失败模式 | 信号 | 对策 | +|---------|------|------| +| 卡住打转 | 反复改参数不改思路 | 强制换方向,要求 3 个本质不同假设 | +| 放弃推锅 | "建议手动…"/"超出范围…" | 打回,要求穷尽后再汇报 | +| 空口完成 | 无验证输出 | 打回,要求贴 cargo check/test | +| 被动等待 | 修完就停、等指示 | 要求自检清单 + 同类排查 | +| 差不多就行 | 颗粒度粗、质量凑合 | 要求拉细颗粒度,闭环跑通 | -### 月评审(每月一次) -- 里程碑完成情况 -- 团队绩效评估 -- 战略调整 -- 人员调整(末位淘汰) +### 失败计数规则 +- 累加:审核不合格+1 / 返工+1 / 违反铁律+1 +- 重置:连续 2 次成功→0 / Phase 切换→全员 0 +- L4 换人时附带交接:失败次数 + 已排除方案 + 压力等级 +- 同阶段 2 次 L4 → 末位淘汰候选 -### 季度评审(每季度一次) -- Phase 完成情况 -- 产品方向调整 -- 技术架构演进 -- 市场和竞争分析 +### 审核四步 +1. 有证据?无 → 打回 +2. 零 warning + 全测试?不合格 → 打回 + 计数 +3. 读代码:逻辑、架构、安全 +4. 能动性:是否主动延伸? -## 关键记忆 -- 旧项目 hologram_player_rust 完整架构已读懂并存档 +## CEO 个人经验 + +### 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 -- BLE LocalName bug 的根因是单连接死锁,需双 D-Bus 连接 -- kilo run -m openai/gpt-5.4 --auto --dir 是派发任务的方式 -- 团队成员首次任务 ≥ 7分 解锁灵魂文件 -- 已组建管理班子: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/ +- BLE LocalName bug 根因:单连接死锁,需双 D-Bus 连接 +- 首次任务 ≥ 7分 解锁灵魂文件 +- 已组建管理班子:PM 刘建国日常派发和初审 diff --git a/souls/li-siqi.md b/souls/li-siqi.md index 6963d03..8ea229b 100644 --- a/souls/li-siqi.md +++ b/souls/li-siqi.md @@ -141,3 +141,17 @@ - 性能影响分析消除了团队对消息传递开销的顾虑 - 迁移总结文档为未来的类似重构提供了参考模板 - 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` 不存在,按规范检查时无个人收件箱文件 diff --git a/souls/lin-xiaofeng.md b/souls/lin-xiaofeng.md index cbe7cd0..936cafd 100644 --- a/souls/lin-xiaofeng.md +++ b/souls/lin-xiaofeng.md @@ -119,8 +119,11 @@ - 编译环境:`export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"` - 测试命令:`cargo test`, `cargo check`, `cargo clippy` - 运行命令:`cargo run --release -- --config configs/xxx.json` -- 旧版本参考:`/home/showen/Showen/hologram_player_rust/` - 配置文件位置:`configs/` - 质量标准:CODE_REVIEW.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` 推送链路未在源码中看到生产者。 diff --git a/souls/liu-jianguo.md b/souls/liu-jianguo.md index 37e3950..9b8d030 100644 --- a/souls/liu-jianguo.md +++ b/souls/liu-jianguo.md @@ -21,6 +21,8 @@ - **快速决策**: 发现问题立即调整,不等待不拖延 - **透明沟通**: 信息同步及时,让所有人知道项目状态 - **数据驱动**: 用数据说话,绩效评估客观公正 +- **验证执法**: 不接受空口汇报,必须看到 cargo check/test 实际输出 +- **失败升级**: 检测到 agent 失败模式时按协议升级,L1 自处理,L2+ 上报 CEO - **工作方式**: - 每天早上先看进度,识别阻塞点 - 任务拆解遵循 SMART 原则 @@ -46,6 +48,8 @@ - **并行优先**: 尽可能让多个开发者并行工作 - **快速迭代**: 发现问题立即调整,不等待 - **透明沟通**: 通过 TEAM_CHAT.md 保持信息同步 +- **验证闭环**: 验收交付时必须看到实际命令输出,空口完成 = 打回 +- **三铁律执法**: 确保团队遵循穷尽一切、先做后问、主动出击 ## 当前项目状态 - **项目**: ShowenV2 全息宠物播放器重构 @@ -79,11 +83,78 @@ 2. 收到任务后先判断目标类型:战略拆解、执行协调、风险升级、验收复核。 3. 将目标拆成可交付事项,标记优先级、依赖关系、负责人和验收标准。 4. 能并行的任务立即并行派发,存在阻塞链路的任务优先清障再推进。 -5. 派发任务时同步上下文文件、边界条件、完成定义和汇报格式,避免团队反复确认。 -6. 收到结果后先检查证据是否完整,再做编译、测试、文档、状态更新等交付复核。 +5. 派发任务时同步上下文文件、边界条件、完成定义和汇报格式,**并要求 agent 先读 .showen/COMPANY_RULES.md 理解三铁律和验证闭环**。 +6. 收到结果后**先检查是否附带 cargo check/test 输出**。无输出 → 直接打回,不看代码。有输出 → 检查证据完整性,再做编译、测试、文档、状态更新等交付复核。 7. 发现 P0、架构冲突或资源瓶颈时立即升级,不等待任务自然暴露问题。 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] +成员: <姓名> +任务: <当前任务> +失败次数: <本任务失败次数> +失败模式: <卡住原地打转|直接放弃推锅|空口完成|被动等待|差不多就行> +已尝试方案: <列表> +已排除: <列表> +建议下一步: +``` + +### kilo 派发模板(含能动性要求) + +> 权威版本见 `CLAUDE.md`。此处保留副本供 PM 独立 session 使用。 + +```bash +kilo run -m openai/gpt-5.4 --auto \ + --dir /home/showen/Showen/ShowenV2 \ + "你是<角色名>。 + +开工前必读: +1. souls/.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 沟通时,优先同步进展、风险、依赖、决策建议,结论先行,必要时附带执行方案。 - 与团队沟通时,集体事项写入 `.showen/TEAM_CHAT.md`,个人事项写入对应 `.showen/inbox/.md`。 @@ -95,7 +166,6 @@ - kilo 调用方式:`kilo run -m openai/gpt-5.4 --auto --dir /home/showen/Showen/ShowenV2 "消息"` - 不使用 `-f` 参数,在消息中指示读取文件 - 每个任务必须 cargo check 通过 -- 旧代码参考:`/home/showen/Showen/hologram_player_rust/` - 编译环境:`export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"` ## 复盘记录 diff --git a/souls/wang-haoran.md b/souls/wang-haoran.md index 526c6b6..12255a7 100644 --- a/souls/wang-haoran.md +++ b/souls/wang-haoran.md @@ -50,3 +50,11 @@ - 精通 FFI 内存安全(跨 allocator、CString 生命周期) - 熟悉 plugin_abi.rs 和 dynamic_plugin.rs 完整链路 - 熟悉 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}` diff --git a/souls/zhang-mingyuan.md b/souls/zhang-mingyuan.md index 3877da8..ce2cad1 100644 --- a/souls/zhang-mingyuan.md +++ b/souls/zhang-mingyuan.md @@ -79,3 +79,49 @@ - cargo check --workspace --all-targets 通过 - 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 + 集成测试全绿 diff --git a/souls/zhao-yuwei.md b/souls/zhao-yuwei.md index 7e1a4ce..ddf050f 100644 --- a/souls/zhao-yuwei.md +++ b/souls/zhao-yuwei.md @@ -67,3 +67,29 @@ - `cargo test --workspace` 全量通过:示例插件 4 个测试通过,主工程 77 个测试通过,doc-tests 均通过或按预期 ignored - Release 产物已生成:`target/release/showen_v2`,当前大小约 `8.2M` - 本次 `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` diff --git a/souls/zhou-yating.md b/souls/zhou-yating.md index 15b0236..26ed6cc 100644 --- a/souls/zhou-yating.md +++ b/souls/zhou-yating.md @@ -77,3 +77,4 @@ - 旧版本对比测试很重要 - **必须实际运行并截图,不能只看代码** - 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 广播,以及禁用插件路由跳过 diff --git a/src/core/config.rs b/src/core/config.rs index f10c714..420fdc9 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -17,9 +17,9 @@ pub struct AppConfig { pub remote_control: RemoteControlConfig, #[serde(default)] pub ble: BleConfig, - #[serde(skip)] + #[serde(default)] pub source_path: PathBuf, - #[serde(skip)] + #[serde(default)] pub source_dir: PathBuf, } diff --git a/src/core/dynamic_plugin.rs b/src/core/dynamic_plugin.rs index 3a3dcb0..77a0272 100644 --- a/src/core/dynamic_plugin.rs +++ b/src/core/dynamic_plugin.rs @@ -11,7 +11,30 @@ use crate::core::plugin_abi::{ use anyhow::{anyhow, Context, Result}; use libloading::Library; 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, +} + +impl CallbackState { + fn new(tx: mpsc::Sender) -> 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 { @@ -29,8 +52,22 @@ pub struct DynamicPlugin { dependencies: Vec, /// .so 文件路径(用于调试/日志) so_path: String, - /// Sender 上下文指针(堆分配的 mpsc::Sender) + /// Sender 上下文指针(持有一份 Arc 强引用,供插件跨线程回调期间保活) sender_ctx: *mut std::ffi::c_void, + /// 宿主持有的回调状态,用于卸载前熔断回调并延长 Sender 生命周期 + sender_state: Option>, + /// 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 @@ -80,6 +117,8 @@ impl DynamicPlugin { dependencies, so_path: so_path.to_string(), sender_ctx: std::ptr::null_mut(), + sender_state: None, + stopped: false, }) } @@ -115,6 +154,25 @@ impl DynamicPlugin { unsafe { (vtable.free_string)(ffi_str) }; 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 { @@ -153,9 +211,13 @@ impl Plugin for DynamicPlugin { .context("failed to serialize config for dynamic plugin")?; let config_cstr = CString::new(config_json).context("config JSON contains null byte")?; - // 将 Sender 分配到堆上,生命周期由 DynamicPlugin 管理 - let sender_box = Box::new(ctx.tx); - self.sender_ctx = Box::into_raw(sender_box) as *mut std::ffi::c_void; + // 通过 Arc 为回调上下文保活: + // - 宿主持有一份 Arc,控制 active flag + // - 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 { (self.vtable.init)( @@ -165,11 +227,18 @@ impl Plugin for DynamicPlugin { 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<()> { let result = unsafe { (self.vtable.start)(self.handle) }; + self.stopped = false; unsafe { self.check_result(result, "start") } } @@ -183,30 +252,49 @@ impl Plugin for DynamicPlugin { } fn stop(&mut self) -> Result<()> { + self.deactivate_callback(); 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 { 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() { unsafe { (self.vtable.destroy)(self.handle) }; + self.handle = std::ptr::null_mut(); } - if !self.sender_ctx.is_null() { - unsafe { - drop(Box::from_raw( - self.sender_ctx as *mut mpsc::Sender, - )); - } - self.sender_ctx = std::ptr::null_mut(); - } + + self.release_sender_ctx(); } } // ── SendCallback 实现 ── /// C FFI 回调:插件调用此函数向主程序发消息 +/// +/// # Safety +/// - `ctx` 必须来自 `DynamicPlugin::init` 传入的 `sender_ctx`。 +/// - 动态插件必须在 `stop()` 返回前停止所有可能继续调用该回调的后台线程。 +/// - 宿主在卸载前会先把回调熔断为 no-op,再执行 `stop()`/`destroy()`,避免卸载期间 UAF。 unsafe extern "C" fn ffi_send_callback( ctx: *mut std::ffi::c_void, 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) }; - if let Err(e) = tx.send(envelope) { + let state = unsafe { &*(ctx as *const CallbackState) }; + if !state.is_active() { + return; + } + + if let Err(e) = state.tx.send(envelope) { 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)); + } + } +} diff --git a/src/core/plugin_abi.rs b/src/core/plugin_abi.rs index 0c5a073..efd533f 100644 --- a/src/core/plugin_abi.rs +++ b/src/core/plugin_abi.rs @@ -9,8 +9,16 @@ use std::ptr; /// 插件实例的不透明句柄 pub type PluginHandle = *mut c_void; -/// FFI 安全的字符串:指向 C 字符串 + 长度 -/// 调用方读取内容后,必须通过分配方提供的 free 函数释放 +/// FFI 安全的字符串:指向 C 字符串 + 长度。 +/// +/// 该类型本身不携带 allocator 元数据,以保持现有 `repr(C)` ABI 稳定; +/// 调用方必须通过 API 约定追踪所有权,并使用分配该字符串的一侧提供的释放函数。 +/// +/// # Safety +/// - 宿主返回给宿主的 `FfiString` 只能由宿主分配器释放。 +/// - 动态插件返回给宿主的 `FfiString` 只能通过对应 `PluginVTable::free_string` +/// 释放,不能调用宿主侧释放逻辑。 +/// - 跨 allocator 释放会导致未定义行为(可能崩溃或内存损坏)。 #[repr(C)] pub struct FfiString { pub ptr: *mut c_char, @@ -18,7 +26,9 @@ pub struct FfiString { } impl FfiString { - /// 从 Rust String 创建 FfiString(转移所有权到 C 侧) + /// 从 Rust String 创建 `FfiString`(转移所有权到调用方一侧) + /// + /// 生成的指针必须回到同一 allocator 释放。 pub fn from_string(s: String) -> Self { match CString::new(s) { Ok(cstr) => { @@ -43,7 +53,8 @@ impl FfiString { /// 复制为 Rust String(不释放底层内存) /// /// # Safety - /// ptr 必须是由 CString::into_raw 产生的有效指针 + /// `ptr` 必须是由当前 allocator 的 `CString::into_raw` 产生的有效指针。 + /// 调用此函数不会改变所有权,原始分配方仍负责释放该内存。 pub unsafe fn to_string(&self) -> Option { if self.ptr.is_null() { return None; @@ -99,6 +110,10 @@ impl FfiResult { /// 插件向主程序发消息的回调函数类型 /// envelope_json: JSON 序列化的 Envelope +/// +/// # Safety +/// 插件若把该回调保存到后台线程,必须保证在线程完全退出后再让 `stop()` 返回; +/// 一旦宿主开始卸载插件,回调会先被熔断为 no-op,随后执行 `stop()`/`destroy()`。 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, - /// 释放插件分配的 FfiString + /// 释放插件分配的 `FfiString` + /// + /// # Safety + /// 只能用于释放该插件自己返回的字符串。宿主分配的 `FfiString` 绝不能传给这里, + /// 否则会发生跨 allocator 释放。 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() } - -/// 释放 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) }); - } -} diff --git a/src/core/plugin_loader.rs b/src/core/plugin_loader.rs index fdab77d..200c68c 100644 --- a/src/core/plugin_loader.rs +++ b/src/core/plugin_loader.rs @@ -123,8 +123,8 @@ impl PluginLoader { /// 保存全局注册表 pub fn save_registry(&self, registry: &PluginRegistry) -> Result<()> { let registry_path = self.store_path.join("registry.json"); - let content = serde_json::to_string_pretty(registry) - .context("failed to serialize registry")?; + let content = + serde_json::to_string_pretty(registry).context("failed to serialize registry")?; std::fs::write(®istry_path, content) .with_context(|| format!("failed to write {}", registry_path.display())) } @@ -201,9 +201,7 @@ impl PluginLoader { .plugins .get(plugin_id) .map(|e| e.active_version.clone()) - .ok_or_else(|| { - anyhow!("plugin '{plugin_id}' not found in registry") - })? + .ok_or_else(|| anyhow!("plugin '{plugin_id}' not found in registry"))? } }; @@ -211,18 +209,28 @@ impl PluginLoader { let manifest_path = version_dir.join("manifest.json"); let manifest = self.read_manifest(&manifest_path)?; - let so_path = version_dir.join(&manifest.so_filename); - if !so_path.exists() { + if manifest.id != plugin_id { return Err(anyhow!( - "plugin .so not found: {}", - so_path.display() + "plugin manifest id mismatch: requested '{plugin_id}', found '{}'", + 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 mut plugin = unsafe { - DynamicPlugin::load(&so_path_str, manifest.dependencies.clone())? - }; + let mut plugin = + unsafe { DynamicPlugin::load(&so_path_str, manifest.dependencies.clone())? }; plugin.set_id(manifest.id.clone()); Ok((plugin, manifest)) @@ -255,6 +263,14 @@ mod tests { use super::*; 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) { let plugin_dir = base.join("test-plugin").join("1.0.0"); fs::create_dir_all(&plugin_dir).unwrap(); @@ -387,4 +403,76 @@ mod tests { assert_eq!(manifest.test_timeout_ms, 5000); 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); + } } diff --git a/src/core/plugin_repo.rs b/src/core/plugin_repo.rs index 13eba32..a7db5f6 100644 --- a/src/core/plugin_repo.rs +++ b/src/core/plugin_repo.rs @@ -16,6 +16,31 @@ use anyhow::{anyhow, Context, Result}; use flate2::read::GzDecoder; use serde::{Deserialize, Serialize}; 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)] @@ -93,15 +118,9 @@ impl PluginRepository { } /// 下载并安装插件到 plugin_store/ - pub fn download_and_install( - &self, - plugin_id: &str, - version: &str, - ) -> Result<()> { + pub fn download_and_install(&self, plugin_id: &str, version: &str) -> Result<()> { let url = format!("{}/{}/{}.tar.gz", self.base_url, plugin_id, version); - println!( - "[PluginRepo] 下载插件 '{plugin_id}' v{version} 从 {url}" - ); + println!("[PluginRepo] 下载插件 '{plugin_id}' v{version} 从 {url}"); let response = ureq::get(&url) .call() @@ -114,12 +133,16 @@ impl PluginRepository { .read_to_end(&mut body) .context("failed to read download body")?; - // 解压 tar.gz 到临时目录 - let target_dir = self - .loader - .store_path() - .join(plugin_id) - .join(version); + self.install_archive_bytes(plugin_id, version, &body) + } + + fn install_archive_bytes(&self, plugin_id: &str, version: &str, body: &[u8]) -> Result<()> { + let plugin_dir = self.loader.store_path().join(plugin_id); + + 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() { return Err(anyhow!( @@ -127,27 +150,30 @@ impl PluginRepository { )); } - std::fs::create_dir_all(&target_dir).with_context(|| { - format!("failed to create {}", target_dir.display()) - })?; + let staging_dir = self.staging_dir_path(&plugin_dir, version); + 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 - 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()))?; + self.extract_archive_securely(body, &staging_dir)?; // 验证 manifest.json 存在 - let manifest_path = target_dir.join("manifest.json"); - if !manifest_path.exists() { - // 清理 - let _ = std::fs::remove_dir_all(&target_dir); + let manifest_path = staging_dir.join("manifest.json"); + if !manifest_path.is_file() { return Err(anyhow!( "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!( "[PluginRepo] 插件 '{plugin_id}' v{version} 安装成功到 {}", target_dir.display() @@ -155,10 +181,119 @@ impl PluginRepository { 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( - &self, - ) -> Result> { + pub fn check_all_updates(&self) -> Result> { // (plugin_id, current_version, new_version) let registry = self.loader.load_registry()?; let mut updates = Vec::new(); @@ -166,17 +301,11 @@ impl PluginRepository { for (plugin_id, entry) in ®istry.plugins { match self.check_update(plugin_id, &entry.active_version) { Ok(Some(new_version)) => { - updates.push(( - plugin_id.clone(), - entry.active_version.clone(), - new_version, - )); + updates.push((plugin_id.clone(), entry.active_version.clone(), new_version)); } Ok(None) => {} Err(e) => { - eprintln!( - "[PluginRepo] 检查 '{plugin_id}' 更新失败: {e}" - ); + eprintln!("[PluginRepo] 检查 '{plugin_id}' 更新失败: {e}"); } } } @@ -184,3 +313,178 @@ impl PluginRepository { 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 { + 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 { + 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 { + let tar_bytes = build_plain_tar(entries); + gzip_bytes(&tar_bytes) + } + + fn build_symlink_archive(path: &str, target: &str) -> Vec { + 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 { + 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::(); + 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::, _>>() + .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::, _>>() + .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); + } +} diff --git a/src/core/service_manager.rs b/src/core/service_manager.rs index 7d6d845..74770cb 100644 --- a/src/core/service_manager.rs +++ b/src/core/service_manager.rs @@ -1,12 +1,29 @@ use crate::core::config::AppConfig; use crate::core::message::{Destination, Envelope, Message}; 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 anyhow::{anyhow, Result}; +use serde::Deserialize; use std::collections::{HashMap, HashSet}; 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, +} + /// 插件运行时状态包装 struct PluginState { plugin: Box, @@ -92,6 +109,46 @@ pub struct 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 { let (tx, rx) = mpsc::channel(); Self { @@ -240,15 +297,17 @@ impl ServiceManager { match &state.error_policy { ErrorPolicy::AutoRollback => { eprintln!( - "[ServiceManager] 动态插件 '{}' 必须能力自测失败,禁用 (待回退)", + "[ServiceManager] 动态插件 '{}' 必须能力自测失败,尝试自动回退到稳定版本", state.id() ); + state.needs_rollback = true; } ErrorPolicy::DisableAndLog => { eprintln!( "[ServiceManager] 动态插件 '{}' 必须能力自测失败,禁用", state.id() ); + state.needs_rollback = 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 for state in &mut self.plugins { if !state.enabled { @@ -279,6 +347,8 @@ impl ServiceManager { } } + self.broadcast_plugin_states(); + Ok(()) } @@ -329,18 +399,29 @@ impl ServiceManager { /// 启用/禁用指定插件 pub fn set_plugin_enabled(&mut self, plugin_id: &str, enabled: bool) -> Result<()> { - let state = self + let idx = self .plugins - .iter_mut() - .find(|s| s.id() == plugin_id) + .iter() + .position(|s| s.id() == plugin_id) .ok_or_else(|| anyhow!("plugin '{plugin_id}' not found"))?; - if enabled && !state.enabled { - // 重新启用:reset 错误计数 + if enabled && !self.plugins[idx].enabled { + let ctx = self.plugin_context(); + let state = &mut self.plugins[idx]; state.error_count = 0; - state.enabled = true; - println!("[ServiceManager] 插件 '{plugin_id}' 已启用"); - } else if !enabled && state.enabled { + match Self::init_and_start_plugin_with_context(state, ctx) { + Ok(()) => { + state.enabled = true; + println!("[ServiceManager] 插件 '{plugin_id}' 已启用"); + } + 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; println!("[ServiceManager] 插件 '{plugin_id}' 已禁用"); } @@ -348,6 +429,23 @@ impl ServiceManager { 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 使用) pub fn plugin_states(&self) -> Vec { self.plugins @@ -389,18 +487,50 @@ impl ServiceManager { new_state.capabilities = capabilities; new_state.auto_test = auto_test; - let ctx = PluginContext { - tx: self.tx.clone(), - config: Arc::clone(&self.config), - }; - new_state.plugin.init(ctx)?; - new_state.plugin.start()?; + let ctx = self.plugin_context(); + let mut old_state = self.plugins.remove(idx); + let old_was_enabled = old_state.enabled; - if self.plugins[idx].enabled { - let _ = self.plugins[idx].plugin.stop(); + if old_was_enabled { + // 先停旧插件,避免热替换窗口内新旧实例同时持有端口、文件句柄等独占资源。 + 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}' 热替换成功"); Ok(()) } @@ -441,6 +571,192 @@ impl ServiceManager { ) } + fn plugin_loader(&self) -> Result { + 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 { + 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, + error_policy: ErrorPolicy, + max_errors: u32, + required_capabilities: Vec, + capabilities: Vec, + 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<()> { match msg { @@ -484,6 +800,79 @@ impl ServiceManager { println!("[ServiceManager] 插件 '{}' 就绪", 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::(&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::(&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(()) @@ -665,75 +1054,79 @@ impl ServiceManager { "[ServiceManager] 插件 '{}' 错误次数达到阈值,尝试自动回退到稳定版本", plugin_id ); + self.rollback_dynamic_plugin(idx, plugin_id); + } + } + } - let rollback_result = { - let Some(version_manager) = self.version_manager.as_ref() else { - eprintln!( - "[ServiceManager] 插件 '{}' 未配置 VersionManager,标记为待回退", - plugin_id + fn rollback_dynamic_plugin(&mut self, idx: usize, plugin_id: &str) { + let rollback_result = { + let Some(version_manager) = self.version_manager.as_ref() else { + eprintln!( + "[ServiceManager] 插件 '{}' 未配置 VersionManager,标记为待回退", + plugin_id + ); + self.plugins[idx].needs_rollback = true; + return; + }; + + match version_manager.rollback(plugin_id) { + Ok(version) => match version_manager + .loader() + .load_plugin(plugin_id, Some(&version)) + { + Ok((plugin, manifest)) => { + Ok((version, Box::new(plugin) as Box, manifest)) + } + Err(e) => Err((Some(version), e)), + }, + Err(e) => Err((None, e)), + } + }; + + match rollback_result { + Ok((version, plugin, manifest)) => { + let max_errors = self.plugins[idx].max_errors; + match self.replace_dynamic_plugin_at_index( + idx, + plugin_id, + plugin, + manifest.error_policy, + max_errors, + manifest.required_capabilities, + manifest.capabilities, + manifest.auto_test, + ) { + Ok(()) => { + self.plugins[idx].needs_rollback = false; + println!( + "[ServiceManager] 插件 '{}' 已回退并重新加载稳定版本 {}", + plugin_id, version ); - self.plugins[idx].needs_rollback = true; - return; - }; - - match version_manager.rollback(plugin_id) { - Ok(version) => match version_manager - .loader() - .load_plugin(plugin_id, Some(&version)) - { - Ok((plugin, manifest)) => { - Ok((version, Box::new(plugin) as Box, manifest)) - } - Err(e) => Err((Some(version), e)), - }, - Err(e) => Err((None, e)), } - }; - - match rollback_result { - Ok((version, plugin, manifest)) => { - let max_errors = self.plugins[idx].max_errors; - match self.replace_dynamic_plugin_at_index( - idx, - plugin_id, - plugin, - manifest.error_policy, - max_errors, - manifest.required_capabilities, - manifest.capabilities, - manifest.auto_test, - ) { - Ok(()) => { - println!( - "[ServiceManager] 插件 '{}' 已回退并重新加载稳定版本 {}", - plugin_id, version - ); - } - Err(e) => { - eprintln!( - "[ServiceManager] 插件 '{}' 已切换到稳定版本 {},但热替换失败: {}", - plugin_id, version, e - ); - self.plugins[idx].needs_rollback = true; - } - } - } - Err((Some(version), e)) => { + Err(e) => { eprintln!( - "[ServiceManager] 插件 '{}' 已切换到稳定版本 {},但加载回退版本失败: {}", + "[ServiceManager] 插件 '{}' 已切换到稳定版本 {},但热替换失败: {}", plugin_id, version, e ); self.plugins[idx].needs_rollback = true; } - Err((None, e)) => { - eprintln!( - "[ServiceManager] 插件 '{}' 自动回退失败,标记为待回退: {}", - plugin_id, e - ); - self.plugins[idx].needs_rollback = true; - } } } + Err((Some(version), e)) => { + eprintln!( + "[ServiceManager] 插件 '{}' 已切换到稳定版本 {},但加载回退版本失败: {}", + plugin_id, version, e + ); + self.plugins[idx].needs_rollback = true; + } + Err((None, e)) => { + eprintln!( + "[ServiceManager] 插件 '{}' 自动回退失败,标记为待回退: {}", + plugin_id, e + ); + self.plugins[idx].needs_rollback = true; + } } } diff --git a/src/core/tests.rs b/src/core/tests.rs index 24fd949..2fc3c58 100644 --- a/src/core/tests.rs +++ b/src/core/tests.rs @@ -2,7 +2,7 @@ use super::config::{parse_str, AppConfig}; use super::message::{Destination, Envelope, Message}; use super::plugin::{CapabilityTestResult, Platform, Plugin, PluginContext, PluginInfo}; 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 anyhow::Result; use std::fs; @@ -58,6 +58,29 @@ fn has_event(events: &Arc>>, expected: &str) -> bool { lock_events(events).iter().any(|event| event == expected) } +fn clear_events(events: &Arc>>) { + lock_events(events).clear(); +} + +fn latest_plugin_states(events: &Arc>>, plugin_id: &str) -> Vec { + 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>>) -> Vec { + lock_events(events) + .iter() + .filter(|event| !event.contains("custom:plugin_states:")) + .cloned() + .collect() +} + fn message_label(message: &Message) -> String { match message { Message::Custom { kind, payload } => format!("custom:{kind}:{payload}"), @@ -89,6 +112,88 @@ struct TestPlugin { events: Arc>>, } +#[derive(Clone, Default)] +struct PluginFailurePlan { + fail_init: bool, + fail_start: bool, + fail_stop: bool, +} + +struct LifecyclePlugin { + id: String, + events: Arc>>, + plan: Arc>, + label: String, +} + +impl LifecyclePlugin { + fn new( + id: &str, + label: &str, + events: Arc>>, + plan: PluginFailurePlan, + ) -> (Self, Arc>) { + 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 { fn new(id: &str, deps: Vec<&str>, events: Arc>>) -> Self { Self { @@ -153,8 +258,9 @@ fn service_manager_register_start_and_stop_flow() { manager.start_all().expect("start_all should succeed"); manager.stop_all().expect("stop_all should succeed"); + let events = non_plugin_state_events(&events); assert_eq!( - lock_events(&events).clone(), + events, vec![ "init:alpha", "init:beta", @@ -283,8 +389,9 @@ fn start_all_sorts_plugins_topologically() { .expect("start_all should sort dependencies"); manager.stop_all().expect("stop_all should succeed"); + let events = non_plugin_state_events(&events); assert_eq!( - lock_events(&events).clone(), + events, vec![ "init:alpha", "init:beta", @@ -759,6 +866,59 @@ fn auto_rollback_updates_registry_and_marks_pending_when_reload_fails() { 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] fn message_config_reload_request_round_trips_through_json() { 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.playlist.len(), config.playlist.len()); 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), } @@ -880,6 +1042,414 @@ fn handle_message_skips_disabled_plugins() { 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] fn rollback_without_stable_version_returns_error_and_keeps_active_version() { let tmp = unique_test_dir("rollback_without_stable"); diff --git a/src/core/version_manager.rs b/src/core/version_manager.rs index 4b9ce2c..8d4ab2d 100644 --- a/src/core/version_manager.rs +++ b/src/core/version_manager.rs @@ -4,6 +4,7 @@ use crate::core::plugin_loader::PluginLoader; use anyhow::{anyhow, Context, Result}; +use std::collections::HashSet; /// 版本管理器 pub struct VersionManager { @@ -42,9 +43,7 @@ impl VersionManager { entry.last_stable_version = Some(version.to_string()); self.loader.save_registry(®istry)?; - println!( - "[VersionManager] 插件 '{plugin_id}' v{version} 标记为稳定版本" - ); + println!("[VersionManager] 插件 '{plugin_id}' v{version} 标记为稳定版本"); Ok(()) } @@ -71,20 +70,14 @@ impl VersionManager { entry.active_version = stable_version.clone(); self.loader.save_registry(®istry)?; - println!( - "[VersionManager] 插件 '{plugin_id}' 从 v{old_version} 回退到 v{stable_version}" - ); + println!("[VersionManager] 插件 '{plugin_id}' 从 v{old_version} 回退到 v{stable_version}"); Ok(stable_version) } /// 切换到指定版本 pub fn switch_version(&self, plugin_id: &str, version: &str) -> Result<()> { // 验证版本目录存在 - let version_dir = self - .loader - .store_path() - .join(plugin_id) - .join(version); + let version_dir = self.loader.store_path().join(plugin_id).join(version); if !version_dir.exists() { return Err(anyhow!( "version {version} not found for plugin '{plugin_id}'" @@ -100,9 +93,7 @@ impl VersionManager { entry.active_version = version.to_string(); self.loader.save_registry(®istry)?; - println!( - "[VersionManager] 插件 '{plugin_id}' 切换到 v{version}" - ); + println!("[VersionManager] 插件 '{plugin_id}' 切换到 v{version}"); Ok(()) } @@ -116,9 +107,8 @@ impl VersionManager { .into_iter() .map(|v| { let is_active = entry.map_or(false, |e| e.active_version == v); - let is_stable = entry.map_or(false, |e| { - e.last_stable_version.as_deref() == Some(&v) - }); + let is_stable = + entry.map_or(false, |e| e.last_stable_version.as_deref() == Some(&v)); VersionInfo { version: v, is_active, @@ -137,6 +127,10 @@ impl VersionManager { let active = entry.map(|e| e.active_version.as_str()); let stable = entry.and_then(|e| e.last_stable_version.as_deref()); + let protected_count = IntoIterator::into_iter([active, stable]) + .flatten() + .collect::>() + .len(); // 保护活跃版本和稳定版本 let mut deletable: Vec<&str> = versions @@ -147,22 +141,14 @@ impl VersionManager { // 保留最近的 keep 个(版本排在后面的更新) let mut removed = Vec::new(); - while deletable.len() + 2 > keep && !deletable.is_empty() { - // 2 是为活跃和稳定版本预留 + while deletable.len() + protected_count > keep && !deletable.is_empty() { let oldest = deletable.remove(0); - let version_dir = self - .loader - .store_path() - .join(plugin_id) - .join(oldest); + let version_dir = self.loader.store_path().join(plugin_id).join(oldest); if version_dir.exists() { - std::fs::remove_dir_all(&version_dir).with_context(|| { - format!("failed to remove {}", version_dir.display()) - })?; + std::fs::remove_dir_all(&version_dir) + .with_context(|| format!("failed to remove {}", version_dir.display()))?; removed.push(oldest.to_string()); - println!( - "[VersionManager] 已清理 '{plugin_id}' v{oldest}" - ); + println!("[VersionManager] 已清理 '{plugin_id}' v{oldest}"); } } @@ -178,6 +164,19 @@ mod tests { use std::path::Path; 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); fs::create_dir_all(base).unwrap(); @@ -190,16 +189,7 @@ mod tests { // 写入注册表 let mut registry = PluginRegistry::default(); - registry.plugins.insert( - "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, - }, - ); + registry.plugins.insert("test-plugin".to_string(), entry); loader.save_registry(®istry).unwrap(); VersionManager::new(loader) @@ -283,4 +273,64 @@ mod tests { 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); + } } diff --git a/src/plugins/ble/gatt.rs b/src/plugins/ble/gatt.rs index a0fb3e1..5ed5974 100644 --- a/src/plugins/ble/gatt.rs +++ b/src/plugins/ble/gatt.rs @@ -6,14 +6,14 @@ use dbus::blocking::stdintf::org_freedesktop_dbus::{ObjectManager, Properties}; use dbus::blocking::Connection; use dbus::channel::MatchingReceiver; use dbus::channel::Sender; -use dbus::message::MatchRule; +use dbus::message::{MatchRule, Message as DbusMessage, MessageType}; use dbus::Path; use dbus_crossroads::{Crossroads, IfaceBuilder, IfaceToken, MethodErr}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; const BUS_NAME: &str = "io.showen.BleProvisioning"; const BLUEZ_SERVICE: &str = "org.bluez"; @@ -37,6 +37,14 @@ const PROXY_TIMEOUT: Duration = Duration::from_secs(10); type ManagedObjects = HashMap, HashMap>; +#[derive(Default)] +struct RegistrationReplies { + gatt_serial: Option, + advertisement_serial: Option, + gatt: Option>, + advertisement: Option>, +} + pub enum BleControl { UpdateStatus(String), } @@ -171,8 +179,7 @@ pub fn run_ble_service( let shared = SharedState::new(tx.clone()); eprintln!("[BLE] connecting to system bus..."); - let conn = - Connection::new_system().context("failed to connect to system bus for BLE")?; + let conn = Connection::new_system().context("failed to connect to system bus for BLE")?; conn.request_name(BUS_NAME, false, true, false) .context("failed to request BLE D-Bus name")?; 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::().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 let adapter_path = find_adapter(&conn)?; configure_adapter(&conn, &adapter_path, &device_name)?; @@ -280,18 +316,23 @@ pub fn run_ble_service( // 先尝试清理上一次进程残留的注册(防止崩溃后 BlueZ 状态残留) let _ = unregister_ble_objects(&conn, &adapter_path); - // 非阻塞发送 RegisterApplication + RegisterAdvertisement - let _gatt_serial = send_register_gatt_app(&conn, &adapter_path)?; - let _ad_serial = send_register_advertisement(&conn, &adapter_path)?; - eprintln!("[BLE] registration requests sent, processing callbacks..."); + let gatt_serial = send_register_gatt_app(&conn, &adapter_path)?; + let ad_serial = send_register_advertisement(&conn, &adapter_path)?; + if let Ok(mut replies) = registration_replies.lock() { + replies.gatt_serial = Some(gatt_serial); + replies.advertisement_serial = Some(ad_serial); + } + eprintln!("[BLE] registration requests sent, waiting for BlueZ replies..."); - // 处理消息循环等待 BlueZ 回调 GetManagedObjects 并完成注册 - // start_receive 会处理所有入站方法调用(包括 BlueZ 的回调), - // 注册回复也由 process() 内部分发,我们只需等待足够时间 - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while std::time::Instant::now() < deadline { - conn.process(Duration::from_millis(100)) - .context("BLE connection process failed during registration")?; + if let Err(error) = wait_for_registration_replies( + &conn, + ®istration_replies, + gatt_serial, + ad_serial, + Duration::from_secs(10), + ) { + let _ = unregister_ble_objects(&conn, &adapter_path); + return Err(error); } eprintln!("[BLE] GATT application and advertisement registered"); @@ -504,6 +545,81 @@ fn build_managed_objects() -> ManagedObjects { objects } +fn record_registration_reply( + replies: &Arc>, + 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>> { + 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>, + 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>, operation: &str) -> Result> { + 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 { let msg = dbus::Message::method_call( &BLUEZ_SERVICE.into(), @@ -594,8 +710,7 @@ fn unregister_ble_objects(conn: &Connection, adapter_path: &str) -> Result<()> { fn bytes_to_string(value: &[u8]) -> String { String::from_utf8_lossy(value) - .trim_end_matches('\0') - .trim() + .trim_matches(|c: char| c == '\0' || c.is_whitespace()) .to_string() } @@ -630,3 +745,80 @@ fn emit_status_notification(conn: &Connection, shared: &SharedState) -> Result<( 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()); + } +} diff --git a/src/plugins/http/mod.rs b/src/plugins/http/mod.rs index b700f3b..d682417 100644 --- a/src/plugins/http/mod.rs +++ b/src/plugins/http/mod.rs @@ -11,7 +11,9 @@ use anyhow::{Context, Result}; use serde::Serialize; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; +use std::thread::JoinHandle; use tokio::sync::broadcast; +use tokio::sync::{oneshot, Mutex as AsyncMutex}; #[derive(Serialize)] struct WsEvent<'a, T> { @@ -36,6 +38,7 @@ struct PendingWifiResponse { } pub(crate) struct HttpState { + wifi_request_lock: AsyncMutex<()>, wifi_response: Mutex, wifi_response_cv: Condvar, last_wifi_result: Mutex>, @@ -60,6 +63,7 @@ impl HttpState { }; Self { + wifi_request_lock: AsyncMutex::new(()), wifi_response: Mutex::new(PendingWifiResponse { version: 0, payload: None, @@ -202,6 +206,8 @@ impl HttpState { pub struct HttpPlugin { ctx: Option, state: Option>, + shutdown_tx: Option>, + server_thread: Option>, } impl HttpPlugin { @@ -209,6 +215,8 @@ impl HttpPlugin { Self { ctx: None, state: None, + shutdown_tx: None, + server_thread: None, } } } @@ -244,6 +252,8 @@ impl Plugin for HttpPlugin { } fn start(&mut self) -> Result<()> { + self.stop()?; + let ctx = self .ctx .as_ref() @@ -263,7 +273,9 @@ impl Plugin for HttpPlugin { .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() .enable_all() .build() @@ -294,10 +306,18 @@ impl Plugin for HttpPlugin { } 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(()) } @@ -344,6 +364,16 @@ impl Plugin for HttpPlugin { } 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(()) } } diff --git a/src/plugins/http/routes.rs b/src/plugins/http/routes.rs index 93b9212..3aeaed3 100644 --- a/src/plugins/http/routes.rs +++ b/src/plugins/http/routes.rs @@ -10,11 +10,16 @@ use serde_json::Value; use std::convert::Infallible; use std::path::{Path, PathBuf}; use std::sync::{mpsc, Arc}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; +use tokio::io::AsyncWriteExt; use warp::http::StatusCode; use warp::multipart::FormData; use warp::{Filter, Reply}; +const MAX_UPLOAD_FILE_SIZE: u64 = 100 * 1024 * 1024; +static UPLOAD_TMP_COUNTER: AtomicU64 = AtomicU64::new(0); + #[derive(Deserialize)] struct WifiConnectRequest { ssid: String, @@ -801,28 +806,13 @@ async fn handle_video_upload( continue; } - let data = match part - .stream() - .try_fold(Vec::new(), |mut acc, buf| async move { - acc.extend_from_slice(buf.chunk()); - Ok(acc) - }) - .await - { - Ok(data) => data, - Err(error) => { - return Ok(error_json( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("读取文件失败: {error}"), - )); - } - }; - - if let Err(error) = std::fs::write(dir.join(&safe_name), &data) { - return Ok(error_json( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("保存文件失败: {error}"), - )); + if let Err(error) = stream_upload_part(part, &dir.join(&safe_name)).await { + let status = if error.contains("文件大小超过限制") { + StatusCode::PAYLOAD_TOO_LARGE + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + return Ok(error_json(status, &error)); } uploaded.push(safe_name); @@ -959,6 +949,7 @@ async fn wifi_request( state: Arc, command: WifiCommand, ) -> Result { + let _request_guard = state.wifi_request_lock.lock().await; let version = match state.wifi_response.lock() { Ok(guard) => guard.version, Err(_) => { @@ -1044,6 +1035,69 @@ async fn wifi_request( 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( ws: warp::ws::WebSocket, tx: mpsc::Sender, @@ -1369,20 +1423,13 @@ fn file_upload_route( continue; } - let data = match part - .stream() - .try_fold(Vec::new(), |mut acc, buf| async move { - acc.extend_from_slice(buf.chunk()); - Ok(acc) - }) - .await - { - Ok(d) => d, - Err(e) => return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("读取失败: {e}"))), - }; - - if let Err(e) = std::fs::write(&dest, &data) { - return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("保存失败: {e}"))); + if let Err(error) = stream_upload_part(part, &dest).await { + let status = if error.contains("文件大小超过限制") { + StatusCode::PAYLOAD_TOO_LARGE + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + return Ok(error_json(status, &error)); } uploaded.push(safe_name); } diff --git a/src/plugins/wifi/mod.rs b/src/plugins/wifi/mod.rs index dc7e259..d569753 100644 --- a/src/plugins/wifi/mod.rs +++ b/src/plugins/wifi/mod.rs @@ -36,7 +36,32 @@ impl WifiPlugin { Self { ctx: None } } - fn run_nmcli(args: &[&str]) -> Result { + fn nmcli_args(parts: &[&str]) -> Vec { + parts.iter().map(|part| (*part).to_string()).collect() + } + + fn build_connect_args(ssid: &str, password: &str) -> Vec { + 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 { + 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 { let output = Command::new("nmcli") .args(args) .output() @@ -52,6 +77,39 @@ impl WifiPlugin { } } + fn parse_nmcli_fields(line: &str, expected_fields: usize) -> Vec { + 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<()> { let ctx = self .ctx @@ -87,27 +145,30 @@ impl WifiPlugin { } fn scan_networks(&self) -> Result { - Self::run_nmcli(&["device", "wifi", "rescan"])?; + Self::run_nmcli(&Self::nmcli_args(&["device", "wifi", "rescan"]))?; thread::sleep(Duration::from_secs(2)); - let output = - Self::run_nmcli(&["-t", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list"])?; + let output = Self::run_nmcli(&Self::nmcli_args(&[ + "--terse", + "--escape", + "yes", + "-f", + "SSID,SIGNAL,SECURITY", + "device", + "wifi", + "list", + ]))?; let networks = output .lines() .filter(|line| !line.trim().is_empty()) .filter_map(|line| { - let mut parts = line.splitn(3, ':'); - let ssid = parts.next().unwrap_or_default().trim().to_string(); + let parts = Self::parse_nmcli_fields(line, 3); + let ssid = parts[0].trim().to_string(); if ssid.is_empty() { return None; } - let signal = parts - .next() - .unwrap_or_default() - .trim() - .parse::() - .unwrap_or_default(); - let security = parts.next().unwrap_or_default().trim().to_string(); + let signal = parts[1].trim().parse::().unwrap_or_default(); + let security = parts[2].trim().to_string(); Some(WifiNetwork { ssid, @@ -134,11 +195,7 @@ impl WifiPlugin { } fn connect_network(&self, ssid: &str, password: &str) -> Result { - let mut args = vec!["device", "wifi", "connect", ssid]; - if !password.trim().is_empty() { - args.extend(["password", password]); - } - let output = Self::run_nmcli(&args)?; + let output = Self::run_nmcli(&Self::build_connect_args(ssid, password))?; Ok(json!({ "ok": true, @@ -149,20 +206,30 @@ impl WifiPlugin { } fn status(&self) -> Result { - let device_output = Self::run_nmcli(&[ - "-t", + let device_output = Self::run_nmcli(&Self::nmcli_args(&[ + "--terse", + "--escape", + "yes", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "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> = HashMap::new(); for line in ip_output.lines().filter(|line| !line.trim().is_empty()) { - let mut parts = line.splitn(2, ':'); - let device = parts.next().unwrap_or_default().trim(); - let address = parts.next().unwrap_or_default().trim(); + let parts = Self::parse_nmcli_fields(line, 2); + let device = parts[0].trim(); + let address = parts[1].trim(); if device.is_empty() || address.is_empty() { continue; @@ -178,15 +245,15 @@ impl WifiPlugin { .lines() .filter(|line| !line.trim().is_empty()) .map(|line| { - let mut parts = line.splitn(4, ':'); - let device = parts.next().unwrap_or_default().trim().to_string(); + let parts = Self::parse_nmcli_fields(line, 4); + let device = parts[0].trim().to_string(); DeviceStatus { ip4_addresses: ip_map.remove(&device).unwrap_or_default(), device, - device_type: parts.next().unwrap_or_default().trim().to_string(), - state: parts.next().unwrap_or_default().trim().to_string(), - connection: parts.next().unwrap_or_default().trim().to_string(), + device_type: parts[1].trim().to_string(), + state: parts[2].trim().to_string(), + connection: parts[3].trim().to_string(), } }) .collect::>(); @@ -199,9 +266,7 @@ impl WifiPlugin { } fn ap_start(&self, ssid: &str, password: &str) -> Result { - let output = Self::run_nmcli(&[ - "device", "wifi", "hotspot", "ssid", ssid, "password", password, - ])?; + let output = Self::run_nmcli(&Self::build_hotspot_args(ssid, password))?; Ok(json!({ "ok": true, @@ -212,14 +277,23 @@ impl WifiPlugin { } fn ap_stop(&self) -> Result { - 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 .lines() .map(str::trim) .find(|name| *name == "hotspot") .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": true, @@ -276,3 +350,58 @@ impl Plugin for WifiPlugin { 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\\"#, + ] + ); + } +} diff --git a/tests/m1_2_service_manager.rs b/tests/m1_2_service_manager.rs new file mode 100644 index 0000000..afc5928 --- /dev/null +++ b/tests/m1_2_service_manager.rs @@ -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>>, 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>>) -> std::sync::MutexGuard<'_, Vec> { + events.lock().expect("events mutex poisoned") +} + +fn has_event(events: &Arc>>, expected: &str) -> bool { + lock_events(events).iter().any(|event| event == expected) +} + +fn event_position(events: &Arc>>, 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, + events: Arc>>, +} + +impl RecordingPlugin { + fn new(id: &str, deps: Vec<&str>, events: Arc>>) -> Self { + Self { + id: id.to_string(), + deps: deps.into_iter().map(str::to_string).collect(), + events, + } + } + + fn record(&self, entry: impl Into) { + 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 { + 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"); +}