//! Database file watcher for detecting external changes. //! //! Watches the SQLite database file for modifications made by external //! processes and emits Tauri events to trigger UI refreshes. use notify::RecursiveMode; use notify_debouncer_mini::new_debouncer; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use tauri::{Emitter, Manager}; use tracing::{debug, error, info, warn}; /// Debounce duration for file change events (milliseconds) const DEBOUNCE_MS: u64 = 500; /// Minimum interval between emitted events (milliseconds) /// Prevents rapid-fire refreshes even after debouncing const MIN_EVENT_INTERVAL_MS: u64 = 1000; /// Starts the database file watcher. /// /// Watches the SQLite database file and its WAL/SHM files for changes. /// When changes are detected, emits a `db:external-change` event to the frontend. pub fn start_db_watcher(app: tauri::AppHandle, shutdown: Arc) { // Get the database path let app_data_dir = match app.path().app_data_dir() { Ok(dir) => dir, Err(e) => { error!("Failed to get app data dir for db watcher: {}", e); return; } }; let db_path = app_data_dir.join("goingson.db"); if !db_path.exists() { warn!(?db_path, "Database file does not exist yet, watcher will start when it's created"); } info!(?db_path, "Starting database file watcher"); // Track last event time to prevent rapid-fire refreshes let last_event_time = Arc::new(AtomicU64::new(0)); let last_event_time_clone = last_event_time.clone(); // Track whether a trailing event is pending (for changes that arrive too soon) let pending_trailing = Arc::new(AtomicBool::new(false)); let pending_trailing_clone = pending_trailing.clone(); // Create a debounced watcher let app_handle = app.clone(); let trailing_app_handle = app.clone(); let trailing_last_event_time = last_event_time.clone(); let trailing_pending = pending_trailing.clone(); // Trailing edge timer: emits a final event after MIN_EVENT_INTERVAL_MS // if any changes were dropped during rate-limiting let trailing_shutdown = shutdown.clone(); std::thread::spawn(move || { loop { std::thread::sleep(Duration::from_millis(MIN_EVENT_INTERVAL_MS)); if trailing_shutdown.load(Ordering::Relaxed) { info!("Trailing timer shutting down"); break; } if trailing_pending.swap(false, Ordering::Relaxed) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); trailing_last_event_time.store(now, Ordering::Relaxed); debug!("Emitting trailing db:external-change event"); if let Err(e) = trailing_app_handle.emit("db:external-change", ()) { warn!("Failed to emit trailing db change event: {}", e); } } } }); let watcher_shutdown = shutdown; std::thread::spawn(move || { let (tx, rx) = std::sync::mpsc::channel(); let mut debouncer = match new_debouncer(Duration::from_millis(DEBOUNCE_MS), tx) { Ok(d) => d, Err(e) => { error!("Failed to create file watcher: {}", e); return; } }; // Watch the app data directory (contains db, wal, shm files) if let Err(e) = debouncer.watcher().watch(&app_data_dir, RecursiveMode::NonRecursive) { error!(?app_data_dir, "Failed to watch directory: {}", e); return; } info!(?app_data_dir, "Database watcher started"); // Process file change events with shutdown checks loop { if watcher_shutdown.load(Ordering::Relaxed) { info!("Database watcher shutting down"); break; } match rx.recv_timeout(Duration::from_secs(1)) { Ok(Ok(events)) => { // Check if any event is for our database files let db_changed = events.iter().any(|event| { is_db_file(&event.path, &db_path) }); if db_changed { // Check if enough time has passed since last event let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let last = last_event_time_clone.load(Ordering::Relaxed); if now - last >= MIN_EVENT_INTERVAL_MS { last_event_time_clone.store(now, Ordering::Relaxed); debug!("Database change detected, emitting db:external-change event"); // Emit event to frontend if let Err(e) = app_handle.emit("db:external-change", ()) { warn!("Failed to emit db change event: {}", e); } } else { // Mark that a trailing event should fire pending_trailing_clone.store(true, Ordering::Relaxed); debug!("Rate-limited db change event, queued trailing emit"); } } } Ok(Err(e)) => { warn!("File watcher error: {:?}", e); } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { // No events, loop back to check shutdown flag continue; } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { warn!("File watcher channel disconnected"); break; } } } }); } /// Check if a path is one of the SQLite database files fn is_db_file(path: &Path, db_path: &Path) -> bool { let db_name = db_path.file_name().and_then(|n| n.to_str()).unwrap_or("goingson.db"); if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { // Match main db file and WAL/SHM/journal files file_name == db_name || file_name == format!("{}-wal", db_name) || file_name == format!("{}-shm", db_name) || file_name == format!("{}-journal", db_name) } else { false } } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; #[test] fn test_is_db_file_matches_main_db() { let db_path = PathBuf::from("/data/goingson.db"); let test_path = PathBuf::from("/data/goingson.db"); assert!(is_db_file(&test_path, &db_path)); } #[test] fn test_is_db_file_matches_wal() { let db_path = PathBuf::from("/data/goingson.db"); let test_path = PathBuf::from("/data/goingson.db-wal"); assert!(is_db_file(&test_path, &db_path)); } #[test] fn test_is_db_file_matches_shm() { let db_path = PathBuf::from("/data/goingson.db"); let test_path = PathBuf::from("/data/goingson.db-shm"); assert!(is_db_file(&test_path, &db_path)); } #[test] fn test_is_db_file_matches_journal() { let db_path = PathBuf::from("/data/goingson.db"); let test_path = PathBuf::from("/data/goingson.db-journal"); assert!(is_db_file(&test_path, &db_path)); } #[test] fn test_is_db_file_ignores_other_files() { let db_path = PathBuf::from("/data/goingson.db"); let test_path = PathBuf::from("/data/other.db"); assert!(!is_db_file(&test_path, &db_path)); } #[test] fn test_is_db_file_ignores_backup_files() { let db_path = PathBuf::from("/data/goingson.db"); let test_path = PathBuf::from("/data/goingson.db.backup"); assert!(!is_db_file(&test_path, &db_path)); } }