From ba0d59f5634b87bb6841c46446ccdb20ecd57c72 Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 21 Apr 2026 07:54:55 +0100 Subject: [PATCH] feat(meeting): opt-in process-list reminder when a meeting app starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/core/src/lib.rs | 1 + crates/core/src/process_watch.rs | 85 +++++++++++++++++++++++++++++++ src-tauri/src/commands/meeting.rs | 17 +++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 2 + src/lib/pages/SettingsPage.svelte | 22 ++++++++ src/lib/stores/page.svelte.ts | 2 + src/lib/types/app.ts | 2 + src/routes/+layout.svelte | 32 ++++++++++++ 9 files changed, 164 insertions(+) create mode 100644 crates/core/src/process_watch.rs create mode 100644 src-tauri/src/commands/meeting.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 953fc14..c312a18 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,6 +2,7 @@ pub mod constants; pub mod error; pub mod hardware; pub mod model_registry; +pub mod process_watch; pub mod providers; pub mod recommendation; pub mod types; diff --git a/crates/core/src/process_watch.rs b/crates/core/src/process_watch.rs new file mode 100644 index 0000000..f344735 --- /dev/null +++ b/crates/core/src/process_watch.rs @@ -0,0 +1,85 @@ +//! 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 { + 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 { + 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)) { + 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"); + } +} diff --git a/src-tauri/src/commands/meeting.rs b/src-tauri/src/commands/meeting.rs new file mode 100644 index 0000000..a73d22e --- /dev/null +++ b/src-tauri/src/commands/meeting.rs @@ -0,0 +1,17 @@ +//! Meeting auto-capture — the single-signal variant. +//! +//! The frontend polls `detect_meeting_processes` on an interval with the +//! user's app patterns. On a positive hit it surfaces a non-modal toast +//! that reminds the user to start recording with their hotkey. We do not +//! start recording from this signal — the user decides. + +use kon_core::process_watch; + +#[tauri::command] +pub fn detect_meeting_processes(patterns: Vec) -> Result, String> { + if patterns.is_empty() { + return Ok(Vec::new()); + } + let processes = process_watch::list_running_process_names(); + Ok(process_watch::match_meeting_patterns(&processes, &patterns)) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 206048b..4f4d842 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod hardware; pub mod hotkey; pub mod live; pub mod llm; +pub mod meeting; pub mod models; pub mod paste; pub mod profiles; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9021a5c..332fc25 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -310,6 +310,8 @@ pub fn run() { // Paste (auto-insert at cursor) commands::paste::paste_text, commands::paste::detect_paste_backends, + // Meeting auto-capture (process-list poll) + commands::meeting::detect_meeting_processes, // Hardware commands::hardware::probe_system, commands::hardware::rank_models, diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 045561d..951878c 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -1518,6 +1518,28 @@ label="Auto-paste into focused window" description={pasteBackendsDescription} /> + + {#if settings.meetingAutoCapture} +
+

Apps to watch (comma-separated)

+ { + const raw = event.currentTarget.value; + settings.meetingAutoCaptureApps = raw + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0); + }} + /> +
+ {/if} { /* update check failure must not affect the app */ }); + + // Meeting auto-capture: poll the process list and toast when a match + // appears (edge-triggered — no repeat toasts until the app goes away + // and comes back). We never start recording from this signal; the + // user decides whether to hit the hotkey. + if (tauriRuntimeAvailable) { + let previous: Set = new Set(); + meetingCapturePoller = window.setInterval(async () => { + if (!settings.meetingAutoCapture) { previous = new Set(); return; } + const patterns = settings.meetingAutoCaptureApps; + if (!Array.isArray(patterns) || patterns.length === 0) return; + try { + const matches: string[] = await invoke("detect_meeting_processes", { patterns }); + const current = new Set(matches); + for (const match of matches) { + if (!previous.has(match)) { + toasts.info( + `${match[0].toUpperCase()}${match.slice(1)} detected`, + `Press ${settings.globalHotkey} to start recording.`, + ); + } + } + previous = current; + } catch { /* ignore — backend may be mid-restart */ } + }, 15000); + } }); + let meetingCapturePoller: number | null = null; + onDestroy(() => { window.removeEventListener("resize", handleResize); if (onWindowError) window.removeEventListener("error", onWindowError); if (onUnhandledRejection) window.removeEventListener("unhandledrejection", onUnhandledRejection); + if (meetingCapturePoller !== null) { + window.clearInterval(meetingCapturePoller); + meetingCapturePoller = null; + } if (!tauriRuntimeAvailable) { return; }