feat: M1.1 完成 + M1.2 启动 — 全量更新

M1.1 收尾:
- 24项 P0/P1/P2 bug 修复 (Rust 107 tests + Flutter 15 tests)
- Flutter App v0.3: cupertino_icons 修复, 单元测试, 调试面板, APK 52.6MB
- 示例插件完善: manifest.json + 请求/响应示范 + 7个测试
- API 文档重写 (以 routes.rs 为唯一权威)
- MILESTONES.md 更新至 100%

M1.2 启动:
- P0: 插件管理 API 闭环 (handle_manager_message Custom 分支 + broadcast_plugin_states)
- ServiceManager 集成测试 8/8 (tests/m1_2_service_manager.rs)
- M1.2 测试计划 (docs/M1.2_TEST_PLAN.md, 18个E2E场景)
- 动态插件系统: auto_rollback + version_manager GC + 路径穿越防护

总计: Rust 115/115 测试, Flutter 15/15 测试, 零 warning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
showen
2026-03-14 18:12:42 +08:00
parent 8ed9cb2d9d
commit d30c111c71
68 changed files with 8115 additions and 1201 deletions

View File

@@ -1,10 +1,11 @@
# Example Plugin — 示例动态插件
演示如何使用 `showen-plugin-sdk` 编写动态插件
演示如何使用 `showen-plugin-sdk` 编写动态插件,并提供可直接打包到
`plugin_store/``manifest.json` 模板。
## 功能
- 仅打印日志,用于验证动态加载流程
- 展示消息路由、配置解析、后台任务、自测以及请求/响应模式
- 展示 `ShowenPlugin` trait 的完整实现
- 编译为 `cdylib``.so` 文件)
@@ -27,3 +28,4 @@ cargo build --release
|------|------|
| `src/lib.rs` | 插件实现,使用 `export_plugin!` 宏导出 |
| `Cargo.toml` | crate 配置,类型为 cdylib |
| `manifest.json` | 动态加载所需的完整清单模板 |

View File

@@ -0,0 +1,23 @@
{
"id": "example-plugin",
"version": "0.1.0",
"sdk_version": "0.2.0",
"dependencies": [],
"error_policy": "auto_rollback",
"so_filename": "libshowen_example_plugin.so",
"capabilities": [
"message_sender",
"message_routing",
"config_parsing",
"background_task",
"self_test",
"request_response"
],
"required_capabilities": [
"message_sender",
"config_parsing",
"self_test"
],
"test_timeout_ms": 5000,
"auto_test": true
}

View File

