`detect_meeting_processes` is called every 15 s when meeting-auto-capture is enabled. The previous `list_running_process_names` allocated a fresh `sysinfo::System` per call and walked /proc cold; on a busy host (~300 processes) that's ~50–100 ms of work, every poll, forever. Add `kon_core::process_watch::ProcessLister`, a thin wrapper around a long-lived `System` whose process table is refreshed in place. The Tauri host holds one behind a `Mutex<ProcessLister>` in a new `MeetingState` managed at app setup. The free `list_running_process_names` is kept as a convenience that constructs a fresh `ProcessLister` per call — its only remaining caller is the existing smoke test. - ProcessLister + Default in crates/core/src/process_watch.rs. - MeetingState in src-tauri/src/commands/meeting.rs; the command takes it via `tauri::State` and locks for the duration of the snapshot. - src-tauri/src/lib.rs registers MeetingState alongside the other managed states. https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
124 lines
4.2 KiB
Rust
124 lines
4.2 KiB
Rust
//! 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<String> {
|
||
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<String> {
|
||
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<String> {
|
||
let mut matches: Vec<String> = 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");
|
||
}
|
||
}
|