Skip to main content

appctl/
plugins.rs

1//! Dynamic plugin loader for appctl.
2//!
3//! Scans `~/.appctl/plugins/` for cdylib files (`*.dylib`, `*.so`, `*.dll`)
4//! built against `appctl-plugin-sdk`, loads them via `libloading`, verifies
5//! the ABI version, and exposes them as [`DynamicPlugin`] instances.
6
7use std::{
8    ffi::{CStr, CString},
9    os::raw::c_char,
10    path::{Path, PathBuf},
11    sync::Arc,
12};
13
14use anyhow::{Context, Result, anyhow, bail};
15use libloading::{Library, Symbol};
16
17use appctl_plugin_sdk::ffi::{PluginManifest, SDK_ABI_VERSION};
18use appctl_plugin_sdk::schema::Schema;
19
20type RegisterFn = unsafe extern "C" fn() -> *const PluginManifest;
21
22/// A loaded dynamic plugin.
23pub struct DynamicPlugin {
24    pub name: String,
25    pub version: String,
26    pub description: String,
27    pub source_path: PathBuf,
28    #[allow(dead_code)]
29    library: Arc<Library>,
30    manifest: *const PluginManifest,
31}
32
33// SAFETY: the underlying manifest/vtable pointers are static within the loaded
34// library. Access is only performed after verifying the ABI version.
35unsafe impl Send for DynamicPlugin {}
36unsafe impl Sync for DynamicPlugin {}
37
38impl DynamicPlugin {
39    /// Load a single cdylib and extract its manifest.
40    pub fn load(path: &Path) -> Result<Self> {
41        let library = unsafe {
42            Library::new(path)
43                .with_context(|| format!("failed to load plugin at {}", path.display()))?
44        };
45        let library = Arc::new(library);
46        let manifest_ptr: *const PluginManifest = unsafe {
47            let register: Symbol<RegisterFn> =
48                library.get(b"appctl_plugin_register").with_context(|| {
49                    format!(
50                        "plugin {} does not export appctl_plugin_register",
51                        path.display()
52                    )
53                })?;
54            register()
55        };
56
57        if manifest_ptr.is_null() {
58            bail!("plugin {} returned null manifest", path.display());
59        }
60        let manifest: &PluginManifest = unsafe { &*manifest_ptr };
61        if manifest.abi_version != SDK_ABI_VERSION {
62            bail!(
63                "plugin {} reports ABI version {} but host expects {}",
64                path.display(),
65                manifest.abi_version,
66                SDK_ABI_VERSION
67            );
68        }
69
70        let name = unsafe { cstr(manifest.name)? };
71        let version = unsafe { cstr(manifest.version)? };
72        let description = unsafe { cstr(manifest.description)? };
73
74        Ok(Self {
75            name,
76            version,
77            description,
78            source_path: path.to_path_buf(),
79            library,
80            manifest: manifest_ptr,
81        })
82    }
83
84    pub fn introspect(&self, input: &appctl_plugin_sdk::SyncInput) -> Result<Schema> {
85        let input_json = serde_json::to_string(input)?;
86        let input_c = CString::new(input_json)?;
87        let mut out_ptr: *mut c_char = std::ptr::null_mut();
88        let code = unsafe {
89            let manifest: &PluginManifest = &*self.manifest;
90            (manifest.vtable.introspect)(input_c.as_ptr(), &mut out_ptr as *mut *mut c_char)
91        };
92        if out_ptr.is_null() {
93            bail!("plugin {} returned null response", self.name);
94        }
95        let output = unsafe { CStr::from_ptr(out_ptr) }
96            .to_string_lossy()
97            .into_owned();
98        unsafe {
99            let manifest: &PluginManifest = &*self.manifest;
100            (manifest.vtable.free_string)(out_ptr);
101        }
102        if code != 0 {
103            bail!("plugin {} errored: {}", self.name, output);
104        }
105        let envelope: appctl_plugin_sdk::ffi::IntrospectResponse =
106            serde_json::from_str(&output).context("plugin returned invalid JSON")?;
107        Ok(envelope.schema)
108    }
109}
110
111impl Drop for DynamicPlugin {
112    fn drop(&mut self) {
113        // Keep the library alive until all plugin handles are dropped.
114        // Arc<Library> handles this via reference counting; nothing to do.
115    }
116}
117
118unsafe fn cstr(ptr: *const c_char) -> Result<String> {
119    if ptr.is_null() {
120        return Err(anyhow!("plugin returned null string"));
121    }
122    Ok(unsafe { CStr::from_ptr(ptr) }
123        .to_string_lossy()
124        .into_owned())
125}
126
127/// Default plugin directory (`~/.appctl/plugins`).
128pub fn plugin_dir() -> Result<PathBuf> {
129    let home = dirs::home_dir().context("cannot determine home directory")?;
130    Ok(home.join(".appctl").join("plugins"))
131}
132
133/// Load every plugin in the user's plugin directory.
134pub fn discover() -> Result<Vec<DynamicPlugin>> {
135    let dir = plugin_dir()?;
136    if !dir.exists() {
137        return Ok(Vec::new());
138    }
139    let mut plugins = Vec::new();
140    for entry in std::fs::read_dir(&dir)? {
141        let entry = entry?;
142        let path = entry.path();
143        let ext = path
144            .extension()
145            .and_then(|e| e.to_str())
146            .unwrap_or_default();
147        if !matches!(ext, "dylib" | "so" | "dll") {
148            continue;
149        }
150        match DynamicPlugin::load(&path) {
151            Ok(plugin) => plugins.push(plugin),
152            Err(err) => tracing::warn!("skipping plugin {}: {err:#}", path.display()),
153        }
154    }
155    Ok(plugins)
156}