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:
2026-03-26 17:48:15 +01:00
parent e2939e266c
commit ab2d3cfe55
5 changed files with 167 additions and 3 deletions

View File

@@ -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"] }

View File

@@ -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")]

View 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));
}
}
}
}

View File

@@ -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

View File

@@ -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 {