use serde::{Deserialize, Serialize};
use crate::permissions::Permission;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
#[serde(default)]
pub long_description: Option<String>,
pub author: String,
#[serde(default)]
pub author_url: Option<String>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub icon: Option<String>,
pub compatibility: CompatibilitySpec,
pub capability_levels: Vec<CapabilityLevel>,
#[serde(default)]
pub integration: IntegrationConfig,
#[serde(default)]
pub window: WindowPolicyConfig,
#[serde(default)]
pub entry: EntryConfig,
#[serde(default)]
pub dependencies: Vec<PluginDependency>,
#[serde(default)]
pub permissions: Vec<Permission>,
#[serde(default)]
pub host_capabilities: Vec<HostCapability>,
#[serde(default)]
pub settings_schema: Option<serde_json::Value>,
#[serde(default)]
pub commands: Vec<CommandDeclaration>,
#[serde(default)]
pub checksum: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompatibilitySpec {
pub min_app_version: String,
#[serde(default)]
pub min_host_api_version: Option<String>,
#[serde(default)]
pub max_app_version: Option<String>,
#[serde(default = "all_platforms")]
pub platforms: Vec<String>,
}
fn all_platforms() -> Vec<String> {
vec!["windows".into(), "macos".into(), "linux".into()]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "u8", into = "u8")]
pub enum CapabilityLevel {
Module = 0,
ModuleFeature = 1,
UiExtension = 2,
AiAssistant = 3,
Service = 4,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HostCapability {
Navigation,
AppState,
FileIntents,
FileDialogs,
#[serde(rename = "aichat")]
AiChat,
EnterpriseGateway,
DeepLinks,
ThemeRead,
EventSubscribe,
}
impl HostCapability {
pub fn as_str(&self) -> &'static str {
match self {
Self::Navigation => "navigation",
Self::AppState => "app_state",
Self::FileIntents => "file_intents",
Self::FileDialogs => "file_dialogs",
Self::AiChat => "aichat",
Self::EnterpriseGateway => "enterprise_gateway",
Self::DeepLinks => "deep_links",
Self::ThemeRead => "theme_read",
Self::EventSubscribe => "event_subscribe",
}
}
}
impl From<u8> for CapabilityLevel {
fn from(v: u8) -> Self {
match v {
0 => Self::Module,
1 => Self::ModuleFeature,
2 => Self::UiExtension,
3 => Self::AiAssistant,
4 => Self::Service,
_ => Self::Service,
}
}
}
impl From<CapabilityLevel> for u8 {
fn from(l: CapabilityLevel) -> u8 {
l as u8
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IntegrationConfig {
#[serde(default)]
pub level0: Option<Level0Config>,
#[serde(default)]
pub level1: Option<Level1Config>,
#[serde(default)]
pub level2: Option<Level2Config>,
#[serde(default)]
pub level3: Option<Level3Config>,
#[serde(default)]
pub level4: Option<Level4Config>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WindowPolicyConfig {
#[serde(default)]
pub default_open_mode: Option<String>,
#[serde(default)]
pub reuse_key: Option<String>,
#[serde(default)]
pub allow_multiple: Option<bool>,
#[serde(default)]
pub document_handlers: Vec<DocumentHandlerConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DocumentHandlerConfig {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub extensions: Vec<String>,
#[serde(default)]
pub mime_types: Vec<String>,
pub route: String,
#[serde(default)]
pub resource_param: Option<String>,
#[serde(default)]
pub open_mode: Option<String>,
#[serde(default)]
pub reuse_key: Option<String>,
#[serde(default)]
pub allow_multiple: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Level0Config {
pub module_id: String,
pub module_label: String,
pub module_icon: String,
#[serde(default = "default_sidebar_position")]
pub sidebar_position: String,
#[serde(default = "default_sidebar_order")]
pub sidebar_order: u32,
pub panel_entry: String,
}
fn default_sidebar_position() -> String { "main".into() }
fn default_sidebar_order() -> u32 { 100 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Level1Config {
pub parent_module: String,
pub tab_id: String,
pub tab_label: String,
pub tab_icon: String,
#[serde(default)]
pub tab_position: Option<String>,
pub panel_entry: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Level2Config {
pub slots: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Level3Config {
pub assistant_id: String,
pub assistant_name: String,
#[serde(default)]
pub assistant_icon: Option<String>,
#[serde(default)]
pub assistant_description: Option<String>,
pub system_prompt_file: String,
#[serde(default)]
pub preferred_model: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Level4Config {
#[serde(default)]
pub workflow_step_types: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EntryConfig {
#[serde(default)]
pub native: Option<NativeEntry>,
#[serde(default)]
pub frontend: Option<String>,
#[serde(default)]
pub frontend_styles: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NativeEntry {
#[serde(default)]
pub macos_arm64: Option<String>,
#[serde(default)]
pub macos_x64: Option<String>,
#[serde(default)]
pub windows_x64: Option<String>,
#[serde(default)]
pub windows_arm64: Option<String>,
#[serde(default)]
pub linux_x64: Option<String>,
#[serde(default)]
pub linux_arm64: Option<String>,
}
impl NativeEntry {
pub fn for_current_platform(&self) -> Option<&str> {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
return self.macos_arm64.as_deref();
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
return self.macos_x64.as_deref();
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
return self.windows_x64.as_deref();
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
return self.windows_arm64.as_deref();
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
return self.linux_x64.as_deref();
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
return self.linux_arm64.as_deref();
#[allow(unreachable_code)]
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginDependency {
pub id: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandDeclaration {
pub id: String,
#[serde(default)]
pub description: Option<String>,
}
#[cfg(test)]
mod tests {
use super::{HostCapability, PluginManifest};
#[test]
fn manifest_supports_public_host_api_fields() {
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"id": "dev.haloforge.example",
"name": "Example",
"version": "0.1.0",
"description": "Example plugin",
"author": "HaloForge Team",
"compatibility": {
"min_app_version": "0.1.0",
"min_host_api_version": "0.1.0",
"platforms": ["windows"]
},
"capability_levels": [2],
"host_capabilities": ["navigation", "aichat"],
"integration": {
"level2": { "slots": ["devkit.toolbar"] }
}
}))
.expect("manifest should deserialize");
assert_eq!(
manifest.compatibility.min_host_api_version.as_deref(),
Some("0.1.0")
);
assert_eq!(
manifest.host_capabilities,
vec![HostCapability::Navigation, HostCapability::AiChat]
);
}
#[test]
fn manifest_supports_window_policy_document_handlers() {
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"id": "dev.haloforge.markdown",
"name": "Markdown",
"version": "0.2.13",
"description": "Markdown plugin",
"author": "HaloForge Team",
"compatibility": {
"min_app_version": "0.8.0",
"min_host_api_version": "0.2.16",
"platforms": ["windows", "macos", "linux"]
},
"capability_levels": [0],
"host_capabilities": ["navigation", "file_intents"],
"integration": {
"level0": {
"module_id": "markdown",
"module_label": "Markdown",
"module_icon": "FileText",
"panel_entry": "app/dist/index.js"
}
},
"window": {
"default_open_mode": "reuse_or_new",
"reuse_key": "resource",
"allow_multiple": true,
"document_handlers": [{
"id": "markdown",
"label": "Markdown",
"extensions": [".md", ".markdown"],
"mime_types": ["text/markdown"],
"route": "/document",
"resource_param": "path"
}]
}
}))
.expect("manifest should deserialize");
assert_eq!(manifest.window.default_open_mode.as_deref(), Some("reuse_or_new"));
assert_eq!(manifest.window.reuse_key.as_deref(), Some("resource"));
assert_eq!(manifest.window.allow_multiple, Some(true));
let handler = manifest
.window
.document_handlers
.first()
.expect("document handler should deserialize");
assert_eq!(handler.extensions, vec![".md", ".markdown"]);
assert_eq!(handler.mime_types, vec!["text/markdown"]);
assert_eq!(handler.route, "/document");
assert_eq!(handler.resource_param.as_deref(), Some("path"));
}
#[test]
fn host_capability_names_are_stable() {
assert_eq!(HostCapability::FileIntents.as_str(), "file_intents");
assert_eq!(HostCapability::FileDialogs.as_str(), "file_dialogs");
assert_eq!(HostCapability::DeepLinks.as_str(), "deep_links");
assert_eq!(HostCapability::ThemeRead.as_str(), "theme_read");
}
}