feat: add filesystem watcher for automatic user plugin hot-reload
Watch ~/.config/owlry/plugins/ for changes using notify-debouncer-mini (500ms debounce) and trigger a full runtime reload on file modifications. Respects OWLRY_SKIP_RUNTIMES=1 to skip watcher in tests.
This commit is contained in:
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
96
crates/owlry-core/src/plugins/watcher.rs
Normal file
96
crates/owlry-core/src/plugins/watcher.rs
Normal file
@@ -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<RwLock<ProviderManager>>) {
|
||||
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<RwLock<ProviderManager>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user