1use std::future::Future;
7use std::path::Path;
8use std::pin::Pin;
9
10use indexmap::IndexMap;
11
12const HOOK_NAMES: &[&str] = &[
17 "on_navigate",
18 "on_drill_down",
19 "on_back",
20 "on_scan_start",
21 "on_scan_progress",
22 "on_scan_complete",
23 "on_delete_start",
24 "on_delete_complete",
25 "on_copy_start",
26 "on_copy_complete",
27 "on_move_start",
28 "on_move_complete",
29 "on_render",
30 "on_action",
31 "on_mode_change",
32 "on_startup",
33 "on_shutdown",
34];
35
36use tokio_util::sync::CancellationToken;
37
38use crate::config::{PluginConfig, PluginMetadata};
39use crate::hooks::{Hook, HookContext, HookResult};
40use crate::sandbox::SandboxConfig;
41use crate::types::{PluginError, PluginKind, PluginResult, Value};
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub struct PluginHandle(pub(crate) usize);
46
47impl PluginHandle {
48 pub(crate) fn new(id: usize) -> Self {
50 Self(id)
51 }
52
53 pub fn id(&self) -> usize {
55 self.0
56 }
57}
58
59pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
61
62pub trait PluginRuntime: Send + Sync {
67 fn name(&self) -> &'static str;
69
70 fn file_extensions(&self) -> &'static [&'static str];
72
73 fn init(&mut self, config: &PluginConfig) -> PluginResult<()>;
75
76 fn load_plugin(&mut self, id: &str, source: &Path) -> PluginResult<PluginHandle>;
80
81 fn unload_plugin(&mut self, handle: PluginHandle) -> PluginResult<()>;
83
84 fn get_metadata(&self, handle: PluginHandle) -> Option<&PluginMetadata>;
86
87 fn has_hook(&self, handle: PluginHandle, hook_name: &str) -> bool;
89
90 fn call_hook_sync(
94 &self,
95 handle: PluginHandle,
96 hook: &Hook,
97 ctx: &HookContext,
98 ) -> PluginResult<HookResult>;
99
100 fn call_hook_async<'a>(
104 &'a self,
105 handle: PluginHandle,
106 hook: &'a Hook,
107 ctx: &'a HookContext,
108 ) -> BoxFuture<'a, PluginResult<HookResult>>;
109
110 fn call_method<'a>(
112 &'a self,
113 handle: PluginHandle,
114 method: &'a str,
115 args: Vec<Value>,
116 ) -> BoxFuture<'a, PluginResult<Value>>;
117
118 fn create_isolated_context(
123 &self,
124 sandbox: &SandboxConfig,
125 ) -> PluginResult<Box<dyn IsolatedContext>>;
126
127 fn loaded_plugins(&self) -> Vec<PluginHandle>;
129
130 fn shutdown(&mut self) -> PluginResult<()>;
132}
133
134pub trait IsolatedContext: Send {
143 fn execute<'a>(
145 &'a self,
146 code: &'a [u8],
147 cancel: CancellationToken,
148 ) -> BoxFuture<'a, PluginResult<Value>>;
149
150 fn call_function<'a>(
152 &'a self,
153 name: &'a str,
154 args: Vec<Value>,
155 cancel: CancellationToken,
156 ) -> BoxFuture<'a, PluginResult<Value>>;
157
158 fn set_global(&mut self, name: &str, value: Value) -> PluginResult<()>;
160
161 fn get_global(&self, name: &str) -> PluginResult<Value>;
163}
164
165#[derive(Debug, Clone)]
167pub struct LoadedPlugin {
168 pub handle: PluginHandle,
170
171 pub id: String,
173
174 pub metadata: PluginMetadata,
176
177 pub path: std::path::PathBuf,
179
180 pub hooks: Vec<String>,
182}
183
184pub struct PluginManager {
186 runtimes: IndexMap<String, Box<dyn PluginRuntime>>,
188
189 plugins: IndexMap<PluginHandle, LoadedPlugin>,
191
192 config: PluginConfig,
194
195 #[allow(dead_code)]
197 next_handle: usize,
198}
199
200impl PluginManager {
201 fn validate_plugin_name(name: &str) -> PluginResult<()> {
205 if name.is_empty() || name.len() > 64 {
206 return Err(PluginError::ConfigError {
207 message: format!("Plugin name '{}' must be 1–64 characters long", name),
208 });
209 }
210 if !name
211 .chars()
212 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
213 {
214 return Err(PluginError::ConfigError {
215 message: format!(
216 "Plugin name '{}' contains invalid characters (allowed: [a-zA-Z0-9_-])",
217 name
218 ),
219 });
220 }
221 Ok(())
222 }
223
224 pub fn new(config: PluginConfig) -> Self {
226 Self {
227 runtimes: IndexMap::new(),
228 plugins: IndexMap::new(),
229 config,
230 next_handle: 0,
231 }
232 }
233
234 pub fn register_runtime(&mut self, runtime: Box<dyn PluginRuntime>) -> PluginResult<()> {
236 let name = runtime.name().to_string();
237 self.runtimes.insert(name, runtime);
238 Ok(())
239 }
240
241 pub fn get_runtime(&self, name: &str) -> Option<&dyn PluginRuntime> {
243 self.runtimes.get(name).map(|r| r.as_ref())
244 }
245
246 pub fn get_runtime_mut(&mut self, name: &str) -> Option<&mut Box<dyn PluginRuntime>> {
248 self.runtimes.get_mut(name)
249 }
250
251 pub fn init_runtimes(&mut self) -> PluginResult<()> {
253 for runtime in self.runtimes.values_mut() {
254 runtime.init(&self.config)?;
255 }
256 Ok(())
257 }
258
259 pub async fn discover_plugins(&mut self) -> PluginResult<Vec<LoadedPlugin>> {
261 let plugin_dir = self.config.plugin_dir.clone();
262 if !plugin_dir.exists() {
263 return Ok(vec![]);
264 }
265
266 let mut loaded = vec![];
267
268 let entries = std::fs::read_dir(&plugin_dir).map_err(PluginError::Io)?;
270
271 for entry in entries.flatten() {
272 let path = entry.path();
273 if !path.is_dir() {
274 continue;
275 }
276
277 let toml_path = path.join("plugin.toml");
279 if !toml_path.exists() {
280 continue;
281 }
282
283 let toml_content = std::fs::read_to_string(&toml_path)?;
285 let metadata: PluginMetadata =
286 toml::from_str(&toml_content).map_err(|e| PluginError::ConfigError {
287 message: e.to_string(),
288 })?;
289
290 if let Err(e) = Self::validate_plugin_name(&metadata.name) {
292 tracing::warn!(
293 path = %path.display(),
294 error = %e,
295 "Skipping plugin with invalid name"
296 );
297 continue;
298 }
299
300 let runtime_name = &metadata.runtime;
302 let runtime = self.runtimes.get_mut(runtime_name).ok_or_else(|| {
303 PluginError::RuntimeNotAvailable {
304 runtime: runtime_name.clone(),
305 }
306 })?;
307
308 let entry_file = path.join(&metadata.entry);
310 let canonical_plugin_dir = match std::fs::canonicalize(&path) {
311 Ok(c) => c,
312 Err(_) => continue,
313 };
314 let canonical_entry = match std::fs::canonicalize(&entry_file) {
315 Ok(c) => c,
316 Err(_) => continue,
317 };
318 if !canonical_entry.starts_with(&canonical_plugin_dir) {
319 tracing::warn!(
320 plugin = %metadata.name,
321 entry = %metadata.entry,
322 "Skipping plugin: entry point escapes plugin directory"
323 );
324 continue;
325 }
326 if !entry_file.exists() {
327 continue;
328 }
329
330 let handle = runtime.load_plugin(&metadata.name, &entry_file)?;
332
333 let hooks: Vec<String> = HOOK_NAMES
335 .iter()
336 .filter(|h| runtime.has_hook(handle, h))
337 .map(|s| s.to_string())
338 .collect();
339
340 let loaded_plugin = LoadedPlugin {
341 handle,
342 id: metadata.name.clone(),
343 metadata,
344 path: path.clone(),
345 hooks,
346 };
347
348 self.plugins.insert(handle, loaded_plugin.clone());
349 loaded.push(loaded_plugin);
350 }
351
352 Ok(loaded)
353 }
354
355 pub fn load_plugin(&mut self, path: &Path) -> PluginResult<LoadedPlugin> {
357 let toml_path = path.join("plugin.toml");
359 let toml_content = std::fs::read_to_string(&toml_path)?;
360 let metadata: PluginMetadata =
361 toml::from_str(&toml_content).map_err(|e| PluginError::ConfigError {
362 message: e.to_string(),
363 })?;
364
365 Self::validate_plugin_name(&metadata.name)?;
367
368 let runtime = self.runtimes.get_mut(&metadata.runtime).ok_or_else(|| {
370 PluginError::RuntimeNotAvailable {
371 runtime: metadata.runtime.clone(),
372 }
373 })?;
374
375 let entry_file = path.join(&metadata.entry);
377 let canonical_plugin_dir = std::fs::canonicalize(path).map_err(PluginError::Io)?;
378 let canonical_entry = std::fs::canonicalize(&entry_file).map_err(PluginError::Io)?;
379 if !canonical_entry.starts_with(&canonical_plugin_dir) {
380 return Err(PluginError::ConfigError {
381 message: format!(
382 "Plugin '{}': entry point '{}' escapes the plugin directory",
383 metadata.name, metadata.entry
384 ),
385 });
386 }
387
388 let handle = runtime.load_plugin(&metadata.name, &entry_file)?;
390
391 let hooks: Vec<String> = HOOK_NAMES
393 .iter()
394 .filter(|h| runtime.has_hook(handle, h))
395 .map(|s| s.to_string())
396 .collect();
397
398 let loaded_plugin = LoadedPlugin {
399 handle,
400 id: metadata.name.clone(),
401 metadata,
402 path: path.to_path_buf(),
403 hooks,
404 };
405
406 self.plugins.insert(handle, loaded_plugin.clone());
407 Ok(loaded_plugin)
408 }
409
410 pub fn unload_plugin(&mut self, handle: PluginHandle) -> PluginResult<()> {
412 if let Some(plugin) = self.plugins.shift_remove(&handle)
413 && let Some(runtime) = self.runtimes.get_mut(&plugin.metadata.runtime)
414 {
415 runtime.unload_plugin(handle)?;
416 }
417 Ok(())
418 }
419
420 pub async fn dispatch_hook(&self, hook: &Hook, ctx: &HookContext) -> Vec<HookResult> {
422 let hook_name = hook.name();
423 let mut results = vec![];
424
425 for (handle, plugin) in &self.plugins {
426 if !plugin.hooks.contains(&hook_name.to_string()) {
427 continue;
428 }
429
430 if let Some(runtime) = self.runtimes.get(&plugin.metadata.runtime) {
431 let result = if hook.is_sync() {
432 runtime.call_hook_sync(*handle, hook, ctx)
433 } else {
434 runtime.call_hook_async(*handle, hook, ctx).await
435 };
436
437 match result {
438 Ok(r) => {
439 results.push(r.clone());
440 if r.stop_propagation {
441 break;
442 }
443 }
444 Err(e) => {
445 tracing::warn!(
447 plugin_id = %plugin.id,
448 error = %e,
449 "Plugin hook dispatch error"
450 );
451 }
452 }
453 }
454 }
455
456 results
457 }
458
459 pub fn plugins(&self) -> impl Iterator<Item = &LoadedPlugin> {
461 self.plugins.values()
462 }
463
464 pub fn get_plugin(&self, handle: PluginHandle) -> Option<&LoadedPlugin> {
466 self.plugins.get(&handle)
467 }
468
469 pub fn plugins_of_kind(&self, kind: PluginKind) -> impl Iterator<Item = &LoadedPlugin> {
471 self.plugins
472 .values()
473 .filter(move |p| p.metadata.kind == kind)
474 }
475
476 pub fn shutdown(&mut self) -> PluginResult<()> {
478 for runtime in self.runtimes.values_mut() {
479 runtime.shutdown()?;
480 }
481 self.plugins.clear();
482 Ok(())
483 }
484}
485
486impl Default for PluginManager {
487 fn default() -> Self {
488 Self::new(PluginConfig::default())
489 }
490}