feat(meeting): opt-in process-list reminder when a meeting app starts
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>
This commit is contained in:
@@ -2,6 +2,7 @@ pub mod constants;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod hardware;
|
pub mod hardware;
|
||||||
pub mod model_registry;
|
pub mod model_registry;
|
||||||
|
pub mod process_watch;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod recommendation;
|
pub mod recommendation;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
85
crates/core/src/process_watch.rs
Normal file
85
crates/core/src/process_watch.rs
Normal file
@@ -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<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");
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src-tauri/src/commands/meeting.rs
Normal file
17
src-tauri/src/commands/meeting.rs
Normal file
@@ -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<String>) -> Result<Vec<String>, 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))
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ pub mod hardware;
|
|||||||
pub mod hotkey;
|
pub mod hotkey;
|
||||||
pub mod live;
|
pub mod live;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
|
pub mod meeting;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod paste;
|
pub mod paste;
|
||||||
pub mod profiles;
|
pub mod profiles;
|
||||||
|
|||||||
@@ -310,6 +310,8 @@ pub fn run() {
|
|||||||
// Paste (auto-insert at cursor)
|
// Paste (auto-insert at cursor)
|
||||||
commands::paste::paste_text,
|
commands::paste::paste_text,
|
||||||
commands::paste::detect_paste_backends,
|
commands::paste::detect_paste_backends,
|
||||||
|
// Meeting auto-capture (process-list poll)
|
||||||
|
commands::meeting::detect_meeting_processes,
|
||||||
// Hardware
|
// Hardware
|
||||||
commands::hardware::probe_system,
|
commands::hardware::probe_system,
|
||||||
commands::hardware::rank_models,
|
commands::hardware::rank_models,
|
||||||
|
|||||||
@@ -1518,6 +1518,28 @@
|
|||||||
label="Auto-paste into focused window"
|
label="Auto-paste into focused window"
|
||||||
description={pasteBackendsDescription}
|
description={pasteBackendsDescription}
|
||||||
/>
|
/>
|
||||||
|
<Toggle
|
||||||
|
bind:checked={settings.meetingAutoCapture}
|
||||||
|
label="Remind me when a meeting starts"
|
||||||
|
description="Toasts when a matching app appears in the process list. You still hit the hotkey — Kon never records on its own."
|
||||||
|
/>
|
||||||
|
{#if settings.meetingAutoCapture}
|
||||||
|
<div class="ml-[50px] mt-2 mb-1 animate-fade-in">
|
||||||
|
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-1.5">Apps to watch (comma-separated)</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full bg-bg-input border border-border-subtle rounded-lg px-3 py-1.5 text-[12px] text-text"
|
||||||
|
value={settings.meetingAutoCaptureApps.join(", ")}
|
||||||
|
oninput={(event) => {
|
||||||
|
const raw = event.currentTarget.value;
|
||||||
|
settings.meetingAutoCaptureApps = raw
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim().toLowerCase())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<Toggle bind:checked={settings.includeTimestamps} label="Include timestamps in exports" />
|
<Toggle bind:checked={settings.includeTimestamps} label="Include timestamps in exports" />
|
||||||
<Toggle
|
<Toggle
|
||||||
bind:checked={settings.saveAudio}
|
bind:checked={settings.saveAudio}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const defaults: SettingsState = {
|
|||||||
britishEnglish: true,
|
britishEnglish: true,
|
||||||
autoCopy: true,
|
autoCopy: true,
|
||||||
autoPaste: false,
|
autoPaste: false,
|
||||||
|
meetingAutoCapture: false,
|
||||||
|
meetingAutoCaptureApps: ["zoom", "teams"],
|
||||||
includeTimestamps: true,
|
includeTimestamps: true,
|
||||||
theme: "Dark",
|
theme: "Dark",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface SettingsState {
|
|||||||
britishEnglish: boolean;
|
britishEnglish: boolean;
|
||||||
autoCopy: boolean;
|
autoCopy: boolean;
|
||||||
autoPaste: boolean;
|
autoPaste: boolean;
|
||||||
|
meetingAutoCapture: boolean;
|
||||||
|
meetingAutoCaptureApps: string[];
|
||||||
includeTimestamps: boolean;
|
includeTimestamps: boolean;
|
||||||
theme: "Dark" | "Light" | "System";
|
theme: "Dark" | "Light" | "System";
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
|
|||||||
@@ -287,12 +287,44 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { /* update check failure must not affect the app */ });
|
.catch(() => { /* 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<string> = 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(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener("resize", handleResize);
|
window.removeEventListener("resize", handleResize);
|
||||||
if (onWindowError) window.removeEventListener("error", onWindowError);
|
if (onWindowError) window.removeEventListener("error", onWindowError);
|
||||||
if (onUnhandledRejection) window.removeEventListener("unhandledrejection", onUnhandledRejection);
|
if (onUnhandledRejection) window.removeEventListener("unhandledrejection", onUnhandledRejection);
|
||||||
|
if (meetingCapturePoller !== null) {
|
||||||
|
window.clearInterval(meetingCapturePoller);
|
||||||
|
meetingCapturePoller = null;
|
||||||
|
}
|
||||||
if (!tauriRuntimeAvailable) {
|
if (!tauriRuntimeAvailable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user