diff --git a/Cargo.lock b/Cargo.lock index c9e72bf..2211fec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -29,6 +35,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.0" @@ -119,6 +131,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -230,12 +251,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -374,7 +416,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "headers-core", "http 0.2.12", @@ -613,6 +655,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -653,6 +713,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -798,6 +868,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "potential_utf" version = "0.1.4" @@ -870,6 +946,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -899,6 +984,68 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "ryu" version = "1.0.23" @@ -989,6 +1136,22 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "showen-example-plugin" +version = "0.1.0" +dependencies = [ + "serde_json", + "showen-plugin-sdk", +] + +[[package]] +name = "showen-plugin-sdk" +version = "0.2.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "showen_v2" version = "0.2.0" @@ -998,12 +1161,17 @@ dependencies = [ "ctrlc", "dbus", "dbus-crossroads", + "flate2", "futures-util", + "libloading", "opencv", "rand", + "semver", "serde", "serde_json", + "tar", "tokio", + "ureq", "warp", ] @@ -1017,6 +1185,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.12" @@ -1061,6 +1235,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -1083,6 +1263,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1234,6 +1425,28 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -1323,6 +1536,24 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1432,6 +1663,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" @@ -1496,6 +1737,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index b20698d..fefd55b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = [".", "plugin-sdk", "plugins/example-plugin"] + [package] name = "showen_v2" version = "0.2.0" @@ -21,3 +24,12 @@ futures-util = "0.3" # Linux 特有插件依赖 dbus = "0.9" dbus-crossroads = "0.5" + +# 动态插件加载 +libloading = "0.8" + +# 远程插件仓库 +ureq = "2" +flate2 = "1" +tar = "0.4" +semver = "1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a99de0 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# ShowenV2 — 数字生命窗口平台 + +基于 Rust 的跨平台插件微内核架构,支持全息/VR/AR/屏幕显示,承载虚拟宠物、数字人、AI 歌姬、3D 模型等数字生命内容。 + +## 架构概览 + +``` +ShowenV2/ +├── src/ +│ ├── core/ # 插件微内核 +│ │ ├── message.rs # 类型安全消息协议 (Serialize/Deserialize) +│ │ ├── plugin.rs # Plugin trait 定义 +│ │ ├── service_manager.rs # 生命周期管理 + 消息路由 + 错误策略 +│ │ ├── config.rs # 配置解析/验证 +│ │ ├── dispatch.rs # 文本命令解析 +│ │ ├── plugin_abi.rs # C FFI 类型 (动态插件边界) +│ │ ├── dynamic_plugin.rs # DynamicPlugin (libloading) +│ │ ├── plugin_loader.rs # plugin_store/ 扫描/加载 +│ │ ├── version_manager.rs # 版本切换/回退/GC +│ │ └── plugin_repo.rs # 远程仓库 HTTP 客户端 +│ └── plugins/ # 功能插件 +│ ├── video/ # 视频播放引擎 (OpenCV) +│ ├── http/ # Web UI + REST API (warp) +│ ├── ble/ # BLE 配网 (D-Bus BlueZ) +│ ├── wifi/ # WiFi 管理 (nmcli) +│ └── screen/ # 屏幕管理 (防息屏/光标) +├── plugin-sdk/ # 动态插件开发 SDK +├── plugins/ +│ └── example-plugin/ # 示例动态插件 (cdylib) +├── configs/ # 配置文件 (状态机 JSON) +├── clients/ # 外部控制客户端应用 +└── souls/ # 团队成员档案 +``` + +## 技术栈 + +- **语言**: Rust (edition 2018) +- **视频**: OpenCV 0.66 +- **HTTP**: warp + tokio +- **BLE**: D-Bus + BlueZ (GATT) +- **WiFi**: nmcli +- **插件加载**: libloading +- **远程仓库**: ureq + flate2 + tar + +## 动态插件系统 + +ShowenV2 支持两种插件模式: + +1. **静态插件** — 编译时链接,5 个内置插件 (video, http, ble, wifi, screen) +2. **动态插件** — 运行时加载 `.so` 文件,通过 `extern "C"` FFI + JSON 序列化通信 + +动态插件特性: +- 多版本管理 (`plugin_store/` 目录结构) +- 远程仓库下载安装 (HTTP + tar.gz) +- 错误自动回退 (AutoRollback / DisableAndLog) +- REST API 管理 (`/api/plugins/*`) + +## 快速开始 + +```bash +# 验证配置 +cargo run -- --validate --config configs/dog_state_machine.json + +# 运行 +cargo run -- --config configs/dog_state_machine.json + +# 测试 +cargo test --workspace +``` + +## 目标硬件 + +ARM aarch64 嵌入式 Linux, 屏幕 1280×800 diff --git a/clients/alipay-miniapp/README.md b/clients/alipay-miniapp/README.md new file mode 100644 index 0000000..844b008 --- /dev/null +++ b/clients/alipay-miniapp/README.md @@ -0,0 +1,3 @@ +# alipay-miniapp/ — 支付宝小程序 + +支付宝小程序客户端。(规划中) diff --git a/clients/android/README.md b/clients/android/README.md new file mode 100644 index 0000000..fb22d01 --- /dev/null +++ b/clients/android/README.md @@ -0,0 +1,3 @@ +# android/ — Android 原生应用 + +Kotlin/Jetpack Compose 实现的 Android 控制应用。(规划中) diff --git a/clients/cli/README.md b/clients/cli/README.md new file mode 100644 index 0000000..cb3e4f3 --- /dev/null +++ b/clients/cli/README.md @@ -0,0 +1,3 @@ +# cli/ — 命令行工具 + +终端命令行控制工具,用于调试和自动化。(规划中) diff --git a/clients/desktop/README.md b/clients/desktop/README.md new file mode 100644 index 0000000..29fa573 --- /dev/null +++ b/clients/desktop/README.md @@ -0,0 +1,3 @@ +# desktop/ — 桌面应用 + +Electron/Tauri 实现的桌面控制应用(Windows/macOS/Linux)。(规划中) diff --git a/clients/docs/README.md b/clients/docs/README.md new file mode 100644 index 0000000..b3a8124 --- /dev/null +++ b/clients/docs/README.md @@ -0,0 +1,6 @@ +# docs/ — 客户端开发文档 + +| 文件 | 说明 | +|------|------| +| `API.md` | ShowenV2 REST API 接口文档 | +| `DESIGN.md` | 客户端 UI/UX 设计规范 | diff --git a/clients/flutter/README.md b/clients/flutter/README.md new file mode 100644 index 0000000..1e17655 --- /dev/null +++ b/clients/flutter/README.md @@ -0,0 +1,3 @@ +# flutter/ — Flutter 跨平台应用 + +Flutter 实现的跨平台移动控制应用(iOS + Android)。(规划中) diff --git a/clients/ios/README.md b/clients/ios/README.md new file mode 100644 index 0000000..96e5e4b --- /dev/null +++ b/clients/ios/README.md @@ -0,0 +1,3 @@ +# ios/ — iOS 原生应用 + +Swift/SwiftUI 实现的 iOS 控制应用。(规划中) diff --git a/clients/plugin-dev/README.md b/clients/plugin-dev/README.md new file mode 100644 index 0000000..0d9ac4b --- /dev/null +++ b/clients/plugin-dev/README.md @@ -0,0 +1,3 @@ +# plugin-dev/ — 插件开发工具 + +动态插件的开发、调试和打包工具。(规划中) diff --git a/clients/sdk/README.md b/clients/sdk/README.md new file mode 100644 index 0000000..98a3a21 --- /dev/null +++ b/clients/sdk/README.md @@ -0,0 +1,3 @@ +# sdk/ — 多语言 SDK + +各语言的 ShowenV2 客户端 SDK(Python、JavaScript、Go、Rust)。(规划中) diff --git a/clients/shared/README.md b/clients/shared/README.md new file mode 100644 index 0000000..8493427 --- /dev/null +++ b/clients/shared/README.md @@ -0,0 +1,9 @@ +# shared/ — 客户端共享代码 + +跨客户端复用的代码库。 + +| 目录 | 说明 | +|------|------| +| `api/` | ShowenV2 HTTP/WebSocket API 客户端封装 | +| `models/` | 共享数据模型定义 | +| `utils/` | 通用工具函数 | diff --git a/clients/shared/api/README.md b/clients/shared/api/README.md new file mode 100644 index 0000000..8691bca --- /dev/null +++ b/clients/shared/api/README.md @@ -0,0 +1,3 @@ +# api/ — API 客户端库 + +ShowenV2 HTTP REST API 和 WebSocket 客户端的封装,供各客户端应用复用。 diff --git a/clients/shared/models/README.md b/clients/shared/models/README.md new file mode 100644 index 0000000..82651f4 --- /dev/null +++ b/clients/shared/models/README.md @@ -0,0 +1,3 @@ +# models/ — 共享数据模型 + +客户端通用的数据模型定义(设备状态、播放列表、配置等)。 diff --git a/clients/shared/utils/README.md b/clients/shared/utils/README.md new file mode 100644 index 0000000..0a93a00 --- /dev/null +++ b/clients/shared/utils/README.md @@ -0,0 +1,3 @@ +# utils/ — 通用工具函数 + +客户端共享的工具函数(网络请求、数据格式化、错误处理等)。 diff --git a/clients/smarthome/README.md b/clients/smarthome/README.md new file mode 100644 index 0000000..f07d39b --- /dev/null +++ b/clients/smarthome/README.md @@ -0,0 +1,3 @@ +# smarthome/ — 智能家居集成 + +HomeKit / 米家 / 小度等智能家居平台接入。(规划中) diff --git a/clients/voice/README.md b/clients/voice/README.md new file mode 100644 index 0000000..de11360 --- /dev/null +++ b/clients/voice/README.md @@ -0,0 +1,3 @@ +# voice/ — 智能音箱集成 + +语音控制集成(天猫精灵、小爱同学、小度等)。(规划中) diff --git a/clients/watch/README.md b/clients/watch/README.md new file mode 100644 index 0000000..1a4360f --- /dev/null +++ b/clients/watch/README.md @@ -0,0 +1,3 @@ +# watch/ — 智能手表应用 + +Apple Watch / Wear OS 控制应用。(规划中) diff --git a/clients/web/README.md b/clients/web/README.md new file mode 100644 index 0000000..bf0a3d2 --- /dev/null +++ b/clients/web/README.md @@ -0,0 +1,3 @@ +# web/ — Web 应用 + +基于 React/Vue 的响应式 Web 控制界面。(规划中) diff --git a/clients/wechat-miniapp/README.md b/clients/wechat-miniapp/README.md new file mode 100644 index 0000000..a45395e --- /dev/null +++ b/clients/wechat-miniapp/README.md @@ -0,0 +1,3 @@ +# wechat-miniapp/ — 微信小程序 + +微信小程序客户端。(规划中) diff --git a/configs/README.md b/configs/README.md new file mode 100644 index 0000000..8f02b61 --- /dev/null +++ b/configs/README.md @@ -0,0 +1,19 @@ +# configs/ — 状态机配置 + +JSON 格式的状态机配置文件,定义视频播放场景和状态转换规则。 + +## 文件 + +| 文件 | 说明 | +|------|------| +| `dog_state_machine.json` | 狗宠物的场景/动画状态机 | +| `cat_state_machine.json` | 猫宠物的场景/动画状态机 | + +## 配置结构 + +每个 JSON 配置定义: +- **场景 (scenes)**: 包含多个动画状态 +- **状态 (states)**: 绑定视频文件,定义播放行为 +- **转换 (transitions)**: 触发器驱动的状态跳转(voice / button / sensor) + +由 `VideoPlugin` 的 `StateMachine` 模块解析和驱动。 diff --git a/configs/dog_state_machine.json b/configs/dog_state_machine.json index 741d78c..0a65ecd 100644 --- a/configs/dog_state_machine.json +++ b/configs/dog_state_machine.json @@ -8,10 +8,10 @@ "offset_x": 0, "offset_y": 0, "prevent_screen_lock": true, - "render_width": 1280, - "render_height": 800, - "output_width": null, - "output_height": null, + "render_width": 1920, + "render_height": 1080, + "output_width": 1920, + "output_height": 1080, "scale_mode": "stretch", "allow_upscale": true, "perspective_correction": { @@ -22,16 +22,16 @@ 0 ], [ - 1280, + 1920, 0 ], [ - 1280, - 800 + 1920, + 1080 ], [ 0, - 800 + 1080 ] ] }, diff --git a/plugin-sdk/Cargo.toml b/plugin-sdk/Cargo.toml new file mode 100644 index 0000000..ce99074 --- /dev/null +++ b/plugin-sdk/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "showen-plugin-sdk" +version = "0.2.0" +authors = ["showen"] +edition = "2018" +description = "SDK for building ShowenV2 dynamic plugins" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md new file mode 100644 index 0000000..3804055 --- /dev/null +++ b/plugin-sdk/README.md @@ -0,0 +1,45 @@ +# ShowenV2 Plugin SDK + +插件开发者使用此 SDK 编写动态插件(`.so` 文件),运行时由主程序动态加载。 + +## 核心接口 + +- **`ShowenPlugin` trait**: 插件作者实现的高级接口(init / start / handle_message / stop) +- **`MessageSender`**: 封装 FFI SendCallback,插件通过它向主程序发送消息 +- **`export_plugin!` 宏**: 自动生成 `extern "C"` 胶水代码,导出 `PluginVTable` + +## 类型 + +SDK 独立定义了与主程序 JSON 兼容的消息类型: +- `PluginInfo`, `Envelope`, `Destination`, `Message` + +## 用法 + +```rust +use showen_plugin_sdk::{export_plugin, ShowenPlugin, MessageSender, PluginInfo, Message}; + +struct MyPlugin { sender: Option } + +impl ShowenPlugin for MyPlugin { + fn info(&self) -> PluginInfo { /* ... */ } + fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String> { Ok(()) } + fn start(&mut self) -> Result<(), String> { Ok(()) } + fn handle_message(&mut self, msg_json: &str) -> Result<(), String> { Ok(()) } + fn stop(&mut self) -> Result<(), String> { Ok(()) } +} + +export_plugin!(MyPlugin, MyPlugin::new); +``` + +## 编译 + +```bash +cd plugin-sdk +cargo build +``` + +插件项目在 `Cargo.toml` 中依赖此 SDK: +```toml +[dependencies] +showen-plugin-sdk = { path = "../plugin-sdk" } +``` diff --git a/plugin-sdk/src/lib.rs b/plugin-sdk/src/lib.rs new file mode 100644 index 0000000..a308a68 --- /dev/null +++ b/plugin-sdk/src/lib.rs @@ -0,0 +1,292 @@ +//! ShowenV2 Plugin SDK +//! +//! 插件开发者使用此 SDK 编写动态插件。 +//! 实现 `ShowenPlugin` trait,然后用 `export_plugin!` 宏导出。 + +use serde::{Deserialize, Serialize}; +use std::ffi::{c_char, c_int, c_void, CString}; +use std::ptr; + +// ── 重新导出消息类型(与主程序共享 JSON 契约) ── + +/// 插件信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginInfo { + pub name: String, + pub version: String, + pub description: String, + pub platform: String, +} + +/// 消息信封 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Envelope { + pub from: String, + pub to: Destination, + pub message: Message, +} + +/// 消息目的地 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Destination { + Plugin(String), + Broadcast, + Manager, +} + +/// 消息类型 — 与主程序 Message 枚举保持 JSON 兼容 +/// 动态插件只需处理自己关心的消息变体 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Message { + PlayerCommand(serde_json::Value), + PlayerStatus(serde_json::Value), + Trigger { name: String, value: String }, + StateChanged { old_state: String, new_state: String }, + ScreenLockRequest(bool), + CursorVisibility(bool), + WifiCommand(serde_json::Value), + WifiResult(String), + WifiProvisioned { ssid: String, ip: String }, + ConfigReloadRequest, + Shutdown, + PluginReady(String), + Custom { kind: String, payload: String }, +} + +// ── FFI 类型(与主程序 plugin_abi.rs 完全对应) ── + +pub type PluginHandle = *mut c_void; +pub type FfiStr = *const c_char; + +#[repr(C)] +pub struct FfiString { + pub ptr: *mut c_char, + pub len: usize, +} + +impl FfiString { + pub fn from_string(s: String) -> Self { + match CString::new(s) { + Ok(cstr) => { + let len = cstr.as_bytes().len(); + Self { + ptr: cstr.into_raw(), + len, + } + } + Err(_) => Self::null(), + } + } + + pub fn null() -> Self { + Self { + ptr: ptr::null_mut(), + len: 0, + } + } +} + +#[repr(C)] +pub struct FfiResult { + pub code: c_int, + pub error: FfiString, +} + +impl FfiResult { + pub fn ok() -> Self { + Self { + code: 0, + error: FfiString::null(), + } + } + + pub fn err(msg: String) -> Self { + Self { + code: -1, + error: FfiString::from_string(msg), + } + } +} + +pub type SendCallback = unsafe extern "C" fn(envelope_json: FfiStr); + +#[repr(C)] +pub struct PluginVTable { + pub create: unsafe extern "C" fn() -> PluginHandle, + pub get_info: unsafe extern "C" fn(handle: PluginHandle) -> FfiString, + pub init: unsafe extern "C" fn(handle: PluginHandle, config_json: FfiStr, send_cb: SendCallback) -> FfiResult, + pub start: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult, + pub handle_message: unsafe extern "C" fn(handle: PluginHandle, message_json: FfiStr) -> FfiResult, + pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult, + pub destroy: unsafe extern "C" fn(handle: PluginHandle), +} + +// ── 高级接口:插件作者实现此 trait ── + +/// 消息发送器 — 封装 SendCallback,提供安全的 Rust API +pub struct MessageSender { + cb: SendCallback, +} + +impl MessageSender { + pub fn new(cb: SendCallback) -> Self { + Self { cb } + } + + /// 发送消息信封到主程序 + pub fn send(&self, envelope: &Envelope) { + if let Ok(json) = serde_json::to_string(envelope) { + if let Ok(cstr) = CString::new(json) { + unsafe { (self.cb)(cstr.as_ptr()) }; + } + } + } + + /// 便捷方法:发送消息给指定插件 + pub fn send_to(&self, from: &str, to_plugin: &str, message: Message) { + self.send(&Envelope { + from: from.to_string(), + to: Destination::Plugin(to_plugin.to_string()), + message, + }); + } + + /// 便捷方法:广播消息 + pub fn broadcast(&self, from: &str, message: Message) { + self.send(&Envelope { + from: from.to_string(), + to: Destination::Broadcast, + message, + }); + } + + /// 便捷方法:发送消息给管理层 + pub fn send_to_manager(&self, from: &str, message: Message) { + self.send(&Envelope { + from: from.to_string(), + to: Destination::Manager, + message, + }); + } +} + +// SendCallback 是 extern "C" fn 指针,可跨线程安全传递 +unsafe impl Send for MessageSender {} +unsafe impl Sync for MessageSender {} + +/// 动态插件 trait — 插件作者实现此接口 +pub trait ShowenPlugin: Send { + /// 插件信息 + fn info(&self) -> PluginInfo; + + /// 初始化,收到配置 JSON 和消息发送器 + fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String>; + + /// 启动 + fn start(&mut self) -> Result<(), String>; + + /// 处理消息 JSON(已反序列化为 Message) + fn handle_message(&mut self, message: Message) -> Result<(), String>; + + /// 停止 + fn stop(&mut self) -> Result<(), String>; +} + +// ── 导出宏:自动生成 extern "C" 胶水代码 ── + +/// 将 ShowenPlugin 实现导出为 C FFI 接口 +/// +/// # 用法 +/// ```ignore +/// struct MyPlugin { ... } +/// impl ShowenPlugin for MyPlugin { ... } +/// +/// export_plugin!(MyPlugin, MyPlugin::new); +/// ``` +#[macro_export] +macro_rules! export_plugin { + ($plugin_type:ty, $constructor:expr) => { + unsafe extern "C" fn __showen_create() -> $crate::PluginHandle { + let plugin: Box<$plugin_type> = Box::new($constructor); + Box::into_raw(plugin) as $crate::PluginHandle + } + + unsafe extern "C" fn __showen_get_info(handle: $crate::PluginHandle) -> $crate::FfiString { + let plugin = unsafe { &*(handle as *const $plugin_type) }; + let info = <$plugin_type as $crate::ShowenPlugin>::info(plugin); + match serde_json::to_string(&info) { + Ok(json) => $crate::FfiString::from_string(json), + Err(_) => $crate::FfiString::null(), + } + } + + unsafe extern "C" fn __showen_init( + handle: $crate::PluginHandle, + config_json: $crate::FfiStr, + send_cb: $crate::SendCallback, + ) -> $crate::FfiResult { + let plugin = unsafe { &mut *(handle as *mut $plugin_type) }; + let config = match unsafe { std::ffi::CStr::from_ptr(config_json) }.to_str() { + Ok(s) => s, + Err(e) => return $crate::FfiResult::err(format!("invalid config UTF-8: {e}")), + }; + let sender = $crate::MessageSender::new(send_cb); + match <$plugin_type as $crate::ShowenPlugin>::init(plugin, config, sender) { + Ok(()) => $crate::FfiResult::ok(), + Err(e) => $crate::FfiResult::err(e), + } + } + + unsafe extern "C" fn __showen_start(handle: $crate::PluginHandle) -> $crate::FfiResult { + let plugin = unsafe { &mut *(handle as *mut $plugin_type) }; + match <$plugin_type as $crate::ShowenPlugin>::start(plugin) { + Ok(()) => $crate::FfiResult::ok(), + Err(e) => $crate::FfiResult::err(e), + } + } + + unsafe extern "C" fn __showen_handle_message( + handle: $crate::PluginHandle, + message_json: $crate::FfiStr, + ) -> $crate::FfiResult { + let plugin = unsafe { &mut *(handle as *mut $plugin_type) }; + let json_str = match unsafe { std::ffi::CStr::from_ptr(message_json) }.to_str() { + Ok(s) => s, + Err(e) => return $crate::FfiResult::err(format!("invalid message UTF-8: {e}")), + }; + let message: $crate::Message = match serde_json::from_str(json_str) { + Ok(m) => m, + Err(e) => return $crate::FfiResult::err(format!("invalid message JSON: {e}")), + }; + match <$plugin_type as $crate::ShowenPlugin>::handle_message(plugin, message) { + Ok(()) => $crate::FfiResult::ok(), + Err(e) => $crate::FfiResult::err(e), + } + } + + unsafe extern "C" fn __showen_stop(handle: $crate::PluginHandle) -> $crate::FfiResult { + let plugin = unsafe { &mut *(handle as *mut $plugin_type) }; + match <$plugin_type as $crate::ShowenPlugin>::stop(plugin) { + Ok(()) => $crate::FfiResult::ok(), + Err(e) => $crate::FfiResult::err(e), + } + } + + unsafe extern "C" fn __showen_destroy(handle: $crate::PluginHandle) { + if !handle.is_null() { + drop(unsafe { Box::from_raw(handle as *mut $plugin_type) }); + } + } + + #[no_mangle] + pub static showen_plugin_vtable: $crate::PluginVTable = $crate::PluginVTable { + create: __showen_create, + get_info: __showen_get_info, + init: __showen_init, + start: __showen_start, + handle_message: __showen_handle_message, + stop: __showen_stop, + destroy: __showen_destroy, + }; + }; +} diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..b82a702 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,19 @@ +# plugins/ — 外部动态插件 + +此目录存放独立编译的动态插件项目(cdylib crate)。 + +## 目录 + +| 目录 | 说明 | +|------|------| +| `example-plugin/` | 示例插件,演示 SDK 用法 | + +## 开发流程 + +1. 创建新 crate,依赖 `showen-plugin-sdk` +2. 实现 `ShowenPlugin` trait +3. 用 `export_plugin!` 宏导出 +4. `cargo build --release` 编译为 `.so` +5. 将产物放入 `plugin_store///` + +详见 `plugin-sdk/README.md`。 diff --git a/plugins/example-plugin/Cargo.toml b/plugins/example-plugin/Cargo.toml new file mode 100644 index 0000000..a907081 --- /dev/null +++ b/plugins/example-plugin/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "showen-example-plugin" +version = "0.1.0" +authors = ["showen"] +edition = "2018" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +showen-plugin-sdk = { path = "../../plugin-sdk" } +serde_json = "1" diff --git a/plugins/example-plugin/README.md b/plugins/example-plugin/README.md new file mode 100644 index 0000000..17907a6 --- /dev/null +++ b/plugins/example-plugin/README.md @@ -0,0 +1,29 @@ +# Example Plugin — 示例动态插件 + +演示如何使用 `showen-plugin-sdk` 编写动态插件。 + +## 功能 + +- 仅打印日志,用于验证动态加载流程 +- 展示 `ShowenPlugin` trait 的完整实现 +- 编译为 `cdylib`(`.so` 文件) + +## 编译 + +```bash +cd plugins/example-plugin +cargo build --release +``` + +产物: `target/release/libshowen_example_plugin.so` + +## 安装 + +将 `.so` 和 `manifest.json` 放入 `plugin_store/example-plugin//` 目录即可被主程序动态加载。 + +## 文件 + +| 文件 | 说明 | +|------|------| +| `src/lib.rs` | 插件实现,使用 `export_plugin!` 宏导出 | +| `Cargo.toml` | crate 配置,类型为 cdylib | diff --git a/plugins/example-plugin/src/lib.rs b/plugins/example-plugin/src/lib.rs new file mode 100644 index 0000000..b538b3e --- /dev/null +++ b/plugins/example-plugin/src/lib.rs @@ -0,0 +1,72 @@ +//! 示例动态插件 — 展示如何使用 showen-plugin-sdk 编写插件 +//! +//! 此插件仅打印日志,用于验证动态加载流程。 + +use showen_plugin_sdk::{ + export_plugin, Message, MessageSender, PluginInfo, ShowenPlugin, +}; + +pub struct ExamplePlugin { + sender: Option, +} + +impl ExamplePlugin { + pub fn new() -> Self { + Self { sender: None } + } +} + +impl ShowenPlugin for ExamplePlugin { + fn info(&self) -> PluginInfo { + PluginInfo { + name: "example-plugin".to_string(), + version: "0.1.0".to_string(), + description: "示例动态插件".to_string(), + platform: "Any".to_string(), + } + } + + fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String> { + eprintln!("[ExamplePlugin] init called, config length: {}", config_json.len()); + self.sender = Some(sender); + + // 通知主程序就绪 + if let Some(sender) = &self.sender { + sender.send_to_manager( + "example-plugin", + Message::PluginReady("example-plugin".to_string()), + ); + } + + Ok(()) + } + + fn start(&mut self) -> Result<(), String> { + eprintln!("[ExamplePlugin] started"); + Ok(()) + } + + fn handle_message(&mut self, message: Message) -> Result<(), String> { + match &message { + Message::Shutdown => { + eprintln!("[ExamplePlugin] received shutdown"); + } + Message::Custom { kind, payload } => { + eprintln!("[ExamplePlugin] custom message: kind={kind}, payload={payload}"); + } + _ => { + eprintln!("[ExamplePlugin] received message: {:?}", message); + } + } + Ok(()) + } + + fn stop(&mut self) -> Result<(), String> { + eprintln!("[ExamplePlugin] stopped"); + self.sender = None; + Ok(()) + } +} + +// 导出 FFI 接口 +export_plugin!(ExamplePlugin, ExamplePlugin::new()); diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..af0fef8 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,8 @@ +# scripts/ — 脚本工具 + +构建、部署和开发辅助脚本。 + +目前为空目录,预留用于: +- 交叉编译脚本(ARM aarch64 目标) +- 部署脚本(推送到设备) +- CI/CD 流水线脚本 diff --git a/souls/README.md b/souls/README.md new file mode 100644 index 0000000..42e621d --- /dev/null +++ b/souls/README.md @@ -0,0 +1,13 @@ +# souls/ — 团队成员档案 + +虚拟团队成员的角色定义文件,每个 `.md` 文件描述一位成员的背景、专长和职责。 + +## 成员 + +团队采用 AI 驱动开发模式,成员由 Claude Opus 4.6 模型扮演,各有分工。 + +## 子目录 + +| 目录 | 说明 | +|------|------| +| `archived/` | 已归档(淘汰)的成员档案 | diff --git a/souls/archived/README.md b/souls/archived/README.md new file mode 100644 index 0000000..a93b6ef --- /dev/null +++ b/souls/archived/README.md @@ -0,0 +1,3 @@ +# archived/ — 已归档成员 + +末尾淘汰的团队成员档案存放于此。 diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..f17dda7 --- /dev/null +++ b/src/README.md @@ -0,0 +1,8 @@ +# src/ — ShowenV2 源代码 + +## 目录结构 + +- `core/` — 插件微内核:消息协议、服务管理、配置、动态插件系统 +- `plugins/` — 功能插件:视频播放、HTTP API、BLE 配网、WiFi、屏幕管理 +- `lib.rs` — 库入口,导出 `core` 和 `plugins` 模块 +- `main.rs` — 可执行入口,注册插件并启动主循环 diff --git a/src/core/README.md b/src/core/README.md new file mode 100644 index 0000000..83777a4 --- /dev/null +++ b/src/core/README.md @@ -0,0 +1,39 @@ +# core/ — 插件微内核 + +ShowenV2 的核心框架,所有插件通过此内核注册、通信和管理。 + +## 模块说明 + +| 文件 | 说明 | +|------|------| +| `message.rs` | 类型安全消息协议:`Envelope` / `Destination` / `Message` 枚举,全部支持 Serialize/Deserialize | +| `plugin.rs` | `Plugin` trait 定义 + `PluginInfo` / `PluginContext` | +| `service_manager.rs` | 中央调度器:插件注册、依赖拓扑排序、消息路由、动态插件错误策略 (PluginState) | +| `config.rs` | `AppConfig` 配置解析/验证,支持状态机、转场、显示参数等 | +| `dispatch.rs` | 文本命令解析器(BLE/WebSocket 用),转换为类型安全的 `Envelope` | +| `plugin_abi.rs` | C FFI 边界类型:`PluginVTable` / `FfiString` / `FfiResult` / `SendCallback` | +| `dynamic_plugin.rs` | `DynamicPlugin`:通过 `libloading` 加载 `.so`,实现 `Plugin` trait,JSON 序列化跨 FFI | +| `plugin_loader.rs` | 扫描 `plugin_store/` 目录,解析 `manifest.json` / `registry.json`,加载动态插件 | +| `version_manager.rs` | 版本管理:切换、回退、稳定标记、垃圾回收 | +| `plugin_repo.rs` | 远程插件仓库 HTTP 客户端:下载 tar.gz、校验、安装 | +| `tests.rs` | 核心模块单元测试 | + +## 消息流 + +``` +插件 A → Envelope{from, to, message} → ServiceManager → 插件 B + ↓ + Manager 消息处理 + (Shutdown/ConfigReload/...) +``` + +## 动态插件加载流 + +``` +plugin_store/ +├── registry.json ← PluginLoader 读取 +└── my-plugin/ + └── 1.0.0/ + ├── manifest.json ← 清单 (id, version, dependencies, error_policy) + └── libmy_plugin.so ← DynamicPlugin 通过 libloading 加载 +``` diff --git a/src/core/dispatch.rs b/src/core/dispatch.rs new file mode 100644 index 0000000..5d5823c --- /dev/null +++ b/src/core/dispatch.rs @@ -0,0 +1,316 @@ +use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand}; + +/// 命令解析结果 +pub struct DispatchResult { + pub envelope: Envelope, +} + +/// 解析文本命令为 DispatchResult。 +/// +/// `ssid_hint` / `password_hint` 仅用于不含参数的 `connect` / `ap_start`(BLE 先写 +/// SSID/Password 特征,再写 Command)。带参数版本 `connect:SSID:PASS` 直接解析内联值。 +pub fn parse_command( + command: &str, + from: &str, + ssid_hint: &str, + password_hint: &str, +) -> Result { + let command = command.trim(); + if command.is_empty() { + return Err("empty command".into()); + } + + // 尝试按冒号拆分以获取命令名和参数 + let (cmd, rest) = match command.find(':') { + Some(pos) => (&command[..pos], Some(&command[pos + 1..])), + None => (command, None), + }; + + match cmd { + // ── 播放控制 ── + "play" => ok_video(from, Message::PlayerCommand(PlayerCommand::Play)), + "pause" => ok_video(from, Message::PlayerCommand(PlayerCommand::Pause)), + "next" => ok_video(from, Message::PlayerCommand(PlayerCommand::Next)), + "prev" => ok_video(from, Message::PlayerCommand(PlayerCommand::Previous)), + + "goto" => { + let index_str = rest.ok_or("goto requires index (e.g. goto:3)")?; + let index: usize = index_str + .parse() + .map_err(|_| format!("invalid index: {index_str}"))?; + ok_video(from, Message::PlayerCommand(PlayerCommand::Goto(index))) + } + + "scene" => { + let name = rest.ok_or("scene requires name (e.g. scene:idle)")?; + if name.is_empty() { + return Err("scene name cannot be empty".into()); + } + ok_video( + from, + Message::PlayerCommand(PlayerCommand::ChangeScene(name.to_string())), + ) + } + + "trigger" => { + let params = rest.ok_or("trigger requires name:value (e.g. trigger:voice:name)")?; + let (name, value) = match params.find(':') { + Some(pos) => (¶ms[..pos], ¶ms[pos + 1..]), + None => (params, ""), + }; + if name.is_empty() { + return Err("trigger name cannot be empty".into()); + } + ok_video( + from, + Message::Trigger { + name: name.to_string(), + value: value.to_string(), + }, + ) + } + + // ── WiFi 命令 ── + "scan" => ok_wifi(from, Message::WifiCommand(WifiCommand::Scan)), + "status" => ok_wifi(from, Message::WifiCommand(WifiCommand::Status)), + + "connect" => { + let (ssid, password) = parse_wifi_credentials(rest, ssid_hint, password_hint); + if ssid.is_empty() { + return Err("ssid required for connect".into()); + } + ok_wifi( + from, + Message::WifiCommand(WifiCommand::Connect { ssid, password }), + ) + } + + "ap_start" => { + let (ssid, password) = parse_wifi_credentials(rest, ssid_hint, password_hint); + if ssid.is_empty() { + return Err("ssid required for ap_start".into()); + } + ok_wifi( + from, + Message::WifiCommand(WifiCommand::ApStart { ssid, password }), + ) + } + + "ap_stop" => ok_wifi(from, Message::WifiCommand(WifiCommand::ApStop)), + + // ── 配置 ── + "config_reload" => Ok(DispatchResult { + envelope: Envelope { + from: from.to_string(), + to: Destination::Manager, + message: Message::ConfigReloadRequest, + }, + }), + + _ => Err(format!("unsupported command: {cmd}")), + } +} + +/// 解析 WiFi 凭据:优先使用内联参数 `SSID:PASS`,回退到 hint 值。 +/// 密码中可能包含冒号,因此用 `splitn(2, ':')` 拆分。 +fn parse_wifi_credentials( + rest: Option<&str>, + ssid_hint: &str, + password_hint: &str, +) -> (String, String) { + match rest { + Some(params) if !params.is_empty() => { + let mut parts = params.splitn(2, ':'); + let ssid = parts.next().unwrap_or("").to_string(); + let password = parts.next().unwrap_or("").to_string(); + if ssid.is_empty() { + (ssid_hint.to_string(), password_hint.to_string()) + } else { + (ssid, password) + } + } + _ => (ssid_hint.to_string(), password_hint.to_string()), + } +} + +fn ok_video(from: &str, message: Message) -> Result { + Ok(DispatchResult { + envelope: Envelope { + from: from.to_string(), + to: Destination::Plugin("video".to_string()), + message, + }, + }) +} + +fn ok_wifi(from: &str, message: Message) -> Result { + Ok(DispatchResult { + envelope: Envelope { + from: from.to_string(), + to: Destination::Plugin("wifi".to_string()), + message, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dispatch(cmd: &str) -> Result { + parse_command(cmd, "test", "", "") + } + + fn dispatch_with_hints(cmd: &str, ssid: &str, pass: &str) -> Result { + parse_command(cmd, "test", ssid, pass) + } + + #[test] + fn test_player_commands() { + assert!(matches!( + dispatch("play").unwrap().envelope.message, + Message::PlayerCommand(PlayerCommand::Play) + )); + assert!(matches!( + dispatch("pause").unwrap().envelope.message, + Message::PlayerCommand(PlayerCommand::Pause) + )); + assert!(matches!( + dispatch("next").unwrap().envelope.message, + Message::PlayerCommand(PlayerCommand::Next) + )); + assert!(matches!( + dispatch("prev").unwrap().envelope.message, + Message::PlayerCommand(PlayerCommand::Previous) + )); + } + + #[test] + fn test_goto() { + let result = dispatch("goto:5").unwrap(); + assert!(matches!( + result.envelope.message, + Message::PlayerCommand(PlayerCommand::Goto(5)) + )); + assert!(dispatch("goto").is_err()); + assert!(dispatch("goto:abc").is_err()); + } + + #[test] + fn test_scene() { + let result = dispatch("scene:idle").unwrap(); + assert!(matches!( + result.envelope.message, + Message::PlayerCommand(PlayerCommand::ChangeScene(ref name)) if name == "idle" + )); + assert!(dispatch("scene").is_err()); + assert!(dispatch("scene:").is_err()); + } + + #[test] + fn test_trigger() { + let result = dispatch("trigger:voice:name").unwrap(); + assert!(matches!( + result.envelope.message, + Message::Trigger { ref name, ref value } if name == "voice" && value == "name" + )); + + // trigger without value + let result = dispatch("trigger:button").unwrap(); + assert!(matches!( + result.envelope.message, + Message::Trigger { ref name, ref value } if name == "button" && value == "" + )); + + assert!(dispatch("trigger").is_err()); + assert!(dispatch("trigger:").is_err()); + } + + #[test] + fn test_wifi_scan_status() { + assert!(matches!( + dispatch("scan").unwrap().envelope.message, + Message::WifiCommand(WifiCommand::Scan) + )); + assert!(matches!( + dispatch("status").unwrap().envelope.message, + Message::WifiCommand(WifiCommand::Status) + )); + } + + #[test] + fn test_connect_with_hint() { + let result = dispatch_with_hints("connect", "MySSID", "pass123").unwrap(); + assert!(matches!( + result.envelope.message, + Message::WifiCommand(WifiCommand::Connect { ref ssid, ref password }) + if ssid == "MySSID" && password == "pass123" + )); + } + + #[test] + fn test_connect_inline() { + let result = dispatch("connect:MySSID:p@ss:w0rd").unwrap(); + assert!(matches!( + result.envelope.message, + Message::WifiCommand(WifiCommand::Connect { ref ssid, ref password }) + if ssid == "MySSID" && password == "p@ss:w0rd" + )); + } + + #[test] + fn test_connect_no_ssid() { + assert!(dispatch("connect").is_err()); + } + + #[test] + fn test_ap_start_inline() { + let result = dispatch("ap_start:showen:12345678").unwrap(); + assert!(matches!( + result.envelope.message, + Message::WifiCommand(WifiCommand::ApStart { ref ssid, ref password }) + if ssid == "showen" && password == "12345678" + )); + } + + #[test] + fn test_ap_stop() { + assert!(matches!( + dispatch("ap_stop").unwrap().envelope.message, + Message::WifiCommand(WifiCommand::ApStop) + )); + } + + #[test] + fn test_config_reload() { + let result = dispatch("config_reload").unwrap(); + assert!(matches!( + result.envelope.message, + Message::ConfigReloadRequest + )); + assert!(matches!(result.envelope.to, Destination::Manager)); + } + + #[test] + fn test_routing() { + let r = dispatch("play").unwrap(); + assert!(matches!(r.envelope.to, Destination::Plugin(ref id) if id == "video")); + + let r = dispatch("scan").unwrap(); + assert!(matches!(r.envelope.to, Destination::Plugin(ref id) if id == "wifi")); + } + + #[test] + fn test_unsupported() { + assert!(dispatch("unknown").is_err()); + assert!(dispatch("").is_err()); + } + + #[test] + fn test_whitespace_trimmed() { + assert!(matches!( + dispatch(" play ").unwrap().envelope.message, + Message::PlayerCommand(PlayerCommand::Play) + )); + } +} diff --git a/src/core/dynamic_plugin.rs b/src/core/dynamic_plugin.rs new file mode 100644 index 0000000..8f06506 --- /dev/null +++ b/src/core/dynamic_plugin.rs @@ -0,0 +1,207 @@ +//! DynamicPlugin — 将动态加载的 .so 包装为 Plugin trait 实现 +//! +//! 通过 C FFI + JSON 序列化与 .so 插件通信。 +//! 对 ServiceManager 而言,DynamicPlugin 与静态插件无区别。 + +use crate::core::message::{Envelope, Message}; +use crate::core::plugin::{Plugin, PluginContext, PluginInfo}; +use crate::core::plugin_abi::{ + ffi_str_to_str, FfiResult, FfiString, PluginHandle, PluginVTable, + PLUGIN_VTABLE_SYMBOL, +}; +use anyhow::{anyhow, Context, Result}; +use libloading::Library; +use std::ffi::CString; +use std::sync::mpsc; + +/// 动态加载的插件 +pub struct DynamicPlugin { + /// 保持 .so 文件加载(RAII) + _library: Library, + /// FFI 虚函数表 + vtable: &'static PluginVTable, + /// 插件实例句柄 + handle: PluginHandle, + /// 缓存的插件信息 + info: PluginInfo, + /// 插件 ID + id: String, + /// 依赖列表 + dependencies: Vec, + /// .so 文件路径(用于调试/日志) + so_path: String, +} + +// PluginHandle 是 *mut c_void,需要手动声明 Send +// 安全性由插件 SDK 的 ShowenPlugin: Send 约束保证 +unsafe impl Send for DynamicPlugin {} + +impl DynamicPlugin { + /// 从 .so 文件加载插件 + /// + /// # Safety + /// .so 文件必须是由 showen-plugin-sdk 编译的合法插件 + pub unsafe fn load( + so_path: &str, + dependencies: Vec, + ) -> Result { + let library = unsafe { + Library::new(so_path) + .with_context(|| format!("failed to load plugin .so: {so_path}"))? + }; + + // 查找 vtable 符号 + let vtable: &'static PluginVTable = unsafe { + let symbol = library + .get::<*const PluginVTable>(PLUGIN_VTABLE_SYMBOL) + .with_context(|| { + format!("symbol 'showen_plugin_vtable' not found in {so_path}") + })?; + &**symbol + }; + + // 创建插件实例 + let handle = unsafe { (vtable.create)() }; + if handle.is_null() { + return Err(anyhow!("plugin create() returned null for {so_path}")); + } + + // 获取插件信息 + let info_ffi: FfiString = unsafe { (vtable.get_info)(handle) }; + let info_json = unsafe { info_ffi.into_string() } + .ok_or_else(|| anyhow!("plugin get_info() returned null for {so_path}"))?; + let info: PluginInfo = serde_json::from_str(&info_json) + .with_context(|| format!("invalid plugin info JSON from {so_path}"))?; + + let id = info.name.clone(); + + Ok(Self { + _library: library, + vtable, + handle, + info, + id, + dependencies, + so_path: so_path.to_string(), + }) + } + + /// 设置插件 ID(由 manifest 中的 id 字段覆盖) + pub fn set_id(&mut self, id: String) { + self.id = id; + } + + /// 获取 .so 文件路径 + pub fn so_path(&self) -> &str { + &self.so_path + } + + /// 将 FfiResult 转为 anyhow::Result + unsafe fn check_result(&self, result: FfiResult, operation: &str) -> Result<()> { + unsafe { result.into_result() }.map_err(|e| { + anyhow!( + "plugin '{}' {} failed: {}", + self.id, + operation, + e + ) + }) + } +} + +impl Plugin for DynamicPlugin { + fn id(&self) -> &str { + &self.id + } + + fn info(&self) -> PluginInfo { + self.info.clone() + } + + fn dependencies(&self) -> Vec { + self.dependencies.clone() + } + + fn init(&mut self, ctx: PluginContext) -> Result<()> { + // 序列化配置为 JSON + let config_json = serde_json::to_string(ctx.config.as_ref()) + .context("failed to serialize config for dynamic plugin")?; + let config_cstr = CString::new(config_json) + .context("config JSON contains null byte")?; + + // 创建 SendCallback — 将 mpsc::Sender 转为 C 函数指针 + // 使用 thread_local 存储 sender(每次 init 更新) + PLUGIN_SENDER.with(|cell| { + *cell.borrow_mut() = Some(ctx.tx); + }); + + let result = unsafe { + (self.vtable.init)(self.handle, config_cstr.as_ptr(), ffi_send_callback) + }; + unsafe { self.check_result(result, "init") } + } + + fn start(&mut self) -> Result<()> { + let result = unsafe { (self.vtable.start)(self.handle) }; + unsafe { self.check_result(result, "start") } + } + + fn handle_message(&mut self, msg: Message) -> Result<()> { + let msg_json = serde_json::to_string(&msg) + .context("failed to serialize message for dynamic plugin")?; + let msg_cstr = CString::new(msg_json) + .context("message JSON contains null byte")?; + + let result = unsafe { + (self.vtable.handle_message)(self.handle, msg_cstr.as_ptr()) + }; + unsafe { self.check_result(result, "handle_message") } + } + + fn stop(&mut self) -> Result<()> { + let result = unsafe { (self.vtable.stop)(self.handle) }; + unsafe { self.check_result(result, "stop") } + } +} + +impl Drop for DynamicPlugin { + fn drop(&mut self) { + if !self.handle.is_null() { + unsafe { (self.vtable.destroy)(self.handle) }; + } + } +} + +// ── SendCallback 实现 ── + +thread_local! { + static PLUGIN_SENDER: std::cell::RefCell>> = + std::cell::RefCell::new(None); +} + +/// C FFI 回调:插件调用此函数向主程序发消息 +unsafe extern "C" fn ffi_send_callback(envelope_json: crate::core::plugin_abi::FfiStr) { + let json_str = match unsafe { ffi_str_to_str(envelope_json) } { + Some(s) => s, + None => { + eprintln!("[DynamicPlugin] send callback received null JSON"); + return; + } + }; + + let envelope: Envelope = match serde_json::from_str(json_str) { + Ok(e) => e, + Err(e) => { + eprintln!("[DynamicPlugin] invalid envelope JSON: {e}"); + return; + } + }; + + PLUGIN_SENDER.with(|cell| { + if let Some(tx) = cell.borrow().as_ref() { + if let Err(e) = tx.send(envelope) { + eprintln!("[DynamicPlugin] failed to send envelope: {e}"); + } + } + }); +} diff --git a/src/core/message.rs b/src/core/message.rs index f04c312..0d8c11f 100644 --- a/src/core/message.rs +++ b/src/core/message.rs @@ -1,19 +1,20 @@ use crate::core::config::AppConfig; +use serde::{Deserialize, Serialize}; use std::sync::Arc; /// 消息信封:包含来源、目的地、消息体 -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Envelope { - pub from: &'static str, + pub from: String, pub to: Destination, pub message: Message, } /// 消息目的地 -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum Destination { /// 点对点发送给指定插件 - Plugin(&'static str), + Plugin(String), /// 广播给所有插件 Broadcast, /// 发给管理层自身 @@ -21,7 +22,7 @@ pub enum Destination { } /// 所有插件间通信的类型安全消息 -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum Message { // ── 播放控制 ── PlayerCommand(PlayerCommand), @@ -48,12 +49,14 @@ pub enum Message { }, // ── 配置 ── + /// Arc 无法跨 FFI 序列化,动态插件通过 init 时传入的 JSON 获取配置 + #[serde(skip)] ConfigReloaded(Arc), ConfigReloadRequest, // ── 系统 ── Shutdown, - PluginReady(&'static str), + PluginReady(String), // ── 扩展(未来插件用) ── Custom { @@ -62,7 +65,7 @@ pub enum Message { }, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum PlayerCommand { Play, Pause, @@ -72,7 +75,7 @@ pub enum PlayerCommand { ChangeScene(String), } -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlayerStatusData { pub running: bool, pub paused: bool, @@ -82,7 +85,7 @@ pub struct PlayerStatusData { pub current_video: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum WifiCommand { Scan, Connect { ssid: String, password: String }, diff --git a/src/core/mod.rs b/src/core/mod.rs index e27c48e..a949977 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,7 +1,13 @@ pub mod config; +pub mod dispatch; +pub mod dynamic_plugin; pub mod message; pub mod plugin; +pub mod plugin_abi; +pub mod plugin_loader; +pub mod plugin_repo; pub mod service_manager; +pub mod version_manager; #[cfg(test)] mod tests; diff --git a/src/core/plugin.rs b/src/core/plugin.rs index 58f4183..8fcf2d1 100644 --- a/src/core/plugin.rs +++ b/src/core/plugin.rs @@ -1,18 +1,19 @@ use crate::core::config::AppConfig; use crate::core::message::{Envelope, Message}; use anyhow::Result; +use serde::{Deserialize, Serialize}; use std::sync::{mpsc, Arc}; /// 所有功能都通过实现此 trait 接入系统 pub trait Plugin: Send { /// 唯一标识 (如 "video", "http", "ble") - fn id(&self) -> &'static str; + fn id(&self) -> &str; /// 插件信息 fn info(&self) -> PluginInfo; /// 声明启动所需的插件依赖 - fn dependencies(&self) -> Vec<&'static str> { + fn dependencies(&self) -> Vec { vec![] } @@ -29,14 +30,15 @@ pub trait Plugin: Send { fn stop(&mut self) -> Result<()>; } +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginInfo { - pub name: &'static str, - pub version: &'static str, - pub description: &'static str, + pub name: String, + pub version: String, + pub description: String, pub platform: Platform, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Platform { Any, Linux, diff --git a/src/core/plugin_abi.rs b/src/core/plugin_abi.rs new file mode 100644 index 0000000..ddf50d6 --- /dev/null +++ b/src/core/plugin_abi.rs @@ -0,0 +1,152 @@ +//! FFI 边界类型定义 +//! +//! 主程序与动态插件 (.so) 之间的 C ABI 契约。 +//! 所有跨边界数据通过 JSON 字符串传递,避免 Rust ABI 不稳定问题。 + +use std::ffi::{c_char, c_int, c_void, CStr, CString}; +use std::ptr; + +/// 插件实例的不透明句柄 +pub type PluginHandle = *mut c_void; + +/// FFI 安全的字符串:指向 C 字符串 + 长度 +/// 调用方负责释放(通过对应的 free 函数) +#[repr(C)] +pub struct FfiString { + pub ptr: *mut c_char, + pub len: usize, +} + +impl FfiString { + /// 从 Rust String 创建 FfiString(转移所有权到 C 侧) + pub fn from_string(s: String) -> Self { + match CString::new(s) { + Ok(cstr) => { + let len = cstr.as_bytes().len(); + Self { + ptr: cstr.into_raw(), + len, + } + } + Err(_) => Self::null(), + } + } + + /// 空字符串 + pub fn null() -> Self { + Self { + ptr: ptr::null_mut(), + len: 0, + } + } + + /// 转换回 Rust String(消耗 FfiString) + /// + /// # Safety + /// ptr 必须是由 CString::into_raw 产生的有效指针 + pub unsafe fn into_string(self) -> Option { + if self.ptr.is_null() { + return None; + } + let cstr = unsafe { CString::from_raw(self.ptr) }; + cstr.into_string().ok() + } +} + +/// 只读 FFI 字符串切片(借用,不转移所有权) +pub type FfiStr = *const c_char; + +/// FFI 调用结果 +#[repr(C)] +pub struct FfiResult { + /// 0 = 成功, 非零 = 失败 + pub code: c_int, + /// 错误信息(成功时为 null) + pub error: FfiString, +} + +impl FfiResult { + pub fn ok() -> Self { + Self { + code: 0, + error: FfiString::null(), + } + } + + pub fn err(msg: String) -> Self { + Self { + code: -1, + error: FfiString::from_string(msg), + } + } + + /// 转换为 Rust Result + /// + /// # Safety + /// 如果 error 非 null,必须是由 CString::into_raw 产生的有效指针 + pub unsafe fn into_result(self) -> Result<(), String> { + if self.code == 0 { + Ok(()) + } else { + let msg = unsafe { self.error.into_string() } + .unwrap_or_else(|| "unknown plugin error".to_string()); + Err(msg) + } + } +} + +/// 插件向主程序发消息的回调函数类型 +/// envelope_json: JSON 序列化的 Envelope +pub type SendCallback = unsafe extern "C" fn(envelope_json: FfiStr); + +/// 插件虚函数表 — 每个动态插件导出一个此结构体 +#[repr(C)] +pub struct PluginVTable { + /// 创建插件实例,返回不透明句柄 + pub create: unsafe extern "C" fn() -> PluginHandle, + + /// 获取插件信息(返回 JSON 序列化的 PluginInfo) + pub get_info: unsafe extern "C" fn(handle: PluginHandle) -> FfiString, + + /// 初始化插件 + /// config_json: 完整的 AppConfig JSON + /// send_cb: 发送消息的回调 + pub init: unsafe extern "C" fn(handle: PluginHandle, config_json: FfiStr, send_cb: SendCallback) -> FfiResult, + + /// 启动插件 + pub start: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult, + + /// 处理消息 + /// message_json: JSON 序列化的 Message + pub handle_message: unsafe extern "C" fn(handle: PluginHandle, message_json: FfiStr) -> FfiResult, + + /// 停止插件 + pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult, + + /// 销毁插件实例,释放资源 + pub destroy: unsafe extern "C" fn(handle: PluginHandle), +} + +/// 动态插件 .so 中导出的符号名称 +pub const PLUGIN_VTABLE_SYMBOL: &[u8] = b"showen_plugin_vtable\0"; + +/// 从 FfiStr 安全读取为 &str +/// +/// # Safety +/// ptr 必须指向有效的 null-terminated C 字符串 +pub unsafe fn ffi_str_to_str<'a>(ptr: FfiStr) -> Option<&'a str> { + if ptr.is_null() { + return None; + } + 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 new file mode 100644 index 0000000..0c4d346 --- /dev/null +++ b/src/core/plugin_loader.rs @@ -0,0 +1,336 @@ +//! PluginLoader — 扫描 plugin_store/ 目录,发现并加载动态插件 +//! +//! 目录结构: +//! ```text +//! plugin_store/ +//! ├── registry.json +//! └── custom-sensor/ +//! ├── 1.0.0/ +//! │ ├── manifest.json +//! │ └── libcustom_sensor.so +//! └── 1.1.0/ +//! ├── manifest.json +//! └── libcustom_sensor.so +//! ``` + +use crate::core::dynamic_plugin::DynamicPlugin; +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// 插件清单 (manifest.json) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginManifest { + pub id: String, + pub version: String, + pub sdk_version: String, + #[serde(default)] + pub dependencies: Vec, + #[serde(default = "default_error_policy")] + pub error_policy: ErrorPolicy, + pub so_filename: String, +} + +fn default_error_policy() -> ErrorPolicy { + ErrorPolicy::AutoRollback +} + +/// 插件错误处理策略 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ErrorPolicy { + /// 自动回退到上一个稳定版本 + AutoRollback, + /// 禁用插件并记录日志 + DisableAndLog, +} + +/// 全局注册表 (registry.json) +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PluginRegistry { + #[serde(default)] + pub plugins: std::collections::HashMap, +} + +/// 注册表中每个插件的条目 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginRegistryEntry { + pub active_version: String, + #[serde(default)] + pub last_stable_version: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_error_policy")] + pub error_policy: ErrorPolicy, + #[serde(default = "default_max_errors")] + pub max_errors: u32, +} + +fn default_true() -> bool { + true +} + +fn default_max_errors() -> u32 { + 5 +} + +/// 插件加载器 +pub struct PluginLoader { + store_path: PathBuf, +} + +impl PluginLoader { + pub fn new(store_path: impl Into) -> Self { + Self { + store_path: store_path.into(), + } + } + + /// 获取存储路径 + pub fn store_path(&self) -> &Path { + &self.store_path + } + + /// 读取全局注册表 + pub fn load_registry(&self) -> Result { + let registry_path = self.store_path.join("registry.json"); + if !registry_path.exists() { + return Ok(PluginRegistry::default()); + } + + let content = std::fs::read_to_string(®istry_path) + .with_context(|| format!("failed to read {}", registry_path.display()))?; + serde_json::from_str(&content) + .with_context(|| format!("failed to parse {}", registry_path.display())) + } + + /// 保存全局注册表 + 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")?; + std::fs::write(®istry_path, content) + .with_context(|| format!("failed to write {}", registry_path.display())) + } + + /// 发现所有可用插件(扫描目录) + pub fn discover_plugins(&self) -> Result> { + let mut manifests = Vec::new(); + + if !self.store_path.exists() { + return Ok(manifests); + } + + for entry in std::fs::read_dir(&self.store_path) + .with_context(|| format!("failed to read {}", self.store_path.display()))? + { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + // 跳过非插件文件(如 registry.json) + let _plugin_id = match path.file_name().and_then(|n| n.to_str()) { + Some(name) if name != "registry.json" => name.to_string(), + _ => continue, + }; + + // 扫描版本子目录 + for version_entry in std::fs::read_dir(&path)? { + let version_entry = version_entry?; + let version_path = version_entry.path(); + if !version_path.is_dir() { + continue; + } + + let manifest_path = version_path.join("manifest.json"); + if manifest_path.exists() { + match self.read_manifest(&manifest_path) { + Ok(manifest) => manifests.push(manifest), + Err(e) => { + eprintln!( + "[PluginLoader] 跳过无效清单 {}: {e}", + manifest_path.display() + ); + } + } + } + } + } + + Ok(manifests) + } + + /// 读取插件清单 + fn read_manifest(&self, path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + serde_json::from_str(&content) + .with_context(|| format!("failed to parse {}", path.display())) + } + + /// 加载指定插件 + /// version: None 表示使用注册表中的 active_version + pub fn load_plugin( + &self, + plugin_id: &str, + version: Option<&str>, + ) -> Result<(DynamicPlugin, PluginManifest)> { + let version = match version { + Some(v) => v.to_string(), + None => { + let registry = self.load_registry()?; + registry + .plugins + .get(plugin_id) + .map(|e| e.active_version.clone()) + .ok_or_else(|| { + anyhow!("plugin '{plugin_id}' not found in registry") + })? + } + }; + + let version_dir = self.store_path.join(plugin_id).join(&version); + 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() { + 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())? + }; + plugin.set_id(manifest.id.clone()); + + Ok((plugin, manifest)) + } + + /// 列出插件的所有已安装版本 + pub fn list_versions(&self, plugin_id: &str) -> Result> { + let plugin_dir = self.store_path.join(plugin_id); + if !plugin_dir.exists() { + return Ok(vec![]); + } + + let mut versions = Vec::new(); + for entry in std::fs::read_dir(&plugin_dir)? { + let entry = entry?; + if entry.path().is_dir() { + if let Some(name) = entry.file_name().to_str() { + versions.push(name.to_string()); + } + } + } + + versions.sort(); + Ok(versions) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup_test_store(base: &Path) { + let plugin_dir = base.join("test-plugin").join("1.0.0"); + fs::create_dir_all(&plugin_dir).unwrap(); + + let manifest = PluginManifest { + id: "test-plugin".to_string(), + version: "1.0.0".to_string(), + sdk_version: "0.2.0".to_string(), + dependencies: vec![], + error_policy: ErrorPolicy::AutoRollback, + so_filename: "libtest_plugin.so".to_string(), + }; + + fs::write( + plugin_dir.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + } + + #[test] + fn discover_plugins_finds_manifests() { + let tmp = std::env::temp_dir().join("showen_test_discover"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + setup_test_store(&tmp); + + let loader = PluginLoader::new(&tmp); + let manifests = loader.discover_plugins().unwrap(); + + assert_eq!(manifests.len(), 1); + assert_eq!(manifests[0].id, "test-plugin"); + assert_eq!(manifests[0].version, "1.0.0"); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn empty_store_returns_no_plugins() { + let tmp = std::env::temp_dir().join("showen_test_empty"); + let _ = fs::remove_dir_all(&tmp); + + let loader = PluginLoader::new(&tmp); + let manifests = loader.discover_plugins().unwrap(); + assert!(manifests.is_empty()); + } + + #[test] + fn registry_round_trip() { + let tmp = std::env::temp_dir().join("showen_test_registry"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + + let loader = PluginLoader::new(&tmp); + + let mut registry = PluginRegistry::default(); + registry.plugins.insert( + "test-plugin".to_string(), + PluginRegistryEntry { + active_version: "1.0.0".to_string(), + last_stable_version: None, + enabled: true, + error_policy: ErrorPolicy::AutoRollback, + max_errors: 5, + }, + ); + + loader.save_registry(®istry).unwrap(); + let loaded = loader.load_registry().unwrap(); + + assert_eq!(loaded.plugins.len(), 1); + let entry = &loaded.plugins["test-plugin"]; + assert_eq!(entry.active_version, "1.0.0"); + assert!(entry.enabled); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn list_versions_returns_sorted() { + let tmp = std::env::temp_dir().join("showen_test_versions"); + let _ = fs::remove_dir_all(&tmp); + + let plugin_dir = tmp.join("my-plugin"); + fs::create_dir_all(plugin_dir.join("1.1.0")).unwrap(); + fs::create_dir_all(plugin_dir.join("1.0.0")).unwrap(); + fs::create_dir_all(plugin_dir.join("2.0.0")).unwrap(); + + let loader = PluginLoader::new(&tmp); + let versions = loader.list_versions("my-plugin").unwrap(); + assert_eq!(versions, vec!["1.0.0", "1.1.0", "2.0.0"]); + + let _ = fs::remove_dir_all(&tmp); + } +} diff --git a/src/core/plugin_repo.rs b/src/core/plugin_repo.rs new file mode 100644 index 0000000..13eba32 --- /dev/null +++ b/src/core/plugin_repo.rs @@ -0,0 +1,186 @@ +//! PluginRepository — 远程插件仓库客户端 +//! +//! 从 HTTP 文件服务器下载插件包 (.tar.gz),解压安装到 plugin_store/。 +//! +//! 远程仓库协议(简单 HTTP 文件服务): +//! ```text +//! https://plugins.example.com/ +//! ├── index.json # 插件目录 +//! ├── custom-sensor/ +//! │ ├── latest.json # {"version": "1.1.0"} +//! │ └── 1.1.0.tar.gz # 包含 manifest.json + .so +//! ``` + +use crate::core::plugin_loader::PluginLoader; +use anyhow::{anyhow, Context, Result}; +use flate2::read::GzDecoder; +use serde::{Deserialize, Serialize}; +use std::io::Read; + +/// 远程仓库索引 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoIndex { + pub plugins: Vec, +} + +/// 远程仓库中的插件条目 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoPluginEntry { + pub id: String, + pub latest_version: String, + pub description: String, + #[serde(default)] + pub versions: Vec, +} + +/// 远程仓库最新版本查询响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LatestVersion { + pub version: String, +} + +/// 远程插件仓库客户端 +pub struct PluginRepository { + base_url: String, + loader: PluginLoader, +} + +impl PluginRepository { + pub fn new(base_url: &str, loader: PluginLoader) -> Self { + let base_url = base_url.trim_end_matches('/').to_string(); + Self { base_url, loader } + } + + pub fn loader(&self) -> &PluginLoader { + &self.loader + } + + /// 获取远程仓库索引 + pub fn fetch_index(&self) -> Result { + let url = format!("{}/index.json", self.base_url); + let response = ureq::get(&url) + .call() + .with_context(|| format!("failed to fetch repo index from {url}"))?; + + let body = response + .into_string() + .context("failed to read repo index body")?; + + serde_json::from_str(&body).context("failed to parse repo index") + } + + /// 检查指定插件是否有新版本 + /// 返回 Some(新版本号) 或 None(已是最新) + pub fn check_update(&self, plugin_id: &str, current_version: &str) -> Result> { + let url = format!("{}/{}/latest.json", self.base_url, plugin_id); + let response = ureq::get(&url) + .call() + .with_context(|| format!("failed to check update for '{plugin_id}'"))?; + + let body = response.into_string()?; + let latest: LatestVersion = serde_json::from_str(&body)?; + + let current = semver::Version::parse(current_version) + .with_context(|| format!("invalid current version: {current_version}"))?; + let remote = semver::Version::parse(&latest.version) + .with_context(|| format!("invalid remote version: {}", latest.version))?; + + if remote > current { + Ok(Some(latest.version)) + } else { + Ok(None) + } + } + + /// 下载并安装插件到 plugin_store/ + 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}" + ); + + let response = ureq::get(&url) + .call() + .with_context(|| format!("failed to download {url}"))?; + + // 读取响应体 + let mut body = Vec::new(); + response + .into_reader() + .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); + + if target_dir.exists() { + return Err(anyhow!( + "version {version} already installed for plugin '{plugin_id}'" + )); + } + + std::fs::create_dir_all(&target_dir).with_context(|| { + format!("failed to create {}", target_dir.display()) + })?; + + // 解压 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()))?; + + // 验证 manifest.json 存在 + let manifest_path = target_dir.join("manifest.json"); + if !manifest_path.exists() { + // 清理 + let _ = std::fs::remove_dir_all(&target_dir); + return Err(anyhow!( + "downloaded archive for '{plugin_id}' v{version} missing manifest.json" + )); + } + + println!( + "[PluginRepo] 插件 '{plugin_id}' v{version} 安装成功到 {}", + target_dir.display() + ); + Ok(()) + } + + /// 批量检查所有已安装插件的更新 + pub fn check_all_updates( + &self, + ) -> Result> { + // (plugin_id, current_version, new_version) + let registry = self.loader.load_registry()?; + let mut updates = Vec::new(); + + 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, + )); + } + Ok(None) => {} + Err(e) => { + eprintln!( + "[PluginRepo] 检查 '{plugin_id}' 更新失败: {e}" + ); + } + } + } + + Ok(updates) + } +} diff --git a/src/core/service_manager.rs b/src/core/service_manager.rs index 48adb1a..3d3b5a0 100644 --- a/src/core/service_manager.rs +++ b/src/core/service_manager.rs @@ -1,13 +1,72 @@ use crate::core::config::AppConfig; use crate::core::message::{Destination, Envelope, Message}; use crate::core::plugin::{Plugin, PluginContext}; +use crate::core::plugin_loader::ErrorPolicy; use anyhow::{anyhow, Result}; use std::collections::{HashMap, HashSet}; use std::sync::{mpsc, Arc}; +/// 插件运行时状态包装 +struct PluginState { + plugin: Box, + /// 是否为动态加载的插件 + is_dynamic: bool, + /// 错误处理策略 + error_policy: ErrorPolicy, + /// 连续错误计数 + error_count: u32, + /// 最大允许错误数 + max_errors: u32, + /// 是否启用 + enabled: bool, +} + +impl PluginState { + fn new_static(plugin: Box) -> Self { + Self { + plugin, + is_dynamic: false, + error_policy: ErrorPolicy::DisableAndLog, + error_count: 0, + max_errors: u32::MAX, // 静态插件不自动禁用 + enabled: true, + } + } + + fn new_dynamic( + plugin: Box, + error_policy: ErrorPolicy, + max_errors: u32, + ) -> Self { + Self { + plugin, + is_dynamic: true, + error_policy, + error_count: 0, + max_errors, + enabled: true, + } + } + + fn id(&self) -> &str { + self.plugin.id() + } + + /// 记录一次错误,返回是否超过阈值 + fn record_error(&mut self) -> bool { + self.error_count += 1; + self.error_count >= self.max_errors + } + + /// 重置错误计数(成功处理消息后调用) + fn reset_errors(&mut self) { + self.error_count = 0; + } +} + /// 中央调度器:插件注册、生命周期管理、消息路由 pub struct ServiceManager { - plugins: Vec>, + plugins: Vec, config: Arc, tx: mpsc::Sender, rx: mpsc::Receiver, @@ -26,30 +85,77 @@ impl ServiceManager { } } - /// 注册插件 + /// 注册静态插件(编译时链接的插件) pub fn register(&mut self, plugin: Box) { println!("[ServiceManager] 注册插件: {}", plugin.id()); - self.plugins.push(plugin); + self.plugins.push(PluginState::new_static(plugin)); + } + + /// 注册动态插件(运行时加载的 .so 插件) + pub fn register_dynamic( + &mut self, + plugin: Box, + error_policy: ErrorPolicy, + max_errors: u32, + ) { + println!( + "[ServiceManager] 注册动态插件: {} (策略: {:?}, 最大错误: {})", + plugin.id(), + error_policy, + max_errors + ); + self.plugins + .push(PluginState::new_dynamic(plugin, error_policy, max_errors)); } /// 按注册顺序 init() + start() 所有插件 + /// 动态插件 init/start 失败时按策略处理,不中断其他插件 pub fn start_all(&mut self) -> Result<()> { self.validate_and_sort_plugins()?; // init - for plugin in &mut self.plugins { + for state in &mut self.plugins { let ctx = PluginContext { tx: self.tx.clone(), config: Arc::clone(&self.config), }; - println!("[ServiceManager] 初始化插件: {}", plugin.id()); - plugin.init(ctx)?; + println!("[ServiceManager] 初始化插件: {}", state.id()); + if let Err(e) = state.plugin.init(ctx) { + if state.is_dynamic { + eprintln!( + "[ServiceManager] 动态插件 '{}' 初始化失败,禁用: {}", + state.id(), + e + ); + state.enabled = false; + continue; + } else { + return Err(e); + } + } } + // start - for plugin in &mut self.plugins { - println!("[ServiceManager] 启动插件: {}", plugin.id()); - plugin.start()?; + for state in &mut self.plugins { + if !state.enabled { + continue; + } + println!("[ServiceManager] 启动插件: {}", state.id()); + if let Err(e) = state.plugin.start() { + if state.is_dynamic { + eprintln!( + "[ServiceManager] 动态插件 '{}' 启动失败,禁用: {}", + state.id(), + e + ); + state.enabled = false; + continue; + } else { + return Err(e); + } + } } + Ok(()) } @@ -69,13 +175,7 @@ impl ServiceManager { match envelope.to { Destination::Plugin(id) => { - if let Some(plugin) = self.plugins.iter_mut().find(|p| p.id() == id) { - if let Err(e) = plugin.handle_message(envelope.message) { - eprintln!("[ServiceManager] 插件 '{}' 处理消息失败: {}", id, e); - } - } else { - eprintln!("[ServiceManager] 目标插件 '{}' 不存在", id); - } + self.deliver_to_plugin(&id, envelope.message); } Destination::Broadcast => { self.broadcast_message(envelope.message); @@ -92,15 +192,94 @@ impl ServiceManager { /// 逆序 stop() 所有插件 pub fn stop_all(&mut self) -> Result<()> { println!("[ServiceManager] 停止所有插件"); - for plugin in self.plugins.iter_mut().rev() { - println!("[ServiceManager] 停止插件: {}", plugin.id()); - if let Err(e) = plugin.stop() { - eprintln!("[ServiceManager] 停止插件 '{}' 失败: {}", plugin.id(), e); + for state in self.plugins.iter_mut().rev() { + if !state.enabled { + continue; + } + println!("[ServiceManager] 停止插件: {}", state.id()); + if let Err(e) = state.plugin.stop() { + eprintln!( + "[ServiceManager] 停止插件 '{}' 失败: {}", + state.id(), + e + ); } } Ok(()) } + /// 启用/禁用指定插件 + pub fn set_plugin_enabled(&mut self, plugin_id: &str, enabled: bool) -> Result<()> { + let state = self + .plugins + .iter_mut() + .find(|s| s.id() == plugin_id) + .ok_or_else(|| anyhow!("plugin '{plugin_id}' not found"))?; + + if enabled && !state.enabled { + // 重新启用:reset 错误计数 + state.error_count = 0; + state.enabled = true; + println!("[ServiceManager] 插件 '{plugin_id}' 已启用"); + } else if !enabled && state.enabled { + state.enabled = false; + println!("[ServiceManager] 插件 '{plugin_id}' 已禁用"); + } + + Ok(()) + } + + /// 查询插件状态信息(供 HTTP API 使用) + pub fn plugin_states(&self) -> Vec { + self.plugins + .iter() + .map(|s| PluginStateInfo { + id: s.id().to_string(), + info: s.plugin.info(), + is_dynamic: s.is_dynamic, + error_policy: s.error_policy.clone(), + error_count: s.error_count, + max_errors: s.max_errors, + enabled: s.enabled, + }) + .collect() + } + + /// 热替换动态插件(stop 旧的 → 替换 → init → start 新的) + pub fn replace_dynamic_plugin( + &mut self, + plugin_id: &str, + new_plugin: Box, + error_policy: ErrorPolicy, + max_errors: u32, + ) -> Result<()> { + let idx = self + .plugins + .iter() + .position(|s| s.id() == plugin_id) + .ok_or_else(|| anyhow!("plugin '{plugin_id}' not found for replacement"))?; + + // Stop old plugin + if self.plugins[idx].enabled { + let _ = self.plugins[idx].plugin.stop(); + } + + // Replace + let mut new_state = PluginState::new_dynamic(new_plugin, error_policy, max_errors); + + // Init new plugin + let ctx = PluginContext { + tx: self.tx.clone(), + config: Arc::clone(&self.config), + }; + new_state.plugin.init(ctx)?; + new_state.plugin.start()?; + + self.plugins[idx] = new_state; + println!("[ServiceManager] 插件 '{plugin_id}' 热替换成功"); + Ok(()) + } + /// 处理发给管理层自身的消息 fn handle_manager_message(&mut self, msg: Message) -> Result<()> { match msg { @@ -154,14 +333,14 @@ impl ServiceManager { let mut plugin_set = HashSet::with_capacity(self.plugins.len()); let mut dependency_map = HashMap::with_capacity(self.plugins.len()); - for plugin in &self.plugins { - let id = plugin.id(); - if !plugin_set.insert(id) { + for state in &self.plugins { + let id = state.id().to_string(); + if !plugin_set.insert(id.clone()) { return Err(anyhow!("duplicate plugin id registered: '{id}'")); } - plugin_ids.push(id); - dependency_map.insert(id, plugin.dependencies()); + plugin_ids.push(id.clone()); + dependency_map.insert(id, state.plugin.dependencies()); } for (plugin_id, dependencies) in &dependency_map { @@ -170,7 +349,7 @@ impl ServiceManager { return Err(anyhow!("plugin '{plugin_id}' cannot depend on itself")); } - if !plugin_set.contains(dependency) { + if !plugin_set.contains(dependency.as_str()) { return Err(anyhow!( "plugin '{plugin_id}' depends on missing plugin '{dependency}'" )); @@ -197,8 +376,8 @@ impl ServiceManager { .iter() .all(|dependency| resolved.contains(dependency)) { - resolved.insert(*plugin_id); - sorted_ids.push(*plugin_id); + resolved.insert(plugin_id.clone()); + sorted_ids.push(plugin_id.clone()); progressed = true; } } @@ -206,8 +385,8 @@ impl ServiceManager { if !progressed { let unresolved = plugin_ids .iter() - .copied() - .filter(|plugin_id| !resolved.contains(plugin_id)) + .filter(|plugin_id| !resolved.contains(plugin_id.as_str())) + .cloned() .collect::>() .join(", "); @@ -220,10 +399,10 @@ impl ServiceManager { let mut remaining_plugins = std::mem::take(&mut self.plugins); let mut ordered_plugins = Vec::with_capacity(remaining_plugins.len()); - for plugin_id in sorted_ids { + for plugin_id in &sorted_ids { let index = remaining_plugins .iter() - .position(|plugin| plugin.id() == plugin_id) + .position(|state| state.id() == plugin_id) .ok_or_else(|| anyhow!("plugin '{plugin_id}' disappeared during sorting"))?; ordered_plugins.push(remaining_plugins.remove(index)); } @@ -232,16 +411,60 @@ impl ServiceManager { Ok(()) } + /// 投递消息给指定插件,带错误计数和策略处理 + fn deliver_to_plugin(&mut self, id: &str, msg: Message) { + let state = match self.plugins.iter_mut().find(|s| s.id() == id) { + Some(s) => s, + None => { + eprintln!("[ServiceManager] 目标插件 '{}' 不存在", id); + return; + } + }; + + if !state.enabled { + return; + } + + match state.plugin.handle_message(msg) { + Ok(()) => { + state.reset_errors(); + } + Err(e) => { + eprintln!( + "[ServiceManager] 插件 '{}' 处理消息失败 ({}/{}): {}", + id, + state.error_count + 1, + state.max_errors, + e + ); + + if state.record_error() && state.is_dynamic { + self.handle_error_threshold(id); + } + } + } + } + fn broadcast_message(&mut self, msg: Message) { let should_shutdown = matches!(&msg, Message::Shutdown); - for plugin in &mut self.plugins { - if let Err(e) = plugin.handle_message(msg.clone()) { - eprintln!( - "[ServiceManager] 插件 '{}' 处理广播消息失败: {}", - plugin.id(), - e - ); + for state in &mut self.plugins { + if !state.enabled { + continue; + } + + match state.plugin.handle_message(msg.clone()) { + Ok(()) => { + state.reset_errors(); + } + Err(e) => { + eprintln!( + "[ServiceManager] 插件 '{}' 处理广播消息失败: {}", + state.id(), + e + ); + // 广播消息的错误不触发阈值处理(避免广播期间修改列表) + } } } @@ -251,8 +474,46 @@ impl ServiceManager { } } + /// 插件错误达到阈值时的处理 + fn handle_error_threshold(&mut self, plugin_id: &str) { + let state = match self.plugins.iter_mut().find(|s| s.id() == plugin_id) { + Some(s) => s, + None => return, + }; + + match state.error_policy { + ErrorPolicy::DisableAndLog => { + eprintln!( + "[ServiceManager] 插件 '{}' 错误次数达到阈值,已禁用", + plugin_id + ); + state.enabled = false; + } + ErrorPolicy::AutoRollback => { + eprintln!( + "[ServiceManager] 插件 '{}' 错误次数达到阈值,需要回退 (由外部 VersionManager 处理)", + plugin_id + ); + // 先禁用,等待外部 (main.rs / HTTP API) 调用 VersionManager 执行回退 + state.enabled = false; + } + } + } + /// 获取发送通道的克隆(供外部使用) pub fn sender(&self) -> mpsc::Sender { self.tx.clone() } } + +/// 插件状态信息(用于 API 查询) +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PluginStateInfo { + pub id: String, + pub info: crate::core::plugin::PluginInfo, + pub is_dynamic: bool, + pub error_policy: ErrorPolicy, + pub error_count: u32, + pub max_errors: u32, + pub enabled: bool, +} diff --git a/src/core/tests.rs b/src/core/tests.rs index 5dc6139..1d2ccc9 100644 --- a/src/core/tests.rs +++ b/src/core/tests.rs @@ -77,14 +77,18 @@ fn message_label(message: &Message) -> String { } struct TestPlugin { - id: &'static str, - deps: Vec<&'static str>, + id: String, + deps: Vec, events: Arc>>, } impl TestPlugin { - fn new(id: &'static str, deps: Vec<&'static str>, events: Arc>>) -> Self { - Self { id, deps, events } + fn new(id: &str, deps: Vec<&str>, events: Arc>>) -> Self { + Self { + id: id.to_string(), + deps: deps.into_iter().map(|s| s.to_string()).collect(), + events, + } } fn record(&self, entry: impl Into) { @@ -93,20 +97,20 @@ impl TestPlugin { } impl Plugin for TestPlugin { - fn id(&self) -> &'static str { - self.id + fn id(&self) -> &str { + &self.id } fn info(&self) -> PluginInfo { PluginInfo { - name: self.id, - version: "test", - description: "test plugin", + name: self.id.clone(), + version: "test".to_string(), + description: "test plugin".to_string(), platform: Platform::Any, } } - fn dependencies(&self) -> Vec<&'static str> { + fn dependencies(&self) -> Vec { self.deps.clone() } @@ -168,8 +172,8 @@ fn routes_plugin_broadcast_and_manager_messages() { sender .send(Envelope { - from: "alpha", - to: Destination::Plugin("beta"), + from: "alpha".to_string(), + to: Destination::Plugin("beta".to_string()), message: Message::Custom { kind: "direct".to_string(), payload: "hello".to_string(), @@ -178,7 +182,7 @@ fn routes_plugin_broadcast_and_manager_messages() { .expect("direct message should send"); sender .send(Envelope { - from: "alpha", + from: "alpha".to_string(), to: Destination::Broadcast, message: Message::Custom { kind: "broadcast".to_string(), @@ -188,14 +192,14 @@ fn routes_plugin_broadcast_and_manager_messages() { .expect("broadcast message should send"); sender .send(Envelope { - from: "alpha", + from: "alpha".to_string(), to: Destination::Manager, - message: Message::PluginReady("alpha"), + message: Message::PluginReady("alpha".to_string()), }) .expect("manager message should send"); sender .send(Envelope { - from: "test", + from: "test".to_string(), to: Destination::Manager, message: Message::Shutdown, }) @@ -301,14 +305,14 @@ fn wifi_result_sent_to_manager_is_broadcast_to_plugins() { sender .send(Envelope { - from: "wifi", + from: "wifi".to_string(), to: Destination::Manager, message: Message::WifiResult("connected".to_string()), }) .expect("wifi result should send"); sender .send(Envelope { - from: "test", + from: "test".to_string(), to: Destination::Manager, message: Message::Shutdown, }) @@ -379,8 +383,8 @@ fn all_plugin_ids_must_be_unique() { let mut ids = HashSet::new(); for plugin in plugins { - let id = plugin.id(); - assert!(ids.insert(id), "duplicate plugin id detected: '{}'", id); + let id = plugin.id().to_string(); + assert!(ids.insert(id.clone()), "duplicate plugin id detected: '{}'", id); } } diff --git a/src/core/version_manager.rs b/src/core/version_manager.rs new file mode 100644 index 0000000..4b9ce2c --- /dev/null +++ b/src/core/version_manager.rs @@ -0,0 +1,286 @@ +//! VersionManager — 插件版本切换、回退、稳定标记 +//! +//! 管理 plugin_store/ 中插件的版本生命周期。 + +use crate::core::plugin_loader::PluginLoader; +use anyhow::{anyhow, Context, Result}; + +/// 版本管理器 +pub struct VersionManager { + loader: PluginLoader, +} + +/// 版本信息 +#[derive(Debug, Clone, serde::Serialize)] +pub struct VersionInfo { + pub version: String, + pub is_active: bool, + pub is_stable: bool, +} + +impl VersionManager { + pub fn new(loader: PluginLoader) -> Self { + Self { loader } + } + + pub fn loader(&self) -> &PluginLoader { + &self.loader + } + + pub fn loader_mut(&mut self) -> &mut PluginLoader { + &mut self.loader + } + + /// 标记当前活跃版本为稳定版本 + pub fn mark_stable(&self, plugin_id: &str, version: &str) -> Result<()> { + let mut registry = self.loader.load_registry()?; + let entry = registry + .plugins + .get_mut(plugin_id) + .ok_or_else(|| anyhow!("plugin '{plugin_id}' not in registry"))?; + + entry.last_stable_version = Some(version.to_string()); + self.loader.save_registry(®istry)?; + + println!( + "[VersionManager] 插件 '{plugin_id}' v{version} 标记为稳定版本" + ); + Ok(()) + } + + /// 回退到上一个稳定版本,返回回退到的版本号 + pub fn rollback(&self, plugin_id: &str) -> Result { + let mut registry = self.loader.load_registry()?; + let entry = registry + .plugins + .get_mut(plugin_id) + .ok_or_else(|| anyhow!("plugin '{plugin_id}' not in registry"))?; + + let stable_version = entry + .last_stable_version + .clone() + .ok_or_else(|| anyhow!("plugin '{plugin_id}' has no stable version to rollback to"))?; + + if stable_version == entry.active_version { + return Err(anyhow!( + "plugin '{plugin_id}' is already at stable version {stable_version}" + )); + } + + let old_version = entry.active_version.clone(); + entry.active_version = stable_version.clone(); + self.loader.save_registry(®istry)?; + + 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); + if !version_dir.exists() { + return Err(anyhow!( + "version {version} not found for plugin '{plugin_id}'" + )); + } + + let mut registry = self.loader.load_registry()?; + let entry = registry + .plugins + .get_mut(plugin_id) + .ok_or_else(|| anyhow!("plugin '{plugin_id}' not in registry"))?; + + entry.active_version = version.to_string(); + self.loader.save_registry(®istry)?; + + println!( + "[VersionManager] 插件 '{plugin_id}' 切换到 v{version}" + ); + Ok(()) + } + + /// 列出插件的所有版本信息 + pub fn list_versions(&self, plugin_id: &str) -> Result> { + let versions = self.loader.list_versions(plugin_id)?; + let registry = self.loader.load_registry()?; + let entry = registry.plugins.get(plugin_id); + + Ok(versions + .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) + }); + VersionInfo { + version: v, + is_active, + is_stable, + } + }) + .collect()) + } + + /// 垃圾回收:保留最近 N 个版本,删除旧版本 + /// 不删除活跃版本和稳定版本 + pub fn gc(&self, plugin_id: &str, keep: usize) -> Result> { + let versions = self.loader.list_versions(plugin_id)?; + let registry = self.loader.load_registry()?; + let entry = registry.plugins.get(plugin_id); + + let active = entry.map(|e| e.active_version.as_str()); + let stable = entry.and_then(|e| e.last_stable_version.as_deref()); + + // 保护活跃版本和稳定版本 + let mut deletable: Vec<&str> = versions + .iter() + .map(|s| s.as_str()) + .filter(|v| Some(*v) != active && Some(*v) != stable) + .collect(); + + // 保留最近的 keep 个(版本排在后面的更新) + let mut removed = Vec::new(); + while deletable.len() + 2 > keep && !deletable.is_empty() { + // 2 是为活跃和稳定版本预留 + let oldest = deletable.remove(0); + 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()) + })?; + removed.push(oldest.to_string()); + println!( + "[VersionManager] 已清理 '{plugin_id}' v{oldest}" + ); + } + } + + Ok(removed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::plugin_loader::{ErrorPolicy, PluginRegistry, PluginRegistryEntry}; + use std::fs; + use std::path::Path; + + fn setup(base: &Path) -> VersionManager { + let _ = fs::remove_dir_all(base); + fs::create_dir_all(base).unwrap(); + + let loader = PluginLoader::new(base); + + // 创建两个版本目录 + for v in &["1.0.0", "1.1.0", "2.0.0"] { + fs::create_dir_all(base.join("test-plugin").join(v)).unwrap(); + } + + // 写入注册表 + 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, + }, + ); + loader.save_registry(®istry).unwrap(); + + VersionManager::new(loader) + } + + #[test] + fn rollback_switches_to_stable_version() { + let tmp = std::env::temp_dir().join("showen_test_rollback"); + let vm = setup(&tmp); + + let rolled = vm.rollback("test-plugin").unwrap(); + assert_eq!(rolled, "1.0.0"); + + let reg = vm.loader().load_registry().unwrap(); + assert_eq!(reg.plugins["test-plugin"].active_version, "1.0.0"); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn switch_version_updates_active() { + let tmp = std::env::temp_dir().join("showen_test_switch"); + let vm = setup(&tmp); + + vm.switch_version("test-plugin", "2.0.0").unwrap(); + + let reg = vm.loader().load_registry().unwrap(); + assert_eq!(reg.plugins["test-plugin"].active_version, "2.0.0"); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn mark_stable_updates_registry() { + let tmp = std::env::temp_dir().join("showen_test_mark_stable"); + let vm = setup(&tmp); + + vm.mark_stable("test-plugin", "1.1.0").unwrap(); + + let reg = vm.loader().load_registry().unwrap(); + assert_eq!( + reg.plugins["test-plugin"].last_stable_version, + Some("1.1.0".to_string()) + ); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn list_versions_shows_flags() { + let tmp = std::env::temp_dir().join("showen_test_list_ver"); + let vm = setup(&tmp); + + let versions = vm.list_versions("test-plugin").unwrap(); + assert_eq!(versions.len(), 3); + + let v100 = versions.iter().find(|v| v.version == "1.0.0").unwrap(); + assert!(!v100.is_active); + assert!(v100.is_stable); + + let v110 = versions.iter().find(|v| v.version == "1.1.0").unwrap(); + assert!(v110.is_active); + assert!(!v110.is_stable); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn gc_removes_old_versions() { + let tmp = std::env::temp_dir().join("showen_test_gc"); + let vm = setup(&tmp); + + // keep=2: active(1.1.0) + stable(1.0.0) are protected, 2.0.0 is deletable + // With keep=2, since we have 2 protected + 1 deletable = 3, and we want to keep at most 2 total... + // Actually the gc tries to keep `keep` total versions including protected ones + let removed = vm.gc("test-plugin", 2).unwrap(); + assert_eq!(removed, vec!["2.0.0"]); + + let versions = vm.loader().list_versions("test-plugin").unwrap(); + assert_eq!(versions.len(), 2); + + let _ = fs::remove_dir_all(&tmp); + } +} diff --git a/src/lib.rs b/src/lib.rs index 75b08de..42f3732 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,5 +5,7 @@ //! //! 核心理念:平台不关心内容是什么,插件决定一切。 +#![recursion_limit = "512"] + pub mod core; pub mod plugins; diff --git a/src/main.rs b/src/main.rs index 8b97bcf..f50b79e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use anyhow::Result; use showen_v2::core::config::AppConfig; +use showen_v2::core::plugin_loader::PluginLoader; use showen_v2::core::service_manager::ServiceManager; use showen_v2::plugins::{ ble::BlePlugin, http::HttpPlugin, screen::ScreenPlugin, video::VideoPlugin, wifi::WifiPlugin, @@ -64,6 +65,46 @@ fn main() -> Result<()> { manager.register(Box::new(HttpPlugin::new())); println!(" ✓ HttpPlugin"); + // 加载动态插件 + let plugin_store = std::path::Path::new("plugin_store"); + if plugin_store.exists() { + println!("扫描动态插件..."); + let loader = PluginLoader::new(plugin_store); + match loader.load_registry() { + Ok(registry) => { + for (plugin_id, entry) in ®istry.plugins { + if !entry.enabled { + println!(" - {plugin_id} (禁用)"); + continue; + } + + match loader.load_plugin(plugin_id, Some(&entry.active_version)) { + Ok((plugin, manifest)) => { + manager.register_dynamic( + Box::new(plugin), + manifest.error_policy, + entry.max_errors, + ); + println!( + " ✓ {} v{} (动态)", + plugin_id, entry.active_version + ); + } + Err(e) => { + eprintln!( + " ✗ {} v{} 加载失败: {e}", + plugin_id, entry.active_version + ); + } + } + } + } + Err(e) => { + eprintln!("读取插件注册表失败: {e}"); + } + } + } + // 设置 Ctrl+C 信号处理 let running = Arc::new(AtomicBool::new(true)); let r = running.clone(); diff --git a/src/plugins/README.md b/src/plugins/README.md new file mode 100644 index 0000000..98c3e98 --- /dev/null +++ b/src/plugins/README.md @@ -0,0 +1,28 @@ +# plugins/ — 内置功能插件 + +ShowenV2 编译时链接的 5 个内置插件。 + +| 插件 | 目录 | 说明 | 平台 | +|------|------|------|------| +| VideoPlugin | `video/` | 视频播放引擎,基于 OpenCV,支持状态机驱动、帧变换、过渡效果 | Any | +| HttpPlugin | `http/` | Web UI + REST API + WebSocket,基于 warp,依赖 VideoPlugin | Any | +| BlePlugin | `ble/` | BLE GATT WiFi 配网,基于 D-Bus/BlueZ | Linux | +| WifiPlugin | `wifi/` | WiFi 管理(扫描/连接/热点),基于 nmcli | Linux | +| ScreenPlugin | `screen/` | 屏幕唤醒锁 + 光标隐藏,基于 systemd-inhibit | Linux | + +## 依赖关系 + +``` +video ←── http +screen (独立) +ble (独立) +wifi (独立) +``` + +## 插件生命周期 + +1. `register()` → ServiceManager 注册 +2. `init(ctx)` → 获取消息通道和配置 +3. `start()` → 启动工作线程 +4. `handle_message(msg)` → 处理消息 +5. `stop()` → 优雅关闭 diff --git a/src/plugins/ble/README.md b/src/plugins/ble/README.md new file mode 100644 index 0000000..4070540 --- /dev/null +++ b/src/plugins/ble/README.md @@ -0,0 +1,22 @@ +# BlePlugin — BLE 配网服务 + +通过 D-Bus 与 BlueZ 交互,注册 GATT 服务和 LE Advertisement,实现 BLE WiFi 配网。 + +## 模块 + +| 文件 | 说明 | +|------|------| +| `mod.rs` | BlePlugin 实现,工作线程管理 | +| `gatt.rs` | D-Bus GATT 服务注册、BLE 广播、命令解析、WiFi 凭据传递 | + +## 功能 + +- GATT 服务注册(含 LocalName 双连接修复) +- LE Advertisement 广播 +- WiFi SSID/Password 特征值写入 +- 命令特征值(play/pause/scan 等文本命令) +- 状态通知推送给 BLE 客户端 + +## 平台 + +Linux only (D-Bus + BlueZ) diff --git a/src/plugins/ble/gatt.rs b/src/plugins/ble/gatt.rs index 2370453..d8dfa7b 100644 --- a/src/plugins/ble/gatt.rs +++ b/src/plugins/ble/gatt.rs @@ -1,4 +1,5 @@ -use crate::core::message::{Destination, Envelope, Message, WifiCommand}; +use crate::core::dispatch; +use crate::core::message::{Destination, Envelope, Message}; use anyhow::{anyhow, Context, Result}; use dbus::arg::{PropMap, Variant}; use dbus::blocking::stdintf::org_freedesktop_dbus::{ObjectManager, Properties}; @@ -12,7 +13,6 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::sync::{Arc, Mutex}; -use std::thread; use std::time::Duration; const BUS_NAME: &str = "io.showen.BleProvisioning"; @@ -106,50 +106,29 @@ impl SharedState { fn dispatch_command(&self, raw: &[u8]) -> Result<()> { let command = bytes_to_string(raw); - let message = match command.as_str() { - "scan" => Message::WifiCommand(WifiCommand::Scan), - "status" => Message::WifiCommand(WifiCommand::Status), - "connect" => { - let ssid = self.ssid.lock().unwrap().clone(); - let password = self.password.lock().unwrap().clone(); - if ssid.trim().is_empty() { - self.set_status(r#"{"ok":false,"action":"connect","error":"ssid required"}"#); - return Err(anyhow!("ssid required before connect")); - } - Message::WifiCommand(WifiCommand::Connect { ssid, password }) - } - "ap_start" => { - let ssid = self.ssid.lock().unwrap().clone(); - let password = self.password.lock().unwrap().clone(); - if ssid.trim().is_empty() { - self.set_status(r#"{"ok":false,"action":"ap_start","error":"ssid required"}"#); - return Err(anyhow!("ssid required before ap_start")); - } - Message::WifiCommand(WifiCommand::ApStart { ssid, password }) - } - "ap_stop" => Message::WifiCommand(WifiCommand::ApStop), - other => { + let ssid = self.ssid.lock().unwrap().clone(); + let password = self.password.lock().unwrap().clone(); + + match dispatch::parse_command(&command, "ble", &ssid, &password) { + Ok(result) => { + self.tx + .send(result.envelope) + .context("failed to send command from BLE")?; self.set_status(format!( - r#"{{"ok":false,"action":"{}","error":"unsupported command"}}"#, - other + r#"{{"ok":true,"action":"{}","state":"queued"}}"#, + command )); - return Err(anyhow!("unsupported BLE command: {}", other)); + Ok(()) } - }; - - self.tx - .send(Envelope { - from: "ble", - to: Destination::Plugin("wifi"), - message, - }) - .context("failed to send WiFi command from BLE")?; - - self.set_status(format!( - r#"{{"ok":true,"action":"{}","state":"queued"}}"#, - command - )); - Ok(()) + Err(error) => { + self.set_status(format!( + r#"{{"ok":false,"action":"{}","error":"{}"}}"#, + command, + error.replace('"', "\\\"") + )); + Err(anyhow!("BLE command error: {}", error)) + } + } } } @@ -190,78 +169,15 @@ pub fn run_ble_service( stop: Arc, ) -> Result<()> { let shared = SharedState::new(tx.clone()); - let (ready_tx, ready_rx) = mpsc::channel(); - let server_stop = Arc::clone(&stop); - let server_shared = shared.clone(); - let server_device_name = device_name.clone(); - let server_thread = thread::spawn(move || { - run_server_connection(server_shared, server_device_name, ready_tx, server_stop) - }); - - match ready_rx - .recv_timeout(Duration::from_secs(5)) - .context("BLE server connection did not become ready in time") - { - Ok(Ok(())) => {} - Ok(Err(error)) => { - stop.store(true, Ordering::SeqCst); - let _ = join_server_thread(server_thread); - return Err(error); - } - Err(error) => { - stop.store(true, Ordering::SeqCst); - let _ = join_server_thread(server_thread); - return Err(error); - } - } - - let client_result = (|| -> Result<()> { - let conn_client = - Connection::new_system().context("failed to connect to system bus for BLE client")?; - let adapter_path = find_adapter(&conn_client)?; - - configure_adapter(&conn_client, &adapter_path, &device_name)?; - register_ble_objects(&conn_client, &adapter_path)?; - - tx.send(Envelope { - from: "ble", - to: Destination::Manager, - message: Message::PluginReady("ble"), - }) - .context("failed to report BLE plugin readiness")?; - - while !stop.load(Ordering::SeqCst) { - drain_control_messages(&shared, &control_rx)?; - thread::sleep(SERVER_TIMEOUT); - } - - drain_control_messages(&shared, &control_rx)?; - - unregister_ble_objects(&conn_client, &adapter_path) - })(); - - if client_result.is_err() { - stop.store(true, Ordering::SeqCst); - } - - join_server_thread(server_thread)?; - - client_result -} - -fn run_server_connection( - shared: SharedState, - device_name: String, - ready_tx: mpsc::Sender>, - stop: Arc, -) -> Result<()> { - let conn_server = - Connection::new_system().context("failed to connect to system bus for BLE server")?; - conn_server - .request_name(BUS_NAME, false, true, false) + eprintln!("[BLE] connecting to system bus..."); + 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"); + // 构建 Crossroads 并注册所有 GATT/Advertisement objects let mut cr = Crossroads::new(); let object_manager = register_object_manager_iface(&mut cr); let service_iface = register_service_iface(&mut cr); @@ -333,14 +249,16 @@ fn run_server_connection( AdvertisementData { advertisement_type: "peripheral".to_string(), service_uuids: vec![SERVICE_UUID.to_string()], - local_name: device_name, - includes: vec!["tx-power".to_string()], + local_name: device_name.clone(), + includes: vec!["tx-power".to_string(), "local-name".to_string()], }, ); + // 注册 Crossroads 消息处理(必须在 RegisterApplication 之前, + // 因为 BlueZ 会在注册过程中回调 GetManagedObjects) let shared_cr = Arc::new(Mutex::new(cr)); let cr_for_handler = Arc::clone(&shared_cr); - conn_server.start_receive( + conn.start_receive( MatchRule::new_method_call(), Box::new(move |msg, conn| { if cr_for_handler @@ -349,25 +267,56 @@ fn run_server_connection( .handle_message(msg, conn) .is_err() { - eprintln!("[ble] crossroads dispatch error"); + eprintln!("[BLE] crossroads dispatch error"); } true }), ); - ready_tx - .send(Ok(())) - .map_err(|_| anyhow!("failed to notify BLE server readiness"))?; + // 配置 adapter + let adapter_path = find_adapter(&conn)?; + configure_adapter(&conn, &adapter_path, &device_name)?; + // 非阻塞发送 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..."); + + // 处理消息循环等待 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")?; + } + + eprintln!("[BLE] GATT application and advertisement registered"); + + tx.send(Envelope { + from: "ble".to_string(), + to: Destination::Manager, + message: Message::PluginReady("ble".to_string()), + }) + .context("failed to report BLE plugin readiness")?; + + eprintln!("[BLE] ready, entering main loop"); + + // 主循环:处理 D-Bus 消息 + control 消息 while !stop.load(Ordering::SeqCst) { if shared.is_notifying() && shared.take_pending_notification() { - emit_status_notification(&conn_server, &shared)?; + emit_status_notification(&conn, &shared)?; } - conn_server - .process(SERVER_TIMEOUT) - .context("BLE server connection process loop failed")?; + drain_control_messages(&shared, &control_rx)?; + conn.process(SERVER_TIMEOUT) + .context("BLE connection process loop failed")?; } + drain_control_messages(&shared, &control_rx)?; + + // 清理 + unregister_ble_objects(&conn, &adapter_path)?; + Ok(()) } @@ -552,6 +501,32 @@ fn build_managed_objects() -> ManagedObjects { objects } +fn send_register_gatt_app(conn: &Connection, adapter_path: &str) -> Result { + let msg = dbus::Message::method_call( + &BLUEZ_SERVICE.into(), + &Path::from(adapter_path.to_string()), + &GATT_MANAGER_IFACE.into(), + &"RegisterApplication".into(), + ) + .append2(Path::from(APP_PATH), PropMap::new()); + + conn.send(msg) + .map_err(|_| anyhow!("failed to send RegisterApplication")) +} + +fn send_register_advertisement(conn: &Connection, adapter_path: &str) -> Result { + let msg = dbus::Message::method_call( + &BLUEZ_SERVICE.into(), + &Path::from(adapter_path.to_string()), + &LE_ADVERTISING_MANAGER_IFACE.into(), + &"RegisterAdvertisement".into(), + ) + .append2(Path::from(ADV_PATH), PropMap::new()); + + conn.send(msg) + .map_err(|_| anyhow!("failed to send RegisterAdvertisement")) +} + fn find_adapter(conn: &Connection) -> Result { let proxy = conn.with_proxy(BLUEZ_SERVICE, "/", PROXY_TIMEOUT); let objects = proxy @@ -582,28 +557,6 @@ fn configure_adapter(conn: &Connection, adapter_path: &str, device_name: &str) - Ok(()) } -fn register_ble_objects(conn: &Connection, adapter_path: &str) -> Result<()> { - let gatt_manager = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT); - gatt_manager - .method_call::<(), _, _, _>( - GATT_MANAGER_IFACE, - "RegisterApplication", - (Path::from(APP_PATH.to_string()), PropMap::new()), - ) - .context("failed to register BLE GATT application")?; - - let adv_manager = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT); - adv_manager - .method_call::<(), _, _, _>( - LE_ADVERTISING_MANAGER_IFACE, - "RegisterAdvertisement", - (Path::from(ADV_PATH.to_string()), PropMap::new()), - ) - .context("failed to register BLE advertisement")?; - - Ok(()) -} - fn unregister_ble_objects(conn: &Connection, adapter_path: &str) -> Result<()> { let adv_manager = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT); let _ = adv_manager.method_call::<(), _, _, _>( @@ -629,12 +582,6 @@ fn bytes_to_string(value: &[u8]) -> String { .to_string() } -fn join_server_thread(server_thread: thread::JoinHandle>) -> Result<()> { - server_thread - .join() - .map_err(|_| anyhow!("BLE server thread panicked"))? -} - fn drain_control_messages(shared: &SharedState, control_rx: &Receiver) -> Result<()> { loop { match control_rx.try_recv() { diff --git a/src/plugins/ble/mod.rs b/src/plugins/ble/mod.rs index d471136..6651e44 100644 --- a/src/plugins/ble/mod.rs +++ b/src/plugins/ble/mod.rs @@ -38,20 +38,20 @@ impl Default for BlePlugin { } impl Plugin for BlePlugin { - fn id(&self) -> &'static str { + fn id(&self) -> &str { "ble" } fn info(&self) -> PluginInfo { PluginInfo { - name: "BLE Provisioning", - version: "0.2.0", - description: "BLE GATT WiFi 配网 (D-Bus BlueZ)", + name: "BLE Provisioning".to_string(), + version: "0.2.0".to_string(), + description: "BLE GATT WiFi 配网 (D-Bus BlueZ)".to_string(), platform: Platform::Linux, } } - fn dependencies(&self) -> Vec<&'static str> { + fn dependencies(&self) -> Vec { vec![] } @@ -82,7 +82,11 @@ impl Plugin for BlePlugin { self.control_tx = Some(control_tx); self.worker = Some(thread::spawn(move || { - gatt::run_ble_service(device_name, tx, control_rx, stop) + let result = gatt::run_ble_service(device_name, tx, control_rx, stop); + if let Err(ref error) = result { + eprintln!("[BlePlugin] worker exited with error: {error:#}"); + } + result })); Ok(()) } diff --git a/src/plugins/http/README.md b/src/plugins/http/README.md new file mode 100644 index 0000000..f622f9d --- /dev/null +++ b/src/plugins/http/README.md @@ -0,0 +1,48 @@ +# HttpPlugin — Web UI + REST API + +基于 warp 的 HTTP 服务插件,提供完整的控制 API 和实时 WebSocket 事件。 + +## 模块 + +| 文件 | 说明 | +|------|------| +| `mod.rs` | HttpPlugin 实现、HttpState 共享状态、WebSocket 事件编码 | +| `routes.rs` | 全部 HTTP 路由定义、请求处理、内嵌 Web UI HTML | + +## API 端点 + +### 播放控制 +- `GET /api/status` — 播放状态 +- `POST /api/play` / `pause` / `next` / `previous` +- `POST /api/goto` — 跳转到指定索引 +- `GET /api/playlist` — 播放列表 +- `POST /api/scene` — 切换场景 +- `POST /api/trigger` — 发送触发器 + +### 配置管理 +- `GET /api/config` — 完整配置 +- `GET /api/config/display` — 显示配置 +- `POST /api/config` — 更新配置(热重载) + +### 媒体管理 +- `GET /api/videos` — 视频文件列表 +- `POST /api/videos/upload` — 上传视频 +- `DELETE /api/videos/:name` — 删除视频 + +### WiFi / BLE +- `GET /api/wifi/status` / `scan` / `connect` / `ap/start` / `ap/stop` +- `POST /api/ble/start` / `stop` / `GET /api/ble/status` + +### 插件管理 (动态插件) +- `GET /api/plugins` — 列出所有插件状态 +- `GET /api/plugins/:id` — 插件详情 +- `POST /api/plugins/:id/enable` / `disable` / `rollback` / `switch` +- `POST /api/plugins/install` — 远程安装 +- `POST /api/plugins/check-updates` — 检查更新 + +### WebSocket +- `ws://host:port/ws` — 实时事件推送 + +## 依赖 + +- 依赖 VideoPlugin(启动顺序) diff --git a/src/plugins/http/mod.rs b/src/plugins/http/mod.rs index 09aac53..1cbfcac 100644 --- a/src/plugins/http/mod.rs +++ b/src/plugins/http/mod.rs @@ -43,6 +43,8 @@ pub(crate) struct HttpState { player_status: Mutex, ble_ready: AtomicBool, ws_events: broadcast::Sender, + /// 动态插件管理状态(由 Custom 消息更新) + plugin_states: Mutex>, } impl HttpState { @@ -68,6 +70,7 @@ impl HttpState { player_status: Mutex::new(player_status), ble_ready: AtomicBool::new(false), ws_events, + plugin_states: Mutex::new(Vec::new()), } } @@ -179,6 +182,21 @@ impl HttpState { self.publish_ws(payload); } } + + pub(crate) fn plugin_states(&self) -> Vec { + self.plugin_states + .lock() + .map(|s| s.clone()) + .unwrap_or_default() + } + + fn update_plugin_states(&self, json: &str) { + if let Ok(states) = serde_json::from_str::>(json) { + if let Ok(mut current) = self.plugin_states.lock() { + *current = states; + } + } + } } pub struct HttpPlugin { @@ -202,21 +220,21 @@ impl Default for HttpPlugin { } impl Plugin for HttpPlugin { - fn id(&self) -> &'static str { + fn id(&self) -> &str { "http" } fn info(&self) -> PluginInfo { PluginInfo { - name: "HTTP API", - version: "0.2.0", - description: "Web UI + REST API (warp)", + name: "HTTP API".to_string(), + version: "0.2.0".to_string(), + description: "Web UI + REST API (warp)".to_string(), platform: Platform::Any, } } - fn dependencies(&self) -> Vec<&'static str> { - vec!["video"] + fn dependencies(&self) -> Vec { + vec!["video".to_string()] } fn init(&mut self, ctx: PluginContext) -> Result<()> { @@ -268,9 +286,9 @@ impl Plugin for HttpPlugin { }; if let Err(error) = tx.send(Envelope { - from: "http", + from: "http".to_string(), to: crate::core::message::Destination::Manager, - message: Message::PluginReady("http"), + message: Message::PluginReady("http".to_string()), }) { eprintln!("[HttpPlugin] failed to report ready state: {error}"); } @@ -314,8 +332,11 @@ impl Plugin for HttpPlugin { state.publish_ws(payload); } } - Message::PluginReady("ble") => state.set_ble_ready(true), + Message::PluginReady(ref id) if id == "ble" => state.set_ble_ready(true), Message::Shutdown => state.set_ble_ready(false), + Message::Custom { ref kind, ref payload } if kind == "plugin_states" => { + state.update_plugin_states(payload); + } _ => {} } diff --git a/src/plugins/http/routes.rs b/src/plugins/http/routes.rs index 9f0d1dc..d61b5a4 100644 --- a/src/plugins/http/routes.rs +++ b/src/plugins/http/routes.rs @@ -1,5 +1,6 @@ use super::HttpState; use crate::core::config::{self, AppConfig}; +use crate::core::dispatch; use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand}; use bytes::Buf; use futures_util::{SinkExt, StreamExt, TryStreamExt}; @@ -67,7 +68,8 @@ pub(crate) fn build_routes( tx: mpsc::Sender, state: Arc, ) -> impl Filter + Clone { - let api = status_route(Arc::clone(&state)) + // 使用 boxed() 分段避免 warp 递归类型溢出 + let core_api = status_route(Arc::clone(&state)) .or(play_route(tx.clone())) .or(pause_route(tx.clone())) .or(next_route(tx.clone())) @@ -79,7 +81,9 @@ pub(crate) fn build_routes( .or(config_get_route(Arc::clone(&state))) .or(config_display_route(Arc::clone(&state))) .or(config_update_route(tx.clone(), Arc::clone(&state))) - .or(video_list_route(Arc::clone(&state))) + .boxed(); + + let media_api = video_list_route(Arc::clone(&state)) .or(video_upload_route(Arc::clone(&state))) .or(video_delete_route(Arc::clone(&state))) .or(wifi_status_route(tx.clone(), Arc::clone(&state))) @@ -89,9 +93,22 @@ pub(crate) fn build_routes( .or(wifi_ap_stop_route(tx.clone(), Arc::clone(&state))) .or(ble_start_route(Arc::clone(&state))) .or(ble_stop_route()) - .or(ble_status_route(Arc::clone(&state))); + .or(ble_status_route(Arc::clone(&state))) + .boxed(); - root_route().or(ws_route(Arc::clone(&state))).or(api).with( + let plugin_api = plugins_list_route(Arc::clone(&state)) + .or(plugin_detail_route(Arc::clone(&state))) + .or(plugin_enable_route(tx.clone())) + .or(plugin_disable_route(tx.clone())) + .or(plugin_rollback_route(tx.clone())) + .or(plugin_switch_route(tx.clone())) + .or(plugin_install_route(tx.clone())) + .or(plugin_check_updates_route(tx.clone())) + .boxed(); + + let api = core_api.or(media_api).or(plugin_api); + + root_route().or(ws_route(tx.clone(), Arc::clone(&state))).or(api).with( warp::cors() .allow_any_origin() .allow_headers(["content-type"]) @@ -112,14 +129,16 @@ fn root_route() -> impl Filter + } fn ws_route( + tx: mpsc::Sender, state: Arc, ) -> impl Filter + Clone { warp::path("ws") .and(warp::path::end()) .and(warp::ws()) + .and(with_tx(tx)) .and(with_state(state)) - .map(|ws: warp::ws::Ws, state: Arc| { - ws.on_upgrade(move |socket| websocket_session(socket, state)) + .map(|ws: warp::ws::Ws, tx: mpsc::Sender, state: Arc| { + ws.on_upgrade(move |socket| websocket_session(socket, tx, state)) }) } @@ -570,7 +589,7 @@ async fn handle_config_update( } if let Err(error) = tx.send(Envelope { - from: "http", + from: "http".to_string(), to: Destination::Manager, message: Message::ConfigReloadRequest, }) { @@ -733,8 +752,8 @@ async fn send_video_command( success_message: impl Into, ) -> Result { match tx.send(Envelope { - from: "http", - to: Destination::Plugin("video"), + from: "http".to_string(), + to: Destination::Plugin("video".to_string()), message, }) { Ok(()) => Ok(success_json(success_message.into())), @@ -778,8 +797,8 @@ async fn wifi_request( }; if let Err(error) = tx.send(Envelope { - from: "http", - to: Destination::Plugin("wifi"), + from: "http".to_string(), + to: Destination::Plugin("wifi".to_string()), message: Message::WifiCommand(command), }) { return Err(error_json( @@ -852,7 +871,11 @@ async fn wifi_request( Ok(payload) } -async fn websocket_session(ws: warp::ws::WebSocket, state: Arc) { +async fn websocket_session( + ws: warp::ws::WebSocket, + tx: mpsc::Sender, + state: Arc, +) { let (mut sender, mut receiver) = ws.split(); let mut events = state.ws_subscribe(); @@ -884,6 +907,11 @@ async fn websocket_session(ws: warp::ws::WebSocket, state: Arc) { } } else if message.is_close() { break; + } else if message.is_text() { + let reply = handle_ws_command(message.to_str().unwrap_or(""), &tx); + if sender.send(warp::ws::Message::text(reply)).await.is_err() { + break; + } } } Some(Err(_)) | None => break, @@ -893,6 +921,104 @@ async fn websocket_session(ws: warp::ws::WebSocket, state: Arc) { } } +/// 解析 WebSocket 收到的 JSON 命令,返回 JSON 响应字符串。 +/// +/// 输入格式: `{"cmd":"play"}` 或 `{"cmd":"goto","index":3}` 或 +/// `{"cmd":"connect","ssid":"x","password":"y"}` 等 +fn handle_ws_command(text: &str, tx: &mpsc::Sender) -> String { + let json: serde_json::Value = match serde_json::from_str(text) { + Ok(v) => v, + Err(_) => return r#"{"ok":false,"error":"invalid JSON"}"#.to_string(), + }; + + let cmd = match json.get("cmd").and_then(|v| v.as_str()) { + Some(c) => c, + None => return r#"{"ok":false,"error":"missing cmd field"}"#.to_string(), + }; + + // 将 JSON 字段组合为文本命令字符串 + let command_str = build_command_string(cmd, &json); + + // 从 JSON 中提取 ssid/password 作为 hint(用于无参数的 connect/ap_start) + let ssid_hint = json + .get("ssid") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let password_hint = json + .get("password") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + match dispatch::parse_command(&command_str, "ws", ssid_hint, password_hint) { + Ok(result) => { + if tx.send(result.envelope).is_ok() { + format!(r#"{{"ok":true,"cmd":"{}"}}"#, cmd) + } else { + r#"{"ok":false,"error":"channel closed"}"#.to_string() + } + } + Err(error) => { + format!( + r#"{{"ok":false,"cmd":"{}","error":"{}"}}"#, + cmd, + error.replace('"', "\\\"") + ) + } + } +} + +/// 从 JSON 对象组装文本命令字符串。 +/// 例: `{"cmd":"goto","index":3}` -> `"goto:3"` +/// `{"cmd":"scene","name":"idle"}` -> `"scene:idle"` +/// `{"cmd":"trigger","name":"voice","value":"hi"}` -> `"trigger:voice:hi"` +/// `{"cmd":"connect","ssid":"x","password":"y"}` -> `"connect:x:y"` +fn build_command_string(cmd: &str, json: &serde_json::Value) -> String { + match cmd { + "goto" => { + if let Some(index) = json.get("index").and_then(|v| v.as_u64()) { + format!("goto:{index}") + } else { + "goto".to_string() + } + } + "scene" => { + if let Some(name) = json.get("name").and_then(|v| v.as_str()) { + format!("scene:{name}") + } else { + "scene".to_string() + } + } + "trigger" => { + let name = json.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let value = json.get("value").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() { + "trigger".to_string() + } else { + format!("trigger:{name}:{value}") + } + } + "connect" => { + let ssid = json.get("ssid").and_then(|v| v.as_str()).unwrap_or(""); + let password = json.get("password").and_then(|v| v.as_str()).unwrap_or(""); + if ssid.is_empty() { + "connect".to_string() + } else { + format!("connect:{ssid}:{password}") + } + } + "ap_start" => { + let ssid = json.get("ssid").and_then(|v| v.as_str()).unwrap_or(""); + let password = json.get("password").and_then(|v| v.as_str()).unwrap_or(""); + if ssid.is_empty() { + "ap_start".to_string() + } else { + format!("ap_start:{ssid}:{password}") + } + } + _ => cmd.to_string(), + } +} + fn parse_optional_json(body: &bytes::Bytes) -> Result> where T: DeserializeOwned + Default, @@ -1000,6 +1126,181 @@ fn error_json(status: StatusCode, message: &str) -> warp::reply::Response { ) } +// ── 插件管理 API ── + +fn plugins_list_route( + state: Arc, +) -> impl Filter + Clone { + warp::path!("api" / "plugins") + .and(warp::get()) + .and(with_state(state)) + .and_then(|state: Arc| async move { + Ok::<_, Infallible>(json_response(StatusCode::OK, &state.plugin_states())) + }) +} + +fn plugin_detail_route( + state: Arc, +) -> impl Filter + Clone { + warp::path!("api" / "plugins" / String) + .and(warp::get()) + .and(with_state(state)) + .and_then(|id: String, state: Arc| async move { + let plugins = state.plugin_states(); + match plugins.iter().find(|p| p.id == id) { + Some(info) => Ok::<_, Infallible>(json_response(StatusCode::OK, info)), + None => Ok(error_json(StatusCode::NOT_FOUND, &format!("plugin '{}' not found", id))), + } + }) +} + +#[derive(Deserialize)] +struct PluginSwitchRequest { + version: String, +} + +#[derive(Deserialize)] +struct PluginInstallRequest { + id: String, + #[serde(default)] + version: Option, +} + +fn plugin_enable_route( + tx: mpsc::Sender, +) -> impl Filter + Clone { + warp::path!("api" / "plugins" / String / "enable") + .and(warp::post()) + .and(with_tx(tx)) + .and_then(|id: String, tx: mpsc::Sender| async move { + send_plugin_command(tx, "plugin_enable", &id).await + }) +} + +fn plugin_disable_route( + tx: mpsc::Sender, +) -> impl Filter + Clone { + warp::path!("api" / "plugins" / String / "disable") + .and(warp::post()) + .and(with_tx(tx)) + .and_then(|id: String, tx: mpsc::Sender| async move { + send_plugin_command(tx, "plugin_disable", &id).await + }) +} + +fn plugin_rollback_route( + tx: mpsc::Sender, +) -> impl Filter + Clone { + warp::path!("api" / "plugins" / String / "rollback") + .and(warp::post()) + .and(with_tx(tx)) + .and_then(|id: String, tx: mpsc::Sender| async move { + send_plugin_command(tx, "plugin_rollback", &id).await + }) +} + +fn plugin_switch_route( + tx: mpsc::Sender, +) -> impl Filter + Clone { + warp::path!("api" / "plugins" / String / "switch") + .and(warp::post()) + .and(warp::body::json::()) + .and(with_tx(tx)) + .and_then( + |id: String, body: PluginSwitchRequest, tx: mpsc::Sender| async move { + let payload = serde_json::json!({ + "id": id, + "version": body.version, + }) + .to_string(); + + match tx.send(Envelope { + from: "http".to_string(), + to: Destination::Manager, + message: Message::Custom { + kind: "plugin_switch".to_string(), + payload, + }, + }) { + Ok(()) => Ok::<_, Infallible>(success_json( + format!("版本切换请求已发送: {} -> v{}", id, body.version), + )), + Err(e) => Ok(error_json( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("发送失败: {e}"), + )), + } + }, + ) +} + +fn plugin_install_route( + tx: mpsc::Sender, +) -> impl Filter + Clone { + warp::path!("api" / "plugins" / "install") + .and(warp::post()) + .and(warp::body::json::()) + .and(with_tx(tx)) + .and_then( + |body: PluginInstallRequest, tx: mpsc::Sender| async move { + let payload = serde_json::json!({ + "id": body.id, + "version": body.version, + }) + .to_string(); + + match tx.send(Envelope { + from: "http".to_string(), + to: Destination::Manager, + message: Message::Custom { + kind: "plugin_install".to_string(), + payload, + }, + }) { + Ok(()) => Ok::<_, Infallible>(success_json( + format!("安装请求已发送: {}", body.id), + )), + Err(e) => Ok(error_json( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("发送失败: {e}"), + )), + } + }, + ) +} + +fn plugin_check_updates_route( + tx: mpsc::Sender, +) -> impl Filter + Clone { + warp::path!("api" / "plugins" / "check-updates") + .and(warp::post()) + .and(with_tx(tx)) + .and_then(|tx: mpsc::Sender| async move { + send_plugin_command(tx, "plugin_check_updates", "").await + }) +} + +async fn send_plugin_command( + tx: mpsc::Sender, + kind: &str, + plugin_id: &str, +) -> Result { + match tx.send(Envelope { + from: "http".to_string(), + to: Destination::Manager, + message: Message::Custom { + kind: kind.to_string(), + payload: plugin_id.to_string(), + }, + }) { + Ok(()) => Ok(success_json(format!("{kind} 命令已发送"))), + Err(e) => Ok(error_json( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("发送失败: {e}"), + )), + } +} + fn json_response(status: StatusCode, payload: &T) -> warp::reply::Response { warp::reply::with_status(warp::reply::json(payload), status).into_response() } @@ -1042,7 +1343,7 @@ const WEB_UI_HTML: &str = r#"

