//! Lightweight meeting-process detection. //! //! Scope (per Jake's ideology note): single signal only — poll the process //! list and match user-editable patterns. No mic-activity heuristic, no //! calendar integration. If the user opts in, we surface a non-modal toast //! so they can decide to start recording. We never start recording //! ourselves from this signal. use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System}; /// Reusable wrapper around a `sysinfo::System` whose process table is /// refreshed in place on every poll, instead of allocating a fresh one. /// /// On a busy host (~300 processes), `System::new_with_specifics` followed by /// `refresh_processes` walks `/proc` cold and costs ~50–100 ms; reusing the /// same instance reuses sysinfo's per-process bookkeeping so subsequent /// refreshes are dominated by diffing rather than allocation. The Tauri /// host holds one of these behind a `Mutex` for the meeting-detection /// command to call every 15 s. pub struct ProcessLister { system: System, } impl Default for ProcessLister { fn default() -> Self { Self::new() } } impl ProcessLister { pub fn new() -> Self { Self { system: System::new_with_specifics( RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()), ), } } /// Refresh the process table in place and return the current /// lowercased executable names. pub fn snapshot(&mut self) -> Vec { self.system .refresh_processes(ProcessesToUpdate::All, true); self.system .processes() .values() .map(|process| process.name().to_string_lossy().to_lowercase()) .collect() } } /// Snapshot the current process list's executable/command names. Lowercased /// for case-insensitive pattern matching. /// /// Convenience wrapper that allocates a fresh `ProcessLister` per call. /// Hot paths (the meeting-detection poller) should hold a long-lived /// `ProcessLister` and call `snapshot()` directly to avoid the per-call /// allocation of `System`'s internal bookkeeping. pub fn list_running_process_names() -> Vec { ProcessLister::new().snapshot() } /// Match a snapshot of process names against case-insensitive substring /// `patterns`. Returns the set of patterns that matched at least once, in /// input order, deduped. Empty / whitespace-only patterns are skipped so /// a stray blank entry in the user's list never matches everything. pub fn match_meeting_patterns(process_names: &[String], patterns: &[String]) -> Vec { let mut matches: Vec = Vec::new(); for raw_pattern in patterns { let needle = raw_pattern.trim().to_lowercase(); if needle.is_empty() { continue; } if process_names.iter().any(|name| name.contains(&needle)) && !matches.iter().any(|existing| existing == &needle) { matches.push(needle); } } matches } #[cfg(test)] mod tests { use super::*; #[test] fn matches_are_case_insensitive_substrings() { let processes = vec![ "Zoom Meeting".to_lowercase(), "firefox".to_lowercase(), "Microsoft Teams".to_lowercase(), ]; let patterns = vec!["ZOOM".into(), "teams".into(), "discord".into()]; let got = match_meeting_patterns(&processes, &patterns); assert_eq!(got, vec!["zoom", "teams"]); } #[test] fn empty_and_whitespace_patterns_are_ignored() { let processes = vec!["anything".to_lowercase()]; let patterns = vec!["".into(), " ".into()]; assert!(match_meeting_patterns(&processes, &patterns).is_empty()); } #[test] fn matches_are_deduped() { let processes = vec!["zoomclient".into(), "zoomhelper".into()]; let patterns = vec!["zoom".into(), "zoom".into()]; assert_eq!(match_meeting_patterns(&processes, &patterns), vec!["zoom"]); } #[test] fn list_running_returns_something_on_this_host() { // Smoke check — this is the test host and always has running procs. let names = list_running_process_names(); assert!(!names.is_empty(), "expected at least one running process"); } }