@@ -10,8 +10,8 @@
use serde::{Deserialize, Serialize};
use showen_plugin_sdk::{
export_plugin, CapabilityTestResult, Destination, Envelope, Message, MessageSender, PluginInfo,
ShowenPlugin,
export_plugin, CapabilityTestResult, Destination, DeviceCommand, DeviceResponse, Envelope,
Message, MessageSender, PluginInfo, ShowenPlugin,
};
use std::sync::{
atomic::{AtomicBool, Ordering},
@@ -26,6 +26,7 @@ const CAP_MESSAGE_ROUTING: &str = "message_routing";
const CAP_CONFIG_PARSING: &str = "config_parsing";
const CAP_BACKGROUND_TASK: &str = "background_task";
const CAP_SELF_TEST: &str = "self_test";
const CAP_REQUEST_RESPONSE: &str = "request_response";
/// 示例插件配置。
///
@@ -42,6 +43,10 @@ struct ExamplePluginConfig {
target_plugin: String,
/// 是否在 `start()` 时发送一组教学用途的示例消息。
announce_on_start: bool,
/// DevicePlugin 的插件 ID用于演示请求/响应模式。
device_plugin: String,
/// 是否在 `start()` 时请求一次显示信息。
request_display_info_on_start: bool,
/// 是否启用后台定时任务。
enable_periodic_task: bool,
/// 周期性上报里携带的示例文本。
@@ -56,6 +61,8 @@ impl Default for ExamplePluginConfig {
heartbeat_interval_ms: 5_000,
target_plugin: PLUGIN_ID.to_string(),
announce_on_start: true,
device_plugin: "device".to_string(),
request_display_info_on_start: true,
enable_periodic_task: true,
periodic_payload: "heartbeat-from-example-plugin".to_string(),
optional_test_should_fail: false,
@@ -74,9 +81,8 @@ impl ExamplePluginConfig {
.map_err(|error| format!("failed to parse example plugin config: {error}"))?
};
config.normalize();
config.validate()?;
config.target_plugin = config.target_plugin.trim().to_string();
config.periodic_payload = config.periodic_payload.trim().to_string();
Ok(config)
}
@@ -84,17 +90,29 @@ impl ExamplePluginConfig {
fn from_value(value: serde_json::Value) -> Result<Self, String> {
let mut config = serde_json::from_value::<Self>(value)
.map_err(|error| format!("failed to decode reloaded config: {error}"))?;
config.normalize();
config.validate()?;
config.target_plugin = config.target_plugin.trim().to_string();
config.periodic_payload = config.periodic_payload.trim().to_string();
Ok(config)
}
fn normalize(&mut self) {
self.target_plugin = self.target_plugin.trim().to_string();
self.device_plugin = self.device_plugin.trim().to_string();
self.periodic_payload = self.periodic_payload.trim().to_string();
}
fn validate(&self) -> Result<(), String> {
if self.target_plugin.trim().is_empty() {
return Err("config field `target_plugin` must not be empty".to_string());
}
if self.request_display_info_on_start && self.device_plugin.trim().is_empty() {
return Err(
"config field `device_plugin` must not be empty when `request_display_info_on_start` is enabled"
.to_string(),
);
}
if self.periodic_payload.trim().is_empty() {
return Err("config field `periodic_payload` must not be empty".to_string());
}
@@ -199,6 +217,30 @@ impl ExamplePlugin {
Ok(())
}
/// 演示典型的请求/响应模式:向 DevicePlugin 请求一次显示信息,随后在
/// `handle_message()` 里处理 `Message::DeviceResponse`。
fn request_display_info(&self) -> Result<(), String> {
let sender = self.sender()?;
sender.send_to(
PLUGIN_ID,
&self.config.device_plugin,
Message::DeviceCommand(DeviceCommand::GetDisplayInfo),
);
sender.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.request_sent".to_string(),
payload: format!(
"requested DeviceCommand::GetDisplayInfo from {}",
self.config.device_plugin
),
},
);
Ok(())
}
/// 启动一个简单的后台线程,周期性向管理层发送心跳消息。
///
/// 这里故意使用 `thread::sleep`,因为这是第三方插件开发者最容易理解的最小示例。
@@ -271,6 +313,9 @@ impl ExamplePlugin {
"example.send_demo_messages" => {
self.emit_demo_messages()?;
}
"example.request_display_info" => {
self.request_display_info()?;
}
_ => {
self.sender()?.send_to_manager(
PLUGIN_ID,
@@ -285,6 +330,44 @@ impl ExamplePlugin {
Ok(())
}
/// 演示如何为自定义消息协议做显式解析和友好错误提示。
///
/// 这里把 `payload` 约定为 JSON模拟一个“订阅更新”场景真实业务可以换成
/// 自己的协议名称和字段。
fn handle_custom_message(&self, kind: String, payload: String) -> Result<(), String> {
eprintln!("[ExamplePlugin] custom message: kind={kind}, payload={payload}");
if kind != "example.subscription.update" {
return Ok(());
}
let parsed: serde_json::Value = serde_json::from_str(&payload).map_err(|error| {
format!(
"custom message `example.subscription.update` expects JSON payload, got parse error: {error}"
)
})?;
let topic = parsed
.get("topic")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|topic| !topic.is_empty())
.ok_or_else(|| {
"custom message `example.subscription.update` requires non-empty string field `topic`"
.to_string()
})?;
self.sender()?.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.subscription.ack".to_string(),
payload: format!("subscription update processed for topic `{topic}`"),
},
);
Ok(())
}
/// 处理配置热重载。
///
/// 最佳实践:
@@ -328,6 +411,7 @@ impl ShowenPlugin for ExamplePlugin {
CAP_CONFIG_PARSING.to_string(),
CAP_BACKGROUND_TASK.to_string(),
CAP_SELF_TEST.to_string(),
CAP_REQUEST_RESPONSE.to_string(),
]
}
@@ -382,6 +466,19 @@ impl ShowenPlugin for ExamplePlugin {
"self-test harness operational".to_string()
},
},
CapabilityTestResult {
capability: CAP_REQUEST_RESPONSE.to_string(),
passed: !self.config.request_display_info_on_start
|| !self.config.device_plugin.is_empty(),
message: if self.config.request_display_info_on_start {
format!(
"request/response demo targets DevicePlugin `{}`",
self.config.device_plugin
)
} else {
"request/response demo disabled by config".to_string()
},
},
]
}
@@ -406,6 +503,11 @@ impl ShowenPlugin for ExamplePlugin {
if self.config.announce_on_start {
self.emit_demo_messages()?;
}
if self.config.request_display_info_on_start {
self.request_display_info()?;
}
self.start_worker()?;
Ok(())
}
@@ -468,17 +570,44 @@ impl ShowenPlugin for ExamplePlugin {
Message::PluginReady(plugin_name) => {
eprintln!("[ExamplePlugin] observed peer readiness: {plugin_name}");
}
Message::DeviceCommand(_cmd) => {
eprintln!("[ExamplePlugin] device command received (not handled)");
Message::DeviceCommand(command) => {
eprintln!("[ExamplePlugin] device command received (not handled): {command:?}");
}
Message::DeviceResponse(_resp) => {
eprintln!("[ExamplePlugin] device response received (not handled)");
Message::DeviceResponse(response) => {
eprintln!("[ExamplePlugin] device response received: {response:?}");
let summary = match response {
DeviceResponse::DisplayInfo {
width,
height,
format,
} => format!(
"display info received: width={width}, height={height}, format={format:?}"
),
DeviceResponse::BatteryLevel(level) => {
format!("battery level response received: {level}%")
}
DeviceResponse::SensorData { sensor, value } => {
format!("sensor response received: {sensor:?}={value}")
}
DeviceResponse::Ok => "device command completed successfully".to_string(),
DeviceResponse::Error(error) => format!("device command failed: {error}"),
DeviceResponse::Custom(value) => format!("custom device response: {value}"),
};
self.sender()?.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.device_response".to_string(),
payload: summary,
},
);
}
Message::DeviceEvent(_event) => {
eprintln!("[ExamplePlugin] device event received (not handled)");
}
Message::Custom { kind, payload } => {
eprintln!("[ExamplePlugin] custom message: kind={kind}, payload={payload}");
self.handle_custom_message(kind, payload)?;
}
}
@@ -494,6 +623,15 @@ impl ShowenPlugin for ExamplePlugin {
}
// 导出动态插件 FFI 接口。
//
// `export_plugin!` 会生成主程序约定的完整 ABI
// - `create` / `destroy`: 创建和销毁插件实例
// - `get_info`: 返回 `PluginInfo` 的 JSON
// - `init`: 接收配置 JSON 和宿主注入的消息发送回调
// - `start` / `stop`: 驱动插件生命周期
// - `handle_message`: 接收宿主转发的 JSON 消息并反序列化为 `Message`
// - `get_capabilities` / `self_test`: 向宿主暴露能力声明和自检结果
// - `free_string`: 释放由当前插件分配给宿主的 `FfiString`
export_plugin!(ExamplePlugin, ExamplePlugin::new());
#[cfg(test)]
@@ -558,6 +696,16 @@ mod tests {
assert!(error.contains("heartbeat_interval_ms"));
}
#[test]
fn config_rejects_missing_device_plugin_when_request_response_is_enabled() {
let error = ExamplePluginConfig::from_json(
r#"{"device_plugin":" ","request_display_info_on_start":true}"#,
)
.expect_err("config should be rejected");
assert!(error.contains("device_plugin"));
}
#[test]
fn start_sends_demo_messages_and_heartbeat() {
let recorder = Recorder::new();
@@ -628,7 +776,9 @@ mod tests {
.handle_message(Message::ConfigReloaded(serde_json::json!({
"heartbeat_interval_ms": 100,
"target_plugin": "example-plugin",
"device_plugin": "device",
"announce_on_start": false,
"request_display_info_on_start": false,
"enable_periodic_task": true,
"periodic_payload": "reloaded-heartbeat",
"optional_test_should_fail": true
@@ -642,4 +792,57 @@ mod tests {
plugin.stop().expect("stop should succeed");
}
#[test]
fn start_requests_display_info_when_enabled() {
let recorder = Recorder::new();
let mut plugin = ExamplePlugin::new();
plugin
.init(
r#"{
"announce_on_start": false,
"request_display_info_on_start": true,
"device_plugin": "device"
}"#,
recorder.sender(),
)
.expect("init should succeed");
plugin.start().expect("start should succeed");
plugin.stop().expect("stop should succeed");
let envelopes = recorder.snapshot();
assert!(
envelopes.iter().any(|env| {
matches!(
(&env.to, &env.message),
(
Destination::Plugin(plugin_id),
Message::DeviceCommand(DeviceCommand::GetDisplayInfo)
) if plugin_id == "device"
)
}),
"expected GetDisplayInfo request to device plugin"
);
}
#[test]
fn custom_subscription_update_requires_valid_payload() {
let recorder = Recorder::new();
let mut plugin = ExamplePlugin::new();
plugin
.init("{}", recorder.sender())
.expect("init should succeed");
let error = plugin
.handle_message(Message::Custom {
kind: "example.subscription.update".to_string(),
payload: "not-json".to_string(),
})
.expect_err("invalid payload should be rejected");
assert!(error.contains("expects JSON payload"));
}
}