krystv's picture
Upload 107 files
3374e90 verified
//! 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<Option<CString>>,
}
// ── 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<String> {
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::<BexPluginInfo>(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::<BexPluginInfo>(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<Engine>, 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<Vec<u8>, BexError>`
///
/// All C strings must be converted to Rust Strings BEFORE calling this,
/// so the closure only captures Send types.
unsafe fn submit_async<F>(
engine: *mut BexEngineInner,
pid: String,
callback: ResultCallback,
user_data: *mut c_void,
work: F,
) -> u64
where
F: FnOnce(&bex_core::Engine, &str) -> Result<Vec<u8>, 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());
}