Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ib513b5846d6c74bfe821da195b7080af6a6a6964
566 lines
16 KiB
Rust
566 lines
16 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
/// 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<String, String>,
|
|
}
|
|
|
|
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<String, String> {
|
|
&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<String>,
|
|
) {
|
|
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);
|
|
}
|
|
}
|