1use 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
22pub 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
33unsafe impl Send for DynamicPlugin {}
36unsafe impl Sync for DynamicPlugin {}
37
38impl DynamicPlugin {
39 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 }
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
127pub 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
133pub 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}