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