//! Local game directory monitor. use std::{ collections::HashSet, path::{Component, Path, PathBuf}, sync::Arc, time::Duration, }; use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; use tokio::sync::{RwLock, mpsc::UnboundedSender}; use crate::{ PeerEvent, config::LOCAL_GAME_FALLBACK_SCAN_SECS, context::Ctx, handlers::update_and_announce_games, local_games::{ is_ignored_game_root_name, is_local_dir_name, rescan_local_game, scan_local_library, }, }; struct WatchState { watcher: RecommendedWatcher, game_dir: PathBuf, watched: HashSet, } #[derive(Clone, Default)] struct RescanGate { running: Arc>>, pending: Arc>>, } /// Monitors the local game directory for changes. pub async fn run_local_game_monitor( tx_notify_ui: UnboundedSender, ctx: Ctx, ) -> eyre::Result<()> { log::info!("Starting notify-based local game directory monitor"); let (watch_tx, mut watch_rx) = tokio::sync::mpsc::unbounded_channel::>(); let mut watch_state = build_watch_state(&ctx, watch_tx.clone()).await; let gate = RescanGate::default(); let mut fallback_interval = tokio::time::interval(Duration::from_secs(LOCAL_GAME_FALLBACK_SCAN_SECS)); loop { tokio::select! { () = ctx.shutdown.cancelled() => return Ok(()), _ = fallback_interval.tick() => { run_fallback_scan(&ctx, &tx_notify_ui).await; reconcile_watch_state(&ctx, &mut watch_state, watch_tx.clone()).await; } Some(event) = watch_rx.recv() => { handle_watch_event( &ctx, &tx_notify_ui, &gate, event, ).await; reconcile_watch_state(&ctx, &mut watch_state, watch_tx.clone()).await; } } } } async fn build_watch_state( ctx: &Ctx, watch_tx: tokio::sync::mpsc::UnboundedSender>, ) -> Option { let game_dir = ctx.game_dir.read().await.clone(); let mut fs_watcher = match RecommendedWatcher::new( move |result| { let _ = watch_tx.send(result); }, Config::default(), ) { Ok(watcher) => watcher, Err(err) => { log::warn!("Filesystem watcher unavailable; falling back to periodic scans: {err}"); return None; } }; let watched_paths = match watch_game_roots(&mut fs_watcher, &game_dir).await { Ok(paths) => paths, Err(err) => { log::warn!( "Failed to initialize filesystem watcher for {}: {err}; falling back to periodic scans", game_dir.display() ); return None; } }; Some(WatchState { watcher: fs_watcher, game_dir, watched: watched_paths, }) } async fn reconcile_watch_state( ctx: &Ctx, watch_state: &mut Option, watch_tx: tokio::sync::mpsc::UnboundedSender>, ) { let current_game_dir = ctx.game_dir.read().await.clone(); if watch_state .as_ref() .is_none_or(|state| state.game_dir != current_game_dir) { *watch_state = build_watch_state(ctx, watch_tx).await; return; } if let Some(state) = watch_state && let Err(err) = reconcile_game_root_watches(state).await { log::warn!( "Failed to reconcile filesystem watches for {}: {err}", state.game_dir.display() ); } } async fn watch_game_roots( watcher: &mut RecommendedWatcher, game_dir: &Path, ) -> eyre::Result> { let mut watched_paths = HashSet::new(); watch_path(watcher, game_dir, &mut watched_paths)?; for root in list_game_roots(game_dir).await? { watch_path(watcher, &root, &mut watched_paths)?; } Ok(watched_paths) } async fn reconcile_game_root_watches(state: &mut WatchState) -> eyre::Result<()> { let desired = { let mut desired = HashSet::from([state.game_dir.clone()]); desired.extend(list_game_roots(&state.game_dir).await?); desired }; let stale_paths = state .watched .difference(&desired) .cloned() .collect::>(); for path in stale_paths { if let Err(err) = state.watcher.unwatch(&path) { log::debug!("Failed to unwatch {}: {err}", path.display()); } state.watched.remove(&path); } let new_paths = desired .difference(&state.watched) .cloned() .collect::>(); for path in new_paths { watch_path(&mut state.watcher, &path, &mut state.watched)?; } Ok(()) } fn watch_path( watcher: &mut RecommendedWatcher, path: &Path, watched_paths: &mut HashSet, ) -> notify::Result<()> { watcher.watch(path, RecursiveMode::NonRecursive)?; watched_paths.insert(path.to_path_buf()); Ok(()) } async fn list_game_roots(game_dir: &Path) -> eyre::Result> { let mut roots = Vec::new(); let mut entries = match tokio::fs::read_dir(game_dir).await { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(roots), Err(err) => return Err(err.into()), }; while let Some(entry) = entries.next_entry().await? { if !entry.file_type().await?.is_dir() { continue; } let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { continue; }; if is_ignored_game_root_name(&name) { continue; } roots.push(entry.path()); } Ok(roots) } async fn handle_watch_event( ctx: &Ctx, tx_notify_ui: &UnboundedSender, gate: &RescanGate, event: notify::Result, ) { let event = match event { Ok(event) => event, Err(err) => { log::warn!("Filesystem watcher event error: {err}"); return; } }; let game_dir = ctx.game_dir.read().await.clone(); let ids = event .paths .iter() .filter_map(|path| game_id_from_event_path(&game_dir, path)) .collect::>(); for id in ids { if ctx.active_operations.read().await.contains_key(&id) { log::debug!("Dropping filesystem event for {id}: operation active"); continue; } queue_rescan(ctx, tx_notify_ui, gate, id).await; } } async fn queue_rescan( ctx: &Ctx, tx_notify_ui: &UnboundedSender, gate: &RescanGate, id: String, ) { { let mut running = gate.running.write().await; if running.contains(&id) { gate.pending.write().await.insert(id); return; } running.insert(id.clone()); } let ctx = ctx.clone(); let tx_notify_ui = tx_notify_ui.clone(); let gate = gate.clone(); ctx.task_tracker.clone().spawn(async move { run_gated_rescan(ctx, tx_notify_ui, gate, id).await; }); } async fn run_gated_rescan( ctx: Ctx, tx_notify_ui: UnboundedSender, gate: RescanGate, id: String, ) { loop { gate.pending.write().await.remove(&id); if ctx.active_operations.read().await.contains_key(&id) { break; } let game_dir = ctx.game_dir.read().await.clone(); let catalog = ctx.catalog.read().await.clone(); match rescan_local_game(&game_dir, &catalog, &id).await { Ok(scan) => update_and_announce_games(&ctx, &tx_notify_ui, scan).await, Err(err) => log::error!("Failed to rescan local game {id}: {err}"), } if !gate.pending.write().await.remove(&id) { break; } } gate.running.write().await.remove(&id); } async fn run_fallback_scan(ctx: &Ctx, tx_notify_ui: &UnboundedSender) { let game_dir = ctx.game_dir.read().await.clone(); let catalog = ctx.catalog.read().await.clone(); match scan_local_library(&game_dir, &catalog).await { Ok(scan) => update_and_announce_games(ctx, tx_notify_ui, scan).await, Err(err) => log::error!("Failed to scan local games directory: {err}"), } } fn game_id_from_event_path(game_dir: &Path, path: &Path) -> Option { let relative = path.strip_prefix(game_dir).ok()?; let mut components = relative.components(); let game_id = component_name(components.next()?)?; if is_ignored_game_root_name(game_id) { return None; } if let Some(second) = components.next().and_then(component_name) && should_ignore_game_child(second) { return None; } Some(game_id.to_string()) } fn component_name(component: Component<'_>) -> Option<&str> { match component { Component::Normal(name) => name.to_str(), _ => None, } } fn should_ignore_game_child(name: &str) -> bool { is_local_dir_name(name) || name.starts_with(".local.") || name.starts_with(".version.ini.") || name == ".lanspread" || name == ".lanspread.json" || name == ".sync" || name == ".softlan_game_installed" } #[cfg(test)] mod tests { use super::{game_id_from_event_path, should_ignore_game_child}; #[test] fn event_paths_map_to_top_level_game_id() { let root = std::path::Path::new("/games"); assert_eq!( game_id_from_event_path(root, std::path::Path::new("/games/aoe2/version.ini")) .as_deref(), Some("aoe2") ); assert_eq!( game_id_from_event_path(root, std::path::Path::new("/games/aoe2/local/save.dat")), None ); assert_eq!( game_id_from_event_path(root, std::path::Path::new("/games/.lanspread/index.json")), None ); } #[test] fn event_ignore_list_covers_reserved_names() { for name in [ "local", ".local.installing", ".local.backup", ".version.ini.tmp", ".version.ini.discarded", ".lanspread", ".lanspread.json", ".sync", ".softlan_game_installed", ] { assert!(should_ignore_game_child(name)); } assert!(!should_ignore_game_child("version.ini")); assert!(!should_ignore_game_child("game.eti")); } }