//! Pure C ABI for the Bex WASM Plugin Engine. //! //! This module exports `extern "C"` functions that match the declarations in //! `bex_engine.h`. The architecture is callback-driven: //! //! 1. C++ calls `bex_submit_search(engine, plugin_id, query, callback, user_data)` //! 2. Rust spawns a Tokio task that does the work //! 3. On completion, Rust invokes `callback(user_data, request_id, success, payload, len)` //! from the Tokio background thread //! 4. C++ receives the result and can parse/copy it before the callback returns //! //! There is NO event queue, NO polling, NO cxx dependency. //! This is a clean, high-performance Pure C ABI boundary. use std::ffi::{CStr, CString}; use std::os::raw::{c_char, c_void}; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use bex_core::EngineConfig; use bex_types::BexError; use crate::runtime::BexRuntime; // ── Opaque Engine Handle ────────────────────────────────────────────── /// The internal representation behind the opaque `BexEngine*` pointer. pub struct BexEngineInner { runtime: BexRuntime, next_request_id: AtomicU64, /// Stores the last error message from a sync operation. last_error: parking_lot::Mutex>, } // ── Callback type matching bex_engine.h ─────────────────────────────── type ResultCallback = unsafe extern "C" fn( user_data: *mut c_void, request_id: u64, success: bool, payload: *const u8, payload_len: usize, ); // ── FFI-visible structs ─────────────────────────────────────────────── /// FFI-visible BexPluginInfo struct — must match the C header exactly. #[repr(C)] pub struct BexPluginInfo { pub id: *mut c_char, pub name: *mut c_char, pub version: *mut c_char, pub capabilities: u32, pub enabled: bool, pub description: *mut c_char, pub author: *mut c_char, pub homepage: *mut c_char, } /// FFI-visible BexPluginInfoList — must match the C header exactly. #[repr(C)] pub struct BexPluginInfoList { pub items: *mut BexPluginInfo, pub count: usize, } // ── Helper functions ────────────────────────────────────────────────── fn set_last_error(inner: &BexEngineInner, msg: &str) { if let Ok(c) = CString::new(msg) { *inner.last_error.lock() = Some(c); } } fn clear_last_error(inner: &BexEngineInner) { *inner.last_error.lock() = None; } fn error_to_code(inner: &BexEngineInner, e: &BexError) -> i32 { let msg = e.to_string(); set_last_error(inner, &msg); match e { BexError::PluginNotFound(_) => 2, BexError::PluginDisabled(_) => 3, BexError::NotReady => 4, BexError::Storage(_) => 5, BexError::Internal(_) => 6, _ => -1, } } fn str_to_cstring(s: &str) -> *mut c_char { CString::new(s) .map(|c| c.into_raw()) .unwrap_or(std::ptr::null_mut()) } fn plugin_info_to_ffi(info: &bex_types::plugin_info::PluginInfo) -> BexPluginInfo { BexPluginInfo { id: str_to_cstring(&info.id), name: str_to_cstring(&info.name), version: str_to_cstring(&info.version), capabilities: info.capabilities, enabled: info.enabled, description: str_to_cstring(""), author: str_to_cstring(""), homepage: str_to_cstring(""), } } fn error_code_short(err: &BexError) -> &'static str { match err { BexError::AbiMismatch { .. } => "ABI_MISMATCH", BexError::ManifestInvalid(_) => "INVALID_MANIFEST", BexError::HashMismatch { .. } => "HASH_MISMATCH", BexError::PluginNotFound(_) => "NOT_FOUND", BexError::PluginDisabled(_) => "DISABLED", BexError::Unsupported(_) => "UNSUPPORTED", BexError::NetworkBlocked(_) => "NETWORK_BLOCKED", BexError::Timeout { .. } => "TIMEOUT", BexError::FuelExhausted => "FUEL_EXHAUSTED", BexError::Cancelled => "CANCELLED", BexError::PluginFault(_) => "PLUGIN_FAULT", BexError::PluginError(_) => "PLUGIN_ERROR", BexError::Network(_) => "NETWORK", BexError::Storage(_) => "STORAGE", BexError::NotReady => "NOT_READY", BexError::Internal(_) => "INTERNAL", _ => "UNKNOWN", } } /// Helper to convert a C string pointer to a Rust String. /// Returns None if the pointer is null or invalid UTF-8. unsafe fn cstr_to_string(ptr: *const c_char) -> Option { if ptr.is_null() { return None; } CStr::from_ptr(ptr).to_str().ok().map(|s| s.to_string()) } // ══════════════════════════════════════════════════════════════════════ // FFI-exported functions — must match bex_engine.h exactly // ══════════════════════════════════════════════════════════════════════ // ── Lifecycle ───────────────────────────────────────────────────────── #[no_mangle] pub unsafe extern "C" fn bex_engine_new(data_dir: *const c_char) -> *mut BexEngineInner { if data_dir.is_null() { return std::ptr::null_mut(); } let data_dir_str = match cstr_to_string(data_dir) { Some(s) => s, None => return std::ptr::null_mut(), }; let config = EngineConfig { data_dir: PathBuf::from(data_dir_str), ..Default::default() }; match BexRuntime::new(config) { Ok(runtime) => { let inner = Box::new(BexEngineInner { runtime, next_request_id: AtomicU64::new(1), last_error: parking_lot::Mutex::new(None), }); Box::into_raw(inner) } Err(e) => { tracing::error!("Failed to create BexEngine: {}", e); std::ptr::null_mut() } } } #[no_mangle] pub unsafe extern "C" fn bex_engine_free(engine: *mut BexEngineInner) { if engine.is_null() { return; } let inner = Box::from_raw(engine); inner.runtime.shutdown(); } // ── Plugin Management (synchronous) ────────────────────────────────── #[no_mangle] pub unsafe extern "C" fn bex_engine_install( engine: *mut BexEngineInner, path: *const c_char, ) -> i32 { if engine.is_null() || path.is_null() { return -1; } let inner = &*engine; clear_last_error(inner); let path_str = match cstr_to_string(path) { Some(s) => s, None => { set_last_error(inner, "Invalid UTF-8 in path"); return -1; } }; match inner.runtime.install_plugin(std::path::Path::new(&path_str)) { Ok(_) => 0, Err(e) => error_to_code(inner, &e), } } #[no_mangle] pub unsafe extern "C" fn bex_engine_uninstall( engine: *mut BexEngineInner, id: *const c_char, ) -> i32 { if engine.is_null() || id.is_null() { return -1; } let inner = &*engine; clear_last_error(inner); let id_str = match cstr_to_string(id) { Some(s) => s, None => { set_last_error(inner, "Invalid UTF-8 in id"); return -1; } }; match inner.runtime.uninstall_plugin(&id_str) { Ok(_) => 0, Err(e) => error_to_code(inner, &e), } } #[no_mangle] pub unsafe extern "C" fn bex_engine_list_plugins( engine: *mut BexEngineInner, ) -> BexPluginInfoList { if engine.is_null() { return BexPluginInfoList { items: std::ptr::null_mut(), count: 0, }; } let inner = &*engine; let plugins = inner.runtime.list_plugins(); let count = plugins.len(); if count == 0 { return BexPluginInfoList { items: std::ptr::null_mut(), count: 0, }; } // Allocate array of BexPluginInfo let layout = std::alloc::Layout::array::(count).unwrap(); let items_ptr = std::alloc::alloc(layout) as *mut BexPluginInfo; if items_ptr.is_null() { return BexPluginInfoList { items: std::ptr::null_mut(), count: 0, }; } for (i, info) in plugins.iter().enumerate() { let ffi_info = plugin_info_to_ffi(info); std::ptr::write(items_ptr.add(i), ffi_info); } BexPluginInfoList { items: items_ptr, count } } #[no_mangle] pub unsafe extern "C" fn bex_engine_plugin_info( engine: *mut BexEngineInner, id: *const c_char, out: *mut BexPluginInfo, ) -> i32 { if engine.is_null() || id.is_null() || out.is_null() { return -1; } let inner = &*engine; clear_last_error(inner); let id_str = match cstr_to_string(id) { Some(s) => s, None => { set_last_error(inner, "Invalid UTF-8 in id"); return -1; } }; match inner.runtime.get_plugin_info(&id_str) { Some(info) => { std::ptr::write(out, plugin_info_to_ffi(&info)); 0 } None => { set_last_error(inner, &format!("Plugin not found: {}", id_str)); 2 } } } #[no_mangle] pub unsafe extern "C" fn bex_engine_enable( engine: *mut BexEngineInner, id: *const c_char, ) -> i32 { if engine.is_null() || id.is_null() { return -1; } let inner = &*engine; clear_last_error(inner); let id_str = match cstr_to_string(id) { Some(s) => s, None => return -1, }; match inner.runtime.enable_plugin(&id_str) { Ok(_) => 0, Err(e) => error_to_code(inner, &e), } } #[no_mangle] pub unsafe extern "C" fn bex_engine_disable( engine: *mut BexEngineInner, id: *const c_char, ) -> i32 { if engine.is_null() || id.is_null() { return -1; } let inner = &*engine; clear_last_error(inner); let id_str = match cstr_to_string(id) { Some(s) => s, None => return -1, }; match inner.runtime.disable_plugin(&id_str) { Ok(_) => 0, Err(e) => error_to_code(inner, &e), } } #[no_mangle] pub unsafe extern "C" fn bex_plugin_info_list_free(list: BexPluginInfoList) { if list.items.is_null() || list.count == 0 { return; } for i in 0..list.count { let info = &*list.items.add(i); if !info.id.is_null() { let _ = CString::from_raw(info.id); } if !info.name.is_null() { let _ = CString::from_raw(info.name); } if !info.version.is_null() { let _ = CString::from_raw(info.version); } if !info.description.is_null() { let _ = CString::from_raw(info.description); } if !info.author.is_null() { let _ = CString::from_raw(info.author); } if !info.homepage.is_null() { let _ = CString::from_raw(info.homepage); } } let layout = std::alloc::Layout::array::(list.count).unwrap(); std::alloc::dealloc(list.items as *mut u8, layout); } #[no_mangle] pub unsafe extern "C" fn bex_plugin_info_free(info: BexPluginInfo) { if !info.id.is_null() { let _ = CString::from_raw(info.id); } if !info.name.is_null() { let _ = CString::from_raw(info.name); } if !info.version.is_null() { let _ = CString::from_raw(info.version); } if !info.description.is_null() { let _ = CString::from_raw(info.description); } if !info.author.is_null() { let _ = CString::from_raw(info.author); } if !info.homepage.is_null() { let _ = CString::from_raw(info.homepage); } } // ── API Key / Secret Management (synchronous) ──────────────────────── #[no_mangle] pub unsafe extern "C" fn bex_engine_secret_set( engine: *mut BexEngineInner, plugin_id: *const c_char, key: *const c_char, value: *const c_char, ) -> i32 { if engine.is_null() || plugin_id.is_null() || key.is_null() || value.is_null() { return -1; } let inner = &*engine; clear_last_error(inner); let pid = match cstr_to_string(plugin_id) { Some(s) => s, None => return -1 }; let k = match cstr_to_string(key) { Some(s) => s, None => return -1 }; let v = match cstr_to_string(value) { Some(s) => s, None => return -1 }; match inner.runtime.secret_set(&pid, &k, &v) { Ok(_) => 0, Err(e) => error_to_code(inner, &e), } } #[no_mangle] pub unsafe extern "C" fn bex_engine_secret_get( engine: *mut BexEngineInner, plugin_id: *const c_char, key: *const c_char, out_buf: *mut c_char, out_buf_len: *mut usize, ) -> i32 { if engine.is_null() || plugin_id.is_null() || key.is_null() || out_buf.is_null() || out_buf_len.is_null() { return -1; } let inner = &*engine; clear_last_error(inner); let pid = match cstr_to_string(plugin_id) { Some(s) => s, None => return -1 }; let k = match cstr_to_string(key) { Some(s) => s, None => return -1 }; match inner.runtime.secret_get(&pid, &k) { Ok(Some(val)) => { let buf_size = *out_buf_len; let val_bytes = val.as_bytes(); let copy_len = val_bytes.len().min(buf_size - 1); if copy_len < val_bytes.len() { set_last_error(inner, "Output buffer too small"); *out_buf_len = val_bytes.len() + 1; return -2; } std::ptr::copy_nonoverlapping(val_bytes.as_ptr(), out_buf as *mut u8, copy_len); *out_buf.add(copy_len) = 0; *out_buf_len = copy_len; 0 } Ok(None) => { set_last_error(inner, &format!("Secret '{}' not found for plugin '{}'", k, pid)); 1 } Err(e) => error_to_code(inner, &e), } } #[no_mangle] pub unsafe extern "C" fn bex_engine_secret_delete( engine: *mut BexEngineInner, plugin_id: *const c_char, key: *const c_char, ) -> i32 { if engine.is_null() || plugin_id.is_null() || key.is_null() { return -1; } let inner = &*engine; clear_last_error(inner); let pid = match cstr_to_string(plugin_id) { Some(s) => s, None => return -1 }; let k = match cstr_to_string(key) { Some(s) => s, None => return -1 }; match inner.runtime.secret_remove(&pid, &k) { Ok(true) => 0, Ok(false) => 1, Err(e) => error_to_code(inner, &e), } } #[no_mangle] pub unsafe extern "C" fn bex_engine_secret_keys( engine: *mut BexEngineInner, plugin_id: *const c_char, ) -> *mut c_char { if engine.is_null() || plugin_id.is_null() { return std::ptr::null_mut(); } let inner = &*engine; let pid = match cstr_to_string(plugin_id) { Some(s) => s, None => return std::ptr::null_mut(), }; match inner.runtime.secret_keys(&pid) { Ok(keys) => { let joined = keys.join(","); str_to_cstring(&joined) } Err(_) => std::ptr::null_mut(), } } #[no_mangle] pub unsafe extern "C" fn bex_string_free(s: *mut c_char) { if !s.is_null() { let _ = CString::from_raw(s); } } // ── Async Operations ───────────────────────────────────────────────── // // Each submit function: // 1. Converts all C strings to owned Rust Strings BEFORE creating the closure // 2. Generates a request_id // 3. Spawns a Tokio task that executes the work and invokes the callback // // The closure captures only owned types (String, Arc, etc.) — // no raw pointers, making it Send-safe. #[no_mangle] pub unsafe extern "C" fn bex_submit_search( engine: *mut BexEngineInner, plugin_id: *const c_char, query: *const c_char, callback: ResultCallback, user_data: *mut c_void, ) -> u64 { let pid = match cstr_to_string(plugin_id) { Some(s) => s, None => return 0 }; let query_str = match cstr_to_string(query) { Some(s) => s, None => return 0 }; submit_async(engine, pid, callback, user_data, move |engine, pid| { engine.call_search_json(pid, &query_str) .map(|s| s.into_bytes()) }) } #[no_mangle] pub unsafe extern "C" fn bex_submit_home( engine: *mut BexEngineInner, plugin_id: *const c_char, callback: ResultCallback, user_data: *mut c_void, ) -> u64 { let pid = match cstr_to_string(plugin_id) { Some(s) => s, None => return 0 }; submit_async(engine, pid, callback, user_data, move |engine, pid| { engine.call_get_home_json(pid) .map(|s| s.into_bytes()) }) } #[no_mangle] pub unsafe extern "C" fn bex_submit_info( engine: *mut BexEngineInner, plugin_id: *const c_char, media_id: *const c_char, callback: ResultCallback, user_data: *mut c_void, ) -> u64 { let pid = match cstr_to_string(plugin_id) { Some(s) => s, None => return 0 }; let mid = match cstr_to_string(media_id) { Some(s) => s, None => return 0 }; submit_async(engine, pid, callback, user_data, move |engine, pid| { engine.call_get_info_json(pid, &mid) .map(|s| s.into_bytes()) }) } #[no_mangle] pub unsafe extern "C" fn bex_submit_servers( engine: *mut BexEngineInner, plugin_id: *const c_char, id: *const c_char, callback: ResultCallback, user_data: *mut c_void, ) -> u64 { let pid = match cstr_to_string(plugin_id) { Some(s) => s, None => return 0 }; let id_str = match cstr_to_string(id) { Some(s) => s, None => return 0 }; submit_async(engine, pid, callback, user_data, move |engine, pid| { engine.call_get_servers_json(pid, &id_str) .map(|s| s.into_bytes()) }) } #[no_mangle] pub unsafe extern "C" fn bex_submit_stream( engine: *mut BexEngineInner, plugin_id: *const c_char, server_json: *const c_char, callback: ResultCallback, user_data: *mut c_void, ) -> u64 { let pid = match cstr_to_string(plugin_id) { Some(s) => s, None => return 0 }; let server_str = match cstr_to_string(server_json) { Some(s) => s, None => return 0 }; submit_async(engine, pid, callback, user_data, move |engine, pid| { engine.call_resolve_stream_json(pid, &server_str) .map(|s| s.into_bytes()) }) } // ── Cancellation ───────────────────────────────────────────────────── #[no_mangle] pub unsafe extern "C" fn bex_cancel_request( engine: *mut BexEngineInner, request_id: u64, ) -> bool { if engine.is_null() { return false; } let inner = &*engine; inner.runtime.cancel_request(request_id) } // ── Engine Stats ───────────────────────────────────────────────────── #[no_mangle] pub unsafe extern "C" fn bex_engine_stats( engine: *mut BexEngineInner, ) -> *mut c_char { if engine.is_null() { return std::ptr::null_mut(); } let inner = &*engine; let stats = inner.runtime.stats(); match serde_json::to_string(&stats) { Ok(json) => str_to_cstring(&json), Err(_) => std::ptr::null_mut(), } } // ── Last Error ─────────────────────────────────────────────────────── #[no_mangle] pub unsafe extern "C" fn bex_engine_last_error( engine: *mut BexEngineInner, ) -> *mut c_char { if engine.is_null() { return std::ptr::null_mut(); } let inner = &*engine; match inner.last_error.lock().as_ref() { Some(cstr) => { let bytes = cstr.as_bytes(); let dup = CString::from_vec_unchecked(bytes.to_vec()); dup.into_raw() } None => std::ptr::null_mut(), } } // ══════════════════════════════════════════════════════════════════════ // Internal async submit helper // ══════════════════════════════════════════════════════════════════════ /// Common implementation for all async submit functions. /// /// - `engine`: raw pointer to BexEngineInner /// - `pid`: owned String (plugin_id, already converted from C) /// - `callback`: C function pointer /// - `user_data`: opaque pointer from C++ /// - `work`: closure that takes `&bex_core::Engine` + `&str` (plugin_id), /// does the work, and returns `Result, BexError>` /// /// All C strings must be converted to Rust Strings BEFORE calling this, /// so the closure only captures Send types. unsafe fn submit_async( engine: *mut BexEngineInner, pid: String, callback: ResultCallback, user_data: *mut c_void, work: F, ) -> u64 where F: FnOnce(&bex_core::Engine, &str) -> Result, BexError> + Send + 'static, { if engine.is_null() { return 0; } let inner = &*engine; let request_id = inner.next_request_id.fetch_add(1, Ordering::Relaxed); // Clone the engine (Arc-based, cheap) for the spawned task let engine_clone = inner.runtime.clone_engine(); // Store callback and user_data as usize for Send-safety across threads. // The callback is a function pointer (inherently Send), and user_data // is an opaque pointer that C++ guarantees remains valid until callback // is invoked. let callback_addr = callback as usize; let user_data_addr = user_data as usize; // Get cancellation token let cancel_token = tokio_util::sync::CancellationToken::new(); inner.runtime.insert_cancellation(request_id, cancel_token.clone()); // Spawn on the BexRuntime's internal Tokio runtime let rt_handle = inner.runtime.tokio_handle(); rt_handle.spawn(async move { // Check cancellation before starting if cancel_token.is_cancelled() { invoke_callback(callback_addr, user_data_addr, request_id, false, format!("CANCELLED: Request {} was cancelled", request_id).as_bytes()); return; } // Execute the work using spawn_blocking since bex-core Engine // internally uses its own Tokio runtime for HTTP/WASM operations. let result = tokio::task::spawn_blocking(move || { work(&engine_clone, &pid) }).await; match result { Ok(Ok(payload)) => { invoke_callback(callback_addr, user_data_addr, request_id, true, &payload); } Ok(Err(e)) => { let err_msg = format!("{}: {}", error_code_short(&e), e); invoke_callback(callback_addr, user_data_addr, request_id, false, err_msg.as_bytes()); } Err(_) => { invoke_callback(callback_addr, user_data_addr, request_id, false, b"INTERNAL: Worker thread panicked"); } } }); request_id } /// Invoke the C callback with a byte payload. unsafe fn invoke_callback( callback_addr: usize, user_data_addr: usize, request_id: u64, success: bool, payload: &[u8], ) { let cb: ResultCallback = std::mem::transmute(callback_addr); let ud = user_data_addr as *mut c_void; cb(ud, request_id, success, payload.as_ptr(), payload.len()); }