Skip to main content

wavecraft_bridge/
plugin_loader.rs

1//! Plugin parameter loader using libloading for FFI.
2
3use libloading::{Library, Symbol};
4use std::ffi::CStr;
5use std::os::raw::c_char;
6use std::path::Path;
7use wavecraft_protocol::{
8    DEV_PROCESSOR_VTABLE_VERSION, DevProcessorVTable, ParameterInfo, ProcessorInfo,
9};
10
11/// Errors that can occur during plugin loading.
12#[derive(Debug)]
13pub enum PluginLoaderError {
14    /// Failed to load the dynamic library.
15    LibraryLoad(libloading::Error),
16    /// Failed to find a required FFI symbol.
17    SymbolNotFound(String),
18    /// FFI function returned a null pointer.
19    NullPointer(&'static str),
20    /// Failed to parse the parameter JSON.
21    JsonParse(serde_json::Error),
22    /// The returned string was not valid UTF-8.
23    InvalidUtf8(std::str::Utf8Error),
24    /// Failed to read a file (e.g., sidecar JSON cache).
25    FileRead(std::io::Error),
26    /// Vtable ABI version mismatch.
27    VtableVersionMismatch { found: u32, expected: u32 },
28}
29
30impl std::fmt::Display for PluginLoaderError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::LibraryLoad(e) => write!(f, "Failed to load plugin library: {}", e),
34            Self::SymbolNotFound(name) => write!(f, "Symbol not found: {}", name),
35            Self::NullPointer(func) => write!(f, "FFI function {} returned null", func),
36            Self::JsonParse(e) => write!(f, "Failed to parse parameter JSON: {}", e),
37            Self::InvalidUtf8(e) => write!(f, "Invalid UTF-8 in FFI response: {}", e),
38            Self::FileRead(e) => write!(f, "Failed to read file: {}", e),
39            Self::VtableVersionMismatch { found, expected } => write!(
40                f,
41                "Dev processor vtable version mismatch: found {}, expected {}",
42                found, expected
43            ),
44        }
45    }
46}
47
48impl std::error::Error for PluginLoaderError {
49    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
50        match self {
51            Self::LibraryLoad(e) => Some(e),
52            Self::JsonParse(e) => Some(e),
53            Self::InvalidUtf8(e) => Some(e),
54            Self::FileRead(e) => Some(e),
55            _ => None,
56        }
57    }
58}
59
60type GetParamsJsonFn = unsafe extern "C" fn() -> *mut c_char;
61type GetProcessorsJsonFn = unsafe extern "C" fn() -> *mut c_char;
62type FreeStringFn = unsafe extern "C" fn(*mut c_char);
63type DevProcessorVTableFn = unsafe extern "C" fn() -> DevProcessorVTable;
64
65fn parse_json_from_ffi<T>(
66    json_ptr: *mut c_char,
67    function_name: &'static str,
68) -> Result<T, PluginLoaderError>
69where
70    T: serde::de::DeserializeOwned,
71{
72    if json_ptr.is_null() {
73        return Err(PluginLoaderError::NullPointer(function_name));
74    }
75
76    // SAFETY: pointer was returned by FFI symbol using CString::into_raw.
77    let c_str = unsafe { CStr::from_ptr(json_ptr) };
78    let json_str = c_str.to_str().map_err(PluginLoaderError::InvalidUtf8)?;
79    serde_json::from_str(json_str).map_err(PluginLoaderError::JsonParse)
80}
81
82/// Plugin loader that extracts parameter metadata and the
83/// dev audio processor vtable via FFI.
84///
85/// # Safety
86///
87/// This struct manages the lifecycle of a dynamically loaded library.
88/// The library must remain loaded while any data from it is in use.
89/// The `PluginParamLoader` ensures proper cleanup via Drop.
90///
91/// # Drop Ordering
92///
93/// Struct fields are dropped in declaration order (first declared = first
94/// dropped). `_library` is the **last** field so it is dropped last,
95/// ensuring the dynamic library remains loaded while `parameters` and
96/// `dev_processor_vtable` are cleaned up.
97///
98/// **External invariant:** any `FfiProcessor` created from the vtable
99/// must be dropped *before* this loader to keep vtable function pointers
100/// valid. The caller must ensure this via local variable declaration
101/// order (later declared = first dropped).
102pub struct PluginParamLoader {
103    parameters: Vec<ParameterInfo>,
104    processors: Vec<ProcessorInfo>,
105    /// Audio processor vtable for in-process dev audio.
106    dev_processor_vtable: DevProcessorVTable,
107    /// Dynamic library handle — must be the last field so it is dropped
108    /// last, after all data that may reference library symbols.
109    _library: Library,
110}
111
112impl PluginParamLoader {
113    /// Load parameters from a sidecar JSON file (bypasses FFI/dlopen).
114    ///
115    /// Used by `wavecraft start` to read cached parameter metadata without
116    /// loading the plugin dylib (which triggers nih-plug static initializers).
117    pub fn load_params_from_file<P: AsRef<Path>>(
118        json_path: P,
119    ) -> Result<Vec<ParameterInfo>, PluginLoaderError> {
120        let contents =
121            std::fs::read_to_string(json_path.as_ref()).map_err(PluginLoaderError::FileRead)?;
122        let params: Vec<ParameterInfo> =
123            serde_json::from_str(&contents).map_err(PluginLoaderError::JsonParse)?;
124        Ok(params)
125    }
126
127    /// Load a plugin from the given path and extract its parameter metadata.
128    pub fn load<P: AsRef<Path>>(dylib_path: P) -> Result<Self, PluginLoaderError> {
129        // SAFETY: Loading a dynamic library is inherently unsafe. The caller
130        // must pass a valid path to a cdylib built with the wavecraft SDK.
131        // The library is kept alive for the lifetime of this struct.
132        let library =
133            unsafe { Library::new(dylib_path.as_ref()) }.map_err(PluginLoaderError::LibraryLoad)?;
134
135        // SAFETY: The symbol `wavecraft_get_params_json` is an `extern "C"`
136        // function generated by the `wavecraft_plugin!` macro with the expected
137        // signature (`GetParamsJsonFn`). The library is valid and loaded.
138        let get_params_json: Symbol<GetParamsJsonFn> = unsafe {
139            library.get(b"wavecraft_get_params_json\0").map_err(|e| {
140                PluginLoaderError::SymbolNotFound(format!("wavecraft_get_params_json: {}", e))
141            })?
142        };
143
144        // SAFETY: The symbol `wavecraft_free_string` is an `extern "C"`
145        // function generated by the `wavecraft_plugin!` macro with the expected
146        // signature (`FreeStringFn`). The library is valid and loaded.
147        let free_string: Symbol<FreeStringFn> = unsafe {
148            library.get(b"wavecraft_free_string\0").map_err(|e| {
149                PluginLoaderError::SymbolNotFound(format!("wavecraft_free_string: {}", e))
150            })?
151        };
152
153        // SAFETY: Symbol is generated by wavecraft_plugin! and uses GetProcessorsJsonFn ABI.
154        let get_processors_json: Symbol<GetProcessorsJsonFn> = unsafe {
155            library
156                .get(b"wavecraft_get_processors_json\0")
157                .map_err(|e| {
158                    PluginLoaderError::SymbolNotFound(format!(
159                        "wavecraft_get_processors_json: {}",
160                        e
161                    ))
162                })?
163        };
164
165        // SAFETY: All FFI calls target `extern "C"` functions generated by
166        // the `wavecraft_plugin!` macro:
167        // - `get_params_json()` returns a heap-allocated C string (or null).
168        //   We check for null before dereferencing.
169        // - `CStr::from_ptr()` is safe because the pointer is non-null and
170        //   the string is NUL-terminated (allocated by `CString::into_raw`).
171        // - `free_string()` deallocates the string originally created by
172        //   `CString::into_raw` — matching allocator, called exactly once.
173        let params = unsafe {
174            let json_ptr = get_params_json();
175            let parsed =
176                parse_json_from_ffi::<Vec<ParameterInfo>>(json_ptr, "wavecraft_get_params_json")?;
177            free_string(json_ptr);
178            parsed
179        };
180
181        let processors = unsafe {
182            let json_ptr = get_processors_json();
183            let parsed = parse_json_from_ffi::<Vec<ProcessorInfo>>(
184                json_ptr,
185                "wavecraft_get_processors_json",
186            )?;
187            free_string(json_ptr);
188            parsed
189        };
190
191        // Load and validate audio processor vtable (required for current SDK dev mode)
192        let dev_processor_vtable = Self::load_processor_vtable(&library)?;
193
194        Ok(Self {
195            parameters: params,
196            processors,
197            dev_processor_vtable,
198            _library: library,
199        })
200    }
201
202    /// Load parameters only (skip processor vtable loading).
203    ///
204    /// Used by hot-reload where only parameter metadata is needed.
205    /// Avoids potential side effects from vtable construction.
206    pub fn load_params_only<P: AsRef<Path>>(
207        dylib_path: P,
208    ) -> Result<Vec<ParameterInfo>, PluginLoaderError> {
209        // SAFETY: Loading a dynamic library is inherently unsafe. The caller
210        // must pass a valid path to a cdylib built with the wavecraft SDK.
211        let library =
212            unsafe { Library::new(dylib_path.as_ref()) }.map_err(PluginLoaderError::LibraryLoad)?;
213
214        // SAFETY: The symbol `wavecraft_get_params_json` is an `extern "C"`
215        // function generated by the `wavecraft_plugin!` macro with the expected
216        // signature (`GetParamsJsonFn`). The library is valid and loaded.
217        let get_params_json: Symbol<GetParamsJsonFn> = unsafe {
218            library.get(b"wavecraft_get_params_json\0").map_err(|e| {
219                PluginLoaderError::SymbolNotFound(format!("wavecraft_get_params_json: {}", e))
220            })?
221        };
222
223        // SAFETY: The symbol `wavecraft_free_string` is an `extern "C"`
224        // function generated by the `wavecraft_plugin!` macro with the expected
225        // signature (`FreeStringFn`). The library is valid and loaded.
226        let free_string: Symbol<FreeStringFn> = unsafe {
227            library.get(b"wavecraft_free_string\0").map_err(|e| {
228                PluginLoaderError::SymbolNotFound(format!("wavecraft_free_string: {}", e))
229            })?
230        };
231
232        // SAFETY: See `load()` for detailed rationale on FFI safety.
233        let params = unsafe {
234            let json_ptr = get_params_json();
235            let parsed =
236                parse_json_from_ffi::<Vec<ParameterInfo>>(json_ptr, "wavecraft_get_params_json")?;
237            free_string(json_ptr);
238            parsed
239        };
240
241        Ok(params)
242    }
243
244    /// Load processor metadata only (skip processor vtable loading).
245    pub fn load_processors_only<P: AsRef<Path>>(
246        dylib_path: P,
247    ) -> Result<Vec<ProcessorInfo>, PluginLoaderError> {
248        // SAFETY: See load_params_only() rationale.
249        let library =
250            unsafe { Library::new(dylib_path.as_ref()) }.map_err(PluginLoaderError::LibraryLoad)?;
251
252        // SAFETY: Symbol is generated by wavecraft_plugin! and uses GetProcessorsJsonFn ABI.
253        let get_processors_json: Symbol<GetProcessorsJsonFn> = unsafe {
254            library
255                .get(b"wavecraft_get_processors_json\0")
256                .map_err(|e| {
257                    PluginLoaderError::SymbolNotFound(format!(
258                        "wavecraft_get_processors_json: {}",
259                        e
260                    ))
261                })?
262        };
263
264        // SAFETY: Symbol generated by macro with expected FreeStringFn ABI.
265        let free_string: Symbol<FreeStringFn> = unsafe {
266            library.get(b"wavecraft_free_string\0").map_err(|e| {
267                PluginLoaderError::SymbolNotFound(format!("wavecraft_free_string: {}", e))
268            })?
269        };
270
271        // SAFETY: See load() for FFI safety rationale.
272        let processors = unsafe {
273            let json_ptr = get_processors_json();
274            let parsed = parse_json_from_ffi::<Vec<ProcessorInfo>>(
275                json_ptr,
276                "wavecraft_get_processors_json",
277            )?;
278            free_string(json_ptr);
279            parsed
280        };
281
282        Ok(processors)
283    }
284
285    /// Get the loaded parameter information.
286    pub fn parameters(&self) -> &[ParameterInfo] {
287        &self.parameters
288    }
289
290    /// Get the loaded processor metadata.
291    pub fn processors(&self) -> &[ProcessorInfo] {
292        &self.processors
293    }
294
295    /// Get a parameter by ID.
296    #[allow(dead_code)]
297    pub fn get_parameter(&self, id: &str) -> Option<&ParameterInfo> {
298        self.parameters.iter().find(|p| p.id == id)
299    }
300
301    /// Returns the validated dev processor vtable.
302    pub fn dev_processor_vtable(&self) -> &DevProcessorVTable {
303        &self.dev_processor_vtable
304    }
305
306    /// Load and validate the dev audio processor vtable from the library.
307    fn load_processor_vtable(library: &Library) -> Result<DevProcessorVTable, PluginLoaderError> {
308        // SAFETY: `library` is a valid loaded Library handle. The symbol name
309        // matches the `extern "C"` function generated by the `wavecraft_plugin!`
310        // macro with identical signature (`DevProcessorVTableFn`).
311        let symbol: Symbol<DevProcessorVTableFn> = unsafe {
312            library
313                .get(b"wavecraft_dev_create_processor\0")
314                .map_err(|e| {
315                    PluginLoaderError::SymbolNotFound(format!(
316                        "wavecraft_dev_create_processor: {}",
317                        e
318                    ))
319                })?
320        };
321
322        // SAFETY: The symbol points to a valid `extern "C"` function generated
323        // by the `wavecraft_plugin!` macro with matching ABI and return type.
324        // The function constructs and returns a `DevProcessorVTable` by value
325        // (no heap allocation, no side effects beyond initialization).
326        // The Library remains loaded for the duration of this call.
327        let vtable = unsafe { symbol() };
328
329        // Version check — catch ABI mismatches early
330        if vtable.version != DEV_PROCESSOR_VTABLE_VERSION {
331            return Err(PluginLoaderError::VtableVersionMismatch {
332                found: vtable.version,
333                expected: DEV_PROCESSOR_VTABLE_VERSION,
334            });
335        }
336
337        Ok(vtable)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_error_display() {
347        let err = PluginLoaderError::SymbolNotFound("test_symbol".to_string());
348        assert!(err.to_string().contains("test_symbol"));
349    }
350
351    #[test]
352    fn test_null_pointer_error() {
353        let err = PluginLoaderError::NullPointer("wavecraft_get_params_json");
354        assert!(err.to_string().contains("null"));
355    }
356
357    #[test]
358    fn test_file_read_error() {
359        let err = PluginLoaderError::FileRead(std::io::Error::new(
360            std::io::ErrorKind::NotFound,
361            "file not found",
362        ));
363        assert!(err.to_string().contains("Failed to read file"));
364    }
365
366    #[test]
367    fn test_vtable_version_mismatch_error_display() {
368        let err = PluginLoaderError::VtableVersionMismatch {
369            found: 1,
370            expected: 2,
371        };
372        assert!(err.to_string().contains("version mismatch"));
373        assert!(err.to_string().contains("found 1"));
374        assert!(err.to_string().contains("expected 2"));
375    }
376
377    #[test]
378    fn test_load_params_from_file() {
379        use wavecraft_protocol::ParameterType;
380
381        let dir = std::env::temp_dir().join("wavecraft_test_sidecar");
382        let _ = std::fs::create_dir_all(&dir);
383        let json_path = dir.join("wavecraft-params.json");
384
385        let params = vec![ParameterInfo {
386            id: "gain".to_string(),
387            name: "Gain".to_string(),
388            param_type: ParameterType::Float,
389            value: 0.5,
390            default: 0.5,
391            min: 0.0,
392            max: 1.0,
393            unit: Some("dB".to_string()),
394            group: Some("Main".to_string()),
395            variants: None,
396        }];
397
398        let json = serde_json::to_string_pretty(&params).unwrap();
399        std::fs::write(&json_path, &json).unwrap();
400
401        let loaded = PluginParamLoader::load_params_from_file(&json_path).unwrap();
402        assert_eq!(loaded.len(), 1);
403        assert_eq!(loaded[0].id, "gain");
404        assert_eq!(loaded[0].name, "Gain");
405        assert!((loaded[0].default - 0.5).abs() < f32::EPSILON);
406
407        // Cleanup
408        let _ = std::fs::remove_file(&json_path);
409        let _ = std::fs::remove_dir(&dir);
410    }
411
412    #[test]
413    fn test_load_params_from_file_not_found() {
414        let result = PluginParamLoader::load_params_from_file("/nonexistent/path.json");
415        assert!(result.is_err());
416        let err = result.unwrap_err();
417        assert!(matches!(err, PluginLoaderError::FileRead(_)));
418    }
419
420    #[test]
421    fn test_load_params_from_file_invalid_json() {
422        let dir = std::env::temp_dir().join("wavecraft_test_bad_json");
423        let _ = std::fs::create_dir_all(&dir);
424        let json_path = dir.join("bad-params.json");
425
426        std::fs::write(&json_path, "not valid json").unwrap();
427
428        let result = PluginParamLoader::load_params_from_file(&json_path);
429        assert!(result.is_err());
430        assert!(matches!(
431            result.unwrap_err(),
432            PluginLoaderError::JsonParse(_)
433        ));
434
435        // Cleanup
436        let _ = std::fs::remove_file(&json_path);
437        let _ = std::fs::remove_dir(&dir);
438    }
439
440    #[test]
441    fn test_parse_processors_json() {
442        let json = r#"[{"id":"oscillator"},{"id":"output_gain"}]"#;
443        let processors: Vec<ProcessorInfo> =
444            serde_json::from_str(json).expect("json should deserialize");
445
446        assert_eq!(processors.len(), 2);
447        assert_eq!(processors[0].id, "oscillator");
448        assert_eq!(processors[1].id, "output_gain");
449    }
450
451    #[test]
452    fn test_parse_processors_json_invalid() {
453        let json = "not valid json";
454        let parsed: Result<Vec<ProcessorInfo>, _> = serde_json::from_str(json);
455        assert!(parsed.is_err());
456    }
457}