//! Plugin UI Registry //! //! Manages plugin-provided UI pages and provides hooks for accessing //! page definitions at runtime. //! //! ## Usage //! //! ```rust,ignore //! // Initialize registry with API client //! let registry = PluginRegistry::new(api_client); //! registry.refresh().await?; //! //! // Access pages //! if let Some(page) = registry.get_page("my-plugin", "demo") { //! println!("Page: {}", page.page.title); //! } //! ``` use std::collections::HashMap; use dioxus::prelude::*; use pinakes_plugin_api::{UiPage, UiWidget}; use crate::client::ApiClient; /// Information about a plugin-provided UI page #[derive(Debug, Clone)] pub struct PluginPage { /// Plugin ID that provides this page pub plugin_id: String, /// Page definition from schema pub page: UiPage, /// Endpoint paths this plugin is allowed to fetch (empty means no /// restriction) pub allowed_endpoints: Vec, } /// Registry of all plugin-provided UI pages and widgets /// /// This is typically stored as a signal in the Dioxus tree. #[derive(Debug, Clone)] pub struct PluginRegistry { /// API client for fetching pages from server client: ApiClient, /// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage` pages: HashMap<(String, String), PluginPage>, /// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget` widgets: Vec<(String, UiWidget)>, /// Merged CSS custom property overrides from all enabled plugins theme_vars: HashMap, } impl PluginRegistry { /// Create a new empty registry pub fn new(client: ApiClient) -> Self { Self { client, pages: HashMap::new(), widgets: Vec::new(), theme_vars: HashMap::new(), } } /// Get merged CSS custom property overrides from all loaded plugins. pub fn theme_vars(&self) -> &HashMap { &self.theme_vars } /// Register a page from a plugin /// /// Pages that fail schema validation are silently skipped with a warning log. pub fn register_page( &mut self, plugin_id: String, page: UiPage, allowed_endpoints: Vec, ) { if let Err(e) = page.validate() { tracing::warn!( plugin_id = %plugin_id, page_id = %page.id, "Skipping invalid page '{}' from '{}': {e}", page.id, plugin_id, ); return; } let page_id = page.id.clone(); // Check for duplicate page_id across different plugins. Same-plugin // re-registration of the same page is allowed to overwrite. let has_duplicate = self.pages.values().any(|existing| { existing.page.id == page_id && existing.plugin_id != plugin_id }); if has_duplicate { tracing::warn!( plugin_id = %plugin_id, page_id = %page_id, "skipping plugin page: page ID conflicts with an existing page from another plugin" ); return; } self.pages.insert((plugin_id.clone(), page_id), PluginPage { plugin_id, page, allowed_endpoints, }); } /// Get a specific page by plugin ID and page ID pub fn get_page( &self, plugin_id: &str, page_id: &str, ) -> Option<&PluginPage> { self .pages .get(&(plugin_id.to_string(), page_id.to_string())) } /// Register a widget from a plugin /// /// Widgets that fail schema validation are silently skipped with a warning /// log. pub fn register_widget(&mut self, plugin_id: String, widget: UiWidget) { if let Err(e) = widget.validate() { tracing::warn!( plugin_id = %plugin_id, widget_id = %widget.id, "Skipping invalid widget '{}' from '{}': {e}", widget.id, plugin_id, ); return; } self.widgets.push((plugin_id, widget)); } /// Get all widgets (for use with `WidgetContainer`) pub fn all_widgets(&self) -> Vec<(String, UiWidget)> { self.widgets.clone() } /// Get all pages #[allow( dead_code, reason = "used in tests and may be needed by future callers" )] pub fn all_pages(&self) -> Vec<&PluginPage> { self.pages.values().collect() } /// Check if any pages are registered pub fn is_empty(&self) -> bool { self.pages.is_empty() } /// Number of registered pages pub fn len(&self) -> usize { self.pages.len() } /// Get all page routes for navigation /// /// Returns `(plugin_id, page_id, full_route)` triples. pub fn routes(&self) -> Vec<(String, String, String)> { self .pages .values() .map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.page.route.clone())) .collect() } /// Refresh pages and widgets from server pub async fn refresh(&mut self) -> Result<(), String> { let pages = self .client .get_plugin_ui_pages() .await .map_err(|e| format!("Failed to refresh plugin pages: {e}"))?; // Build into a temporary registry to avoid a window where state appears // empty during the two async fetches. let mut tmp = Self::new(self.client.clone()); for (plugin_id, page, endpoints) in pages { tmp.register_page(plugin_id, page, endpoints); } match self.client.get_plugin_ui_widgets().await { Ok(widgets) => { for (plugin_id, widget) in widgets { tmp.register_widget(plugin_id, widget); } }, Err(e) => tracing::warn!("Failed to refresh plugin widgets: {e}"), } match self.client.get_plugin_ui_theme_extensions().await { Ok(vars) => tmp.theme_vars = vars, Err(e) => { tracing::warn!("Failed to refresh plugin theme extensions: {e}") }, } // Atomic swap: no window where the registry appears empty. self.pages = tmp.pages; self.widgets = tmp.widgets; self.theme_vars = tmp.theme_vars; Ok(()) } } impl Default for PluginRegistry { fn default() -> Self { Self::new(ApiClient::default()) } } #[cfg(test)] mod tests { use pinakes_plugin_api::UiElement; use super::*; fn create_test_page(id: &str, title: &str) -> UiPage { UiPage { id: id.to_string(), title: title.to_string(), route: format!("/plugins/test/{id}"), icon: None, root_element: UiElement::Container { children: vec![], gap: 16, padding: None, }, data_sources: HashMap::new(), actions: HashMap::new(), } } #[test] fn test_registry_empty() { let client = ApiClient::default(); let registry = PluginRegistry::new(client); assert!(registry.is_empty()); assert_eq!(registry.all_pages().len(), 0); } #[test] fn test_register_and_get_page() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); let page = create_test_page("demo", "Demo Page"); registry.register_page("my-plugin".to_string(), page.clone(), vec![]); assert!(!registry.is_empty()); assert_eq!(registry.all_pages().len(), 1); let retrieved = registry.get_page("my-plugin", "demo"); assert!(retrieved.is_some()); assert_eq!(retrieved.unwrap().page.id, "demo"); assert_eq!(retrieved.unwrap().page.title, "Demo Page"); } #[test] fn test_get_page_not_found() { let client = ApiClient::default(); let registry = PluginRegistry::new(client); let result = registry.get_page("nonexistent", "page"); assert!(result.is_none()); } #[test] fn test_all_pages() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); registry.register_page( "plugin1".to_string(), create_test_page("page1", "Page 1"), vec![], ); registry.register_page( "plugin2".to_string(), create_test_page("page2", "Page 2"), vec![], ); let all = registry.all_pages(); assert_eq!(all.len(), 2); } #[test] fn test_register_widget_and_all_widgets() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); let widget: UiWidget = serde_json::from_value(serde_json::json!({ "id": "my-widget", "target": "library_header", "content": { "type": "badge", "text": "hello", "variant": "default" } })) .unwrap(); assert!(registry.all_widgets().is_empty()); registry.register_widget("test-plugin".to_string(), widget.clone()); let widgets = registry.all_widgets(); assert_eq!(widgets.len(), 1); assert_eq!(widgets[0].0, "test-plugin"); assert_eq!(widgets[0].1.id, "my-widget"); } #[test] fn test_register_page_overwrites_same_key() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); registry.register_page( "plugin1".to_string(), create_test_page("p", "Original"), vec![], ); registry.register_page( "plugin1".to_string(), create_test_page("p", "Updated"), vec![], ); assert_eq!(registry.all_pages().len(), 1); assert_eq!( registry.get_page("plugin1", "p").unwrap().page.title, "Updated" ); } #[test] fn test_default_registry_is_empty() { let registry = PluginRegistry::default(); assert!(registry.is_empty()); assert_eq!(registry.all_pages().len(), 0); } #[test] fn test_len() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); assert_eq!(registry.len(), 0); registry.register_page("p".to_string(), create_test_page("a", "A"), vec![]); assert_eq!(registry.len(), 1); } #[test] fn test_page_route() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); registry.register_page( "my-plugin".to_string(), create_test_page("demo", "Demo Page"), vec![], ); let plugin_page = registry.get_page("my-plugin", "demo").unwrap(); assert_eq!(plugin_page.page.route, "/plugins/test/demo"); } #[test] fn test_routes() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); registry.register_page( "plugin1".to_string(), create_test_page("page1", "Page 1"), vec![], ); let routes = registry.routes(); assert_eq!(routes.len(), 1); assert_eq!(routes[0].0, "plugin1"); assert_eq!(routes[0].1, "page1"); assert_eq!(routes[0].2, "/plugins/test/page1"); } #[test] fn test_with_pages_builds_registry() { let client = ApiClient::default(); let pages = vec![ ("plugin1".to_string(), create_test_page("page1", "Page 1")), ("plugin2".to_string(), create_test_page("page2", "Page 2")), ]; // Build via register_page loop (equivalent to old with_pages) let mut registry = PluginRegistry::new(client); for (plugin_id, page) in pages { registry.register_page(plugin_id, page, vec![]); } assert_eq!(registry.len(), 2); assert!(registry.get_page("plugin1", "page1").is_some()); assert!(registry.get_page("plugin2", "page2").is_some()); } #[test] fn test_all_pages_returns_references() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); registry.register_page( "p1".to_string(), create_test_page("a", "A"), vec![], ); registry.register_page( "p2".to_string(), create_test_page("b", "B"), vec![], ); let pages = registry.all_pages(); assert_eq!(pages.len(), 2); let titles: Vec<&str> = pages.iter().map(|p| p.page.title.as_str()).collect(); assert!(titles.contains(&"A")); assert!(titles.contains(&"B")); } #[test] fn test_different_plugins_same_page_id_second_rejected() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); // First plugin registers "stats" - should succeed. registry.register_page( "plugin-a".to_string(), create_test_page("stats", "A Stats"), vec![], ); // Second plugin attempts to register the same page ID "stats" - should be // rejected to avoid route collisions at /plugins/stats. registry.register_page( "plugin-b".to_string(), create_test_page("stats", "B Stats"), vec![], ); // Only one page should be registered; the second was rejected. assert_eq!(registry.all_pages().len(), 1); assert_eq!( registry.get_page("plugin-a", "stats").unwrap().page.title, "A Stats" ); assert!( registry.get_page("plugin-b", "stats").is_none(), "plugin-b's page with duplicate ID should have been rejected" ); } #[test] fn test_same_plugin_same_page_id_overwrites() { // Same plugin re-registering the same page ID should still be allowed // (overwrite semantics, not a cross-plugin conflict). let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); registry.register_page( "plugin-a".to_string(), create_test_page("stats", "A Stats v1"), vec![], ); registry.register_page( "plugin-a".to_string(), create_test_page("stats", "A Stats v2"), vec![], ); assert_eq!(registry.all_pages().len(), 1); assert_eq!( registry.get_page("plugin-a", "stats").unwrap().page.title, "A Stats v2" ); } #[test] fn test_register_invalid_page_is_skipped() { use pinakes_plugin_api::UiElement; let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); // A page with an empty ID fails validation let invalid_page = UiPage { id: String::new(), // invalid: empty title: "Bad Page".to_string(), route: "/plugins/bad".to_string(), icon: None, root_element: UiElement::Container { children: vec![], gap: 16, padding: None, }, data_sources: HashMap::new(), actions: HashMap::new(), }; registry.register_page("test-plugin".to_string(), invalid_page, vec![]); assert!(registry.is_empty(), "invalid page should have been skipped"); } #[test] fn test_register_valid_page_after_invalid() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); use pinakes_plugin_api::UiElement; // Invalid page let invalid_page = UiPage { id: String::new(), title: "Bad".to_string(), route: "/bad".to_string(), icon: None, root_element: UiElement::Container { children: vec![], gap: 0, padding: None, }, data_sources: HashMap::new(), actions: HashMap::new(), }; registry.register_page("p".to_string(), invalid_page, vec![]); assert_eq!(registry.all_pages().len(), 0); // Valid page; should still register fine registry.register_page( "p".to_string(), create_test_page("good", "Good"), vec![], ); assert_eq!(registry.all_pages().len(), 1); } #[test] fn test_register_invalid_widget_is_skipped() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); let widget: pinakes_plugin_api::UiWidget = serde_json::from_value(serde_json::json!({ "id": "my-widget", "target": "library_header", "content": { "type": "badge", "text": "hi", "variant": "default" } })) .unwrap(); // Mutate: create an invalid widget with empty id let invalid_widget = pinakes_plugin_api::UiWidget { id: String::new(), // invalid target: "library_header".to_string(), content: widget.content.clone(), }; assert!(registry.all_widgets().is_empty()); registry.register_widget("test-plugin".to_string(), invalid_widget); assert!( registry.all_widgets().is_empty(), "invalid widget should have been skipped" ); // Valid widget is still accepted registry.register_widget("test-plugin".to_string(), widget); assert_eq!(registry.all_widgets().len(), 1); } }