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