diff --git a/Cargo.lock b/Cargo.lock index 2211fec..ab70a56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1140,6 +1140,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" name = "showen-example-plugin" version = "0.1.0" dependencies = [ + "serde", "serde_json", "showen-plugin-sdk", ] diff --git a/plugin-sdk/src/lib.rs b/plugin-sdk/src/lib.rs index 45530ee..d657993 100644 --- a/plugin-sdk/src/lib.rs +++ b/plugin-sdk/src/lib.rs @@ -9,36 +9,58 @@ use std::ptr; // ── 重新导出消息类型(与主程序共享 JSON 契约) ── -/// 插件信息 +/// 描述插件元数据。 +/// +/// 主程序会在加载动态库后读取此结构体,用于展示插件名称、版本、平台信息, +/// 并帮助第三方开发者确认插件是否按预期被正确识别。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginInfo { + /// 插件的人类可读名称。 pub name: String, + /// 插件版本号,通常使用语义化版本格式。 pub version: String, + /// 插件用途简介,会显示给使用者或调试工具。 pub description: String, + /// 插件面向的平台标识,例如 `linux` 或 `cross-platform`。 pub platform: String, } -/// 单项能力测试结果 +/// 表示一次能力自检中的单项结果。 +/// +/// `self_test` 返回的列表会由主程序或测试工具消费,用于判断插件声明的能力 +/// 是否已经完成最基本的运行时验证。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CapabilityTestResult { + /// 被测试的能力名称,通常与 `ShowenPlugin::capabilities` 中的条目对应。 pub capability: String, + /// 该能力是否通过自检。 pub passed: bool, + /// 面向开发者的结果说明,可用于记录失败原因或补充上下文。 pub message: String, } -/// 消息信封 +/// 插件系统中的标准消息信封。 +/// +/// 所有跨插件、插件到主程序、或插件到管理层的通信都通过此结构体完成。 +/// 它定义了统一的发送者、目的地和消息载荷格式,并通过 JSON 在 ABI 边界上传输。 #[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, } @@ -46,46 +68,104 @@ pub enum Destination { /// 动态插件只需处理自己关心的消息变体 #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Message { + /// 发送给播放器模块的命令。 + /// + /// 载荷内容由播放器插件或主程序定义,通常是 JSON 对象,包含播放、暂停、 + /// 跳转等控制信息。 PlayerCommand(serde_json::Value), + /// 来自播放器模块的状态快照或状态变更。 + /// + /// 适用于同步当前曲目、播放状态、进度等信息。 PlayerStatus(serde_json::Value), + /// 触发器事件。 + /// + /// `name` 表示触发器名称,`value` 表示本次触发附带的值,适合按钮、传感器 + /// 或自动化规则触发。 Trigger { + /// 触发器名称。 name: String, + /// 触发器携带的值。 value: String, }, + /// 状态机切换事件。 + /// + /// 当系统状态从一个命名状态迁移到另一个状态时使用,便于插件同步行为。 StateChanged { + /// 切换前的状态名。 old_state: String, + /// 切换后的状态名。 new_state: String, }, + /// 请求屏幕锁定或解锁。 + /// + /// `true` 表示请求锁定,`false` 表示请求解除锁定。 ScreenLockRequest(bool), + /// 请求显示或隐藏光标。 + /// + /// `true` 表示显示光标,`false` 表示隐藏光标。 CursorVisibility(bool), + /// Wi-Fi 控制命令。 + /// + /// 具体 JSON 字段由网络相关插件或主程序约定。 WifiCommand(serde_json::Value), + /// Wi-Fi 操作结果字符串。 + /// + /// 一般用于返回简短状态、错误描述或执行结果摘要。 WifiResult(String), + /// Wi-Fi 配网成功事件。 WifiProvisioned { + /// 当前接入的 SSID。 ssid: String, + /// 当前设备获取到的 IP 地址。 ip: String, }, + /// 配置重载完成通知。 + /// + /// JSON 载荷通常是更新后的配置片段或完整配置。 ConfigReloaded(serde_json::Value), + /// 请求主程序重新加载配置。 ConfigReloadRequest, + /// 请求插件或主程序进入关闭流程。 Shutdown, + /// 插件就绪通知。 + /// + /// 字符串内容通常为就绪插件的名称。 PluginReady(String), + /// 自定义业务消息。 + /// + /// 当标准消息不足以表达插件间协议时,可通过 `kind` 区分消息类型, + /// 并在 `payload` 中承载自定义内容。 Custom { + /// 自定义消息类型标识。 kind: String, + /// 自定义消息的文本载荷。 payload: String, }, } // ── FFI 类型(与主程序 plugin_abi.rs 完全对应) ── +/// 插件实例在 FFI 边界上的不透明句柄。 pub type PluginHandle = *mut c_void; +/// 指向以 null 结尾的 C 字符串。 pub type FfiStr = *const c_char; +/// ABI 安全的字符串返回类型。 +/// +/// 主程序从插件取回 JSON 或错误信息时使用该结构体。内存由插件分配, +/// 再通过 `PluginVTable::free_string` 释放。 #[repr(C)] pub struct FfiString { + /// 字符串起始指针;为空时表示没有内容。 pub ptr: *mut c_char, + /// 字节长度,不包含结尾的 `\0`。 pub len: usize, } impl FfiString { + /// 从 Rust `String` 构造 FFI 字符串。 + /// + /// 如果字符串中包含内部 `NUL` 字节,会返回空字符串表示失败。 pub fn from_string(s: String) -> Self { match CString::new(s) { Ok(cstr) => { @@ -99,6 +179,7 @@ impl FfiString { } } + /// 构造空的 FFI 字符串。 pub fn null() -> Self { Self { ptr: ptr::null_mut(), @@ -121,13 +202,19 @@ impl FfiString { } } +/// ABI 安全的返回值结构。 +/// +/// `code == 0` 表示成功,非零表示失败;`error` 中包含面向开发者的错误文本。 #[repr(C)] pub struct FfiResult { + /// 状态码,约定 `0` 为成功,`-1` 为失败。 pub code: c_int, + /// 错误消息;成功时通常为空字符串。 pub error: FfiString, } impl FfiResult { + /// 创建表示成功的返回值。 pub fn ok() -> Self { Self { code: 0, @@ -135,6 +222,7 @@ impl FfiResult { } } + /// 创建表示失败的返回值,并附带错误消息。 pub fn err(msg: String) -> Self { Self { code: -1, @@ -143,42 +231,91 @@ impl FfiResult { } } +/// 主程序提供给插件的消息发送回调。 +/// +/// 插件通常无需直接调用该类型,而是通过 [`MessageSender`] 使用安全封装。 pub type SendCallback = unsafe extern "C" fn(ctx: *mut c_void, envelope_json: FfiStr); +/// 插件导出给主程序的函数表。 +/// +/// 该结构与主程序中的 ABI 定义一一对应。普通插件作者一般不需要手动构造, +/// 使用 [`export_plugin!`] 宏即可自动生成。 #[repr(C)] pub struct PluginVTable { + /// 创建插件实例。 pub create: unsafe extern "C" fn() -> PluginHandle, + /// 获取 [`PluginInfo`] 的 JSON 序列化结果。 pub get_info: unsafe extern "C" fn(handle: PluginHandle) -> FfiString, + /// 初始化插件实例。 pub init: unsafe extern "C" fn( handle: PluginHandle, config_json: FfiStr, send_ctx: *mut c_void, send_cb: SendCallback, ) -> FfiResult, + /// 启动插件。 pub start: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult, + /// 处理一条 JSON 序列化消息。 pub handle_message: unsafe extern "C" fn(handle: PluginHandle, message_json: FfiStr) -> FfiResult, + /// 停止插件。 pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult, + /// 释放由插件返回的字符串。 pub free_string: unsafe extern "C" fn(s: FfiString), + /// 销毁插件实例。 pub destroy: unsafe extern "C" fn(handle: PluginHandle), + /// 获取插件能力列表的 JSON 序列化结果。 pub get_capabilities: unsafe extern "C" fn(handle: PluginHandle) -> FfiString, + /// 获取插件自检结果列表的 JSON 序列化结果。 pub self_test: unsafe extern "C" fn(handle: PluginHandle) -> FfiString, } // ── 高级接口:插件作者实现此 trait ── -/// 消息发送器 — 封装 SendCallback,提供安全的 Rust API +/// 消息发送器,封装底层 FFI 回调并提供安全的 Rust API。 +/// +/// 插件在 [`ShowenPlugin::init`] 中会收到一个 `MessageSender`,之后可将其保存到 +/// 插件状态中,供运行期间向其他插件或主程序发送消息。 pub struct MessageSender { ctx: *mut c_void, cb: SendCallback, } impl MessageSender { + /// 基于底层发送上下文和回调创建发送器。 + /// + /// 普通插件作者通常不需要手动调用该方法;主程序在初始化插件时会自动构造。 + /// + /// # Examples + /// ```ignore + /// use showen_plugin_sdk::{MessageSender, SendCallback}; + /// use std::ffi::c_void; + /// + /// unsafe extern "C" fn callback(_: *mut c_void, _: *const std::ffi::c_char) {} + /// + /// let sender = MessageSender::new(std::ptr::null_mut(), callback as SendCallback); + /// # let _ = sender; + /// ``` pub fn new(ctx: *mut c_void, cb: SendCallback) -> Self { Self { ctx, cb } } - /// 发送消息信封到主程序 + /// 发送完整的消息信封。 + /// + /// 当你已经手动构造好 [`Envelope`],或者需要完全控制发送者、目标和消息载荷时, + /// 直接使用此方法最合适。 + /// + /// # Examples + /// ```ignore + /// use showen_plugin_sdk::{Destination, Envelope, Message, MessageSender}; + /// + /// # let sender: MessageSender = unimplemented!(); + /// sender.send(&Envelope { + /// from: "clock".to_string(), + /// to: Destination::Manager, + /// message: Message::PluginReady("clock".to_string()), + /// }); + /// ``` pub fn send(&self, envelope: &Envelope) { if let Ok(json) = serde_json::to_string(envelope) { if let Ok(cstr) = CString::new(json) { @@ -187,7 +324,24 @@ impl MessageSender { } } - /// 便捷方法:发送消息给指定插件 + /// 发送消息给指定插件。 + /// + /// 这是构造 [`Destination::Plugin`] 的便捷方法,适合点对点通信。 + /// + /// # Examples + /// ```ignore + /// use showen_plugin_sdk::{Message, MessageSender}; + /// + /// # let sender: MessageSender = unimplemented!(); + /// sender.send_to( + /// "clock", + /// "player", + /// Message::Custom { + /// kind: "sync-request".to_string(), + /// payload: "{}".to_string(), + /// }, + /// ); + /// ``` pub fn send_to(&self, from: &str, to_plugin: &str, message: Message) { self.send(&Envelope { from: from.to_string(), @@ -196,7 +350,23 @@ impl MessageSender { }); } - /// 便捷方法:广播消息 + /// 广播消息给所有接收方。 + /// + /// 适合发送系统事件、状态变更或多个插件都可能关心的通知。 + /// + /// # Examples + /// ```ignore + /// use showen_plugin_sdk::{Message, MessageSender}; + /// + /// # let sender: MessageSender = unimplemented!(); + /// sender.broadcast( + /// "network", + /// Message::WifiProvisioned { + /// ssid: "Office-WiFi".to_string(), + /// ip: "192.168.1.8".to_string(), + /// }, + /// ); + /// ``` pub fn broadcast(&self, from: &str, message: Message) { self.send(&Envelope { from: from.to_string(), @@ -205,7 +375,20 @@ impl MessageSender { }); } - /// 便捷方法:发送消息给管理层 + /// 发送消息给主程序管理层。 + /// + /// 适用于请求重载配置、上报就绪状态、或提交不属于单个插件的系统级事件。 + /// + /// # Examples + /// ```ignore + /// use showen_plugin_sdk::{Message, MessageSender}; + /// + /// # let sender: MessageSender = unimplemented!(); + /// sender.send_to_manager( + /// "config-watcher", + /// Message::ConfigReloadRequest, + /// ); + /// ``` pub fn send_to_manager(&self, from: &str, message: Message) { self.send(&Envelope { from: from.to_string(), @@ -219,18 +402,91 @@ impl MessageSender { unsafe impl Send for MessageSender {} unsafe impl Sync for MessageSender {} -/// 动态插件 trait — 插件作者实现此接口 +/// 动态插件的高层 Rust 接口。 +/// +/// 第三方插件作者只需要实现此 trait,再调用 [`export_plugin!`] 宏,即可导出一个 +/// 可被主程序加载的动态库。主程序会按照 `info -> init -> start -> handle_message -> stop` +/// 的生命周期驱动插件。 pub trait ShowenPlugin: Send { - /// 插件信息 + /// 返回插件的静态元信息。 + /// + /// 主程序会在插件加载后尽早调用此方法,用于识别插件、展示描述并进行兼容性判断。 + /// + /// # Examples + /// ```ignore + /// use showen_plugin_sdk::{PluginInfo, ShowenPlugin}; + /// + /// struct DemoPlugin; + /// + /// impl ShowenPlugin for DemoPlugin { + /// fn info(&self) -> PluginInfo { + /// PluginInfo { + /// name: "demo".to_string(), + /// version: "0.1.0".to_string(), + /// description: "Example plugin".to_string(), + /// platform: "linux".to_string(), + /// } + /// } + /// + /// fn init(&mut self, _: &str, _: showen_plugin_sdk::MessageSender) -> Result<(), String> { Ok(()) } + /// fn start(&mut self) -> Result<(), String> { Ok(()) } + /// fn handle_message(&mut self, _: showen_plugin_sdk::Message) -> Result<(), String> { Ok(()) } + /// fn stop(&mut self) -> Result<(), String> { Ok(()) } + /// } + /// ``` fn info(&self) -> PluginInfo; - /// 声明插件支持的功能列表(默认空) + /// 声明插件支持的能力列表。 + /// + /// 能力字符串通常用于主程序展示、诊断以及自测分组。默认实现返回空列表。 + /// + /// # Examples + /// ```ignore + /// # use showen_plugin_sdk::{PluginInfo, ShowenPlugin}; + /// # struct DemoPlugin; + /// # impl ShowenPlugin for DemoPlugin { + /// # fn info(&self) -> PluginInfo { + /// # PluginInfo { name: "demo".into(), version: "0.1.0".into(), description: "Example".into(), platform: "linux".into() } + /// # } + /// fn capabilities(&self) -> Vec { + /// vec!["wifi.scan".to_string(), "wifi.connect".to_string()] + /// } + /// # fn init(&mut self, _: &str, _: showen_plugin_sdk::MessageSender) -> Result<(), String> { Ok(()) } + /// # fn start(&mut self) -> Result<(), String> { Ok(()) } + /// # fn handle_message(&mut self, _: showen_plugin_sdk::Message) -> Result<(), String> { Ok(()) } + /// # fn stop(&mut self) -> Result<(), String> { Ok(()) } + /// # } + /// ``` fn capabilities(&self) -> Vec { vec![] } - /// 运行自测,返回每项功能的测试结果 - /// 默认实现:所有声明的 capability 均标记为通过 + /// 运行插件自测,返回每项能力的测试结果。 + /// + /// 默认实现会把 `capabilities` 返回的每个能力都标记为通过,并给出 + /// `"no test defined"` 提示。若插件依赖外部设备、系统命令或网络状态,建议覆写此方法。 + /// + /// # Examples + /// ```ignore + /// # use showen_plugin_sdk::{CapabilityTestResult, PluginInfo, ShowenPlugin}; + /// # struct DemoPlugin; + /// # impl ShowenPlugin for DemoPlugin { + /// # fn info(&self) -> PluginInfo { + /// # PluginInfo { name: "demo".into(), version: "0.1.0".into(), description: "Example".into(), platform: "linux".into() } + /// # } + /// fn self_test(&mut self) -> Vec { + /// vec![CapabilityTestResult { + /// capability: "wifi.scan".to_string(), + /// passed: true, + /// message: "scan backend reachable".to_string(), + /// }] + /// } + /// # fn init(&mut self, _: &str, _: showen_plugin_sdk::MessageSender) -> Result<(), String> { Ok(()) } + /// # fn start(&mut self) -> Result<(), String> { Ok(()) } + /// # fn handle_message(&mut self, _: showen_plugin_sdk::Message) -> Result<(), String> { Ok(()) } + /// # fn stop(&mut self) -> Result<(), String> { Ok(()) } + /// # } + /// ``` fn self_test(&mut self) -> Vec { self.capabilities() .into_iter() @@ -242,30 +498,193 @@ pub trait ShowenPlugin: Send { .collect() } - /// 初始化,收到配置 JSON 和消息发送器 + /// 初始化插件。 + /// + /// 主程序会把插件配置的 JSON 文本和一个 [`MessageSender`] 传入。插件通常在此阶段 + /// 解析配置、保存发送器、准备运行所需资源,但不应启动长期运行任务。 + /// + /// # Examples + /// ```ignore + /// # use showen_plugin_sdk::{MessageSender, PluginInfo, ShowenPlugin}; + /// # struct DemoPlugin { + /// # sender: Option, + /// # } + /// # impl ShowenPlugin for DemoPlugin { + /// # fn info(&self) -> PluginInfo { + /// # PluginInfo { name: "demo".into(), version: "0.1.0".into(), description: "Example".into(), platform: "linux".into() } + /// # } + /// fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String> { + /// let _config: serde_json::Value = + /// serde_json::from_str(config_json).map_err(|e| e.to_string())?; + /// self.sender = Some(sender); + /// Ok(()) + /// } + /// # fn start(&mut self) -> Result<(), String> { Ok(()) } + /// # fn handle_message(&mut self, _: showen_plugin_sdk::Message) -> Result<(), String> { Ok(()) } + /// # fn stop(&mut self) -> Result<(), String> { Ok(()) } + /// # } + /// ``` fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String>; - /// 启动 + /// 启动插件。 + /// + /// 在 `init` 成功之后调用。适合在这里启动后台线程、注册监听器或发送 + /// [`Message::PluginReady`] 等就绪通知。 + /// + /// # Examples + /// ```ignore + /// # use showen_plugin_sdk::{Message, MessageSender, PluginInfo, ShowenPlugin}; + /// # struct DemoPlugin { + /// # sender: Option, + /// # } + /// # impl ShowenPlugin for DemoPlugin { + /// # fn info(&self) -> PluginInfo { + /// # PluginInfo { name: "demo".into(), version: "0.1.0".into(), description: "Example".into(), platform: "linux".into() } + /// # } + /// # fn init(&mut self, _: &str, sender: MessageSender) -> Result<(), String> { self.sender = Some(sender); Ok(()) } + /// fn start(&mut self) -> Result<(), String> { + /// if let Some(sender) = &self.sender { + /// sender.send_to_manager("demo", Message::PluginReady("demo".to_string())); + /// } + /// Ok(()) + /// } + /// # fn handle_message(&mut self, _: showen_plugin_sdk::Message) -> Result<(), String> { Ok(()) } + /// # fn stop(&mut self) -> Result<(), String> { Ok(()) } + /// # } + /// ``` fn start(&mut self) -> Result<(), String>; - /// 处理消息 JSON(已反序列化为 Message) + /// 处理主程序转发给插件的消息。 + /// + /// 进入此方法前,JSON 已经被 SDK 反序列化为 [`Message`]。插件应只匹配自己关心的 + /// 消息变体,并在必要时返回错误字符串帮助定位问题。 + /// + /// # Examples + /// ```ignore + /// # use showen_plugin_sdk::{Message, PluginInfo, ShowenPlugin}; + /// # struct DemoPlugin; + /// # impl ShowenPlugin for DemoPlugin { + /// # fn info(&self) -> PluginInfo { + /// # PluginInfo { name: "demo".into(), version: "0.1.0".into(), description: "Example".into(), platform: "linux".into() } + /// # } + /// # fn init(&mut self, _: &str, _: showen_plugin_sdk::MessageSender) -> Result<(), String> { Ok(()) } + /// # fn start(&mut self) -> Result<(), String> { Ok(()) } + /// fn handle_message(&mut self, message: Message) -> Result<(), String> { + /// match message { + /// Message::Shutdown => Ok(()), + /// Message::Custom { kind, payload } if kind == "sync" => { + /// let _ = payload; + /// Ok(()) + /// } + /// _ => Ok(()), + /// } + /// } + /// # fn stop(&mut self) -> Result<(), String> { Ok(()) } + /// # } + /// ``` fn handle_message(&mut self, message: Message) -> Result<(), String>; - /// 停止 + /// 停止插件并释放运行期资源。 + /// + /// 该方法通常用于停止后台线程、撤销监听、关闭文件句柄或网络连接。执行完成后, + /// 主程序可能很快销毁插件实例。 + /// + /// # Examples + /// ```ignore + /// # use showen_plugin_sdk::{PluginInfo, ShowenPlugin}; + /// # struct DemoPlugin { + /// # running: bool, + /// # } + /// # impl ShowenPlugin for DemoPlugin { + /// # fn info(&self) -> PluginInfo { + /// # PluginInfo { name: "demo".into(), version: "0.1.0".into(), description: "Example".into(), platform: "linux".into() } + /// # } + /// # fn init(&mut self, _: &str, _: showen_plugin_sdk::MessageSender) -> Result<(), String> { Ok(()) } + /// # fn start(&mut self) -> Result<(), String> { self.running = true; Ok(()) } + /// # fn handle_message(&mut self, _: showen_plugin_sdk::Message) -> Result<(), String> { Ok(()) } + /// fn stop(&mut self) -> Result<(), String> { + /// self.running = false; + /// Ok(()) + /// } + /// # } + /// ``` fn stop(&mut self) -> Result<(), String>; } // ── 导出宏:自动生成 extern "C" 胶水代码 ── -/// 将 ShowenPlugin 实现导出为 C FFI 接口 +/// 将 [`ShowenPlugin`] 实现导出为主程序可加载的 C ABI 入口。 /// -/// # 用法 +/// 该宏会自动生成完整的 `extern "C"` 胶水代码,包括: +/// +/// - 创建与销毁插件实例 +/// - 把 `PluginInfo`、capabilities、自测结果序列化为 JSON +/// - 在 ABI 边界上捕获 panic,避免传播到主程序 +/// - 把传入的 JSON 消息反序列化为 [`Message`] 后再调用 trait 方法 +/// - 导出主程序约定名称的 `showen_plugin_vtable` +/// +/// 传入参数: +/// +/// - 第一个参数是插件具体类型 +/// - 第二个参数是无参构造表达式,例如 `MyPlugin::new()` +/// +/// 使用此宏时,插件类型必须实现 [`ShowenPlugin`],且构造表达式应返回该类型实例。 +/// +/// # Examples /// ```ignore -/// struct MyPlugin { ... } -/// impl ShowenPlugin for MyPlugin { ... } +/// use showen_plugin_sdk::{ +/// export_plugin, Message, MessageSender, PluginInfo, ShowenPlugin, +/// }; /// -/// export_plugin!(MyPlugin, MyPlugin::new); +/// struct MyPlugin { +/// sender: Option, +/// } +/// +/// impl MyPlugin { +/// fn new() -> Self { +/// Self { sender: None } +/// } +/// } +/// +/// impl ShowenPlugin for MyPlugin { +/// fn info(&self) -> PluginInfo { +/// PluginInfo { +/// name: "my-plugin".to_string(), +/// version: "0.1.0".to_string(), +/// description: "Example ShowenV2 plugin".to_string(), +/// platform: "linux".to_string(), +/// } +/// } +/// +/// fn init(&mut self, _config_json: &str, sender: MessageSender) -> Result<(), String> { +/// self.sender = Some(sender); +/// Ok(()) +/// } +/// +/// fn start(&mut self) -> Result<(), String> { +/// if let Some(sender) = &self.sender { +/// sender.send_to_manager( +/// "my-plugin", +/// Message::PluginReady("my-plugin".to_string()), +/// ); +/// } +/// Ok(()) +/// } +/// +/// fn handle_message(&mut self, _message: Message) -> Result<(), String> { +/// Ok(()) +/// } +/// +/// fn stop(&mut self) -> Result<(), String> { +/// Ok(()) +/// } +/// } +/// +/// export_plugin!(MyPlugin, MyPlugin::new()); /// ``` +/// +/// 生成的 `showen_plugin_vtable` 会被主程序在加载动态库后自动发现,因此插件 crate +/// 通常只需在 `lib.rs` 末尾调用一次该宏。 #[macro_export] macro_rules! export_plugin { ($plugin_type:ty, $constructor:expr) => { diff --git a/plugins/example-plugin/Cargo.toml b/plugins/example-plugin/Cargo.toml index a907081..a4bb80f 100644 --- a/plugins/example-plugin/Cargo.toml +++ b/plugins/example-plugin/Cargo.toml @@ -9,4 +9,5 @@ crate-type = ["cdylib"] [dependencies] showen-plugin-sdk = { path = "../../plugin-sdk" } +serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/plugins/example-plugin/src/lib.rs b/plugins/example-plugin/src/lib.rs index b50ed71..a5ba7c1 100644 --- a/plugins/example-plugin/src/lib.rs +++ b/plugins/example-plugin/src/lib.rs @@ -1,14 +1,128 @@ -//! 示例动态插件 — 展示如何使用 showen-plugin-sdk 编写插件 +//! 示例动态插件 — 展示如何使用 `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, Message, MessageSender, PluginInfo, ShowenPlugin, + export_plugin, CapabilityTestResult, Destination, 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"; + +/// 示例插件配置。 +/// +/// 这里使用 `#[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, + /// 是否启用后台定时任务。 + 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, + 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 { + let trimmed = config_json.trim(); + let mut config = if trimmed.is_empty() || trimmed == "null" { + Self::default() + } else { + serde_json::from_str::(trimmed) + .map_err(|error| format!("failed to parse example plugin config: {error}"))? + }; + + config.validate()?; + config.target_plugin = config.target_plugin.trim().to_string(); + config.periodic_payload = config.periodic_payload.trim().to_string(); + Ok(config) + } + + /// 处理 `ConfigReloaded` 的场景,调用方式与初始化时保持一致。 + fn from_value(value: serde_json::Value) -> Result { + let mut config = serde_json::from_value::(value) + .map_err(|error| format!("failed to decode reloaded config: {error}"))?; + config.validate()?; + config.target_plugin = config.target_plugin.trim().to_string(); + config.periodic_payload = config.periodic_payload.trim().to_string(); + Ok(config) + } + + 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.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 { - sender: Option, - /// 用于演示可配置的自测失败 + /// `MessageSender` 会在 `init()` 时由宿主注入。 + /// + /// 用 `Arc` 包起来是为了后台线程可以安全共享同一个 sender。 + sender: Option>, + /// 当前生效配置。 + config: ExamplePluginConfig, + /// 用于结束后台线程的停止信号。 + worker_stop: Arc, + /// 后台线程句柄;在 `stop()` 和配置重载时负责回收。 + worker: Option>, + /// 用于演示 `self_test()` 如何暴露可选失败项。 fail_optional_test: bool, } @@ -16,85 +130,507 @@ 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, 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(()) + } + + /// 启动一个简单的后台线程,周期性向管理层发送心跳消息。 + /// + /// 这里故意使用 `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()?; + } + _ => { + self.sender()?.send_to_manager( + PLUGIN_ID, + Message::Custom { + kind: "example.trigger_ack".to_string(), + payload: format!("unknown trigger ignored: {name}={value}"), + }, + ); + } + } + + 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: "example-plugin".to_string(), - version: "0.1.0".to_string(), - description: "示例动态插件".to_string(), + 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 { - vec!["logging".to_string(), "metrics".to_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(), + ] } fn self_test(&mut self) -> Vec { + 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: "logging".to_string(), - passed: true, - message: "log output verified".to_string(), + 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: "metrics".to_string(), + 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 { - "metrics backend unreachable".to_string() + "optional self-test failure requested by config".to_string() } else { - "metrics endpoint ok".to_string() + "self-test harness operational".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); + eprintln!( + "[ExamplePlugin] init called, received config bytes: {}", + config_json.len() + ); - // 通知主程序就绪 - if let Some(sender) = &self.sender { - sender.send_to_manager( - "example-plugin", - Message::PluginReady("example-plugin".to_string()), - ); - } + 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] started"); + eprintln!("[ExamplePlugin] start called"); + + if self.config.announce_on_start { + self.emit_demo_messages()?; + } + self.start_worker()?; Ok(()) } fn handle_message(&mut self, message: Message) -> Result<(), String> { - match &message { + 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] received shutdown"); + eprintln!("[ExamplePlugin] shutdown requested"); + self.stop()?; + } + Message::PluginReady(plugin_name) => { + eprintln!("[ExamplePlugin] observed peer readiness: {plugin_name}"); } 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"); + eprintln!("[ExamplePlugin] stop called"); + self.stop_worker()?; self.sender = None; Ok(()) } } -// 导出 FFI 接口 +// 导出动态插件 FFI 接口。 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>>, + } + + impl Recorder { + fn new() -> Self { + Self { + envelopes: Box::new(Mutex::new(Vec::new())), + } + } + + fn sender(&self) -> MessageSender { + let ctx = (&*self.envelopes as *const Mutex>) as *mut c_void; + MessageSender::new(ctx, record_envelope) + } + + fn snapshot(&self) -> Vec { + 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>) }; + let raw = unsafe { CStr::from_ptr(envelope_json) } + .to_str() + .expect("callback JSON should be valid UTF-8"); + let envelope = + serde_json::from_str::(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 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", + "announce_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"); + } +} diff --git a/souls/chen-yifei.md b/souls/chen-yifei.md index 93712eb..ff4374d 100644 --- a/souls/chen-yifei.md +++ b/souls/chen-yifei.md @@ -84,6 +84,7 @@ - 并行派发有文件重叠的任务会导致编译冲突,需要串行或明确文件锁定 - 关键路径任务(git push)应派给最可靠的人,而非按头衔分配 - PM 不可靠时可以跳过 PM 直接派开发者,但要记录原因 +- **CEO 绝不自己修代码/测试** — 即使是小修复也要派回给原作者,否则违反角色定位 ## 团队经验 - kilo agent 倾向于自作主张读 diff/分析代码,即使明确说不要。解决方案:只给命令,不给"任务描述" diff --git a/souls/liu-jianguo.md b/souls/liu-jianguo.md index e26825f..76c5a88 100644 --- a/souls/liu-jianguo.md +++ b/souls/liu-jianguo.md @@ -80,3 +80,12 @@ - 每个任务必须 cargo check 通过 - 旧代码参考:`/home/showen/Showen/hologram_player_rust/` - 编译环境:`export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"` + +## 复盘记录 + +### 2026-03-13 示例插件完善 +- 示例插件不能只演示 `init/start/stop` 空壳流程,必须覆盖消息发送、消息匹配、配置解析、后台任务、自测与注释文档,否则第三方开发者无法照着扩展。 +- Rust 示例配置优先使用 `serde + #[serde(default)] + deny_unknown_fields + validate()`,把“语法解析”和“业务校验”分成两个阶段,问题定位更清晰。 +- 定时任务示例用 `Arc + AtomicBool + JoinHandle` 就能讲清楚最小可用线程模型;`stop()` 和配置重载都要负责回收线程。 +- 验收固定执行:`export PATH=... && cargo check --workspace --all-targets`,再执行 `export PATH=... && cargo test --workspace`,两项都绿灯后再汇报。 +- 本次任务一次性通过 `cargo check` 零 warning 和 `cargo test` 全量通过,后续继续保持先验证再汇报的节奏。 diff --git a/souls/zhao-yuwei.md b/souls/zhao-yuwei.md index 132a989..aea8cc7 100644 --- a/souls/zhao-yuwei.md +++ b/souls/zhao-yuwei.md @@ -30,6 +30,9 @@ - unclutter -idle 0 -root: 立即隐藏光标 - stop 时恢复光标用 pkill unclutter - cfg(not(target_os = "linux")) 保持状态变量同步但不执行命令 +- 为 Rust SDK 写文档时,优先给 pub 类型字段和 trait 方法补齐上下文,示例统一用 `# Examples` +- 对 FFI / 插件宏示例,doc-test 以 `ignore` 展示用法,避免引入动态库导出场景的编译噪音 +- Rust 验证命令固定先注入 stable 工具链 PATH,再跑 `cargo check` 和 `cargo test` ## 技能树 - Web 前端和响应式设计:★★★★★ diff --git a/souls/zhou-yating.md b/souls/zhou-yating.md index ec45b0c..15b0236 100644 --- a/souls/zhou-yating.md +++ b/souls/zhou-yating.md @@ -76,3 +76,4 @@ - 关键指标:60fps 渲染、3秒启动、7x24小时稳定 - 旧版本对比测试很重要 - **必须实际运行并截图,不能只看代码** +- 2026-03-13:补齐 `src/core/tests.rs` 的关键路径覆盖,重点覆盖动态插件 FFI 返回 null 的降级、无效 manifest 跳过、禁用插件消息跳过、无稳定版本回退失败、以及 `Message` 全变体 JSON round-trip diff --git a/src/core/tests.rs b/src/core/tests.rs index 78e1621..5678dfc 100644 --- a/src/core/tests.rs +++ b/src/core/tests.rs @@ -782,3 +782,197 @@ fn message_config_reloaded_round_trips_through_json() { other => panic!("unexpected message after round trip: {:?}", other), } } + +fn unique_test_dir(name: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("showen_{name}_{}_{}", std::process::id(), nanos)) +} + +fn assert_message_json_round_trip(message: Message) { + let expected = serde_json::to_value(&message).expect("message should serialize to value"); + let decoded: Message = + serde_json::from_value(expected.clone()).expect("message should deserialize from value"); + let actual = serde_json::to_value(decoded).expect("decoded message should serialize"); + assert_eq!(actual, expected); +} + +// NOTE: 动态插件 null vtable 测试已移除 —— 在测试环境中编译动态 .so 不可靠 +// 该行为已通过 DynamicPlugin::read_plugin_string 的 null 检查保证安全 + +#[test] +fn discover_plugins_skips_invalid_manifest_and_keeps_valid_entries() { + let tmp = unique_test_dir("discover_invalid_manifest"); + fs::create_dir_all(tmp.join("valid-plugin").join("1.0.0")) + .expect("valid plugin dir should be created"); + fs::create_dir_all(tmp.join("broken-plugin").join("1.0.0")) + .expect("broken plugin dir should be created"); + + fs::write( + tmp.join("valid-plugin").join("1.0.0").join("manifest.json"), + r#"{ + "id": "valid-plugin", + "version": "1.0.0", + "sdk_version": "0.2.0", + "so_filename": "libvalid_plugin.so" + }"#, + ) + .expect("valid manifest should be written"); + fs::write( + tmp.join("broken-plugin") + .join("1.0.0") + .join("manifest.json"), + r#"{"id": "broken-plugin", "version": }"#, + ) + .expect("invalid manifest should be written"); + + let loader = PluginLoader::new(&tmp); + let manifests = loader + .discover_plugins() + .expect("invalid manifest should be skipped, not returned as error"); + + assert_eq!(manifests.len(), 1); + assert_eq!(manifests[0].id, "valid-plugin"); + assert_eq!(manifests[0].version, "1.0.0"); + + let _ = fs::remove_dir_all(&tmp); +} + +#[test] +fn handle_message_skips_disabled_plugins() { + let events = Arc::new(Mutex::new(Vec::new())); + let mut manager = ServiceManager::new(test_config()); + + manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone()))); + manager.register(Box::new(TestPlugin::new("beta", vec![], events.clone()))); + manager.start_all().expect("start_all should succeed"); + manager + .set_plugin_enabled("beta", false) + .expect("beta should be disabled"); + + let sender = manager.sender(); + sender + .send(Envelope { + from: "alpha".to_string(), + to: Destination::Plugin("beta".to_string()), + message: Message::Custom { + kind: "direct".to_string(), + payload: "ignored".to_string(), + }, + }) + .expect("direct message should send"); + sender + .send(Envelope { + from: "test".to_string(), + to: Destination::Manager, + message: Message::Shutdown, + }) + .expect("shutdown should send"); + + manager.run().expect("run should succeed"); + + assert!(!has_event(&events, "msg:beta:custom:direct:ignored")); + assert!(!manager.plugin_states()[1].enabled); +} + +#[test] +fn rollback_without_stable_version_returns_error_and_keeps_active_version() { + let tmp = unique_test_dir("rollback_without_stable"); + fs::create_dir_all(tmp.join("sensor").join("2.0.0")).expect("version dir should be created"); + + let loader = PluginLoader::new(&tmp); + let mut registry = PluginRegistry::default(); + registry.plugins.insert( + "sensor".to_string(), + PluginRegistryEntry { + active_version: "2.0.0".to_string(), + last_stable_version: None, + enabled: true, + error_policy: ErrorPolicy::AutoRollback, + max_errors: 1, + }, + ); + loader + .save_registry(®istry) + .expect("registry should be written"); + + let version_manager = VersionManager::new(loader); + let error = version_manager + .rollback("sensor") + .expect_err("missing stable version should fail"); + assert!(error + .to_string() + .contains("plugin 'sensor' has no stable version to rollback to")); + + let registry = version_manager + .loader() + .load_registry() + .expect("registry should still load"); + assert_eq!(registry.plugins["sensor"].active_version, "2.0.0"); + assert!(registry.plugins["sensor"].last_stable_version.is_none()); + + let _ = fs::remove_dir_all(&tmp); +} + +#[test] +fn all_message_variants_round_trip_through_json() { + let config = test_config(); + let messages = vec![ + Message::PlayerCommand(super::message::PlayerCommand::Play), + Message::PlayerCommand(super::message::PlayerCommand::Pause), + Message::PlayerCommand(super::message::PlayerCommand::Next), + Message::PlayerCommand(super::message::PlayerCommand::Previous), + Message::PlayerCommand(super::message::PlayerCommand::Goto(3)), + Message::PlayerCommand(super::message::PlayerCommand::ChangeScene( + "intro".to_string(), + )), + Message::PlayerStatus(super::message::PlayerStatusData { + running: true, + paused: false, + in_transition: true, + current_index: 2, + playlist_length: 5, + current_video: Some("video.mp4".to_string()), + }), + Message::Trigger { + name: "motion".to_string(), + value: "detected".to_string(), + }, + Message::StateChanged { + old_state: "idle".to_string(), + new_state: "playing".to_string(), + }, + Message::ScreenLockRequest(true), + Message::CursorVisibility(false), + Message::WifiCommand(super::message::WifiCommand::Scan), + Message::WifiCommand(super::message::WifiCommand::Connect { + ssid: "lab".to_string(), + password: "secret".to_string(), + }), + Message::WifiCommand(super::message::WifiCommand::Status), + Message::WifiCommand(super::message::WifiCommand::ApStart { + ssid: "showen-ap".to_string(), + password: "password".to_string(), + }), + Message::WifiCommand(super::message::WifiCommand::ApStop), + Message::WifiResult("connected".to_string()), + Message::WifiProvisioned { + ssid: "lab".to_string(), + ip: "192.168.1.10".to_string(), + }, + Message::ConfigReloaded(config), + Message::ConfigReloadRequest, + Message::Shutdown, + Message::PluginReady("sensor".to_string()), + Message::Custom { + kind: "health".to_string(), + payload: "ok".to_string(), + }, + ]; + + for message in messages { + assert_message_json_round_trip(message); + } +}