diff --git a/crates/owlry-core/Cargo.toml b/crates/owlry-core/Cargo.toml index 6223798..ea9e878 100644 --- a/crates/owlry-core/Cargo.toml +++ b/crates/owlry-core/Cargo.toml @@ -36,6 +36,10 @@ dirs = "5" # Error handling thiserror = "2" +# Filesystem watching (plugin hot-reload) +notify = "7" +notify-debouncer-mini = "0.5" + # Signal handling ctrlc = { version = "3", features = ["termination"] } diff --git a/crates/owlry-core/src/plugins/mod.rs b/crates/owlry-core/src/plugins/mod.rs index ebaf17f..b7fa684 100644 --- a/crates/owlry-core/src/plugins/mod.rs +++ b/crates/owlry-core/src/plugins/mod.rs @@ -26,6 +26,7 @@ pub mod manifest; pub mod native_loader; pub mod registry; pub mod runtime_loader; +pub mod watcher; // Lua-specific modules (require mlua) #[cfg(feature = "lua")] diff --git a/crates/owlry-core/src/plugins/watcher.rs b/crates/owlry-core/src/plugins/watcher.rs new file mode 100644 index 0000000..15ebadb --- /dev/null +++ b/crates/owlry-core/src/plugins/watcher.rs @@ -0,0 +1,96 @@ +//! Filesystem watcher for user plugin hot-reload +//! +//! Watches `~/.config/owlry/plugins/` for changes and triggers +//! runtime reload when plugin files are modified. + +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use std::thread; +use std::time::Duration; + +use log::{info, warn}; +use notify_debouncer_mini::{new_debouncer, DebouncedEventKind}; + +use crate::providers::ProviderManager; + +/// Start watching the user plugins directory for changes. +/// +/// Spawns a background thread that monitors the directory and triggers +/// a full runtime reload on any file change. Returns immediately. +/// +/// Respects `OWLRY_SKIP_RUNTIMES=1` — returns early if set. +pub fn start_watching(pm: Arc>) { + if std::env::var("OWLRY_SKIP_RUNTIMES").is_ok() { + info!("OWLRY_SKIP_RUNTIMES set, skipping file watcher"); + return; + } + + let plugins_dir = match crate::paths::plugins_dir() { + Some(d) => d, + None => { + info!("No plugins directory configured, skipping file watcher"); + return; + } + }; + + if !plugins_dir.exists() + && std::fs::create_dir_all(&plugins_dir).is_err() + { + warn!( + "Failed to create plugins directory: {}", + plugins_dir.display() + ); + return; + } + + info!( + "Plugin file watcher started for {}", + plugins_dir.display() + ); + + thread::spawn(move || { + if let Err(e) = watch_loop(&plugins_dir, &pm) { + warn!("Plugin watcher stopped: {}", e); + } + }); +} + +fn watch_loop( + plugins_dir: &PathBuf, + pm: &Arc>, +) -> Result<(), Box> { + let (tx, rx) = std::sync::mpsc::channel(); + + let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?; + + debouncer + .watcher() + .watch(plugins_dir.as_ref(), notify::RecursiveMode::Recursive)?; + + info!("Watching {} for plugin changes", plugins_dir.display()); + + loop { + match rx.recv() { + Ok(Ok(events)) => { + let has_relevant_change = events.iter().any(|e| { + matches!( + e.kind, + DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous + ) + }); + + if has_relevant_change { + info!("Plugin file change detected, reloading runtimes..."); + let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner()); + pm_guard.reload_runtimes(); + } + } + Ok(Err(error)) => { + warn!("File watcher error: {}", error); + } + Err(e) => { + return Err(Box::new(e)); + } + } + } +} diff --git a/crates/owlry-core/src/providers/mod.rs b/crates/owlry-core/src/providers/mod.rs index 75424b4..11dc66e 100644 --- a/crates/owlry-core/src/providers/mod.rs +++ b/crates/owlry-core/src/providers/mod.rs @@ -234,8 +234,9 @@ impl ProviderManager { let owlry_version = env!("CARGO_PKG_VERSION"); let skip_runtimes = std::env::var("OWLRY_SKIP_RUNTIMES").is_ok(); - if !skip_runtimes { - if let Some(plugins_dir) = crate::paths::plugins_dir() { + if !skip_runtimes + && let Some(plugins_dir) = crate::paths::plugins_dir() + { // Try Lua runtime match LoadedRuntime::load_lua(&plugins_dir, owlry_version) { Ok(rt) => { @@ -267,7 +268,6 @@ impl ProviderManager { info!("Rune runtime not available: {}", e); } } - } } // skip_runtimes // Merge runtime providers into core providers @@ -282,6 +282,66 @@ impl ProviderManager { manager } + /// Reload all script runtime providers (called by filesystem watcher) + pub fn reload_runtimes(&mut self) { + use crate::plugins::runtime_loader::LoadedRuntime; + + // Remove old runtime providers from the core providers list + self.providers.retain(|p| { + let type_str = format!("{}", p.provider_type()); + !self.runtime_type_ids.contains(&type_str) + }); + + // Drop old runtimes + self.runtimes.clear(); + self.runtime_type_ids.clear(); + + let owlry_version = env!("CARGO_PKG_VERSION"); + let plugins_dir = match crate::paths::plugins_dir() { + Some(d) => d, + None => return, + }; + + // Reload Lua runtime + match LoadedRuntime::load_lua(&plugins_dir, owlry_version) { + Ok(rt) => { + info!("Reloaded Lua runtime with {} provider(s)", rt.providers().len()); + for provider in rt.create_providers() { + let type_id = format!("{}", provider.provider_type()); + self.runtime_type_ids.insert(type_id); + self.providers.push(provider); + } + self.runtimes.push(rt); + } + Err(e) => { + info!("Lua runtime not available on reload: {}", e); + } + } + + // Reload Rune runtime + match LoadedRuntime::load_rune(&plugins_dir, owlry_version) { + Ok(rt) => { + info!("Reloaded Rune runtime with {} provider(s)", rt.providers().len()); + for provider in rt.create_providers() { + let type_id = format!("{}", provider.provider_type()); + self.runtime_type_ids.insert(type_id); + self.providers.push(provider); + } + self.runtimes.push(rt); + } + Err(e) => { + info!("Rune runtime not available on reload: {}", e); + } + } + + // Refresh the newly added providers + for provider in &mut self.providers { + provider.refresh(); + } + + info!("Runtime reload complete"); + } + #[allow(dead_code)] pub fn is_dmenu_mode(&self) -> bool { self.providers diff --git a/crates/owlry-core/src/server.rs b/crates/owlry-core/src/server.rs index 7f5e105..8bef4f2 100644 --- a/crates/owlry-core/src/server.rs +++ b/crates/owlry-core/src/server.rs @@ -57,6 +57,9 @@ impl Server { /// Accept connections in a loop, spawning a thread per client. pub fn run(&self) -> io::Result<()> { + // Start filesystem watcher for user plugin hot-reload + crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager)); + info!("Server entering accept loop"); for stream in self.listener.incoming() { match stream {