GUI plugins #9

Merged
NotAShelf merged 46 commits from notashelf/push-mytsqvppsvxu into main 2026-03-12 16:53:43 +00:00
2 changed files with 122 additions and 0 deletions
Showing only changes of commit 5a0901ba95 - Show all commits

pinakes-plugin-api: add required_endpoints and theme_extensions to manifest UI section

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I203fabfe07548106abb5bac760bbfec06a6a6964
raf 2026-03-10 00:02:35 +03:00
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -10,6 +10,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
# For plugin manifest parsing
toml = { workspace = true }

View file

@ -40,6 +40,33 @@ pub struct UiSection {
/// Widgets to inject into existing host pages
#[serde(default)]
pub widgets: Vec<UiWidget>,
/// API endpoint paths this plugin's UI requires.
/// Each must start with `/api/`. Informational; host may check availability.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_endpoints: Vec<String>,
/// CSS custom property overrides provided by this plugin.
/// Keys are property names (e.g. `--accent-color`), values are CSS values.
/// The host applies these to `document.documentElement` on startup.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub theme_extensions: HashMap<String, String>,
}
impl UiSection {
/// Validate that all declared required endpoints start with `/api/`.
///
/// # Errors
///
/// Returns an error string for the first invalid endpoint found.
pub fn validate(&self) -> Result<(), String> {
for ep in &self.required_endpoints {
if !ep.starts_with("/api/") {
return Err(format!("required_endpoint must start with '/api/': {ep}"));
}
}
Ok(())
}
}
/// Entry for a UI page in the manifest - can be inline or file reference
@ -267,6 +294,15 @@ impl PluginManifest {
));
}
// Validate UI section (required_endpoints format); non-fatal: warn only
if let Err(e) = self.ui.validate() {
tracing::warn!(
plugin = %self.plugin.name,
error = %e,
"plugin UI section has invalid required_endpoints"
);
}
// Validate UI pages
for (idx, page_entry) in self.ui.pages.iter().enumerate() {
match page_entry {
@ -663,4 +699,89 @@ gap = 16
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(manifest.ui.pages.len(), 2);
}
#[test]
fn test_ui_section_validate_accepts_api_paths() {
let section = UiSection {
pages: vec![],
widgets: vec![],
required_endpoints: vec![
"/api/v1/media".to_string(),
"/api/plugins/my-plugin/data".to_string(),
],
theme_extensions: HashMap::new(),
};
assert!(section.validate().is_ok());
}
#[test]
fn test_ui_section_validate_rejects_non_api_path() {
let section = UiSection {
pages: vec![],
widgets: vec![],
required_endpoints: vec!["/not-api/something".to_string()],
theme_extensions: HashMap::new(),
};
assert!(section.validate().is_err());
}
#[test]
fn test_ui_section_validate_rejects_empty_sections_with_bad_path() {
let section = UiSection {
pages: vec![],
widgets: vec![],
required_endpoints: vec!["/api/ok".to_string(), "no-slash".to_string()],
theme_extensions: HashMap::new(),
};
let err = section.validate().unwrap_err();
assert!(
err.contains("no-slash"),
"error should mention the bad endpoint"
);
}
#[test]
fn test_theme_extensions_roundtrip() {
let toml = r##"
[plugin]
name = "theme-plugin"
version = "1.0.0"
api_version = "1.0"
kind = ["theme_provider"]
[plugin.binary]
wasm = "plugin.wasm"
[ui.theme_extensions]
"--accent-color" = "#ff6b6b"
"--sidebar-width" = "280px"
"##;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(
manifest.ui.theme_extensions.get("--accent-color").map(String::as_str),
Some("#ff6b6b")
);
assert_eq!(
manifest.ui.theme_extensions.get("--sidebar-width").map(String::as_str),
Some("280px")
);
}
#[test]
fn test_theme_extensions_empty_by_default() {
let toml = r#"
[plugin]
name = "no-theme"
version = "1.0.0"
api_version = "1.0"
kind = ["media_type"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert!(manifest.ui.theme_extensions.is_empty());
}
}