use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use libloading::Library;
use thiserror::Error;
use rpi_plugin_sdk::{RpiPluginRegisterUnified, RPI_PLUGIN_ABI_VERSION_UNIFIED};
use crate::registry::{ExtensionRegistry, RegistrySnapshot};
use crate::{
clear_current_api, set_current_api, ActionBridge, HostApi, NullDiagnostics, PluginDiagnostics,
};
#[derive(Debug, Error)]
pub enum PluginLoadError {
#[error("could not open library {path}: {source}")]
Open {
path: PathBuf,
#[source]
source: libloading::Error,
},
#[error("`rpi_plugin_register` was not found in {path}: {error}")]
Symbol { path: PathBuf, error: String },
#[error("register returned nonzero code {code} for {path}")]
RegisterReturned { path: PathBuf, code: i32 },
#[error(
"ABI version mismatch in {path}: plugin built for {plugin_version}, host is {host_version}"
)]
AbiVersionMismatch {
path: PathBuf,
plugin_version: u32,
host_version: u32,
},
}
pub struct LoadedPlugin {
pub library: Library,
pub path: PathBuf,
pub abi_version: u32,
pub registry: ExtensionRegistry,
}
fn call_register(register: RpiPluginRegisterUnified, host_api: &Arc<HostApi>) -> i32 {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let api: &'static rpi_plugin_sdk::PluginApi = Box::leak(Box::new(host_api.build_vtable()));
register(api as *const rpi_plugin_sdk::PluginApi)
})) {
Ok(code) => code,
Err(_) => rpi_plugin_sdk::REGISTER_PANIC_STATUS,
}
}
pub fn load_one(
path: impl AsRef<Path>,
diagnostics: Arc<dyn PluginDiagnostics>,
action_bridge: Option<Arc<ActionBridge>>,
) -> Result<LoadedPlugin, PluginLoadError> {
let path = path.as_ref().to_path_buf();
let library = unsafe { Library::new(&path) }.map_err(|e| PluginLoadError::Open {
path: path.clone(),
source: e,
})?;
let register: RpiPluginRegisterUnified = unsafe {
library
.get::<RpiPluginRegisterUnified>(rpi_plugin_sdk::REGISTER_SYMBOL_UNIFIED)
.map(|symbol| *symbol)
}
.map_err(|e| PluginLoadError::Symbol {
path: path.clone(),
error: e.to_string(),
})?;
let abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED;
let plugin_name = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "<unknown-plugin>".to_string());
let registry = ExtensionRegistry::new();
let host_api = match action_bridge {
Some(bridge) => HostApi::with_action_bridge(
plugin_name.clone(),
registry,
Arc::clone(&diagnostics),
bridge,
),
None => HostApi::new(plugin_name, registry, Arc::clone(&diagnostics)),
};
unsafe { set_current_api(&host_api) };
let rc = call_register(register, &host_api);
clear_current_api();
if rc != 0 {
if rc == rpi_plugin_sdk::REGISTER_PANIC_STATUS {
diagnostics.warn(&format!(
"plugin {} panicked during registration — skipped (the host is unaffected)",
path.display()
));
} else {
diagnostics.warn(&format!(
"plugin {} ABI v{} register returned code {} — skipped",
path.display(),
abi_version,
rc
));
}
return Err(PluginLoadError::RegisterReturned { path, code: rc });
}
let registry = host_api
.take_registry()
.ok_or_else(|| PluginLoadError::RegisterReturned {
path: path.clone(),
code: -2,
})?;
tracing::debug!(path = %path.display(), abi_version, "loaded native plugin");
Ok(LoadedPlugin {
library,
path,
abi_version,
registry,
})
}
const CDYLIB_EXTS: &[&str] = &["dll", "so", "dylib", "pyd"];
pub fn load_dir(
dir: impl AsRef<Path>,
diagnostics: Arc<dyn PluginDiagnostics>,
action_bridge: Option<Arc<ActionBridge>>,
) -> Vec<LoadedPlugin> {
let dir = dir.as_ref();
let mut out = Vec::new();
let read = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(e) => {
diagnostics.warn(&format!(
"extensions dir {} unreadable: {}",
dir.display(),
e
));
return out;
}
};
for entry in read.flatten() {
let path = entry.path();
if !is_cdylib(&path) {
continue;
}
match load_one(&path, Arc::clone(&diagnostics), action_bridge.clone()) {
Ok(p) => out.push(p),
Err(e) => diagnostics.warn(&format!("skipped plugin {}: {e}", path.display())),
}
}
out
}
fn is_cdylib(path: &Path) -> bool {
path.extension()
.and_then(OsStr::to_str)
.map(|ext| CDYLIB_EXTS.iter().any(|e| e.eq_ignore_ascii_case(ext)))
.unwrap_or(false)
}
pub fn merge_registries(plugins: &mut [LoadedPlugin]) -> ExtensionRegistry {
let mut session = ExtensionRegistry::new();
for p in plugins.iter_mut() {
let taken = std::mem::take(&mut p.registry);
session.absorb(taken);
}
session
}
pub struct PluginKeepalive {
#[allow(dead_code)]
libraries: Vec<Library>,
#[allow(dead_code)]
action_bridge: Option<Arc<ActionBridge>>,
}
impl PluginKeepalive {
pub fn new(libraries: Vec<Library>, action_bridge: Option<Arc<ActionBridge>>) -> Self {
Self {
libraries,
action_bridge,
}
}
pub fn empty() -> Arc<Self> {
Arc::new(Self::new(Vec::new(), None))
}
}
#[derive(Clone)]
pub struct ExtensionSession {
keepalive: Arc<PluginKeepalive>,
snapshot: Option<Arc<RegistrySnapshot>>,
loaded_paths: Vec<PathBuf>,
action_bridge: Option<Arc<ActionBridge>>,
}
impl ExtensionSession {
pub fn from_parts(
snapshot: Arc<RegistrySnapshot>,
keepalive: Arc<PluginKeepalive>,
loaded_paths: Vec<PathBuf>,
action_bridge: Option<Arc<ActionBridge>>,
) -> Self {
Self {
keepalive,
snapshot: Some(snapshot),
loaded_paths,
action_bridge,
}
}
pub fn none() -> Self {
Self {
keepalive: Arc::new(PluginKeepalive::new(Vec::new(), None)),
snapshot: None,
loaded_paths: Vec::new(),
action_bridge: None,
}
}
pub fn keepalive(&self) -> Arc<PluginKeepalive> {
Arc::clone(&self.keepalive)
}
pub fn snapshot(&self) -> Option<&RegistrySnapshot> {
self.snapshot.as_deref()
}
pub fn snapshot_arc(&self) -> Option<Arc<RegistrySnapshot>> {
self.snapshot.clone()
}
pub fn loaded_paths(&self) -> &[PathBuf] {
&self.loaded_paths
}
pub fn is_empty(&self) -> bool {
self.loaded_paths.is_empty()
}
pub fn summary(&self) -> Option<String> {
if self.is_empty() {
return None;
}
let tools = self.snapshot.as_ref().map(|s| s.tools().len()).unwrap_or(0);
Some(format!(
"loaded {} plugin(s) ({} tool(s))",
self.loaded_paths.len(),
tools
))
}
pub fn action_bridge(&self) -> Option<Arc<ActionBridge>> {
self.action_bridge.clone()
}
}
pub fn load_session(
dirs: &[PathBuf],
diagnostics: Arc<dyn PluginDiagnostics>,
action_bridge: Option<Arc<ActionBridge>>,
) -> ExtensionSession {
load_session_mixed(dirs, &[], diagnostics, action_bridge)
}
pub fn load_session_mixed(
dirs: &[PathBuf],
files: &[PathBuf],
diagnostics: Arc<dyn PluginDiagnostics>,
action_bridge: Option<Arc<ActionBridge>>,
) -> ExtensionSession {
let mut loaded: Vec<LoadedPlugin> = Vec::new();
for dir in dirs {
loaded.extend(load_dir(
dir,
Arc::clone(&diagnostics),
action_bridge.clone(),
));
}
for f in files {
if let Ok(plugin) = load_one(f, Arc::clone(&diagnostics), action_bridge.clone()) {
loaded.push(plugin);
}
}
if loaded.is_empty() {
return ExtensionSession::none();
}
let loaded_paths: Vec<PathBuf> = loaded.iter().map(|p| p.path.clone()).collect();
let session_registry = merge_registries(&mut loaded);
let mut libs: Vec<Library> = Vec::with_capacity(loaded.len());
for p in loaded {
let LoadedPlugin {
library,
registry: _,
path: _,
abi_version: _,
} = p;
libs.push(library);
}
let snapshot = Arc::new(session_registry.snapshot());
ExtensionSession {
keepalive: Arc::new(PluginKeepalive::new(libs, action_bridge.clone())),
snapshot: Some(snapshot),
loaded_paths,
action_bridge,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
use std::sync::Mutex;
static UNIFIED_CALLS: AtomicUsize = AtomicUsize::new(0);
static UNIFIED_SEEN_VERSION: AtomicUsize = AtomicUsize::new(0);
static UNIFIED_RETURN: AtomicI32 = AtomicI32::new(0);
extern "C" fn test_register_unified(api: *const rpi_plugin_sdk::PluginApi) -> i32 {
if api.is_null() {
return -99;
}
UNIFIED_CALLS.fetch_add(1, Ordering::SeqCst);
UNIFIED_SEEN_VERSION.store(unsafe { (*api).abi_version as usize }, Ordering::SeqCst);
UNIFIED_RETURN.load(Ordering::SeqCst)
}
#[derive(Default)]
struct CapturingDiag {
warns: Mutex<Vec<String>>,
}
impl PluginDiagnostics for CapturingDiag {
fn warn(&self, msg: &str) {
self.warns.lock().unwrap().push(msg.to_string());
}
fn unsupported(&self, msg: &str) {
self.warn(msg);
}
}
fn test_host_api() -> Arc<HostApi> {
HostApi::new(
"test-plugin",
ExtensionRegistry::new(),
Arc::new(CapturingDiag::default()),
)
}
struct CdylibFixture {
dir: PathBuf,
path: PathBuf,
}
impl Drop for CdylibFixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
fn build_cdylib_fixture(name: &str, source: &str) -> CdylibFixture {
static NEXT_FIXTURE: AtomicUsize = AtomicUsize::new(0);
let unique = NEXT_FIXTURE.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"rpi-abi-loader-{}-{name}-{unique}",
std::process::id()
));
fs::create_dir_all(&dir).expect("create ABI fixture directory");
let source_path = dir.join("fixture.rs");
fs::write(&source_path, source).expect("write ABI fixture source");
let filename = if cfg!(windows) {
format!("{name}.dll")
} else if cfg!(target_os = "macos") {
format!("lib{name}.dylib")
} else {
format!("lib{name}.so")
};
let path = dir.join(filename);
let output = Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
.arg("--crate-name")
.arg(name)
.arg("--crate-type")
.arg("cdylib")
.arg("--edition")
.arg("2021")
.arg(&source_path)
.arg("-o")
.arg(&path)
.output()
.expect("run rustc for ABI fixture");
assert!(
output.status.success(),
"fixture build failed: {}",
String::from_utf8_lossy(&output.stderr)
);
CdylibFixture { dir, path }
}
#[test]
fn loads_unified_plugin() {
const PREFIX: &str = "use std::ffi::c_void;\n";
let diag: Arc<dyn PluginDiagnostics> = Arc::new(CapturingDiag::default());
let unified = build_cdylib_fixture(
"abi_unified_only",
&format!(
"{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register(api: *const c_void) -> i32 {{ if !api.is_null() {{ 0 }} else {{ 91 }} }}\n"
),
);
let loaded = load_one(&unified.path, Arc::clone(&diag), None).expect("load unified plugin");
assert_eq!(loaded.abi_version, RPI_PLUGIN_ABI_VERSION_UNIFIED);
drop(loaded);
drop(unified);
}
#[test]
fn unified_registration_failure_is_reported() {
const PREFIX: &str = "use std::ffi::c_void;\n";
let diag: Arc<dyn PluginDiagnostics> = Arc::new(CapturingDiag::default());
let failed = build_cdylib_fixture(
"abi_unified_failure",
&format!(
"{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register(_: *const c_void) -> i32 {{ 73 }}\n"
),
);
let error = match load_one(&failed.path, diag, None) {
Ok(_) => panic!("failed registration must be reported as error"),
Err(error) => error,
};
assert!(matches!(
error,
PluginLoadError::RegisterReturned { code: 73, .. }
));
drop(failed);
}
#[test]
fn panicking_register_body_is_contained_and_skipped() {
let diag = Arc::new(CapturingDiag::default());
let diag_dyn: Arc<dyn PluginDiagnostics> = diag.clone();
let panic_status = rpi_plugin_sdk::REGISTER_PANIC_STATUS;
const PREFIX: &str = "use std::ffi::c_void;\n";
let source = format!(
r#"{PREFIX}
fn body() -> i32 {{ panic!("register body exploded"); }}
#[no_mangle]
pub extern "C" fn rpi_plugin_register(_: *const c_void) -> i32 {{
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) {{
Ok(code) => code,
Err(_) => {panic_status},
}}
}}
"#
);
let fixture = build_cdylib_fixture("abi_unified_panic", &source);
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let result = load_one(&fixture.path, diag_dyn, None);
std::panic::set_hook(prev_hook);
match result {
Ok(_) => panic!("a panicking register must be skipped, not loaded"),
Err(PluginLoadError::RegisterReturned { code, .. }) => {
assert_eq!(code, panic_status);
}
Err(other) => panic!("expected RegisterReturned, got {other:?}"),
}
let warns = diag.warns.lock().unwrap().clone();
assert!(
warns
.iter()
.any(|w| w.contains("panicked during registration")),
"diagnostic should mention the panic: {warns:?}"
);
drop(fixture);
}
#[test]
fn load_one_missing_file_reports_open_error() {
let diag: Arc<dyn PluginDiagnostics> = Arc::new(CapturingDiag::default());
let res = load_one("definitely_not_a_plugin.dll", diag, None);
assert!(matches!(res, Err(PluginLoadError::Open { .. })));
}
#[test]
fn load_dir_missing_dir_returns_empty_and_warns() {
let empty = load_dir(
"no_such_dir_xyz",
Arc::new(CapturingDiag::default()) as Arc<dyn PluginDiagnostics>,
None,
);
assert!(empty.is_empty());
}
#[test]
fn is_cdylib_recognizes_extensions() {
assert!(is_cdylib(Path::new("foo.dll")));
assert!(is_cdylib(Path::new("foo.so")));
assert!(is_cdylib(Path::new("foo.dylib")));
assert!(is_cdylib(Path::new("FOO.DLL")));
assert!(!is_cdylib(Path::new("foo.md")));
assert!(!is_cdylib(Path::new("foo")));
}
}
#[allow(dead_code)]
fn _ensure_nulldiagnostics_referenced() -> Arc<dyn PluginDiagnostics> {
Arc::new(NullDiagnostics)
}