Skip to main content

gldf_rs/
plugin.rs

1//! GLDF Plugin System
2//!
3//! This module provides support for viewer plugins embedded in GLDF files.
4//! Plugins are WASM modules stored in `other/viewer/<plugin-type>/` directories
5//! with a `manifest.json` describing their capabilities.
6//!
7//! ## Plugin Structure in GLDF
8//!
9//! ```text
10//! myfile.gldf (ZIP archive)
11//! └── other/
12//!     └── viewer/
13//!         └── starsky/                    # Plugin directory
14//!             ├── manifest.json           # Plugin metadata
15//!             ├── plugin.js               # WASM JS bindings
16//!             └── plugin_bg.wasm          # WASM binary
17//! ```
18//!
19//! ## Example
20//!
21//! ```rust,ignore
22//! use gldf_rs::plugin::{PluginManager, PluginManifest};
23//!
24//! let gldf = FileBufGldf::load("luminaire.gldf")?;
25//! let plugins = PluginManager::discover_plugins(&gldf);
26//!
27//! for plugin in plugins {
28//!     println!("Found plugin: {} ({})", plugin.manifest.name, plugin.manifest.plugin_type);
29//! }
30//! ```
31
32use serde::{Deserialize, Serialize};
33
34use crate::BufFile;
35
36/// Plugin manifest describing a viewer plugin embedded in a GLDF file.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct PluginManifest {
39    /// Plugin type identifier (e.g., "starsky", "acadlisp")
40    #[serde(rename = "type")]
41    pub plugin_type: String,
42
43    /// Human-readable plugin name
44    pub name: String,
45
46    /// Plugin version (semver)
47    pub version: String,
48
49    /// Plugin description
50    #[serde(default)]
51    pub description: String,
52
53    /// JavaScript file name (wasm-bindgen generated)
54    pub js: String,
55
56    /// WASM binary file name
57    pub wasm: String,
58
59    /// Data file this plugin consumes (path within GLDF, e.g., "other/sky_data.json")
60    #[serde(default)]
61    pub data_file: Option<String>,
62
63    /// Canvas element ID the plugin renders to
64    #[serde(default)]
65    pub canvas_id: Option<String>,
66
67    /// Entry point function name to call after loading
68    #[serde(default)]
69    pub entry_point: Option<String>,
70
71    /// localStorage key for passing data to the plugin
72    #[serde(default)]
73    pub storage_key: Option<String>,
74
75    /// LDT file this plugin uses (path within GLDF, e.g., "ldc/file.ldt")
76    #[serde(default)]
77    pub ldt_file: Option<String>,
78
79    /// Multiple storage keys (for plugins needing config + LDT)
80    #[serde(default)]
81    pub storage_keys: Option<StorageKeys>,
82}
83
84/// Storage keys for plugins that need multiple data files
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
86pub struct StorageKeys {
87    /// localStorage key for LDT data
88    #[serde(default)]
89    pub ldt: Option<String>,
90    /// localStorage key for config data
91    #[serde(default)]
92    pub config: Option<String>,
93}
94
95/// A discovered plugin with its manifest and file contents.
96#[derive(Debug, Clone, PartialEq)]
97pub struct Plugin {
98    /// Plugin manifest
99    pub manifest: PluginManifest,
100
101    /// Directory path within GLDF (e.g., "other/viewer/starsky")
102    pub plugin_dir: String,
103
104    /// JavaScript file content
105    pub js_content: Vec<u8>,
106
107    /// WASM binary content
108    pub wasm_content: Vec<u8>,
109
110    /// Optional data file content (if data_file is specified and exists)
111    pub data_content: Option<Vec<u8>>,
112
113    /// Optional LDT file content (if ldt_file is specified and exists)
114    pub ldt_content: Option<Vec<u8>>,
115}
116
117impl Plugin {
118    /// Get the JS content as a string
119    pub fn js_as_string(&self) -> Result<String, std::string::FromUtf8Error> {
120        String::from_utf8(self.js_content.clone())
121    }
122
123    /// Check if this plugin has associated data
124    pub fn has_data(&self) -> bool {
125        self.data_content.is_some()
126    }
127
128    /// Get the data content as a string (for JSON data)
129    pub fn data_as_string(&self) -> Option<Result<String, std::string::FromUtf8Error>> {
130        self.data_content
131            .as_ref()
132            .map(|d| String::from_utf8(d.clone()))
133    }
134}
135
136/// Plugin discovery and management for GLDF files.
137pub struct PluginManager;
138
139impl PluginManager {
140    /// Discover all plugins in a GLDF file's embedded files.
141    ///
142    /// Scans for `other/viewer/*/manifest.json` files and loads the associated
143    /// plugin files (JS, WASM, and optional data).
144    pub fn discover_plugins(files: &[BufFile]) -> Vec<Plugin> {
145        let mut plugins = Vec::new();
146
147        // Find all manifest.json files in other/viewer/*/
148        let manifests: Vec<_> = files
149            .iter()
150            .filter(|f| {
151                f.name
152                    .as_ref()
153                    .map(|n| n.starts_with("other/viewer/") && n.ends_with("/manifest.json"))
154                    .unwrap_or(false)
155            })
156            .collect();
157
158        for manifest_file in manifests {
159            let manifest_path = match &manifest_file.name {
160                Some(p) => p,
161                None => continue,
162            };
163
164            let manifest_content = match &manifest_file.content {
165                Some(c) => c,
166                None => continue,
167            };
168
169            // Parse the manifest
170            let manifest: PluginManifest = match serde_json::from_slice(manifest_content) {
171                Ok(m) => m,
172                Err(_e) => {
173                    #[cfg(debug_assertions)]
174                    eprintln!(
175                        "[Plugin] Failed to parse manifest {}: {}",
176                        manifest_path, _e
177                    );
178                    continue;
179                }
180            };
181
182            // Get the plugin directory
183            let plugin_dir = manifest_path
184                .strip_suffix("/manifest.json")
185                .unwrap_or(manifest_path)
186                .to_string();
187
188            // Find JS file
189            let js_path = format!("{}/{}", plugin_dir, manifest.js);
190            let js_content = files
191                .iter()
192                .find(|f| f.name.as_ref() == Some(&js_path))
193                .and_then(|f| f.content.clone());
194
195            // Find WASM file
196            let wasm_path = format!("{}/{}", plugin_dir, manifest.wasm);
197            let wasm_content = files
198                .iter()
199                .find(|f| f.name.as_ref() == Some(&wasm_path))
200                .and_then(|f| f.content.clone());
201
202            // Find data file if specified
203            let data_content = manifest.data_file.as_ref().and_then(|data_path| {
204                files
205                    .iter()
206                    .find(|f| f.name.as_ref() == Some(data_path))
207                    .and_then(|f| f.content.clone())
208            });
209
210            // Find LDT file if specified
211            let ldt_content = manifest.ldt_file.as_ref().and_then(|ldt_path| {
212                files
213                    .iter()
214                    .find(|f| f.name.as_ref() == Some(ldt_path))
215                    .and_then(|f| f.content.clone())
216            });
217
218            // Only add if we have both JS and WASM
219            if let (Some(js), Some(wasm)) = (js_content, wasm_content) {
220                plugins.push(Plugin {
221                    manifest,
222                    plugin_dir,
223                    js_content: js,
224                    wasm_content: wasm,
225                    data_content,
226                    ldt_content,
227                });
228            } else {
229                #[cfg(debug_assertions)]
230                eprintln!("[Plugin] {} missing JS or WASM files", manifest.name);
231            }
232        }
233
234        plugins
235    }
236
237    /// Check if a GLDF file contains any plugins.
238    pub fn has_plugins(files: &[BufFile]) -> bool {
239        files.iter().any(|f| {
240            f.name
241                .as_ref()
242                .map(|n| n.starts_with("other/viewer/") && n.ends_with("/manifest.json"))
243                .unwrap_or(false)
244        })
245    }
246
247    /// Get plugin types available in a GLDF file.
248    pub fn get_plugin_types(files: &[BufFile]) -> Vec<String> {
249        Self::discover_plugins(files)
250            .iter()
251            .map(|p| p.manifest.plugin_type.clone())
252            .collect()
253    }
254
255    /// Find a specific plugin by type.
256    pub fn find_plugin(files: &[BufFile], plugin_type: &str) -> Option<Plugin> {
257        Self::discover_plugins(files)
258            .into_iter()
259            .find(|p| p.manifest.plugin_type == plugin_type)
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    fn create_test_files() -> Vec<BufFile> {
268        let manifest = r#"{
269            "type": "test-plugin",
270            "name": "Test Plugin",
271            "version": "1.0.0",
272            "description": "A test plugin",
273            "js": "test.js",
274            "wasm": "test_bg.wasm",
275            "data_file": "other/test_data.json"
276        }"#;
277
278        vec![
279            BufFile {
280                name: Some("other/viewer/test-plugin/manifest.json".to_string()),
281                content: Some(manifest.as_bytes().to_vec()),
282                file_id: None,
283                path: None,
284            },
285            BufFile {
286                name: Some("other/viewer/test-plugin/test.js".to_string()),
287                content: Some(b"// test JS".to_vec()),
288                file_id: None,
289                path: None,
290            },
291            BufFile {
292                name: Some("other/viewer/test-plugin/test_bg.wasm".to_string()),
293                content: Some(b"\0asm\x01\0\0\0".to_vec()), // WASM magic header
294                file_id: None,
295                path: None,
296            },
297            BufFile {
298                name: Some("other/test_data.json".to_string()),
299                content: Some(b"{\"test\": true}".to_vec()),
300                file_id: None,
301                path: None,
302            },
303        ]
304    }
305
306    #[test]
307    fn test_discover_plugins() {
308        let files = create_test_files();
309        let plugins = PluginManager::discover_plugins(&files);
310
311        assert_eq!(plugins.len(), 1);
312        let plugin = &plugins[0];
313        assert_eq!(plugin.manifest.plugin_type, "test-plugin");
314        assert_eq!(plugin.manifest.name, "Test Plugin");
315        assert!(plugin.has_data());
316    }
317
318    #[test]
319    fn test_has_plugins() {
320        let files = create_test_files();
321        assert!(PluginManager::has_plugins(&files));
322
323        let empty_files: Vec<BufFile> = vec![];
324        assert!(!PluginManager::has_plugins(&empty_files));
325    }
326
327    #[test]
328    fn test_find_plugin() {
329        let files = create_test_files();
330
331        let found = PluginManager::find_plugin(&files, "test-plugin");
332        assert!(found.is_some());
333
334        let not_found = PluginManager::find_plugin(&files, "nonexistent");
335        assert!(not_found.is_none());
336    }
337}