Default off. When on, the layout polls detect_meeting_processes every 15s
with the user's app-name patterns. On a fresh match (edge-triggered — no
re-toast until the app goes away and comes back) we fire a reminder toast
that tells the user which meeting app appeared and their global hotkey. We
never start recording on this signal; the ideology rule says the user
decides. The signal is a single channel: process list match only — no mic
activity heuristic, no calendar.
Backend adds kon_core::process_watch::{list_running_process_names,
match_meeting_patterns} over sysinfo, exposed to the frontend as the
detect_meeting_processes Tauri command.
Settings ships two new fields — meetingAutoCapture (bool) and
meetingAutoCaptureApps (string[]) — with a comma-separated input in the
Output section. Default app list is ["zoom", "teams"], user-editable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
86 lines
3.0 KiB
Rust
86 lines
3.0 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};
|
|
|
|
/// Snapshot the current process list's executable/command names. Lowercased
|
|
/// for case-insensitive pattern matching.
|
|
pub fn list_running_process_names() -> Vec<String> {
|
|
let mut system = System::new_with_specifics(
|
|
RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
|
|
);
|
|
system.refresh_processes(ProcessesToUpdate::All, true);
|
|
system
|
|
.processes()
|
|
.values()
|
|
.map(|process| process.name().to_string_lossy().to_lowercase())
|
|
.collect()
|
|
}
|
|
|
|
/// 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)) {
|
|
if !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");
|
|
}
|
|
}
|