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>
849 lines
30 KiB
Rust
849 lines
30 KiB
Rust
//! 示例动态插件 — 展示如何使用 `showen-plugin-sdk` 编写较完整的插件。
|
||
//!
|
||
//! 这个示例特意覆盖几个第三方开发者最常见的需求:
|
||
//! 1. 使用 `MessageSender` 发送点对点、广播、管理层以及原始 `Envelope` 消息。
|
||
//! 2. 在 `handle_message` 中处理多种 `Message` 变体。
|
||
//! 3. 用 `serde` 解析配置,并在解析后做显式校验。
|
||
//! 4. 用 `thread + sleep` 模拟一个简单的定时后台任务。
|
||
//! 5. 提供完整的 `capabilities` 和 `self_test` 实现。
|
||
//! 6. 用注释解释每个阶段应该做什么,作为插件作者的参考模板。
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use showen_plugin_sdk::{
|
||
export_plugin, CapabilityTestResult, Destination, DeviceCommand, DeviceResponse, Envelope,
|
||
Message, MessageSender, PluginInfo, ShowenPlugin,
|
||
};
|
||
use std::sync::{
|
||
atomic::{AtomicBool, Ordering},
|
||
Arc,
|
||
};
|
||
use std::thread::{self, JoinHandle};
|
||
use std::time::Duration;
|
||
|
||
const PLUGIN_ID: &str = "example-plugin";
|
||
const CAP_MESSAGE_SENDER: &str = "message_sender";
|
||
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";
|
||
|
||
/// 示例插件配置。
|
||
///
|
||
/// 这里使用 `#[serde(default)]` 而不是依赖调用方传完整配置,目的是让示例更健壮:
|
||
/// - 新增字段后,旧配置仍然可以工作。
|
||
/// - 每个字段都有清晰的默认值。
|
||
/// - `validate()` 负责做业务约束校验,把解析错误和业务错误分开。
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||
#[serde(default, deny_unknown_fields)]
|
||
struct ExamplePluginConfig {
|
||
/// 后台任务的心跳间隔,单位毫秒。
|
||
heartbeat_interval_ms: u64,
|
||
/// 启动后示例消息要发给哪个插件。
|
||
target_plugin: String,
|
||
/// 是否在 `start()` 时发送一组教学用途的示例消息。
|
||
announce_on_start: bool,
|
||
/// DevicePlugin 的插件 ID,用于演示请求/响应模式。
|
||
device_plugin: String,
|
||
/// 是否在 `start()` 时请求一次显示信息。
|
||
request_display_info_on_start: bool,
|
||
/// 是否启用后台定时任务。
|
||
enable_periodic_task: bool,
|
||
/// 周期性上报里携带的示例文本。
|
||
periodic_payload: String,
|
||
/// 用来演示可配置的自测失败项。
|
||
optional_test_should_fail: bool,
|
||
}
|
||
|
||
impl Default for ExamplePluginConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
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,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ExamplePluginConfig {
|
||
/// 解析 JSON 配置,并在成功后执行额外校验。
|
||
fn from_json(config_json: &str) -> Result<Self, String> {
|
||
let trimmed = config_json.trim();
|
||
let mut config = if trimmed.is_empty() || trimmed == "null" {
|
||
Self::default()
|
||
} else {
|
||
serde_json::from_str::<Self>(trimmed)
|
||
.map_err(|error| format!("failed to parse example plugin config: {error}"))?
|
||
};
|
||
|
||
config.normalize();
|
||
config.validate()?;
|
||
Ok(config)
|
||
}
|
||
|
||
/// 处理 `ConfigReloaded` 的场景,调用方式与初始化时保持一致。
|
||
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()?;
|
||
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());
|
||
}
|
||
|
||
if self.enable_periodic_task && self.heartbeat_interval_ms < 100 {
|
||
return Err(
|
||
"config field `heartbeat_interval_ms` must be at least 100 when the periodic task is enabled"
|
||
.to_string(),
|
||
);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// 一个更完整的示例插件实现。
|
||
pub struct ExamplePlugin {
|
||
/// `MessageSender` 会在 `init()` 时由宿主注入。
|
||
///
|
||
/// 用 `Arc` 包起来是为了后台线程可以安全共享同一个 sender。
|
||
sender: Option<Arc<MessageSender>>,
|
||
/// 当前生效配置。
|
||
config: ExamplePluginConfig,
|
||
/// 用于结束后台线程的停止信号。
|
||
worker_stop: Arc<AtomicBool>,
|
||
/// 后台线程句柄;在 `stop()` 和配置重载时负责回收。
|
||
worker: Option<JoinHandle<()>>,
|
||
/// 用于演示 `self_test()` 如何暴露可选失败项。
|
||
fail_optional_test: bool,
|
||
}
|
||
|
||
impl ExamplePlugin {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
sender: None,
|
||
config: ExamplePluginConfig::default(),
|
||
worker_stop: Arc::new(AtomicBool::new(false)),
|
||
worker: None,
|
||
fail_optional_test: false,
|
||
}
|
||
}
|
||
|
||
fn sender(&self) -> Result<&Arc<MessageSender>, String> {
|
||
self.sender
|
||
.as_ref()
|
||
.ok_or_else(|| "plugin sender is not initialized; call init() first".to_string())
|
||
}
|
||
|
||
/// 发送一个“插件已准备好”的管理消息。
|
||
///
|
||
/// 这是插件初始化阶段最常见的通知类型之一。
|
||
fn notify_ready(&self) -> Result<(), String> {
|
||
self.sender()?
|
||
.send_to_manager(PLUGIN_ID, Message::PluginReady(PLUGIN_ID.to_string()));
|
||
Ok(())
|
||
}
|
||
|
||
/// 演示 `MessageSender` 的四种常见用法:
|
||
/// - `send_to_manager()`
|
||
/// - `broadcast()`
|
||
/// - `send_to()`
|
||
/// - `send()` + 原始 `Envelope`
|
||
fn emit_demo_messages(&self) -> Result<(), String> {
|
||
let sender = self.sender()?;
|
||
|
||
sender.send_to_manager(
|
||
PLUGIN_ID,
|
||
Message::Custom {
|
||
kind: "example.lifecycle".to_string(),
|
||
payload: "start() completed".to_string(),
|
||
},
|
||
);
|
||
|
||
sender.broadcast(
|
||
PLUGIN_ID,
|
||
Message::StateChanged {
|
||
old_state: "initialized".to_string(),
|
||
new_state: "running".to_string(),
|
||
},
|
||
);
|
||
|
||
sender.send_to(
|
||
PLUGIN_ID,
|
||
&self.config.target_plugin,
|
||
Message::Trigger {
|
||
name: "example.handshake".to_string(),
|
||
value: "hello-from-example-plugin".to_string(),
|
||
},
|
||
);
|
||
|
||
let config_payload = serde_json::to_string(&self.config)
|
||
.map_err(|error| format!("failed to serialize config snapshot: {error}"))?;
|
||
sender.send(&Envelope {
|
||
from: PLUGIN_ID.to_string(),
|
||
to: Destination::Manager,
|
||
message: Message::Custom {
|
||
kind: "example.config_snapshot".to_string(),
|
||
payload: config_payload,
|
||
},
|
||
});
|
||
|
||
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`,因为这是第三方插件开发者最容易理解的最小示例。
|
||
fn start_worker(&mut self) -> Result<(), String> {
|
||
self.stop_worker()?;
|
||
|
||
if !self.config.enable_periodic_task {
|
||
eprintln!("[ExamplePlugin] periodic task disabled by config");
|
||
return Ok(());
|
||
}
|
||
|
||
let sender = Arc::clone(self.sender()?);
|
||
let stop_flag = Arc::clone(&self.worker_stop);
|
||
let interval = Duration::from_millis(self.config.heartbeat_interval_ms);
|
||
let payload = self.config.periodic_payload.clone();
|
||
|
||
stop_flag.store(false, Ordering::SeqCst);
|
||
self.worker = Some(thread::spawn(move || {
|
||
let mut tick = 0_u64;
|
||
|
||
loop {
|
||
thread::sleep(interval);
|
||
if stop_flag.load(Ordering::SeqCst) {
|
||
break;
|
||
}
|
||
|
||
tick += 1;
|
||
sender.send_to_manager(
|
||
PLUGIN_ID,
|
||
Message::Custom {
|
||
kind: "example.heartbeat".to_string(),
|
||
payload: format!("{{\"tick\":{tick},\"payload\":{payload:?}}}"),
|
||
},
|
||
);
|
||
}
|
||
}));
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 停止后台线程并回收资源。
|
||
fn stop_worker(&mut self) -> Result<(), String> {
|
||
self.worker_stop.store(true, Ordering::SeqCst);
|
||
|
||
if let Some(handle) = self.worker.take() {
|
||
handle
|
||
.join()
|
||
.map_err(|_| "example background worker panicked".to_string())?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 统一处理触发器消息,让 `handle_message()` 保持可读性。
|
||
fn handle_trigger(&mut self, name: String, value: String) -> Result<(), String> {
|
||
eprintln!("[ExamplePlugin] trigger received: {name}={value}");
|
||
|
||
match name.as_str() {
|
||
"example.report_self_test" => {
|
||
let payload = serde_json::to_string(&self.self_test())
|
||
.map_err(|error| format!("failed to serialize self_test results: {error}"))?;
|
||
self.sender()?.send_to_manager(
|
||
PLUGIN_ID,
|
||
Message::Custom {
|
||
kind: "example.self_test_report".to_string(),
|
||
payload,
|
||
},
|
||
);
|
||
}
|
||
"example.send_demo_messages" => {
|
||
self.emit_demo_messages()?;
|
||
}
|
||
"example.request_display_info" => {
|
||
self.request_display_info()?;
|
||
}
|
||
_ => {
|
||
self.sender()?.send_to_manager(
|
||
PLUGIN_ID,
|
||
Message::Custom {
|
||
kind: "example.trigger_ack".to_string(),
|
||
payload: format!("unknown trigger ignored: {name}={value}"),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
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(())
|
||
}
|
||
|
||
/// 处理配置热重载。
|
||
///
|
||
/// 最佳实践:
|
||
/// 1. 先解析并校验新配置。
|
||
/// 2. 仅在成功后替换运行时状态。
|
||
/// 3. 如果配置会影响后台任务,重启对应任务。
|
||
fn reload_config(&mut self, value: serde_json::Value) -> Result<(), String> {
|
||
let next_config = ExamplePluginConfig::from_value(value)?;
|
||
self.config = next_config;
|
||
self.fail_optional_test = self.config.optional_test_should_fail;
|
||
self.start_worker()?;
|
||
|
||
let payload = serde_json::to_string(&self.config)
|
||
.map_err(|error| format!("failed to serialize reloaded config: {error}"))?;
|
||
self.sender()?.send_to_manager(
|
||
PLUGIN_ID,
|
||
Message::Custom {
|
||
kind: "example.config_reloaded".to_string(),
|
||
payload,
|
||
},
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl ShowenPlugin for ExamplePlugin {
|
||
fn info(&self) -> PluginInfo {
|
||
PluginInfo {
|
||
name: PLUGIN_ID.to_string(),
|
||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||
description: "Feature-complete example plugin for third-party developers".to_string(),
|
||
platform: "Any".to_string(),
|
||
}
|
||
}
|
||
|
||
fn capabilities(&self) -> Vec<String> {
|
||
vec![
|
||
CAP_MESSAGE_SENDER.to_string(),
|
||
CAP_MESSAGE_ROUTING.to_string(),
|
||
CAP_CONFIG_PARSING.to_string(),
|
||
CAP_BACKGROUND_TASK.to_string(),
|
||
CAP_SELF_TEST.to_string(),
|
||
CAP_REQUEST_RESPONSE.to_string(),
|
||
]
|
||
}
|
||
|
||
fn self_test(&mut self) -> Vec<CapabilityTestResult> {
|
||
let config_ok = self.config.validate().is_ok();
|
||
let worker_ok = !self.config.enable_periodic_task || self.worker.is_some();
|
||
let sender_ready = self.sender.is_some();
|
||
|
||
vec![
|
||
CapabilityTestResult {
|
||
capability: CAP_MESSAGE_SENDER.to_string(),
|
||
passed: sender_ready,
|
||
message: if sender_ready {
|
||
"MessageSender injected during init()".to_string()
|
||
} else {
|
||
"MessageSender not available yet".to_string()
|
||
},
|
||
},
|
||
CapabilityTestResult {
|
||
capability: CAP_MESSAGE_ROUTING.to_string(),
|
||
passed: sender_ready,
|
||
message: "send_to_manager / broadcast / send_to / send are implemented".to_string(),
|
||
},
|
||
CapabilityTestResult {
|
||
capability: CAP_CONFIG_PARSING.to_string(),
|
||
passed: config_ok,
|
||
message: if config_ok {
|
||
"config parsed with serde defaults + validation".to_string()
|
||
} else {
|
||
"current config failed validation".to_string()
|
||
},
|
||
},
|
||
CapabilityTestResult {
|
||
capability: CAP_BACKGROUND_TASK.to_string(),
|
||
passed: worker_ok,
|
||
message: if self.config.enable_periodic_task {
|
||
if worker_ok {
|
||
"periodic worker thread is running".to_string()
|
||
} else {
|
||
"periodic worker thread is not running".to_string()
|
||
}
|
||
} else {
|
||
"periodic task disabled by config".to_string()
|
||
},
|
||
},
|
||
CapabilityTestResult {
|
||
capability: CAP_SELF_TEST.to_string(),
|
||
passed: !self.fail_optional_test,
|
||
message: if self.fail_optional_test {
|
||
"optional self-test failure requested by config".to_string()
|
||
} else {
|
||
"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()
|
||
},
|
||
},
|
||
]
|
||
}
|
||
|
||
fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String> {
|
||
eprintln!(
|
||
"[ExamplePlugin] init called, received config bytes: {}",
|
||
config_json.len()
|
||
);
|
||
|
||
let config = ExamplePluginConfig::from_json(config_json)?;
|
||
self.config = config;
|
||
self.fail_optional_test = self.config.optional_test_should_fail;
|
||
self.sender = Some(Arc::new(sender));
|
||
|
||
self.notify_ready()?;
|
||
Ok(())
|
||
}
|
||
|
||
fn start(&mut self) -> Result<(), String> {
|
||
eprintln!("[ExamplePlugin] start called");
|
||
|
||
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(())
|
||
}
|
||
|
||
fn handle_message(&mut self, message: Message) -> Result<(), String> {
|
||
match message {
|
||
Message::PlayerCommand(payload) => {
|
||
eprintln!("[ExamplePlugin] player command: {payload}");
|
||
}
|
||
Message::PlayerStatus(payload) => {
|
||
eprintln!("[ExamplePlugin] player status: {payload}");
|
||
}
|
||
Message::Trigger { name, value } => {
|
||
self.handle_trigger(name, value)?;
|
||
}
|
||
Message::StateChanged {
|
||
old_state,
|
||
new_state,
|
||
} => {
|
||
eprintln!("[ExamplePlugin] state changed: {old_state} -> {new_state}");
|
||
}
|
||
Message::ScreenLockRequest(locked) => {
|
||
eprintln!("[ExamplePlugin] screen lock requested: {locked}");
|
||
}
|
||
Message::CursorVisibility(visible) => {
|
||
eprintln!("[ExamplePlugin] cursor visibility requested: {visible}");
|
||
}
|
||
Message::WifiCommand(payload) => {
|
||
eprintln!("[ExamplePlugin] wifi command payload: {payload}");
|
||
}
|
||
Message::WifiResult(result) => {
|
||
eprintln!("[ExamplePlugin] wifi result: {result}");
|
||
}
|
||
Message::WifiProvisioned { ssid, ip } => {
|
||
eprintln!("[ExamplePlugin] wifi provisioned: ssid={ssid}, ip={ip}");
|
||
self.sender()?.broadcast(
|
||
PLUGIN_ID,
|
||
Message::Custom {
|
||
kind: "example.wifi_provisioned".to_string(),
|
||
payload: format!("ssid={ssid}, ip={ip}"),
|
||
},
|
||
);
|
||
}
|
||
Message::ConfigReloaded(config) => {
|
||
self.reload_config(config)?;
|
||
}
|
||
Message::ConfigReloadRequest => {
|
||
self.sender()?.send_to_manager(
|
||
PLUGIN_ID,
|
||
Message::Custom {
|
||
kind: "example.reload_request".to_string(),
|
||
payload: "host should respond with ConfigReloaded(JSON)".to_string(),
|
||
},
|
||
);
|
||
}
|
||
Message::Shutdown => {
|
||
eprintln!("[ExamplePlugin] shutdown requested");
|
||
self.stop()?;
|
||
}
|
||
Message::PluginReady(plugin_name) => {
|
||
eprintln!("[ExamplePlugin] observed peer readiness: {plugin_name}");
|
||
}
|
||
Message::DeviceCommand(command) => {
|
||
eprintln!("[ExamplePlugin] device command received (not handled): {command:?}");
|
||
}
|
||
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 } => {
|
||
self.handle_custom_message(kind, payload)?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn stop(&mut self) -> Result<(), String> {
|
||
eprintln!("[ExamplePlugin] stop called");
|
||
self.stop_worker()?;
|
||
self.sender = None;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
// 导出动态插件 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)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::ffi::{c_char, c_void, CStr};
|
||
use std::sync::Mutex;
|
||
use std::thread;
|
||
|
||
struct Recorder {
|
||
envelopes: Box<Mutex<Vec<Envelope>>>,
|
||
}
|
||
|
||
impl Recorder {
|
||
fn new() -> Self {
|
||
Self {
|
||
envelopes: Box::new(Mutex::new(Vec::new())),
|
||
}
|
||
}
|
||
|
||
fn sender(&self) -> MessageSender {
|
||
let ctx = (&*self.envelopes as *const Mutex<Vec<Envelope>>) as *mut c_void;
|
||
MessageSender::new(ctx, record_envelope)
|
||
}
|
||
|
||
fn snapshot(&self) -> Vec<Envelope> {
|
||
self.envelopes
|
||
.lock()
|
||
.expect("recorder mutex poisoned")
|
||
.clone()
|
||
}
|
||
}
|
||
|
||
unsafe extern "C" fn record_envelope(ctx: *mut c_void, envelope_json: *const c_char) {
|
||
let storage = unsafe { &*(ctx as *const Mutex<Vec<Envelope>>) };
|
||
let raw = unsafe { CStr::from_ptr(envelope_json) }
|
||
.to_str()
|
||
.expect("callback JSON should be valid UTF-8");
|
||
let envelope =
|
||
serde_json::from_str::<Envelope>(raw).expect("callback JSON should decode to Envelope");
|
||
storage
|
||
.lock()
|
||
.expect("recorder mutex poisoned")
|
||
.push(envelope);
|
||
}
|
||
|
||
#[test]
|
||
fn config_defaults_when_json_is_empty() {
|
||
assert_eq!(
|
||
ExamplePluginConfig::from_json(" ").expect("empty config should use defaults"),
|
||
ExamplePluginConfig::default()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn config_rejects_too_small_heartbeat_interval() {
|
||
let error = ExamplePluginConfig::from_json(
|
||
r#"{"heartbeat_interval_ms":99,"enable_periodic_task":true}"#,
|
||
)
|
||
.expect_err("config should be rejected");
|
||
|
||
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();
|
||
let mut plugin = ExamplePlugin::new();
|
||
|
||
plugin
|
||
.init(
|
||
r#"{
|
||
"heartbeat_interval_ms": 100,
|
||
"target_plugin": "example-plugin",
|
||
"announce_on_start": true,
|
||
"enable_periodic_task": true,
|
||
"periodic_payload": "test-heartbeat"
|
||
}"#,
|
||
recorder.sender(),
|
||
)
|
||
.expect("init should succeed");
|
||
|
||
plugin.start().expect("start should succeed");
|
||
thread::sleep(Duration::from_millis(130));
|
||
plugin.stop().expect("stop should succeed");
|
||
|
||
let envelopes = recorder.snapshot();
|
||
assert!(
|
||
envelopes
|
||
.iter()
|
||
.any(|env| matches!(env.message, Message::PluginReady(_))),
|
||
"expected PluginReady message"
|
||
);
|
||
assert!(
|
||
envelopes
|
||
.iter()
|
||
.any(|env| { matches!(env.message, Message::StateChanged { .. }) }),
|
||
"expected StateChanged broadcast"
|
||
);
|
||
assert!(
|
||
envelopes
|
||
.iter()
|
||
.any(|env| matches!(env.message, Message::Trigger { .. })),
|
||
"expected Trigger direct message"
|
||
);
|
||
assert!(
|
||
envelopes.iter().any(|env| match &env.message {
|
||
Message::Custom { kind, .. } => kind == "example.heartbeat",
|
||
_ => false,
|
||
}),
|
||
"expected heartbeat custom message"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn config_reloaded_updates_runtime_state() {
|
||
let recorder = Recorder::new();
|
||
let mut plugin = ExamplePlugin::new();
|
||
|
||
plugin
|
||
.init(
|
||
r#"{
|
||
"announce_on_start": false,
|
||
"enable_periodic_task": false
|
||
}"#,
|
||
recorder.sender(),
|
||
)
|
||
.expect("init should succeed");
|
||
|
||
plugin.start().expect("start should succeed");
|
||
plugin
|
||
.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
|
||
})))
|
||
.expect("config reload should succeed");
|
||
|
||
assert!(plugin.config.enable_periodic_task);
|
||
assert_eq!(plugin.config.periodic_payload, "reloaded-heartbeat");
|
||
assert!(plugin.fail_optional_test);
|
||
assert!(plugin.worker.is_some());
|
||
|
||
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"));
|
||
}
|
||
}
|