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