pinakes/crates/pinakes-ui/src/plugin_ui/registry.rs
NotAShelf 90504609e9
pinakes-ui: supply local_state to Conditional and Progress; remove last_refresh
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib513b5846d6c74bfe821da195b7080af6a6a6964
2026-03-12 20:49:36 +03:00

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);
}
}