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
|
# Error handling
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
||||||
|
# Filesystem watching (plugin hot-reload)
|
||||||
|
notify = "7"
|
||||||
|
notify-debouncer-mini = "0.5"
|
||||||
|
|
||||||
# Signal handling
|
# Signal handling
|
||||||
ctrlc = { version = "3", features = ["termination"] }
|
ctrlc = { version = "3", features = ["termination"] }
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub mod manifest;
|
|||||||
pub mod native_loader;
|
pub mod native_loader;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod runtime_loader;
|
pub mod runtime_loader;
|
||||||
|
pub mod watcher;
|
||||||
|
|
||||||
// Lua-specific modules (require mlua)
|
// Lua-specific modules (require mlua)
|
||||||
#[cfg(feature = "lua")]
|
#[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 owlry_version = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
let skip_runtimes = std::env::var("OWLRY_SKIP_RUNTIMES").is_ok();
|
let skip_runtimes = std::env::var("OWLRY_SKIP_RUNTIMES").is_ok();
|
||||||
if !skip_runtimes {
|
if !skip_runtimes
|
||||||
if let Some(plugins_dir) = crate::paths::plugins_dir() {
|
&& let Some(plugins_dir) = crate::paths::plugins_dir()
|
||||||
|
{
|
||||||
// Try Lua runtime
|
// Try Lua runtime
|
||||||
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
|
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
|
||||||
Ok(rt) => {
|
Ok(rt) => {
|
||||||
@@ -267,7 +268,6 @@ impl ProviderManager {
|
|||||||
info!("Rune runtime not available: {}", e);
|
info!("Rune runtime not available: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} // skip_runtimes
|
} // skip_runtimes
|
||||||
|
|
||||||
// Merge runtime providers into core providers
|
// Merge runtime providers into core providers
|
||||||
@@ -282,6 +282,66 @@ impl ProviderManager {
|
|||||||
manager
|
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)]
|
#[allow(dead_code)]
|
||||||
pub fn is_dmenu_mode(&self) -> bool {
|
pub fn is_dmenu_mode(&self) -> bool {
|
||||||
self.providers
|
self.providers
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ impl Server {
|
|||||||
|
|
||||||
/// Accept connections in a loop, spawning a thread per client.
|
/// Accept connections in a loop, spawning a thread per client.
|
||||||
pub fn run(&self) -> io::Result<()> {
|
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");
|
info!("Server entering accept loop");
|
||||||
for stream in self.listener.incoming() {
|
for stream in self.listener.incoming() {
|
||||||
match stream {
|
match stream {
|
||||||
|
|||||||
Reference in New Issue
Block a user