1use serde::{Deserialize, Serialize};
2use crate::permissions::Permission;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct PluginManifest {
7 pub id: String,
8 pub name: String,
9 pub version: String,
10 pub description: String,
11 #[serde(default)]
12 pub long_description: Option<String>,
13 pub author: String,
14 #[serde(default)]
15 pub author_url: Option<String>,
16 #[serde(default)]
17 pub homepage: Option<String>,
18 #[serde(default)]
19 pub license: Option<String>,
20 #[serde(default)]
21 pub keywords: Vec<String>,
22 #[serde(default)]
23 pub icon: Option<String>,
24
25 pub compatibility: CompatibilitySpec,
26
27 pub capability_levels: Vec<CapabilityLevel>,
29
30 #[serde(default)]
32 pub integration: IntegrationConfig,
33
34 #[serde(default)]
36 pub window: WindowPolicyConfig,
37
38 #[serde(default)]
40 pub entry: EntryConfig,
41
42 #[serde(default)]
44 pub dependencies: Vec<PluginDependency>,
45
46 #[serde(default)]
48 pub permissions: Vec<Permission>,
49
50 #[serde(default)]
53 pub host_capabilities: Vec<HostCapability>,
54
55 #[serde(default)]
57 pub settings_schema: Option<serde_json::Value>,
58
59 #[serde(default)]
61 pub commands: Vec<CommandDeclaration>,
62
63 #[serde(default)]
65 pub checksum: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct CompatibilitySpec {
70 pub min_app_version: String,
71 #[serde(default)]
72 pub min_host_api_version: Option<String>,
73 #[serde(default)]
74 pub max_app_version: Option<String>,
75 #[serde(default = "all_platforms")]
76 pub platforms: Vec<String>,
77}
78
79fn all_platforms() -> Vec<String> {
80 vec!["windows".into(), "macos".into(), "linux".into()]
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(from = "u8", into = "u8")]
86pub enum CapabilityLevel {
87 Module = 0,
89 ModuleFeature = 1,
91 UiExtension = 2,
93 AiAssistant = 3,
95 Service = 4,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum HostCapability {
103 Navigation,
104 AppState,
105 FileIntents,
106 FileDialogs,
107 #[serde(rename = "aichat")]
108 AiChat,
109 EnterpriseGateway,
110 DeepLinks,
111 ThemeRead,
112 EventSubscribe,
113}
114
115impl HostCapability {
116 pub fn as_str(&self) -> &'static str {
117 match self {
118 Self::Navigation => "navigation",
119 Self::AppState => "app_state",
120 Self::FileIntents => "file_intents",
121 Self::FileDialogs => "file_dialogs",
122 Self::AiChat => "aichat",
123 Self::EnterpriseGateway => "enterprise_gateway",
124 Self::DeepLinks => "deep_links",
125 Self::ThemeRead => "theme_read",
126 Self::EventSubscribe => "event_subscribe",
127 }
128 }
129}
130
131impl From<u8> for CapabilityLevel {
132 fn from(v: u8) -> Self {
133 match v {
134 0 => Self::Module,
135 1 => Self::ModuleFeature,
136 2 => Self::UiExtension,
137 3 => Self::AiAssistant,
138 4 => Self::Service,
139 _ => Self::Service,
140 }
141 }
142}
143
144impl From<CapabilityLevel> for u8 {
145 fn from(l: CapabilityLevel) -> u8 {
146 l as u8
147 }
148}
149
150#[derive(Debug, Clone, Default, Serialize, Deserialize)]
152pub struct IntegrationConfig {
153 #[serde(default)]
154 pub level0: Option<Level0Config>,
155 #[serde(default)]
156 pub level1: Option<Level1Config>,
157 #[serde(default)]
158 pub level2: Option<Level2Config>,
159 #[serde(default)]
160 pub level3: Option<Level3Config>,
161 #[serde(default)]
162 pub level4: Option<Level4Config>,
163}
164
165#[derive(Debug, Clone, Default, Serialize, Deserialize)]
171pub struct WindowPolicyConfig {
172 #[serde(default)]
175 pub preferred_role: Option<String>,
176 #[serde(default)]
179 pub default_open_mode: Option<String>,
180 #[serde(default)]
182 pub reuse_key: Option<String>,
183 #[serde(default)]
186 pub allow_multiple: Option<bool>,
187 #[serde(default)]
189 pub document_handlers: Vec<DocumentHandlerConfig>,
190}
191
192#[derive(Debug, Clone, Default, Serialize, Deserialize)]
194pub struct DocumentHandlerConfig {
195 #[serde(default)]
197 pub id: Option<String>,
198 #[serde(default)]
200 pub label: Option<String>,
201 #[serde(default)]
203 pub extensions: Vec<String>,
204 #[serde(default)]
206 pub mime_types: Vec<String>,
207 pub route: String,
209 #[serde(default)]
211 pub resource_param: Option<String>,
212 #[serde(default)]
214 pub open_mode: Option<String>,
215 #[serde(default)]
217 pub reuse_key: Option<String>,
218 #[serde(default)]
220 pub preferred_role: Option<String>,
221 #[serde(default)]
223 pub allow_multiple: Option<bool>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct Level0Config {
229 pub module_id: String,
231 pub module_label: String,
232 pub module_icon: String,
234 #[serde(default = "default_sidebar_position")]
236 pub sidebar_position: String,
237 #[serde(default = "default_sidebar_order")]
239 pub sidebar_order: u32,
240 pub panel_entry: String,
242}
243
244fn default_sidebar_position() -> String { "main".into() }
245fn default_sidebar_order() -> u32 { 100 }
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct Level1Config {
250 pub parent_module: String,
252 pub tab_id: String,
254 pub tab_label: String,
255 pub tab_icon: String,
257 #[serde(default)]
259 pub tab_position: Option<String>,
260 pub panel_entry: String,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct Level2Config {
267 pub slots: Vec<String>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct Level3Config {
274 pub assistant_id: String,
275 pub assistant_name: String,
276 #[serde(default)]
277 pub assistant_icon: Option<String>,
278 #[serde(default)]
279 pub assistant_description: Option<String>,
280 pub system_prompt_file: String,
282 #[serde(default)]
284 pub preferred_model: Option<String>,
285}
286
287#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289pub struct Level4Config {
290 #[serde(default)]
292 pub workflow_step_types: Vec<String>,
293}
294
295#[derive(Debug, Clone, Default, Serialize, Deserialize)]
297pub struct EntryConfig {
298 #[serde(default)]
299 pub native: Option<NativeEntry>,
300 #[serde(default)]
301 pub frontend: Option<String>,
302 #[serde(default)]
303 pub frontend_styles: Option<String>,
304}
305
306#[derive(Debug, Clone, Default, Serialize, Deserialize)]
307pub struct NativeEntry {
308 #[serde(default)]
309 pub macos_arm64: Option<String>,
310 #[serde(default)]
311 pub macos_x64: Option<String>,
312 #[serde(default)]
313 pub windows_x64: Option<String>,
314 #[serde(default)]
315 pub windows_arm64: Option<String>,
316 #[serde(default)]
317 pub linux_x64: Option<String>,
318 #[serde(default)]
319 pub linux_arm64: Option<String>,
320}
321
322impl NativeEntry {
323 pub fn for_current_platform(&self) -> Option<&str> {
325 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
326 return self.macos_arm64.as_deref();
327
328 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
329 return self.macos_x64.as_deref();
330
331 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
332 return self.windows_x64.as_deref();
333
334 #[cfg(all(target_os = "windows", target_arch = "aarch64"))]
335 return self.windows_arm64.as_deref();
336
337 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
338 return self.linux_x64.as_deref();
339
340 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
341 return self.linux_arm64.as_deref();
342
343 #[allow(unreachable_code)]
344 None
345 }
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct PluginDependency {
350 pub id: String,
351 pub version: String,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct CommandDeclaration {
357 pub id: String,
358 #[serde(default)]
359 pub description: Option<String>,
360}
361
362#[cfg(test)]
363mod tests {
364 use super::{HostCapability, PluginManifest};
365
366 #[test]
367 fn manifest_supports_public_host_api_fields() {
368 let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
369 "id": "dev.haloforge.example",
370 "name": "Example",
371 "version": "0.1.0",
372 "description": "Example plugin",
373 "author": "HaloForge Team",
374 "compatibility": {
375 "min_app_version": "0.1.0",
376 "min_host_api_version": "0.1.0",
377 "platforms": ["windows"]
378 },
379 "capability_levels": [2],
380 "host_capabilities": ["navigation", "aichat"],
381 "integration": {
382 "level2": { "slots": ["devkit.toolbar"] }
383 }
384 }))
385 .expect("manifest should deserialize");
386
387 assert_eq!(
388 manifest.compatibility.min_host_api_version.as_deref(),
389 Some("0.1.0")
390 );
391 assert_eq!(
392 manifest.host_capabilities,
393 vec![HostCapability::Navigation, HostCapability::AiChat]
394 );
395 }
396
397 #[test]
398 fn manifest_supports_window_policy_document_handlers() {
399 let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
400 "id": "dev.haloforge.markdown",
401 "name": "Markdown",
402 "version": "0.2.10",
403 "description": "Markdown plugin",
404 "author": "HaloForge Team",
405 "compatibility": {
406 "min_app_version": "0.7.0",
407 "min_host_api_version": "0.2.10",
408 "platforms": ["windows", "macos", "linux"]
409 },
410 "capability_levels": [0],
411 "host_capabilities": ["navigation", "file_intents"],
412 "integration": {
413 "level0": {
414 "module_id": "markdown",
415 "module_label": "Markdown",
416 "module_icon": "FileText",
417 "panel_entry": "app/dist/index.js"
418 }
419 },
420 "window": {
421 "preferred_role": "document",
422 "default_open_mode": "reuse_or_new",
423 "reuse_key": "resource",
424 "allow_multiple": true,
425 "document_handlers": [{
426 "id": "markdown",
427 "label": "Markdown",
428 "extensions": [".md", ".markdown"],
429 "mime_types": ["text/markdown"],
430 "route": "/document",
431 "resource_param": "path"
432 }]
433 }
434 }))
435 .expect("manifest should deserialize");
436
437 assert_eq!(manifest.window.preferred_role.as_deref(), Some("document"));
438 assert_eq!(manifest.window.default_open_mode.as_deref(), Some("reuse_or_new"));
439 assert_eq!(manifest.window.reuse_key.as_deref(), Some("resource"));
440 assert_eq!(manifest.window.allow_multiple, Some(true));
441 let handler = manifest
442 .window
443 .document_handlers
444 .first()
445 .expect("document handler should deserialize");
446 assert_eq!(handler.extensions, vec![".md", ".markdown"]);
447 assert_eq!(handler.mime_types, vec!["text/markdown"]);
448 assert_eq!(handler.route, "/document");
449 assert_eq!(handler.resource_param.as_deref(), Some("path"));
450 }
451
452 #[test]
453 fn host_capability_names_are_stable() {
454 assert_eq!(HostCapability::FileIntents.as_str(), "file_intents");
455 assert_eq!(HostCapability::FileDialogs.as_str(), "file_dialogs");
456 assert_eq!(HostCapability::DeepLinks.as_str(), "deep_links");
457 assert_eq!(HostCapability::ThemeRead.as_str(), "theme_read");
458 }
459}