播放状态

状态--
当前视频--
索引--
列表长度--
-

播放控制

+

播放控制

触发器

@@ -1055,24 +1356,29 @@ const WEB_UI_HTML: &str = r#" "#; diff --git a/src/plugins/screen/README.md b/src/plugins/screen/README.md new file mode 100644 index 0000000..f5f1e17 --- /dev/null +++ b/src/plugins/screen/README.md @@ -0,0 +1,15 @@ +# ScreenPlugin — 屏幕管理 + +防止屏幕休眠和管理光标显示。 + +## 功能 + +- **唤醒锁**: `systemd-inhibit --what=idle:sleep` 阻止系统休眠 +- **光标隐藏**: `unclutter -idle 0 -root` 隐藏鼠标光标 +- 播放时自动获取唤醒锁,暂停时释放 +- 响应 `ScreenLockRequest` / `CursorVisibility` 消息 + +## 平台 + +Linux only (systemd-inhibit, unclutter) +其他平台编译通过但功能为空操作。 diff --git a/src/plugins/screen/mod.rs b/src/plugins/screen/mod.rs index 22d25f0..bfaba57 100644 --- a/src/plugins/screen/mod.rs +++ b/src/plugins/screen/mod.rs @@ -113,20 +113,20 @@ impl Default for ScreenPlugin { } impl Plugin for ScreenPlugin { - fn id(&self) -> &'static str { + fn id(&self) -> &str { "screen" } fn info(&self) -> PluginInfo { PluginInfo { - name: "Screen Manager", - version: "0.2.0", - description: "屏幕唤醒锁 + 光标管理", + name: "Screen Manager".to_string(), + version: "0.2.0".to_string(), + description: "屏幕唤醒锁 + 光标管理".to_string(), platform: Platform::Linux, } } - fn dependencies(&self) -> Vec<&'static str> { + fn dependencies(&self) -> Vec { vec![] } diff --git a/src/plugins/video/README.md b/src/plugins/video/README.md new file mode 100644 index 0000000..be5eb5e --- /dev/null +++ b/src/plugins/video/README.md @@ -0,0 +1,24 @@ +# VideoPlugin — 视频播放引擎 + +基于 OpenCV 的视频播放插件,支持状态机驱动的场景切换和帧变换。 + +## 模块 + +| 文件 | 说明 | +|------|------| +| `mod.rs` | VideoPlugin 实现 Plugin trait,工作线程管理,状态/消息发布 | +| `processor.rs` | VideoProcessor:视频捕获、帧处理、过渡效果、播放列表管理 | +| `state_machine.rs` | StateMachine:JSON 配置驱动的场景/动画状态机 | + +## 功能 + +- 视频播放/暂停/上一个/下一个/跳转 +- 场景切换 (ChangeScene) +- 触发器驱动的状态转换 (voice/button/sensor) +- 帧变换:旋转、翻转、透视校正、色键抠像、亮度调节 +- 过渡效果:淡入淡出、直切 +- 配置热重载 + +## 平台 + +Any (需要 OpenCV 运行时) diff --git a/src/plugins/video/mod.rs b/src/plugins/video/mod.rs index 2497fc2..6213761 100644 --- a/src/plugins/video/mod.rs +++ b/src/plugins/video/mod.rs @@ -48,7 +48,7 @@ impl VideoPlugin { }; if let Err(error) = ctx.tx.send(Envelope { - from: self.id(), + from: self.id().to_string(), to: Destination::Broadcast, message: Message::PlayerStatus(status), }) { @@ -70,7 +70,7 @@ impl VideoPlugin { } if let Err(error) = ctx.tx.send(Envelope { - from: self.id(), + from: self.id().to_string(), to: Destination::Broadcast, message: Message::StateChanged { old_state, @@ -89,20 +89,20 @@ impl Default for VideoPlugin { } impl Plugin for VideoPlugin { - fn id(&self) -> &'static str { + fn id(&self) -> &str { "video" } fn info(&self) -> PluginInfo { PluginInfo { - name: "Video Player", - version: "0.2.0", - description: "视频播放引擎 (OpenCV)", + name: "Video Player".to_string(), + version: "0.2.0".to_string(), + description: "视频播放引擎 (OpenCV)".to_string(), platform: Platform::Any, } } - fn dependencies(&self) -> Vec<&'static str> { + fn dependencies(&self) -> Vec { vec![] } @@ -130,9 +130,9 @@ impl Plugin for VideoPlugin { self.worker = Some(handle); ctx.tx.send(Envelope { - from: self.id(), + from: self.id().to_string(), to: Destination::Manager, - message: Message::PluginReady(self.id()), + message: Message::PluginReady(self.id().to_string()), })?; self.publish_status(); @@ -150,8 +150,8 @@ impl Plugin for VideoPlugin { // 恢复播放时重新获取防息屏锁 if let Some(ctx) = &self.ctx { let _ = ctx.tx.send(Envelope { - from: self.id(), - to: Destination::Plugin("screen"), + from: self.id().to_string(), + to: Destination::Plugin("screen".to_string()), message: Message::ScreenLockRequest(true), }); } @@ -161,8 +161,8 @@ impl Plugin for VideoPlugin { // 暂停时释放防息屏锁 if let Some(ctx) = &self.ctx { let _ = ctx.tx.send(Envelope { - from: self.id(), - to: Destination::Plugin("screen"), + from: self.id().to_string(), + to: Destination::Plugin("screen".to_string()), message: Message::ScreenLockRequest(false), }); } @@ -324,7 +324,7 @@ fn publish_status_message( status: PlayerStatusData, ) -> Result<()> { tx.send(Envelope { - from: "video", + from: "video".to_string(), to: Destination::Broadcast, message: Message::PlayerStatus(status), })?; @@ -345,7 +345,7 @@ fn publish_state_changed( } tx.send(Envelope { - from: "video", + from: "video".to_string(), to: Destination::Broadcast, message: Message::StateChanged { old_state, diff --git a/src/plugins/video/processor.rs b/src/plugins/video/processor.rs index 9a14da6..d9aa562 100644 --- a/src/plugins/video/processor.rs +++ b/src/plugins/video/processor.rs @@ -929,8 +929,10 @@ impl VideoProcessor { if let Some(resolution) = parts.first() { let dims: Vec<&str> = resolution.split('x').collect(); if dims.len() == 2 { + let w_str = dims[0].trim_end_matches(|c: char| !c.is_ascii_digit()); + let h_str = dims[1].trim_end_matches(|c: char| !c.is_ascii_digit()); if let (Ok(w), Ok(h)) = - (dims[0].parse::(), dims[1].parse::()) + (w_str.parse::(), h_str.parse::()) { println!( "Detected screen size from xrandr (X11 active): {}x{}", diff --git a/src/plugins/wifi/README.md b/src/plugins/wifi/README.md new file mode 100644 index 0000000..853e673 --- /dev/null +++ b/src/plugins/wifi/README.md @@ -0,0 +1,19 @@ +# WifiPlugin — WiFi 管理 + +通过 nmcli 实现 WiFi 网络管理。 + +## 功能 + +- `scan` — 扫描可用 WiFi 网络(去重、信号强度排序) +- `connect` — 连接到指定 SSID +- `status` — 查询设备状态和 IP 地址 +- `ap_start` — 启动 WiFi 热点 +- `ap_stop` — 停止热点 + +## 消息流 + +收到 `WifiCommand` → 执行 nmcli → 广播 `WifiResult` (JSON) + +## 平台 + +Linux only (nmcli) diff --git a/src/plugins/wifi/mod.rs b/src/plugins/wifi/mod.rs index 988ba49..dc7e259 100644 --- a/src/plugins/wifi/mod.rs +++ b/src/plugins/wifi/mod.rs @@ -59,7 +59,7 @@ impl WifiPlugin { .context("wifi plugin context is not initialized")?; ctx.tx.send(Envelope { - from: "wifi", + from: "wifi".to_string(), to: Destination::Broadcast, message: Message::WifiResult(payload), })?; @@ -237,20 +237,20 @@ impl Default for WifiPlugin { } impl Plugin for WifiPlugin { - fn id(&self) -> &'static str { + fn id(&self) -> &str { "wifi" } fn info(&self) -> PluginInfo { PluginInfo { - name: "WiFi Manager", - version: "0.2.0", - description: "WiFi 管理 (nmcli)", + name: "WiFi Manager".to_string(), + version: "0.2.0".to_string(), + description: "WiFi 管理 (nmcli)".to_string(), platform: Platform::Linux, } } - fn dependencies(&self) -> Vec<&'static str> { + fn dependencies(&self) -> Vec { vec![] }