Skip to main content

audiorouter_core/
monitor.rs

1//! Shared monitoring primitives for device connectivity and config file changes.
2//!
3//! Both the TUI main loop (sync, crossterm event loop) and the dashboard API
4//! (async, tokio) need to detect when audio devices appear/disappear and when
5//! the config file is edited on disk. This module provides the two polling /
6//! watching primitives they share, so neither consumer re-implements the logic.
7//!
8//! Both types are synchronous and runtime-agnostic:
9//!
10//! - [`DevicePoller`] wraps a rate-limited CPAL enumeration loop and exposes a
11//!   simple `poll() -> Option<Vec<String>>` interface.
12//! - [`ConfigFileWatcher`] spawns an OS-native file watcher thread (`notify`)
13//!   and exposes `poll() -> bool`, matching the existing TUI contract.
14
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::time::{Duration, Instant};
20
21use crate::device_inventory::{DevicesResponse, device_diff, list_audio_devices};
22
23// ── DevicePoller ──────────────────────────────────────────────────────────
24
25/// Polls the system audio device inventory at a fixed interval and reports
26/// connectivity changes as human-readable event strings.
27///
28/// Created once and polled repeatedly. The first [`DevicePoller::poll`] after
29/// `interval` has elapsed performs the expensive CPAL enumeration; calls
30/// before that return `None` immediately.
31///
32/// **Consumers:**
33/// - TUI: call `poll()` every tick (50 ms); the poller internally rate-limits.
34/// - Dashboard API: call `poll()` in a `tokio::time::interval` task.
35pub struct DevicePoller {
36    interval: Duration,
37    last_poll: Instant,
38    prev: DevicesResponse,
39}
40
41impl DevicePoller {
42    /// Create a poller with the given minimum interval between CPAL queries.
43    ///
44    /// On creation, immediately takes a baseline snapshot so the first `poll()`
45    /// only reports changes that happen *after* construction.
46    pub fn new(interval: Duration) -> Self {
47        let prev = list_audio_devices().unwrap_or_default();
48        Self {
49            interval,
50            last_poll: Instant::now(),
51            prev,
52        }
53    }
54
55    /// Check for device changes. Returns `Some(events)` if devices were
56    /// added, removed, or changed since the last check (subject to `interval`
57    /// rate-limiting), or `None` if nothing changed or the interval hasn't
58    /// elapsed yet.
59    pub fn poll(&mut self) -> Option<Vec<String>> {
60        if self.last_poll.elapsed() < self.interval {
61            return None;
62        }
63        self.last_poll = Instant::now();
64        let curr = list_audio_devices().ok()?;
65        let events = device_diff(&self.prev, &curr);
66        if events.is_empty() {
67            return None;
68        }
69        self.prev = curr;
70        Some(events)
71    }
72
73    /// The most recent device inventory snapshot (cached, does not query CPAL).
74    pub fn snapshot(&self) -> &DevicesResponse {
75        &self.prev
76    }
77}
78
79// ── ConfigFileWatcher ─────────────────────────────────────────────────────
80
81/// Watches the config file for changes using OS-native file notifications.
82///
83/// Spawns a background thread on construction. When a change is detected,
84/// sets an internal flag that the main loop can consume via [`poll`](Self::poll).
85///
86/// **Consumers:**
87/// - TUI: `poll()` in the main event loop to trigger hot-reload.
88/// - Dashboard API (future): `poll()` in a loop to emit `ConfigChanged` SSE.
89pub struct ConfigFileWatcher {
90    config_changed: Arc<AtomicBool>,
91}
92
93impl ConfigFileWatcher {
94    /// Start watching `config_path`. Returns a watcher handle.
95    ///
96    /// If the OS file watcher fails to initialise (e.g. sandbox restrictions),
97    /// the watcher is silently disabled — `poll()` will always return `false`.
98    pub fn new(config_path: &Path) -> Self {
99        let config_changed = Arc::new(AtomicBool::new(false));
100        let flag = config_changed.clone();
101        let watch_path = config_path.to_path_buf();
102
103        std::thread::spawn(move || {
104            use notify::{EventKind, RecursiveMode, Watcher};
105
106            let (tx, rx) = std::sync::mpsc::channel();
107            let mut watcher = match notify::recommended_watcher(tx) {
108                Ok(w) => w,
109                Err(e) => {
110                    tracing::warn!("config watch disabled: {e}");
111                    return;
112                }
113            };
114
115            let canonical_watch_path = std::fs::canonicalize(&watch_path).ok();
116
117            for watch_dir in config_watch_dirs(&watch_path, canonical_watch_path.as_deref()) {
118                if let Err(e) = watcher.watch(&watch_dir, RecursiveMode::NonRecursive) {
119                    tracing::warn!("config watch disabled: {e}");
120                    return;
121                }
122            }
123
124            for event in rx.into_iter().flatten() {
125                let is_config_event = config_event_matches(
126                    &event.paths,
127                    &watch_path,
128                    canonical_watch_path.as_deref(),
129                );
130                if !is_config_event {
131                    continue;
132                }
133                if matches!(
134                    event.kind,
135                    EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
136                ) {
137                    flag.store(true, Ordering::SeqCst);
138                }
139            }
140        });
141
142        Self { config_changed }
143    }
144
145    /// Check (and consume) the config-changed flag.
146    pub fn poll(&self) -> bool {
147        self.config_changed.swap(false, Ordering::SeqCst)
148    }
149}
150
151// ── Path helpers (moved from audio.rs) ────────────────────────────────────
152
153/// Collect directories to watch for the config file.
154///
155/// We watch the parent directory of the config path (and its canonical
156/// equivalent when the path is a symlink) because FSEvents/inotify report
157/// changes on directory entries, not on file descriptors.
158fn config_watch_dirs(watch_path: &Path, canonical_watch_path: Option<&Path>) -> Vec<PathBuf> {
159    let mut dirs = Vec::new();
160    push_unique_path(
161        &mut dirs,
162        watch_path.parent().unwrap_or(Path::new(".")).to_path_buf(),
163    );
164    if let Some(canonical_watch_path) = canonical_watch_path {
165        push_unique_path(
166            &mut dirs,
167            canonical_watch_path
168                .parent()
169                .unwrap_or(Path::new("."))
170                .to_path_buf(),
171        );
172    }
173    // Remove duplicates (symlink dir may equal real dir).
174    let mut seen: HashSet<PathBuf> = HashSet::new();
175    dirs.retain(|d| seen.insert(d.clone()));
176    dirs
177}
178
179fn config_event_matches(
180    event_paths: &[PathBuf],
181    watch_path: &Path,
182    canonical_watch_path: Option<&Path>,
183) -> bool {
184    event_paths
185        .iter()
186        .any(|p| p == watch_path || canonical_watch_path.is_some_and(|canonical| p == canonical))
187}
188
189fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
190    if !paths.iter().any(|existing| existing == &path) {
191        paths.push(path);
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn config_symlink_watches_and_matches_real_target_file() {
201        let link_path = PathBuf::from("/tmp/audiorouter-test/link/config.toml");
202        let target_path = PathBuf::from("/tmp/audiorouter-test/real/config.toml");
203        let dirs = config_watch_dirs(&link_path, Some(&target_path));
204        assert!(dirs.iter().any(|d| d.ends_with("link")));
205        assert!(dirs.iter().any(|d| d.ends_with("real")));
206    }
207
208    #[test]
209    fn config_event_matches_direct_and_canonical() {
210        let watch = Path::new("/tmp/audiorouter-test/config.toml");
211        let canonical = Path::new("/tmp/audiorouter-test/real/config.toml");
212
213        assert!(config_event_matches(&[watch.to_path_buf()], watch, None));
214        assert!(config_event_matches(
215            &[canonical.to_path_buf()],
216            watch,
217            Some(canonical)
218        ));
219        assert!(!config_event_matches(
220            &[PathBuf::from("/tmp/other.toml")],
221            watch,
222            Some(canonical)
223        ));
224    }
225}