GUI plugins #9
5 changed files with 2053 additions and 7 deletions
pinakes-plugin-api: initial UI schema types; manifest extension for GUI plugins
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I508f94798a6eaa800672bd95fa8127d86a6a6964
commit
c86d9399ac
|
|
@ -158,7 +158,7 @@ impl Default for PluginRegistry {
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use pinakes_plugin_api::Capabilities;
|
use pinakes_plugin_api::{Capabilities, manifest::ManifestCapabilities};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -493,12 +493,9 @@ impl HostFunctions {
|
||||||
if let Some(ref allowed) =
|
if let Some(ref allowed) =
|
||||||
caller.data().context.capabilities.network.allowed_domains
|
caller.data().context.capabilities.network.allowed_domains
|
||||||
{
|
{
|
||||||
let parsed = match url::Url::parse(&url_str) {
|
let parsed = if let Ok(u) = url::Url::parse(&url_str) { u } else {
|
||||||
Ok(u) => u,
|
tracing::warn!(url = %url_str, "plugin provided invalid URL");
|
||||||
_ => {
|
return -1;
|
||||||
tracing::warn!(url = %url_str, "plugin provided invalid URL");
|
|
||||||
return -1;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
let domain = parsed.host_str().unwrap_or("");
|
let domain = parsed.host_str().unwrap_or("");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,12 @@ use thiserror::Error;
|
||||||
|
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub mod ui_schema;
|
||||||
pub mod wasm;
|
pub mod wasm;
|
||||||
|
|
||||||
pub use manifest::PluginManifest;
|
pub use manifest::PluginManifest;
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
pub use ui_schema::*;
|
||||||
pub use wasm::host_functions;
|
pub use wasm::host_functions;
|
||||||
|
|
||||||
/// Plugin API version - plugins must match this version
|
/// Plugin API version - plugins must match this version
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use crate::{
|
||||||
EnvironmentCapability,
|
EnvironmentCapability,
|
||||||
FilesystemCapability,
|
FilesystemCapability,
|
||||||
NetworkCapability,
|
NetworkCapability,
|
||||||
|
UiPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Plugin manifest file format (TOML)
|
/// Plugin manifest file format (TOML)
|
||||||
|
|
@ -22,6 +23,73 @@ pub struct PluginManifest {
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub config: HashMap<String, toml::Value>,
|
pub config: HashMap<String, toml::Value>,
|
||||||
|
|
||||||
|
/// UI pages provided by this plugin
|
||||||
|
#[serde(default)]
|
||||||
|
pub ui: UiSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI section of the plugin manifest
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct UiSection {
|
||||||
|
/// UI pages defined by this plugin
|
||||||
|
#[serde(default)]
|
||||||
|
pub pages: Vec<UiPageEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry for a UI page in the manifest - can be inline or file reference
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum UiPageEntry {
|
||||||
|
/// Inline UI page definition (boxed to reduce enum size)
|
||||||
|
Inline(Box<UiPage>),
|
||||||
|
/// Reference to a JSON file containing the page definition
|
||||||
|
File { file: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for UiPageEntry {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
use serde::de::Error;
|
||||||
|
|
||||||
|
// First try to deserialize as a file reference (has "file" key)
|
||||||
|
// We use toml::Value since the manifest is TOML
|
||||||
|
let value = toml::Value::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
|
||||||
|
if file.is_empty() {
|
||||||
|
return Err(D::Error::custom("file path cannot be empty"));
|
||||||
|
}
|
||||||
|
return Ok(Self::File {
|
||||||
|
file: file.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise try to deserialize as inline UiPage
|
||||||
|
// Convert toml::Value back to a deserializer for UiPage
|
||||||
|
let page: UiPage = UiPage::deserialize(value)
|
||||||
|
.map_err(|e| D::Error::custom(format!("invalid inline UI page: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Self::Inline(Box::new(page)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for UiPageEntry {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
match self {
|
||||||
|
Self::Inline(page) => page.serialize(serializer),
|
||||||
|
Self::File { file } => {
|
||||||
|
use serde::ser::SerializeStruct;
|
||||||
|
let mut state = serializer.serialize_struct("UiPageEntry", 1)?;
|
||||||
|
state.serialize_field("file", file)?;
|
||||||
|
state.end()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_priority() -> u16 {
|
const fn default_priority() -> u16 {
|
||||||
|
|
@ -168,6 +236,7 @@ impl PluginManifest {
|
||||||
"search_backend",
|
"search_backend",
|
||||||
"event_handler",
|
"event_handler",
|
||||||
"theme_provider",
|
"theme_provider",
|
||||||
|
"ui_page",
|
||||||
];
|
];
|
||||||
|
|
||||||
for kind in &self.plugin.kind {
|
for kind in &self.plugin.kind {
|
||||||
|
|
@ -193,9 +262,77 @@ impl PluginManifest {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate UI pages
|
||||||
|
for (idx, page_entry) in self.ui.pages.iter().enumerate() {
|
||||||
|
match page_entry {
|
||||||
|
UiPageEntry::Inline(page) => {
|
||||||
|
if let Err(e) = page.validate() {
|
||||||
|
return Err(ManifestError::ValidationError(format!(
|
||||||
|
"UI page {} validation failed: {e}",
|
||||||
|
idx + 1
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
UiPageEntry::File { file } => {
|
||||||
|
if file.is_empty() {
|
||||||
|
return Err(ManifestError::ValidationError(format!(
|
||||||
|
"UI page {} file path cannot be empty",
|
||||||
|
idx + 1
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load and resolve all UI page definitions
|
||||||
|
///
|
||||||
|
/// For inline pages, returns them directly. For file references, loads
|
||||||
|
/// and parses the JSON file.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `base_path` - Base directory for resolving relative file paths
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ManifestError::IoError`] if a file cannot be read, or
|
||||||
|
/// [`ManifestError::ValidationError`] if JSON parsing fails.
|
||||||
|
pub fn load_ui_pages(
|
||||||
|
&self,
|
||||||
|
base_path: &Path,
|
||||||
|
) -> Result<Vec<UiPage>, ManifestError> {
|
||||||
|
let mut pages = Vec::with_capacity(self.ui.pages.len());
|
||||||
|
|
||||||
|
for entry in &self.ui.pages {
|
||||||
|
let page = match entry {
|
||||||
|
UiPageEntry::Inline(page) => (**page).clone(),
|
||||||
|
UiPageEntry::File { file } => {
|
||||||
|
let file_path = base_path.join(file);
|
||||||
|
let content = std::fs::read_to_string(&file_path)?;
|
||||||
|
let page: UiPage = serde_json::from_str(&content).map_err(|e| {
|
||||||
|
ManifestError::ValidationError(format!(
|
||||||
|
"Failed to parse UI page from file '{}': {e}",
|
||||||
|
file_path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if let Err(e) = page.validate() {
|
||||||
|
return Err(ManifestError::ValidationError(format!(
|
||||||
|
"UI page validation failed for file '{}': {e}",
|
||||||
|
file_path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
page
|
||||||
|
},
|
||||||
|
};
|
||||||
|
pages.push(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pages)
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert manifest capabilities to API capabilities
|
/// Convert manifest capabilities to API capabilities
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn to_capabilities(&self) -> Capabilities {
|
pub fn to_capabilities(&self) -> Capabilities {
|
||||||
|
|
@ -353,4 +490,172 @@ wasm = "plugin.wasm"
|
||||||
|
|
||||||
assert!(PluginManifest::parse_str(toml).is_err());
|
assert!(PluginManifest::parse_str(toml).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ui_page_inline() {
|
||||||
|
let toml = r#"
|
||||||
|
[plugin]
|
||||||
|
name = "ui-demo"
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1.0"
|
||||||
|
kind = ["ui_page"]
|
||||||
|
|
||||||
|
[plugin.binary]
|
||||||
|
wasm = "plugin.wasm"
|
||||||
|
|
||||||
|
[[ui.pages]]
|
||||||
|
id = "demo"
|
||||||
|
title = "Demo Page"
|
||||||
|
route = "/plugins/demo"
|
||||||
|
icon = "star"
|
||||||
|
|
||||||
|
[ui.pages.layout]
|
||||||
|
type = "container"
|
||||||
|
children = []
|
||||||
|
gap = 16
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let manifest = PluginManifest::parse_str(toml).unwrap();
|
||||||
|
assert_eq!(manifest.ui.pages.len(), 1);
|
||||||
|
|
||||||
|
match &manifest.ui.pages[0] {
|
||||||
|
UiPageEntry::Inline(page) => {
|
||||||
|
assert_eq!(page.id, "demo");
|
||||||
|
assert_eq!(page.title, "Demo Page");
|
||||||
|
assert_eq!(page.route, "/plugins/demo");
|
||||||
|
assert_eq!(page.icon, Some("star".to_string()));
|
||||||
|
},
|
||||||
|
_ => panic!("Expected inline page"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ui_page_file_reference() {
|
||||||
|
let toml = r#"
|
||||||
|
[plugin]
|
||||||
|
name = "ui-demo"
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1.0"
|
||||||
|
kind = ["ui_page"]
|
||||||
|
|
||||||
|
[plugin.binary]
|
||||||
|
wasm = "plugin.wasm"
|
||||||
|
|
||||||
|
[[ui.pages]]
|
||||||
|
file = "pages/demo.json"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let manifest = PluginManifest::parse_str(toml).unwrap();
|
||||||
|
assert_eq!(manifest.ui.pages.len(), 1);
|
||||||
|
|
||||||
|
match &manifest.ui.pages[0] {
|
||||||
|
UiPageEntry::File { file } => {
|
||||||
|
assert_eq!(file, "pages/demo.json");
|
||||||
|
},
|
||||||
|
_ => panic!("Expected file reference"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ui_page_invalid_kind() {
|
||||||
|
// ui_page must be in valid_kinds list
|
||||||
|
let toml = r#"
|
||||||
|
[plugin]
|
||||||
|
name = "test"
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1.0"
|
||||||
|
kind = ["ui_page"]
|
||||||
|
|
||||||
|
[plugin.binary]
|
||||||
|
wasm = "plugin.wasm"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
// Should succeed now that ui_page is in valid_kinds
|
||||||
|
let manifest = PluginManifest::parse_str(toml);
|
||||||
|
assert!(manifest.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ui_page_validation_failure() {
|
||||||
|
let toml = r#"
|
||||||
|
[plugin]
|
||||||
|
name = "ui-demo"
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1.0"
|
||||||
|
kind = ["ui_page"]
|
||||||
|
|
||||||
|
[plugin.binary]
|
||||||
|
wasm = "plugin.wasm"
|
||||||
|
|
||||||
|
[[ui.pages]]
|
||||||
|
id = ""
|
||||||
|
title = "Demo"
|
||||||
|
route = "/plugins/demo"
|
||||||
|
|
||||||
|
[ui.pages.layout]
|
||||||
|
type = "container"
|
||||||
|
children = []
|
||||||
|
gap = 16
|
||||||
|
"#;
|
||||||
|
|
||||||
|
// Empty ID should fail validation
|
||||||
|
assert!(PluginManifest::parse_str(toml).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ui_page_empty_file() {
|
||||||
|
let toml = r#"
|
||||||
|
[plugin]
|
||||||
|
name = "ui-demo"
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1.0"
|
||||||
|
kind = ["ui_page"]
|
||||||
|
|
||||||
|
[plugin.binary]
|
||||||
|
wasm = "plugin.wasm"
|
||||||
|
|
||||||
|
[[ui.pages]]
|
||||||
|
file = ""
|
||||||
|
"#;
|
||||||
|
|
||||||
|
// Empty file path should fail validation
|
||||||
|
assert!(PluginManifest::parse_str(toml).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ui_page_multiple_pages() {
|
||||||
|
let toml = r#"
|
||||||
|
[plugin]
|
||||||
|
name = "ui-demo"
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1.0"
|
||||||
|
kind = ["ui_page"]
|
||||||
|
|
||||||
|
[plugin.binary]
|
||||||
|
wasm = "plugin.wasm"
|
||||||
|
|
||||||
|
[[ui.pages]]
|
||||||
|
id = "page1"
|
||||||
|
title = "Page 1"
|
||||||
|
route = "/plugins/page1"
|
||||||
|
|
||||||
|
[ui.pages.layout]
|
||||||
|
type = "container"
|
||||||
|
children = []
|
||||||
|
gap = 16
|
||||||
|
|
||||||
|
[[ui.pages]]
|
||||||
|
id = "page2"
|
||||||
|
title = "Page 2"
|
||||||
|
route = "/plugins/page2"
|
||||||
|
|
||||||
|
[ui.pages.layout]
|
||||||
|
type = "container"
|
||||||
|
children = []
|
||||||
|
gap = 16
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let manifest = PluginManifest::parse_str(toml).unwrap();
|
||||||
|
assert_eq!(manifest.ui.pages.len(), 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1742
crates/pinakes-plugin-api/src/ui_schema.rs
Normal file
1742
crates/pinakes-plugin-api/src/ui_schema.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue