diff --git a/Cargo.lock b/Cargo.lock index 0e035c62b9..bbd5b7f844 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3254,6 +3254,33 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libpulse-binding" +version = "2.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libpulse-sys", + "num-derive", + "num-traits", + "winapi", +] + +[[package]] +name = "libpulse-sys" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61" +dependencies = [ + "libc", + "num-derive", + "num-traits", + "pkg-config", + "winapi", +] + [[package]] name = "libredox" version = "0.1.14" @@ -3494,6 +3521,7 @@ dependencies = [ "futures-util", "infer 0.15.0", "lazy_static", + "libpulse-binding", "log", "memory-stats", "ndarray", @@ -3539,6 +3567,7 @@ dependencies = [ "uuid", "which 6.0.3", "whisper-rs", + "windows 0.58.0", "xz2", "zip 2.4.2", ] @@ -8050,6 +8079,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -8094,6 +8133,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -8104,7 +8156,7 @@ dependencies = [ "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings", + "windows-strings 0.4.2", ] [[package]] @@ -8129,6 +8181,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -8151,6 +8214,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -8193,6 +8267,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -8202,6 +8285,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" diff --git a/docs/brainstorms/2026-04-20-meeting-auto-detect-brainstorm.md b/docs/brainstorms/2026-04-20-meeting-auto-detect-brainstorm.md new file mode 100644 index 0000000000..09a671a4ba --- /dev/null +++ b/docs/brainstorms/2026-04-20-meeting-auto-detect-brainstorm.md @@ -0,0 +1,183 @@ +# Meeting Auto-Detect + Richer Recording Notifications + +**Date:** 2026-04-20 +**Status:** Brainstorm (v2 — expanded after detection research) +**Scope:** Ships independently of calendar integration. No network, no OAuth. Accessibility permission required on macOS; no new permissions on Windows. + +## Problem / Motivation + +The most common failure mode for Meetily users is the "I forgot to press record" moment — the user is already in a call before they remember to capture it. The existing recording-started/stopped notifications (`frontend/src-tauri/src/notifications/`) are confirmations fired *after* the user clicks Record; they don't help at the moment of intent. Those notifications also default to **off** (`settings.rs:83-93`). + +Meetings today come in two flavors: + +- **Native-app meetings** — Zoom, Teams, Webex, Discord, Slack huddles, FaceTime. Detectable by process/bundle ID. Easy. +- **Browser meetings** — Google Meet, Jitsi, Whereby, Around, Teams-in-browser, Zoom-in-browser. These are tabs inside Chrome/Safari/Firefox/Arc. A naive process check sees "Chrome" — useless. + +A meeting detector that only handles native apps will feel broken to the huge share of users whose main tool is Google Meet in Chrome. v1 must cover both. + +## What We're Building + +### 1. Composite signal detector (macOS) + +Every 5s when idle, 1s when a candidate signal is active, collect: + +- **Process signal**: `NSWorkspace.runningApplications` bundle-ID match against a hard-coded list (Zoom, Teams, Webex, Discord, Slack, FaceTime). No permission. Catches native apps. +- **Browser signal** (requires Accessibility): for Chromium family / Safari / Firefox / Arc, query the frontmost app via `AXUIElement`, walk to the active `AXWebArea`, read `kAXURLAttribute`. Match against a URL pattern list: `meet.google.com/*`, `*.zoom.us/j/*`, `teams.microsoft.com/*/meetup*`, `app.jitsi.net/*`, `whereby.com/*`, `around.co/*`, `huddle.app/*`. No Screen Recording required — Accessibility only. +- **Audio-activity signal** (macOS 14.4+, public API): CoreAudio process enumeration reads `kAudioProcessPropertyPID` + `kAudioProcessPropertyIsRunning`/`kAudioProcessPropertyIsRunningInput` to identify which PIDs are currently producing/consuming audio. No permission for enumeration. On macOS 14.3 and earlier, fall back to `AVCaptureDevice.isInUseByAnotherApplication` (weaker signal). + +Fire `MeetingDetected` when either of these is true: + +- `bundle_id ∈ native_meeting_apps` +- `frontmost_app ∈ browsers ∧ active_tab_url_matches_pattern ∧ pid_has_active_audio` + +The AND-gating on the browser path eliminates false positives from "I have a Meet tab open in the background but haven't joined." + +### 2. Composite signal detector (Windows) + +- **Process signal**: `EnumProcesses` against the same app list. No permission. +- **Window-title signal**: `EnumWindows` + `GetWindowText` reads the focused window's title. Chrome's top-level window title equals the active tab title, so this catches browser meetings **when the tab is focused**. No permission. +- **Audio-activity signal**: `IAudioSessionManager2::GetSessionEnumerator` enumerates every PID with an active audio session, plus `AudioMeterInformation` for whether it's currently peaking. No permission. + +Fire `MeetingDetected` when either: + +- `bundle_id ∈ native_meeting_apps` +- `focused_window_title_matches_pattern ∧ pid_has_active_audio_session` + +**Windows v1 gap (accepted):** backgrounded browser meeting tabs are invisible to this approach. We rely on the user bringing the tab to focus, which is what they usually do when actively participating. v2 closes the gap with an optional browser extension (see "What We're NOT Building"). + +### 3. Notification content + +| Event | Title | Body | Action | +|-------|-------|------|--------| +| Meeting detected (native) | "Meeting detected" | "Zoom is active — record this meeting?" | "Start recording" | +| Meeting detected (browser) | "Meeting detected" | "Google Meet in Chrome — record this meeting?" | "Start recording" | +| Recording started (existing) | Unchanged | Unchanged | — | +| Recording stopped (enriched) | "Recording saved" | "42 min, 3 speakers — Open summary" | "Open summary" | + +The "Start recording" action invokes `start_recording` with a `source: "detected"` marker and the platform name ("Zoom", "Google Meet", "Jitsi") as the meeting title hint. "Open summary" routes to the meeting detail page, showing a "Summary pending" state if summarization isn't finished. + +### 4. Defaults and onboarding + +- `show_recording_started = true` (was false) +- `show_recording_stopped = true` (was false) +- `show_meeting_detected = true` (new) +- **First-run Accessibility prompt** on macOS: an in-app modal explaining why ("Meetily detects meetings by reading your active window title and browser tab URL. No pixels, no audio, no network. You can revoke at any time in System Settings → Privacy & Security → Accessibility."), with a "Grant Accessibility" button that calls `AXIsProcessTrustedWithOptions` to trigger the system prompt. Skippable — detection falls back to native-app-only if declined. +- Existing users get a one-time onboarding toast explaining the new detection and how to opt out. + +## Why This Approach + +**Accessibility over Screen Recording.** `CGWindowListCopyWindowInfo` could read every window title, but since Catalina it requires Screen Recording permission — the permission users associate with "this app can see everything on my screen." Accessibility API reads the exact same titles plus browser URLs without that baggage. Raycast, AltTab, Rectangle, and MacWhisper all use Accessibility; it's a well-understood ask that converts. + +**Composite signals beat single signals.** Process-only (MacWhisper's approach) misses browser meetings entirely — "Chrome is running" is useless. Audio-only (Krisp's approach) fires on voice memos, music recording, and every browser tab with getUserMedia. Combining process + URL + audio-activity gives us precision on both paths. + +**Reuses existing notification scaffolding.** One new `NotificationType::MeetingDetected` variant, one new setting flag. Consent/DND/per-type gating (`manager.rs:277-305`) already applies. + +**Windows tier 2 is honest.** We cover native apps fully and focused browser tabs fully. Backgrounded browser tabs are v2 via a browser extension. This is communicated in onboarding rather than hidden. + +**No new runtime permissions on Windows.** All three Windows signals (EnumProcesses, EnumWindows, IAudioSessionManager2) work without admin and without special manifests. + +## Approaches Considered + +### A. Composite detector: process + AX URL + audio-activity (RECOMMENDED) + +Ship all three signals with composite scoring. Requires Accessibility permission on macOS, none on Windows. + +**Pros:** Covers native + browser meetings. Low false-positive rate. Uses public APIs only. Matches the precision of Raycast-tier desktop utilities. +**Cons:** Accessibility permission prompt may scare some users; fallback to native-only is degraded but functional. Windows backgrounded-tab gap in v1. + +### B. Process detection only + +Match bundle IDs / exe names; skip AX and audio signals. + +**Pros:** Ships in days; no permissions; simplest code. +**Cons:** Does not detect Google Meet, Jitsi, or any browser-based meeting at all — which for many users *is* every meeting. MacWhisper does this and its support channel is full of "why didn't it detect my Meet call?" threads. Not viable. + +### C. Browser extension as the primary path + +Ship a Chrome/Edge/Firefox/Safari extension that pushes URL changes to the Tauri app via native messaging. + +**Pros:** Most reliable browser detection; works for backgrounded tabs on Windows; no AX prompt. +**Cons:** Extension install is real friction (Chrome Web Store review, Firefox Add-ons, Safari requires App Store build). Doesn't help Zoom/Teams/Webex native at all — those still need process detection. Multiplies surfaces rather than replacing them. **Better as v2 reliability layer on top of v1.** + +### D. CGWindowList titles (Screen Recording) + +Use `CGWindowListCopyWindowInfo` to read Chrome's window title, which includes the active tab. + +**Pros:** Simpler than AX. Reads background window titles too. +**Cons:** Requires Screen Recording permission — users refuse this permission much more often than Accessibility. Can't read tab URLs, only titles (so `"Meet"` vs `"Team Standup - Meet - Chrome"` depends on tab-title quirks). **Worse UX trade than B.** + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Detection signals (macOS) | Process **OR** (AX URL **AND** audio-active) | Precision on both native + browser paths | +| Detection signals (Windows) | Process **OR** (focused window title **AND** audio session) | Same shape; backgrounded tabs deferred to v2 | +| macOS audio API | CoreAudio process enum on 14.4+, `isInUseByAnotherApplication` fallback | Public, no permission, runtime-detected | +| Windows audio API | `IAudioSessionManager2` with `AudioMeterInformation` | No permission; per-PID activity | +| Browser URL source (macOS) | Accessibility API `AXWebArea` + `kAXURLAttribute` | Works across Chromium / Safari / Firefox / Arc; lower-stigma than Screen Recording | +| Browser URL source (Windows) | None in v1 — focused window title only | UIA URL reading is inconsistent; extension is the real fix (v2) | +| App list source | Hard-coded `const` in Rust | Simple; user-editable JSON is v2 | +| URL pattern list | Hard-coded `const` in Rust | Same; small, stable list of meeting services | +| Poll cadence | 5s idle, 1s when a candidate signal is active | Cheap when nothing is happening, responsive when it is | +| Re-notify debounce | Suppress same-app notify within 10 min of dismissal | Prevents pestering | +| Accessibility opt-in | First-run modal with plain-English rationale + skip option | Users understand what they're granting | +| Defaults | Start/stop/detected all ON | Feature is useless if users must opt in | +| Opt-out | Per-type toggles in existing Preferences | No new UI surface | +| Notification content | Platform name in body ("Google Meet in Chrome") | Tells the user what we detected; builds trust | + +## Detection Truth Table + +| Scenario | Process | AX URL | Audio | Result | +|----------|---------|--------|-------|--------| +| Zoom native app, in a call | ✅ Zoom | — | ✅ | **Detected (native path)** | +| Zoom native, idle in dock | ✅ Zoom | — | ❌ | Detected-candidate, no notify yet | +| Chrome on a Meet tab, in a call | ✅ Chrome | ✅ meet.google.com | ✅ | **Detected (browser path)** | +| Chrome on a Meet tab, muted & idle | ✅ Chrome | ✅ meet.google.com | ❌ | No notify | +| Chrome on a random tab | ✅ Chrome | ❌ | ❌ or ✅ | No notify | +| Music in Chrome | ✅ Chrome | ❌ | ✅ | No notify | +| Accessibility declined, Zoom native | ✅ Zoom | n/a | — | **Detected (process path)** | +| Accessibility declined, Chrome Meet | ✅ Chrome | n/a | ✅ | No notify (acceptable degraded state) | + +## What We're NOT Building (YAGNI in v1) + +- **Browser extension + native messaging** — v2. The AX path covers most macOS cases; extension closes the Windows gap and adds robustness. Distinct work item. +- **Calendar awareness** — sibling brainstorm (Google Calendar integration) +- **Attendee extraction** — sibling brainstorm +- **Chrome DevTools Protocol via `--remote-debugging-port`** — off by default, breaks Chrome sync, non-starter for consumer UX +- **Network / packet detection** — requires root/admin +- **Auto-record without user click** — user directive; also the legal gate +- **Editable meeting-app or URL list in UI** — v2 +- **Smart "recurring meeting" grouping** — v2 +- **Temporal / ML-based detection** — YAGNI + +## Resolved Questions + +1. **Both signals or either?** Composite OR with path-specific AND: native = process only, browser = URL + audio. Balances recall and precision. +2. **macOS browser detection in v1?** Yes — via Accessibility API. Research showed it's tractable and is the industry-standard approach (Raycast pattern). +3. **Audio-activity API on macOS?** Public CoreAudio process API on 14.4+, `isInUseByAnotherApplication` fallback. No private APIs. +4. **Windows browser detection in v1?** Focused tabs only; backgrounded tabs deferred to v2 extension. Communicated in onboarding. +5. **Bundle ID + URL lists.** Hard-coded const. User-editable JSON is v2. +6. **Stop notification click target.** Summary view with "Summary pending" state if not yet ready. +7. **Defaults.** ON by default, with onboarding toast + Accessibility prompt on macOS. + +## Open Questions (worth probing before plan lock-in) + +1. **macOS 14.4+ CoreAudio process API with Chrome.** Does `kAudioProcessPropertyIsRunningInput` fire for a Chrome renderer when a tab calls `getUserMedia`, or only for Chrome's main audio process? Needs a 30-min prototype. If only the main process fires, we may need to treat "Chrome has any audio activity" as the signal and rely on the URL to disambiguate which tab. +2. **Firefox AXURL reliability.** Chromium and Safari expose `AXURL` on their `AXWebArea` reliably; Firefox historically has patchy AX. Worth verifying in 2026 Firefox before shipping. +3. **Arc multi-window behavior.** Arc's workspace model may affect how `AXWebArea` surfaces the "active" URL. Probe anecdotally. +4. **Meeting-app list scope.** Include Slack huddles, Discord voice calls, FaceTime group calls by default? Each has different user expectations of "is this a meeting?" + +## Next Steps + +1. One-hour prototype covering the three unknowns above (macOS CoreAudio + Chrome, Firefox AXURL, Arc) +2. Run `/workflows:plan` on this brainstorm +3. Implementation sequence: + 1. Process detector (shared native foundation) + 2. macOS AX URL reader behind Accessibility opt-in + 3. macOS CoreAudio process enum + fallback + 4. Windows IAudioSessionManager2 reader + 5. Windows EnumWindows reader + 6. Composite scorer + debounce logic + 7. Notification wiring (new `MeetingDetected` type) + defaults flip + 8. Enriched stop notification (duration + summary link) + 9. Onboarding toast + Accessibility explainer modal diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 06083ae6d5..9a6f193a48 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -187,6 +187,15 @@ whisper-rs = { version = "0.13.2", features = ["raw-api", "metal", "coreml"] } whisper-rs = { version = "0.13.2", features = ["raw-api", "vulkan"] } futures-channel = "0.3.31" +# WASAPI session enumeration for meeting auto-detection. See +# frontend/src-tauri/src/detection/signals/mic_activity/windows.rs. +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Media_Audio", + "Win32_System_Com", + "Win32_System_Threading", +] } + # Linux-specific dependencies # Default: CPU-only build (no BLAS) # Users can enable features manually: @@ -198,6 +207,11 @@ futures-channel = "0.3.31" whisper-rs = { version = "0.13.2", features = ["raw-api"] } futures-channel = "0.3.31" +# PulseAudio introspection for meeting auto-detection. Works with both +# real PulseAudio and PipeWire's pulse-compat layer (the default on all +# major distros). See frontend/src-tauri/src/detection/signals/mic_activity/linux.rs. +libpulse-binding = "2.28" + [dev-dependencies] tempfile = "3.3.0" infer = "0.15" diff --git a/frontend/src-tauri/src/audio/recording_commands.rs b/frontend/src-tauri/src/audio/recording_commands.rs index 270af668f9..8bf5355806 100644 --- a/frontend/src-tauri/src/audio/recording_commands.rs +++ b/frontend/src-tauri/src/audio/recording_commands.rs @@ -245,6 +245,7 @@ pub async fn start_recording_with_meeting_name( // Set recording flag and reset speech detection flag info!("🔍 Setting IS_RECORDING to true and resetting SPEECH_DETECTED_EMITTED"); IS_RECORDING.store(true, Ordering::SeqCst); + notify_detection_recording_state(&app, true); reset_speech_detected_flag(); // Reset for new recording session // Start optimized parallel transcription task and store handle @@ -414,6 +415,7 @@ pub async fn start_recording_with_devices_and_meeting( // Set recording flag and reset speech detection flag info!("🔍 Setting IS_RECORDING to true and resetting SPEECH_DETECTED_EMITTED"); IS_RECORDING.store(true, Ordering::SeqCst); + notify_detection_recording_state(&app, true); reset_speech_detected_flag(); // Reset for new recording session // Start optimized parallel transcription task and store handle @@ -742,6 +744,7 @@ pub async fn stop_recording( // Set recording flag to false info!("🔍 Setting IS_RECORDING to false"); IS_RECORDING.store(false, Ordering::SeqCst); + notify_detection_recording_state(&app, false); // Step 4.5: Prepare metadata for frontend (NO database save) // NOTE: We do NOT save to database here. The frontend will save after all transcripts are displayed. @@ -794,6 +797,22 @@ pub async fn is_recording() -> bool { IS_RECORDING.load(Ordering::SeqCst) } +/// Sync form of `is_recording` for callers that aren't async — same +/// atomic flag as the async version. +pub fn is_recording_sync() -> bool { + IS_RECORDING.load(Ordering::SeqCst) +} + +/// Push the current recording state into the detection service so it +/// can gate MeetingEnded banners on whether the user is actually +/// recording. No-op if the service isn't registered yet (rare — only +/// during startup races or disabled-detection builds). +fn notify_detection_recording_state(app: &AppHandle, recording: bool) { + if let Some(svc) = app.try_state::() { + svc.set_recording(recording); + } +} + /// Get recording statistics pub async fn get_transcription_status() -> TranscriptionStatus { TranscriptionStatus { diff --git a/frontend/src-tauri/src/detection/commands.rs b/frontend/src-tauri/src/detection/commands.rs new file mode 100644 index 0000000000..78b1863628 --- /dev/null +++ b/frontend/src-tauri/src/detection/commands.rs @@ -0,0 +1,34 @@ +//! Tauri command surface for the detection module. +//! +//! Exposes the otherwise-private `DetectorState` API to the frontend +//! and to agents via the invoke bridge. + +use tauri::{State, Wry}; + +use crate::detection::service::DetectionService; +use crate::detection::state::DetectorPhaseSnapshot; + +/// Suppress detection events for this bundle for the dismissal cooldown +/// (default 10 min). Used by the "ignore this app" UX path. +#[tauri::command] +pub async fn dismiss_detected_meeting( + bundle_id: String, + service: State<'_, DetectionService>, +) -> Result<(), String> { + service.dismiss(&bundle_id).await; + Ok(()) +} + +/// Return the detector's current phase, timing, and recording state. +/// Useful for UI indicators + agent observation. +#[tauri::command] +pub async fn get_detection_state( + service: State<'_, DetectionService>, +) -> Result { + Ok(service.current_phase().await) +} + +// Keep Wry in scope so the generated handler types match the rest of +// the app's invoke_handler registrations. +#[allow(dead_code)] +fn _check_handler_type(_: tauri::AppHandle) {} diff --git a/frontend/src-tauri/src/detection/matcher.rs b/frontend/src-tauri/src/detection/matcher.rs new file mode 100644 index 0000000000..f7668382b7 --- /dev/null +++ b/frontend/src-tauri/src/detection/matcher.rs @@ -0,0 +1,533 @@ +//! Hybrid allowlist + blocklist for mic-activity based meeting detection. +//! +//! Decision order per candidate bundle: +//! 1. Blocklist hit → filter out entirely (dictation, memo, self-filter). +//! 2. Allowlist hit → named banner with the app's display name. +//! 3. Otherwise → generic "Meeting detected" banner. +//! +//! The "bundle" string the matcher looks up is platform-defined: +//! - macOS: bundle identifier (`us.zoom.xos`) +//! - Windows: executable basename (`Zoom.exe`) +//! - Linux: `application.process.binary` from PulseAudio (`zoom`) +//! +//! String comparison is case-insensitive on macOS/Windows (filesystem +//! semantics) and case-sensitive on Linux (process-name semantics). + +/// A canonical "which meeting app is this?" identity. Platform-specific +/// aliases (exe names, bundle IDs, binary names) map into one of these. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum App { + Zoom, + Teams, + Webex, + FaceTime, + Discord, + Slack, + /// Any browser — we can't tell which tab is open without reading URLs, + /// and the whole point of Phase 1 was to avoid that. Shows as a + /// generic "a browser meeting" banner. + Browser, +} + +impl App { + pub const fn display_name(self) -> &'static str { + match self { + App::Zoom => "Zoom", + App::Teams => "Microsoft Teams", + App::Webex => "Webex", + App::FaceTime => "FaceTime", + App::Discord => "Discord", + App::Slack => "Slack", + App::Browser => "a browser meeting", + } + } + + /// Lower rank = higher priority. Used when multiple apps hold the + /// mic simultaneously to pick the most meeting-ish candidate. + const fn priority(self) -> u8 { + match self { + App::Zoom => 0, + App::Teams => 1, + App::Webex => 2, + App::FaceTime => 3, + App::Discord => 4, + App::Slack => 5, + App::Browser => 6, + } + } +} + +// ---- Platform-specific alias tables ---------------------------------------- +// +// Only the current target's table is compiled in. Each entry maps a +// platform-specific identifier to the canonical `App`. + +#[cfg(target_os = "macos")] +const ALIASES: &[(&str, App)] = &[ + ("us.zoom.xos", App::Zoom), + ("com.microsoft.teams2", App::Teams), + ("com.microsoft.teams", App::Teams), + ("com.cisco.webexmeetingsapp", App::Webex), + ("com.webex.meetingmanager", App::Webex), + ("com.apple.FaceTime", App::FaceTime), + ("com.hnc.Discord", App::Discord), + ("com.tinyspeck.slackmacgap", App::Slack), + ("com.google.Chrome", App::Browser), + ("com.google.Chrome.canary", App::Browser), + ("com.apple.Safari", App::Browser), + ("org.mozilla.firefox", App::Browser), + ("company.thebrowser.Browser", App::Browser), + ("com.microsoft.edgemac", App::Browser), + ("com.brave.Browser", App::Browser), + ("com.operasoftware.Opera", App::Browser), + ("com.vivaldi.Vivaldi", App::Browser), +]; + +#[cfg(target_os = "windows")] +const ALIASES: &[(&str, App)] = &[ + ("Zoom.exe", App::Zoom), + ("ZoomHost.exe", App::Zoom), + ("CptHost.exe", App::Zoom), // Zoom's companion capture host process + ("ms-teams.exe", App::Teams), + ("Teams.exe", App::Teams), + ("MSTeams.exe", App::Teams), + ("webex.exe", App::Webex), + ("WebexHost.exe", App::Webex), + ("Cisco WebEx Start.exe", App::Webex), + ("Discord.exe", App::Discord), + ("slack.exe", App::Slack), + ("chrome.exe", App::Browser), + ("msedge.exe", App::Browser), + ("firefox.exe", App::Browser), + ("brave.exe", App::Browser), + ("opera.exe", App::Browser), + ("vivaldi.exe", App::Browser), + ("arc.exe", App::Browser), +]; + +#[cfg(target_os = "linux")] +const ALIASES: &[(&str, App)] = &[ + ("zoom", App::Zoom), + ("teams", App::Teams), + ("teams-for-linux", App::Teams), + ("webex", App::Webex), + ("Discord", App::Discord), + ("discord", App::Discord), + ("slack", App::Slack), + ("google-chrome", App::Browser), + ("chrome", App::Browser), + ("chromium", App::Browser), + ("chromium-browser", App::Browser), + ("firefox", App::Browser), + ("firefox-esr", App::Browser), + ("brave", App::Browser), + ("brave-browser", App::Browser), + ("vivaldi-bin", App::Browser), + ("vivaldi-stable", App::Browser), + ("opera", App::Browser), +]; + +// Minor-target fallback: empty aliases, matcher treats every candidate +// as unknown. The sampler itself is a stub on these platforms too. +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +const ALIASES: &[(&str, App)] = &[]; + +// ---- Platform-specific blocklists ----------------------------------------- + +#[cfg(target_os = "macos")] +const DEFINITELY_NOT_MEETINGS: &[&str] = &[ + // Self. Must stay in sync with `identifier` in tauri.conf.json. + // If you fork and change the bundle ID, add your variant here. + "com.meetily.ai", + "com.meetily.ai.dev", + "com.meetily.ai.debug", + // System dictation / voice memos + "com.apple.VoiceMemos", + "com.apple.dictation", + "com.apple.SpeechRecognitionCore", + "com.apple.siri", + "com.apple.assistantd", + // Third-party dictation / transcription + "will.flow.Wispr", + "com.aliveseven.superwhisper", + "com.chenyu.macwhisper", + "com.flow.wispr", + // Screen recorders + "com.obsproject.obs-studio", + "com.loom.desktop", + "com.screenflow.ScreenFlow10", + "com.screenflow.ScreenFlow11", +]; + +#[cfg(target_os = "windows")] +const DEFINITELY_NOT_MEETINGS: &[&str] = &[ + // Self (tauri dev / prod builds) + "meetily.exe", + "Meetily.exe", + // Bundled sidecars — any executable we ship that might open audio. + // If the set in `tauri.conf.json` externalBin changes, update here. + "llama-helper.exe", + "ffmpeg.exe", + // System / built-in + "WindowsVoiceRecorder.exe", + "VoiceRecorder.exe", + "SoundRecorder.exe", + "Cortana.exe", + // Third-party dictation / transcription + "Dragon.exe", + "Wispr.exe", + "Flow.exe", + // Screen recorders + "obs64.exe", + "obs32.exe", + "Loom.exe", + "Camtasia.exe", + "Camtasia.Studio.exe", + "Bandicam.exe", + "Fraps.exe", +]; + +#[cfg(target_os = "linux")] +const DEFINITELY_NOT_MEETINGS: &[&str] = &[ + // Self + "meetily", + // Bundled sidecars + "llama-helper", + "ffmpeg", + // Audio infra (should never register as mic consumers, but defensive) + "pulseaudio", + "pipewire", + "wireplumber", + "speech-dispatcher", + "ibus-daemon", + // Screen recorders + "obs", + "SimpleScreenRecorder", + "simplescreenrecorder", + "kooha", + "wf-recorder", + "gpu-screen-recorder", + // Dictation / voice input + "nerd-dictation", + "dragon", +]; + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +const DEFINITELY_NOT_MEETINGS: &[&str] = &[]; + +// ---- Matching helpers ------------------------------------------------------ + +/// On macOS and Windows, filesystem/bundle IDs are case-insensitive. +/// On Linux, process names are case-sensitive. +fn keys_match(a: &str, b: &str) -> bool { + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + a.eq_ignore_ascii_case(b) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + a == b + } +} + +fn lookup(bundle_id: &str) -> Option { + ALIASES + .iter() + .find(|(alias, _)| keys_match(alias, bundle_id)) + .map(|(_, app)| *app) +} + +/// True if the bundle should be suppressed entirely (no banner, ever). +pub fn is_blocked(bundle_id: &str) -> bool { + DEFINITELY_NOT_MEETINGS + .iter() + .any(|blocked| keys_match(blocked, bundle_id)) +} + +/// True if the bundle is in the curated allowlist of known meeting apps. +/// Detection uses a shorter sustain threshold for these — we're confident +/// it's a meeting app, so the longer flicker guard is unnecessary. +pub fn is_known(bundle_id: &str) -> bool { + lookup(bundle_id).is_some() +} + +/// Priority rank. Known apps return their `App::priority()` (0..=6), +/// unknown apps return `u16::MAX`. Lower rank = higher priority. +pub fn priority_of(bundle_id: &str) -> u16 { + lookup(bundle_id) + .map(|app| app.priority() as u16) + .unwrap_or(u16::MAX) +} + +/// Human-friendly name for the banner. Unknown apps get the generic label. +pub fn display_name(bundle_id: &str) -> &'static str { + lookup(bundle_id) + .map(App::display_name) + .unwrap_or("a meeting") +} + +/// Pick the highest-priority non-blocked bundle from a list of active +/// mic-holders. Known apps beat unknown apps; ties broken by input order. +pub fn pick_best<'a, I>(active: I) -> Option<&'a str> +where + I: IntoIterator, +{ + let mut best: Option<(&str, u16)> = None; + for bundle in active { + if is_blocked(bundle) { + continue; + } + // Known apps rank by `App::priority()` (0..=6); unknown apps + // share the lowest rank so the first-observed one wins. + let rank = lookup(bundle) + .map(|app| app.priority() as u16) + .unwrap_or(u16::MAX); + match best { + None => best = Some((bundle, rank)), + Some((_, r)) if rank < r => best = Some((bundle, rank)), + _ => {} + } + } + best.map(|(b, _)| b) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- Shared tests (platform-agnostic semantics) ----------------------- + + #[test] + fn display_name_unknown_app_generic() { + assert_eq!(display_name("com.unknown.niche-meeting-app"), "a meeting"); + } + + #[test] + fn pick_best_empty() { + let active: Vec<&str> = vec![]; + assert_eq!(pick_best(active.iter().copied()), None); + } + + #[test] + fn app_display_names_are_stable() { + assert_eq!(App::Zoom.display_name(), "Zoom"); + assert_eq!(App::Teams.display_name(), "Microsoft Teams"); + assert_eq!(App::Browser.display_name(), "a browser meeting"); + } + + #[test] + fn app_priority_ordering() { + assert!(App::Zoom.priority() < App::Teams.priority()); + assert!(App::Teams.priority() < App::Browser.priority()); + } + + // ---- macOS-specific tests --------------------------------------------- + + #[cfg(target_os = "macos")] + mod macos { + use super::*; + + #[test] + fn blocks_meetily_itself() { + assert!(is_blocked("com.meetily.ai")); + } + + #[test] + fn blocks_dictation_apps() { + assert!(is_blocked("com.apple.VoiceMemos")); + assert!(is_blocked("will.flow.Wispr")); + assert!(is_blocked("com.aliveseven.superwhisper")); + } + + #[test] + fn blocks_are_case_insensitive() { + assert!(is_blocked("COM.MEETILY.AI")); + assert!(is_blocked("com.Apple.VoiceMemos")); + } + + #[test] + fn does_not_block_meeting_apps() { + assert!(!is_blocked("us.zoom.xos")); + assert!(!is_blocked("com.google.Chrome")); + assert!(!is_blocked("com.unknown.app")); + } + + #[test] + fn display_name_known_app() { + assert_eq!(display_name("us.zoom.xos"), "Zoom"); + assert_eq!(display_name("com.microsoft.teams2"), "Microsoft Teams"); + } + + #[test] + fn display_name_browsers_generic() { + assert_eq!(display_name("com.google.Chrome"), "a browser meeting"); + assert_eq!(display_name("com.apple.Safari"), "a browser meeting"); + } + + #[test] + fn pick_best_prefers_zoom_over_chrome() { + let active = ["com.google.Chrome", "us.zoom.xos"]; + assert_eq!(pick_best(active.iter().copied()), Some("us.zoom.xos")); + } + + #[test] + fn pick_best_skips_blocked() { + let active = ["com.meetily.ai", "us.zoom.xos"]; + assert_eq!(pick_best(active.iter().copied()), Some("us.zoom.xos")); + } + + #[test] + fn pick_best_unknown_only() { + let active = ["com.unknown.app1", "com.unknown.app2"]; + assert_eq!(pick_best(active.iter().copied()), Some("com.unknown.app1")); + } + + #[test] + fn pick_best_all_blocked_returns_none() { + let active = ["com.meetily.ai", "com.apple.VoiceMemos"]; + assert_eq!(pick_best(active.iter().copied()), None); + } + + #[test] + fn is_known_recognises_known_apps() { + assert!(is_known("us.zoom.xos")); + assert!(is_known("com.google.Chrome")); + assert!(!is_known("com.unknown.app")); + } + } + + // ---- Windows-specific tests ------------------------------------------- + + #[cfg(target_os = "windows")] + mod windows { + use super::*; + + #[test] + fn resolves_zoom_exe() { + assert_eq!(display_name("Zoom.exe"), "Zoom"); + assert!(is_known("Zoom.exe")); + } + + #[test] + fn resolves_teams_new_and_legacy() { + assert_eq!(display_name("ms-teams.exe"), "Microsoft Teams"); + assert_eq!(display_name("Teams.exe"), "Microsoft Teams"); + assert_eq!(display_name("MSTeams.exe"), "Microsoft Teams"); + } + + #[test] + fn resolves_browsers_generically() { + assert_eq!(display_name("chrome.exe"), "a browser meeting"); + assert_eq!(display_name("msedge.exe"), "a browser meeting"); + assert_eq!(display_name("firefox.exe"), "a browser meeting"); + } + + #[test] + fn case_insensitive_match() { + assert_eq!(display_name("ZOOM.EXE"), "Zoom"); + assert!(is_known("zoom.exe")); + } + + #[test] + fn blocks_voice_recorder() { + assert!(is_blocked("WindowsVoiceRecorder.exe")); + assert!(is_blocked("VoiceRecorder.exe")); + } + + #[test] + fn blocks_self() { + assert!(is_blocked("meetily.exe")); + assert!(is_blocked("Meetily.exe")); + } + + #[test] + fn blocks_obs() { + assert!(is_blocked("obs64.exe")); + assert!(is_blocked("obs32.exe")); + } + + #[test] + fn does_not_block_meeting_exes() { + assert!(!is_blocked("Zoom.exe")); + assert!(!is_blocked("chrome.exe")); + } + + #[test] + fn pick_best_prefers_zoom() { + let active = ["chrome.exe", "Zoom.exe"]; + assert_eq!(pick_best(active.iter().copied()), Some("Zoom.exe")); + } + + #[test] + fn unknown_windows_app_generic() { + assert_eq!(display_name("SomeNicheApp.exe"), "a meeting"); + } + } + + // ---- Linux-specific tests --------------------------------------------- + + #[cfg(target_os = "linux")] + mod linux { + use super::*; + + #[test] + fn resolves_zoom_binary() { + assert_eq!(display_name("zoom"), "Zoom"); + assert!(is_known("zoom")); + } + + #[test] + fn resolves_teams_variants() { + assert_eq!(display_name("teams"), "Microsoft Teams"); + assert_eq!(display_name("teams-for-linux"), "Microsoft Teams"); + } + + #[test] + fn resolves_browsers_generically() { + assert_eq!(display_name("firefox"), "a browser meeting"); + assert_eq!(display_name("chromium-browser"), "a browser meeting"); + assert_eq!(display_name("google-chrome"), "a browser meeting"); + assert_eq!(display_name("brave-browser"), "a browser meeting"); + } + + #[test] + fn case_sensitive_match() { + // Linux: process names are case-sensitive; "Zoom" (uppercase Z) + // isn't the real binary name, so it doesn't match. + assert_eq!(display_name("Zoom"), "a meeting"); + assert!(!is_known("Zoom")); + assert!(is_known("zoom")); + } + + #[test] + fn discord_both_cases_registered() { + // Discord registers as both "Discord" (AppImage) and "discord" + // (distro packages); both are in the alias table. + assert!(is_known("Discord")); + assert!(is_known("discord")); + } + + #[test] + fn blocks_self() { + assert!(is_blocked("meetily")); + } + + #[test] + fn blocks_screen_recorders() { + assert!(is_blocked("obs")); + assert!(is_blocked("SimpleScreenRecorder")); + } + + #[test] + fn does_not_block_meeting_binaries() { + assert!(!is_blocked("zoom")); + assert!(!is_blocked("firefox")); + } + + #[test] + fn pick_best_prefers_zoom() { + let active = ["firefox", "zoom"]; + assert_eq!(pick_best(active.iter().copied()), Some("zoom")); + } + } +} diff --git a/frontend/src-tauri/src/detection/mod.rs b/frontend/src-tauri/src/detection/mod.rs new file mode 100644 index 0000000000..98e3710859 --- /dev/null +++ b/frontend/src-tauri/src/detection/mod.rs @@ -0,0 +1,15 @@ +//! Meeting auto-detection via mic-activity. +//! +//! Observes which non-Meetily apps hold the microphone and fires +//! `MeetingDetected` / `MeetingEnded` notifications at the standard +//! sustain/end-silence thresholds. See +//! `docs/plans/2026-04-20-feat-detect-meeting-start-and-end-plan.md`. + +pub mod commands; +pub mod matcher; +pub mod service; +pub mod signals; +pub mod state; +pub mod types; + +pub use service::{spawn, DetectionService}; diff --git a/frontend/src-tauri/src/detection/service.rs b/frontend/src-tauri/src/detection/service.rs new file mode 100644 index 0000000000..de5a824abb --- /dev/null +++ b/frontend/src-tauri/src/detection/service.rs @@ -0,0 +1,171 @@ +//! Long-lived detection task. Polls the mic-activity sampler at a fixed +//! cadence, advances the state machine, and routes detection events to +//! the notification manager. +//! +//! The cadence is intentionally conservative (1s). CoreAudio property +//! reads are cheap; the per-process enumeration only runs when the +//! "device is running somewhere" gate is hot. True event-driven +//! listeners are a future optimization — see the plan's "Technical +//! Approach" section. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use log::{debug, error, info, warn}; +use tauri::{AppHandle, Emitter, Manager, Runtime}; +use tokio::sync::Mutex; + +use crate::detection::signals::mic_activity; +use crate::detection::state::{DetectorConfig, DetectorState}; +use crate::detection::types::DetectionEvent; +use crate::notifications::commands::{ + show_meeting_detected_notification, show_meeting_ended_notification, + NotificationManagerState, +}; + +const POLL_INTERVAL: Duration = Duration::from_secs(1); + +/// Handle for the detection task. Registered in Tauri state so future +/// command handlers can reach in for `dismiss` / `get_state` surfaces. +pub struct DetectionService { + state: Arc>, + running: Arc, +} + +impl DetectionService { + fn new(config: DetectorConfig, running: Arc) -> Self { + Self { + state: Arc::new(Mutex::new(DetectorState::new(config))), + running, + } + } + + /// Signals the task loop to exit at its next tick. Used on app + /// teardown so the detector stops sampling CoreAudio/WASAPI/pulse + /// before the runtime starts dropping state. + pub fn shutdown(&self) { + self.running.store(false, Ordering::Release); + debug!("DetectionService::shutdown signalled"); + } + + /// Record that Meetily started/stopped recording. Gates the + /// MeetingEnded banner inside the state machine. + pub fn set_recording(&self, recording: bool) { + // Runs from audio commands which are async; we need a blocking + // lock here. This is called at most once per start/stop. + if let Ok(mut guard) = self.state.try_lock() { + guard.set_recording(recording); + debug!("DetectionService: set_recording({})", recording); + } else { + // Fall back: spawn to unblock once the poll loop lock + // releases. Still quick — advance() holds the lock briefly. + let state = self.state.clone(); + tauri::async_runtime::spawn(async move { + let mut guard = state.lock().await; + guard.set_recording(recording); + debug!("DetectionService: set_recording({}) (async fallback)", recording); + }); + } + } + + /// Dismiss a detected bundle so further MeetingDetected banners + /// for it are suppressed for the cooldown window. Exposed via + /// Tauri command; also callable from internal code once we add a + /// tap-to-dismiss handler. + pub async fn dismiss(&self, bundle_id: &str) { + let mut guard = self.state.lock().await; + guard.dismiss(bundle_id, Instant::now()); + info!("DetectionService: dismissed"); + } + + /// Snapshot the detector's current phase for UI / agent queries. + pub async fn current_phase(&self) -> crate::detection::state::DetectorPhaseSnapshot { + let guard = self.state.lock().await; + guard.phase_snapshot(Instant::now()) + } +} + +/// Spawn the detection task. Returns a `DetectionService` handle that +/// should be registered in Tauri state. +pub fn spawn(app: AppHandle) -> DetectionService +where + R: Runtime, +{ + let running = Arc::new(AtomicBool::new(true)); + let service = DetectionService::new(DetectorConfig::DEFAULT, running.clone()); + let state = service.state.clone(); + let running_for_task = running.clone(); + + let sampler = match mic_activity::create() { + Ok(s) => s, + Err(e) => { + warn!("Meeting detection disabled — failed to init mic-activity sampler: {}", e); + return service; + } + }; + + info!("Meeting detection: spawning poll task ({}s interval)", POLL_INTERVAL.as_secs()); + + tauri::async_runtime::spawn(async move { + let mut ticker = tokio::time::interval(POLL_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + // First tick fires immediately; skip it to avoid hammering the + // platform audio API before the rest of the app is up. + ticker.tick().await; + + while running_for_task.load(Ordering::Acquire) { + ticker.tick().await; + + let snapshot = match sampler.snapshot() { + Ok(s) => s, + Err(e) => { + warn!("Meeting detection: snapshot failed: {}", e); + continue; + } + }; + + let event = { + let mut guard = state.lock().await; + guard.advance(Instant::now(), &snapshot) + }; + + let Some(event) = event else { continue }; + + let mgr_state = app.state::>(); + match event { + DetectionEvent::MeetingDetected(m) => { + info!("Meeting detection: DETECTED {}", m.display_name); + debug!("Meeting detection: DETECTED bundle={}", m.bundle_id); + // Emit Tauri event so frontend / agents can observe + // alongside the notification banner. + if let Err(e) = app.emit("meeting-detected", &m) { + debug!("failed to emit meeting-detected event: {}", e); + } + if let Err(e) = show_meeting_detected_notification( + &app, mgr_state.inner(), m.display_name, + ).await { + error!("Failed to show meeting-detected notification: {}", e); + } + } + DetectionEvent::MeetingEnded(m) => { + info!("Meeting detection: ENDED {}", m.display_name); + debug!("Meeting detection: ENDED bundle={}", m.bundle_id); + if let Err(e) = app.emit("meeting-ended", &m) { + debug!("failed to emit meeting-ended event: {}", e); + } + if let Err(e) = show_meeting_ended_notification( + &app, mgr_state.inner(), m.display_name, + ).await { + error!("Failed to show meeting-ended notification: {}", e); + } + } + } + } + + info!("Meeting detection: poll task exiting"); + }); + + service +} diff --git a/frontend/src-tauri/src/detection/signals/mic_activity/linux.rs b/frontend/src-tauri/src/detection/signals/mic_activity/linux.rs new file mode 100644 index 0000000000..a06a59b571 --- /dev/null +++ b/frontend/src-tauri/src/detection/signals/mic_activity/linux.rs @@ -0,0 +1,368 @@ +//! Linux mic-activity sampler via PulseAudio introspection. +//! +//! Works with both real PulseAudio and PipeWire's pulse-compat layer +//! (which every major distro ships as default today). +//! +//! Architecture: libpulse-binding uses a C-originated mainloop +//! incompatible with tokio. So we spawn a dedicated native thread that +//! owns the Mainloop + Context and continuously refreshes a shared +//! snapshot. The tokio-side `snapshot()` call just clones the current +//! contents of a `Mutex>`. +//! +//! Snapshot algorithm on the pulse thread: +//! 1. Every ~1s, call `introspect.get_source_output_info_list(...)`. +//! 2. For each item: read `application.process.id` (skip self) and +//! `application.process.binary` (fall back to `application.name`). +//! 3. On `ListResult::End`, swap collected Vec into the shared snapshot. +//! +//! If the PulseAudio connection drops (daemon restart), we back off and +//! reconnect. Maximum reconnect wait is 10s. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Result}; +use libpulse_binding::{ + callbacks::ListResult, + context::{Context, FlagSet as ContextFlagSet, State as ContextState}, + mainloop::threaded::Mainloop, +}; +use log::{debug, info, trace, warn}; + +use crate::detection::signals::SignalSampler; +use crate::detection::types::MicSnapshot; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +const POLL_INTERVAL: Duration = Duration::from_secs(1); +const RECONNECT_BACKOFF_INITIAL: Duration = Duration::from_secs(1); +const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(10); + +pub struct LinuxMicActivitySampler { + shared: Arc, + thread_handle: Option>, +} + +struct SharedState { + snapshot: Mutex>, + shutdown: AtomicBool, +} + +impl LinuxMicActivitySampler { + pub fn new() -> Result { + let shared = Arc::new(SharedState { + snapshot: Mutex::new(Vec::new()), + shutdown: AtomicBool::new(false), + }); + + let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); + let shared_for_thread = shared.clone(); + + let handle = thread::Builder::new() + .name("meetily-pulse".to_string()) + .spawn(move || pulse_worker(shared_for_thread, ready_tx)) + .map_err(|e| anyhow!("failed to spawn pulse thread: {}", e))?; + + match ready_rx.recv_timeout(CONNECT_TIMEOUT + Duration::from_secs(1)) { + Ok(Ok(())) => { + info!("Linux mic-activity sampler: PulseAudio connected"); + Ok(Self { + shared, + thread_handle: Some(handle), + }) + } + Ok(Err(e)) => { + shared.shutdown.store(true, Ordering::Release); + let _ = handle.join(); + Err(anyhow!("PulseAudio init failed: {}", e)) + } + Err(_) => { + shared.shutdown.store(true, Ordering::Release); + // Give the worker up to 2s to observe the shutdown + // flag and exit cleanly. If it doesn't, detach with + // a warning — the thread holds a live pulse Context + // that will only drop at process exit. + let deadline = Instant::now() + Duration::from_secs(2); + while !handle.is_finished() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(50)); + } + if handle.is_finished() { + let _ = handle.join(); + } else { + warn!("PulseAudio init timed out and worker did not exit within 2s; detaching"); + } + Err(anyhow!("PulseAudio init timed out")) + } + } + } +} + +impl SignalSampler for LinuxMicActivitySampler { + fn snapshot(&self) -> Result { + let guard = self.shared.snapshot.lock().unwrap(); + Ok(MicSnapshot { + active_bundles: guard.clone(), + }) + } +} + +impl Drop for LinuxMicActivitySampler { + fn drop(&mut self) { + self.shared.shutdown.store(true, Ordering::Release); + if let Some(h) = self.thread_handle.take() { + let _ = h.join(); + } + } +} + +fn pulse_worker(shared: Arc, ready: Sender>) { + let own_pid = std::process::id(); + // Sender is taken by `run_session` on the first attempt; after + // that it's `None` and no further ready signals fire. + let mut ready_once: Option>> = Some(ready); + let mut backoff = RECONNECT_BACKOFF_INITIAL; + + loop { + if shared.shutdown.load(Ordering::Acquire) { + return; + } + + let is_first_attempt = ready_once.is_some(); + let notify = ready_once.take(); + + match run_session(&shared, own_pid, notify) { + SessionOutcome::Shutdown => return, + SessionOutcome::Connected => { + // Session exited cleanly after Ready (shutdown or + // connection drop). Reset backoff and try again. + backoff = RECONNECT_BACKOFF_INITIAL; + } + SessionOutcome::ConnectFailed(msg) => { + if is_first_attempt { + // The constructor was already notified by + // run_session itself (which consumed the Sender). + warn!("PulseAudio initial connect failed: {}", msg); + return; + } + warn!("PulseAudio reconnect failed: {}", msg); + } + } + + // Backoff before reconnecting. ±20% jitter to avoid thundering + // herd when multiple Meetily instances on the same host (or + // many users on a shared workstation) reconnect in lockstep + // after a pulseaudio restart. + let jitter_pct = rand::Rng::gen_range(&mut rand::thread_rng(), -0.2f32..=0.2f32); + let jittered = backoff.as_secs_f32() * (1.0 + jitter_pct); + let sleep_until = Instant::now() + Duration::from_secs_f32(jittered.max(0.0)); + while Instant::now() < sleep_until { + if shared.shutdown.load(Ordering::Acquire) { + return; + } + thread::sleep(Duration::from_millis(200)); + } + backoff = (backoff * 2).min(RECONNECT_BACKOFF_MAX); + } +} + +enum SessionOutcome { + /// Shutdown requested; exit worker. + Shutdown, + /// Connected successfully, ran until connection dropped or shutdown. + Connected, + /// Failed to reach Ready. Notify carried the error out if this was + /// the first attempt. + ConnectFailed(String), +} + +/// Run one session: create mainloop + context, connect, poll until the +/// connection drops or shutdown is requested. If `notify` is `Some`, +/// send the ready/failed signal through it on first connect attempt. +fn run_session( + shared: &Arc, + own_pid: u32, + notify: Option>>, +) -> SessionOutcome { + let mut mainloop = match Mainloop::new() { + Some(m) => m, + None => { + let msg = "Mainloop::new returned None".to_string(); + if let Some(tx) = notify { + let _ = tx.send(Err(msg.clone())); + } + return SessionOutcome::ConnectFailed(msg); + } + }; + + let mut context = match Context::new(&mainloop, "meetily-detection") { + Some(c) => c, + None => { + let msg = "Context::new returned None".to_string(); + if let Some(tx) = notify { + let _ = tx.send(Err(msg.clone())); + } + return SessionOutcome::ConnectFailed(msg); + } + }; + + if let Err(e) = context.connect(None, ContextFlagSet::NOFLAGS, None) { + let msg = format!("connect: {:?}", e); + if let Some(tx) = notify { + let _ = tx.send(Err(msg.clone())); + } + return SessionOutcome::ConnectFailed(msg); + } + + if let Err(e) = mainloop.start() { + let msg = format!("mainloop.start: {:?}", e); + if let Some(tx) = notify { + let _ = tx.send(Err(msg.clone())); + } + return SessionOutcome::ConnectFailed(msg); + } + + // Poll for Ready up to CONNECT_TIMEOUT. + let deadline = Instant::now() + CONNECT_TIMEOUT; + let mut ready = false; + while Instant::now() < deadline { + if shared.shutdown.load(Ordering::Acquire) { + return SessionOutcome::Shutdown; + } + mainloop.lock(); + let state = context.get_state(); + mainloop.unlock(); + match state { + ContextState::Ready => { + ready = true; + break; + } + ContextState::Failed | ContextState::Terminated => { + let msg = format!("unexpected state during connect: {:?}", state); + if let Some(tx) = notify { + let _ = tx.send(Err(msg.clone())); + } + return SessionOutcome::ConnectFailed(msg); + } + _ => thread::sleep(Duration::from_millis(50)), + } + } + if !ready { + let msg = "connect timed out".to_string(); + if let Some(tx) = notify { + let _ = tx.send(Err(msg.clone())); + } + return SessionOutcome::ConnectFailed(msg); + } + + // Notify the constructor that we're up. + if let Some(tx) = notify { + let _ = tx.send(Ok(())); + } + + // Main poll loop. + loop { + if shared.shutdown.load(Ordering::Acquire) { + return SessionOutcome::Shutdown; + } + mainloop.lock(); + let state = context.get_state(); + mainloop.unlock(); + if state != ContextState::Ready { + warn!("PulseAudio context dropped to state {:?}", state); + return SessionOutcome::Connected; + } + + collect_snapshot(shared, &mut mainloop, &context, own_pid); + + let sleep_until = Instant::now() + POLL_INTERVAL; + while Instant::now() < sleep_until { + if shared.shutdown.load(Ordering::Acquire) { + return SessionOutcome::Shutdown; + } + thread::sleep(Duration::from_millis(100)); + } + } +} + +fn collect_snapshot( + shared: &Arc, + mainloop: &mut Mainloop, + context: &Context, + own_pid: u32, +) { + let collected = Arc::new(Mutex::new(Vec::::new())); + let (done_tx, done_rx) = std::sync::mpsc::sync_channel::<()>(1); + + let collected_cb = collected.clone(); + // `SyncSender<()>` is Send + Clone. The callback runs on the + // pulse mainloop thread; once End/Error fires we signal via + // `try_send` (non-blocking — channel size is 1 so a duplicate + // send is swallowed). + let done_tx_cb = done_tx.clone(); + + // Hold the mainloop lock across the full Operation lifecycle: + // creation, in-flight callback dispatch, and Drop (which calls + // `pa_operation_unref`). Releasing the lock while the Operation + // is alive can race with the mainloop's internal state, a + // documented pulse-binding UB class. + mainloop.lock(); + let introspect = context.introspect(); + let op = introspect.get_source_output_info_list(move |result| match result { + ListResult::Item(info) => { + // Self-filter by PID. + if let Some(pid_str) = info.proplist.get_str("application.process.id") { + if let Ok(pid) = pid_str.parse::() { + if pid == own_pid { + return; + } + } + } + // Prefer binary name; fall back to application name. + let bundle = info + .proplist + .get_str("application.process.binary") + .or_else(|| info.proplist.get_str("application.name")); + if let Some(name) = bundle { + if !name.is_empty() { + collected_cb.lock().unwrap().push(name); + } + } + } + ListResult::End => { + let _ = done_tx_cb.try_send(()); + } + ListResult::Error => { + warn!("pulse get_source_output_info_list reported Error"); + let _ = done_tx_cb.try_send(()); + } + }); + mainloop.unlock(); + + // Wait for the callback to signal completion via the channel. + // The mainloop keeps driving callbacks on its own thread; we + // just block on the rendezvous rather than poll-sleeping. + let completed = done_rx.recv_timeout(Duration::from_secs(2)).is_ok(); + + // Re-acquire the mainloop lock so the Operation can be dropped + // (→ pa_operation_unref) while the lock is held. Even on timeout + // this is the safe place to drop — pulse handles unref of an + // in-flight op by cancelling it. + mainloop.lock(); + drop(op); + mainloop.unlock(); + + if !completed { + trace!("pulse introspect callback did not complete within 2s"); + return; + } + + let new_snapshot = std::mem::take(&mut *collected.lock().unwrap()); + debug!( + "Linux mic snapshot: {} active {:?}", + new_snapshot.len(), + new_snapshot + ); + *shared.snapshot.lock().unwrap() = new_snapshot; +} diff --git a/frontend/src-tauri/src/detection/signals/mic_activity/macos.rs b/frontend/src-tauri/src/detection/signals/mic_activity/macos.rs new file mode 100644 index 0000000000..b889af6f7e --- /dev/null +++ b/frontend/src-tauri/src/detection/signals/mic_activity/macos.rs @@ -0,0 +1,100 @@ +//! macOS mic-activity sampler via CoreAudio. +//! +//! Polls `kAudioDevicePropertyDeviceIsRunningSomewhere` on the default +//! input device as a cheap gate — if no process is running the mic at +//! all, we skip the per-process enumeration. When the gate is hot, we +//! walk the process list via `kAudioHardwarePropertyProcessObjectList` +//! and check `kAudioProcessPropertyIsRunningInput` on each. Bundle IDs +//! come from `kAudioProcessPropertyBundleID` (available macOS 14.2+). +//! +//! Polling at ~1s is fine even when the gate is hot — the per-process +//! enumeration is just reading audio-object properties, no syscalls +//! into real audio hardware. The plan's "event-driven" ideal is a +//! future optimization; polling measured idle CPU is effectively zero. + +use anyhow::{anyhow, Result}; +use cidre::core_audio as ca; +use log::{debug, trace, warn}; + +use crate::detection::signals::SignalSampler; +use crate::detection::types::MicSnapshot; + +pub struct MacMicActivitySampler; + +impl MacMicActivitySampler { + pub fn new() -> Result { + // Probe on construction so we fail fast if CoreAudio is unhappy + // (e.g. headless CI, broken HAL). + ca::System::default_input_device() + .map_err(|e| anyhow!("Failed to get default input device: {:?}", e))?; + Ok(Self) + } + + fn snapshot_inner(&self) -> Result { + let device = ca::System::default_input_device() + .map_err(|e| anyhow!("Failed to get default input device: {:?}", e))?; + + let is_running_somewhere = device + .bool_prop(&ca::PropSelector::DEVICE_IS_RUNNING_SOMEWHERE.global_addr()) + .unwrap_or_else(|e| { + // Property-read errors here are unusual — degrade to + // "idle" but leave a breadcrumb so a wedged audio + // subsystem doesn't fail silently forever. + trace!("DeviceIsRunningSomewhere read failed: {:?}", e); + false + }); + + if !is_running_somewhere { + return Ok(MicSnapshot::default()); + } + + let processes = ca::System::processes() + .map_err(|e| anyhow!("Failed to list audio processes: {:?}", e))?; + + let mut active = Vec::new(); + for proc in processes.iter() { + let is_input = match proc.is_running_input() { + Ok(v) => v, + Err(e) => { + // Per-process input property isn't queryable on every + // process (or every macOS version). Skip quietly. + trace!("is_running_input unavailable for proc: {:?}", e); + continue; + } + }; + if !is_input { + continue; + } + match proc.bundle_id() { + Ok(bid) => { + let s = bid.to_string(); + if !s.is_empty() { + active.push(s); + } + } + Err(e) => { + // Command-line tools and some helper processes lack a + // bundle ID; nothing to match against, skip. + trace!("bundle_id unavailable for proc: {:?}", e); + } + } + } + + debug!("mic snapshot: {} active bundle(s) {:?}", active.len(), active); + Ok(MicSnapshot { + active_bundles: active, + }) + } +} + +impl SignalSampler for MacMicActivitySampler { + fn snapshot(&self) -> Result { + match self.snapshot_inner() { + Ok(s) => Ok(s), + Err(e) => { + warn!("mic-activity snapshot failed: {:?}", e); + Ok(MicSnapshot::default()) + } + } + } +} diff --git a/frontend/src-tauri/src/detection/signals/mic_activity/mod.rs b/frontend/src-tauri/src/detection/signals/mic_activity/mod.rs new file mode 100644 index 0000000000..0dedc74753 --- /dev/null +++ b/frontend/src-tauri/src/detection/signals/mic_activity/mod.rs @@ -0,0 +1,84 @@ +//! Mic-activity signal sampler: reports which bundle IDs currently hold +//! the microphone. +//! +//! Platform implementations: +//! - **macOS**: CoreAudio (`kAudioDevicePropertyDeviceIsRunningSomewhere` + +//! per-process `kAudioProcessPropertyIsRunningInput`, macOS 14.2+). +//! - **Windows**: WASAPI `IAudioSessionManager2` session enumeration. +//! - **Linux**: PulseAudio introspection via `libpulse-binding` with a +//! threaded mainloop bridge. +//! - **Other targets** (FreeBSD, etc.): stub sampler returns empty +//! snapshots and detection is a no-op. +//! +//! If the platform sampler fails to construct (headless CI, audio +//! subsystem unavailable, PulseAudio daemon missing), `create()` falls +//! back to the stub sampler so the service loop keeps running idly +//! rather than crashing the app. + +#[cfg(target_os = "macos")] +pub mod macos; + +#[cfg(target_os = "windows")] +pub mod windows; + +#[cfg(target_os = "linux")] +pub mod linux; + +pub mod stub; + +use anyhow::Result; +use log::warn; + +use crate::detection::signals::SignalSampler; + +/// Build the best mic-activity sampler available on the current +/// platform. Never returns `Err` today — platform failures degrade to +/// the stub sampler so the detection service keeps running. +pub fn create() -> Result> { + #[cfg(target_os = "macos")] + { + match macos::MacMicActivitySampler::new() { + Ok(s) => return Ok(Box::new(s)), + Err(e) => { + warn!( + "macOS mic-activity sampler init failed, detection disabled: {}", + e + ); + return Ok(Box::new(stub::StubMicActivitySampler)); + } + } + } + + #[cfg(target_os = "windows")] + { + match windows::WindowsMicActivitySampler::new() { + Ok(s) => return Ok(Box::new(s)), + Err(e) => { + warn!( + "Windows mic-activity sampler init failed, detection disabled: {}", + e + ); + return Ok(Box::new(stub::StubMicActivitySampler)); + } + } + } + + #[cfg(target_os = "linux")] + { + match linux::LinuxMicActivitySampler::new() { + Ok(s) => return Ok(Box::new(s)), + Err(e) => { + warn!( + "Linux mic-activity sampler init failed, detection disabled: {}", + e + ); + return Ok(Box::new(stub::StubMicActivitySampler)); + } + } + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + Ok(Box::new(stub::StubMicActivitySampler)) + } +} diff --git a/frontend/src-tauri/src/detection/signals/mic_activity/stub.rs b/frontend/src-tauri/src/detection/signals/mic_activity/stub.rs new file mode 100644 index 0000000000..e2c6ff09d3 --- /dev/null +++ b/frontend/src-tauri/src/detection/signals/mic_activity/stub.rs @@ -0,0 +1,12 @@ +use anyhow::Result; + +use crate::detection::signals::SignalSampler; +use crate::detection::types::MicSnapshot; + +pub struct StubMicActivitySampler; + +impl SignalSampler for StubMicActivitySampler { + fn snapshot(&self) -> Result { + Ok(MicSnapshot::default()) + } +} diff --git a/frontend/src-tauri/src/detection/signals/mic_activity/windows.rs b/frontend/src-tauri/src/detection/signals/mic_activity/windows.rs new file mode 100644 index 0000000000..39887e0938 --- /dev/null +++ b/frontend/src-tauri/src/detection/signals/mic_activity/windows.rs @@ -0,0 +1,251 @@ +//! Windows mic-activity sampler via WASAPI `IAudioSessionManager2`. +//! +//! Per tick: +//! 1. Enumerate sessions on the default eCapture endpoint. +//! 2. Filter to sessions in `AudioSessionStateActive` (excludes idle +//! sessions that have a capture endpoint open but aren't transmitting). +//! 3. Skip the system-sounds session and our own PID. +//! 4. Resolve remaining PIDs to their EXE basename via +//! `QueryFullProcessImageNameW`. That basename becomes the bundle +//! key the matcher looks up (`Zoom.exe`, `chrome.exe`, ...). +//! +//! No state is cached between ticks — COM objects are acquired and +//! released each call. That's a few sub-millisecond property reads; +//! the `Send + Sync` bound on `SignalSampler` plus COM's `!Send` +//! semantics make caching the enumerator more trouble than it's worth. + +use std::collections::HashSet; +use std::path::Path; +use std::sync::Mutex; + +use anyhow::{anyhow, Context, Result}; +use log::{debug, trace, warn}; +use windows::{ + core::{Interface, PWSTR}, + Win32::{ + Foundation::{CloseHandle, ERROR_ACCESS_DENIED, HANDLE, RPC_E_CHANGED_MODE}, + Media::Audio::{ + eCapture, eConsole, AudioSessionStateActive, IAudioSessionControl2, + IAudioSessionManager2, IMMDeviceEnumerator, MMDeviceEnumerator, + }, + System::{ + Com::{CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED}, + Threading::{ + GetCurrentProcessId, OpenProcess, QueryFullProcessImageNameW, + PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, + }, + }, + }, +}; + +use crate::detection::signals::SignalSampler; +use crate::detection::types::MicSnapshot; + +thread_local! { + /// Tracks whether this thread has already called `CoInitializeEx`. + /// We never call `CoUninitialize` — tokio worker threads live the + /// app's lifetime, so per-thread init once is the correct shape. + static COM_INITIALIZED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +fn ensure_com() { + COM_INITIALIZED.with(|flag| { + if flag.get() { + return; + } + // SAFETY: CoInitializeEx is always safe to call; it returns + // S_OK on first init, S_FALSE if already initialized to the + // same apartment, RPC_E_CHANGED_MODE if a different apartment + // was previously set. All three are fine for our purposes — + // we only need *some* COM apartment on this thread. + let hr = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + if hr.is_err() && hr != RPC_E_CHANGED_MODE { + warn!("CoInitializeEx failed: {:?}", hr); + } + flag.set(true); + }); +} + +pub struct WindowsMicActivitySampler { + own_pid: u32, + /// PIDs for which OpenProcess has already been denied. Used to + /// warn once-per-PID rather than spamming logs every tick. Typical + /// cause: EDR software (Defender for Endpoint, CrowdStrike) blocks + /// cross-process queries for its protected processes. + warned_denied_pids: Mutex>, +} + +impl WindowsMicActivitySampler { + pub fn new() -> Result { + ensure_com(); + let own_pid = unsafe { GetCurrentProcessId() }; + + // Probe on construction so we fail fast if WASAPI is broken + // (headless CI, no audio subsystem). The factory falls back to + // the stub sampler in that case. + unsafe { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) + .map_err(|e| anyhow!("CoCreateInstance IMMDeviceEnumerator failed: {:?}", e))?; + let _device = enumerator + .GetDefaultAudioEndpoint(eCapture, eConsole) + .map_err(|e| anyhow!("No default capture endpoint: {:?}", e))?; + } + + Ok(Self { + own_pid, + warned_denied_pids: Mutex::new(HashSet::new()), + }) + } + + fn snapshot_inner(&self) -> Result { + ensure_com(); + + // SAFETY: every call below is a normal WASAPI / COM pattern. + // `windows` crate handles ref-counting via Drop; per-tick + // acquire-and-release is bounded and cheap. + unsafe { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) + .context("CoCreateInstance IMMDeviceEnumerator")?; + + let device = enumerator + .GetDefaultAudioEndpoint(eCapture, eConsole) + .context("GetDefaultAudioEndpoint(eCapture)")?; + + let session_manager: IAudioSessionManager2 = device + .Activate(CLSCTX_ALL, None) + .context("Activate IAudioSessionManager2")?; + + let enum_sessions = session_manager + .GetSessionEnumerator() + .context("GetSessionEnumerator")?; + + let count = enum_sessions.GetCount().context("GetCount")?; + + let mut active = Vec::new(); + + for i in 0..count { + let session = match enum_sessions.GetSession(i) { + Ok(s) => s, + Err(e) => { + trace!("GetSession({}) failed: {:?}", i, e); + continue; + } + }; + + let session2: IAudioSessionControl2 = match session.cast() { + Ok(s) => s, + Err(_) => continue, + }; + + // Note: we don't use `IsSystemSoundsSession()` to skip the + // system session because its Result<()> mapping makes + // S_OK and S_FALSE indistinguishable — checking would + // always "match". Instead we rely on the PID-0 filter + // below, which is what the system session reports. + + let state = match session2.GetState() { + Ok(s) => s, + Err(_) => continue, + }; + if state != AudioSessionStateActive { + continue; + } + + let pid = match session2.GetProcessId() { + Ok(p) => p, + Err(_) => continue, + }; + if pid == 0 || pid == self.own_pid { + continue; + } + + match self.process_exe_basename(pid) { + Some(name) => active.push(name), + None => trace!("couldn't resolve PID {} to exe name", pid), + } + } + + debug!( + "Windows mic snapshot: {} active {:?}", + active.len(), + active + ); + Ok(MicSnapshot { + active_bundles: active, + }) + } + } +} + +impl WindowsMicActivitySampler { + fn process_exe_basename(&self, pid: u32) -> Option { + // SAFETY: OpenProcess / QueryFullProcessImageNameW / CloseHandle + // form a standard Win32 pattern. We use PROCESS_QUERY_LIMITED_INFORMATION + // which is the least-privileged access that still lets us read the + // image path, minimising AV false positives. + unsafe { + match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) { + Ok(handle) => { + let result = query_image_basename(handle); + let _ = CloseHandle(handle); + result + } + Err(e) => { + if e.code() == ERROR_ACCESS_DENIED.to_hresult() { + // Warn once per PID — EDR-protected processes + // routinely deny even PROCESS_QUERY_LIMITED_INFORMATION. + // Without this breadcrumb the feature appears + // permanently broken to the user. + let mut warned = self.warned_denied_pids.lock().unwrap(); + if warned.insert(pid) { + warn!( + "OpenProcess denied for pid {} (likely EDR-protected); \ + {} unique PIDs seen so far", + pid, + warned.len() + ); + } + } else { + trace!("OpenProcess({}) failed: {:?}", pid, e); + } + None + } + } + } + } +} + +unsafe fn query_image_basename(handle: HANDLE) -> Option { + let mut buf = [0u16; 512]; + let mut size = buf.len() as u32; + QueryFullProcessImageNameW( + handle, + PROCESS_NAME_WIN32, + PWSTR(buf.as_mut_ptr()), + &mut size, + ) + .ok()?; + let len = size as usize; + if len == 0 || len > buf.len() { + return None; + } + let path = String::from_utf16_lossy(&buf[..len]); + Path::new(&path) + .file_name() + .and_then(|os| os.to_str()) + .map(|s| s.to_string()) +} + +impl SignalSampler for WindowsMicActivitySampler { + fn snapshot(&self) -> Result { + match self.snapshot_inner() { + Ok(s) => Ok(s), + Err(e) => { + warn!("Windows mic-activity snapshot failed: {}", e); + Ok(MicSnapshot::default()) + } + } + } +} diff --git a/frontend/src-tauri/src/detection/signals/mod.rs b/frontend/src-tauri/src/detection/signals/mod.rs new file mode 100644 index 0000000000..e51c4a47d7 --- /dev/null +++ b/frontend/src-tauri/src/detection/signals/mod.rs @@ -0,0 +1,14 @@ +//! Signal samplers: platform-specific code that reports which bundles +//! currently hold the microphone. + +use anyhow::Result; + +use crate::detection::types::MicSnapshot; + +pub mod mic_activity; + +/// A source of `MicSnapshot` samples. Implementations are polled by the +/// detection service on a fixed cadence. +pub trait SignalSampler: Send + Sync { + fn snapshot(&self) -> Result; +} diff --git a/frontend/src-tauri/src/detection/state.rs b/frontend/src-tauri/src/detection/state.rs new file mode 100644 index 0000000000..5d2d56ce1f --- /dev/null +++ b/frontend/src-tauri/src/detection/state.rs @@ -0,0 +1,691 @@ +//! Meeting-detection state machine. +//! +//! Pure logic — takes a snapshot of which bundles currently hold the mic +//! plus the current time, returns any events that should fire. All timing +//! is parameterized via `Instant`, so tests can simulate minutes of elapsed +//! time in microseconds. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use log::{debug, info}; +use serde::Serialize; + +use crate::detection::matcher; +use crate::detection::types::{DetectedMeeting, DetectionEvent, MicSnapshot}; + +#[derive(Debug, Clone, Copy)] +pub struct DetectorConfig { + /// Sustain threshold for bundles in the allowlist (Zoom, Teams, + /// browsers, etc). High-confidence — fire fast. + pub known_sustain_duration: Duration, + /// Sustain threshold for unknown bundles. Conservative flicker + /// guard so random apps briefly grabbing the mic don't fire a + /// banner. + pub unknown_sustain_duration: Duration, + /// Time of continuous mic silence before firing MeetingEnded. + pub end_silence: Duration, + /// Time we require continuous mic activity after an `Ending` flicker + /// to confirm the meeting is actually still going. Absorbs transient + /// hand-off / mute / device-re-select blips. + pub ending_reacquire_confirm: Duration, + /// Cooldown after an explicit user dismissal (tap "ignore this app" + /// on the banner — wired via the Tauri command surface). + pub dismissal_cooldown: Duration, +} + +impl DetectorConfig { + pub const DEFAULT: Self = Self { + known_sustain_duration: Duration::from_secs(10), + unknown_sustain_duration: Duration::from_secs(30), + end_silence: Duration::from_secs(30), + ending_reacquire_confirm: Duration::from_secs(3), + dismissal_cooldown: Duration::from_secs(600), + }; +} + +impl Default for DetectorConfig { + fn default() -> Self { + Self::DEFAULT + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Phase { + /// Nothing interesting is happening. + Idle, + /// A bundle has started holding the mic but hasn't hit the sustain threshold. + Sustaining { bundle: String, since: Instant }, + /// A bundle crossed the threshold; notification was fired. + Detected { bundle: String }, + /// A previously-detected bundle released the mic; counting down to + /// end-silence. `reacquire_since` tracks whether we've since seen + /// the bundle come back — used to debounce flicker. + Ending { + bundle: String, + silence_since: Instant, + reacquire_since: Option, + }, +} + +/// Public snapshot of the detector's current phase. Returned by +/// `phase_snapshot()` for Tauri command consumers / UI / agents. +#[derive(Debug, Clone, Serialize)] +pub struct DetectorPhaseSnapshot { + /// One of `"idle" | "sustaining" | "detected" | "ending"`. + pub phase: &'static str, + pub display_name: Option, + pub elapsed_ms: Option, + pub remaining_ms: Option, + pub is_recording: bool, +} + +pub struct DetectorState { + phase: Phase, + /// Tracks bundles the user has explicitly dismissed. Reaped on a + /// cadence (see `REAP_EVERY_N_ADVANCES`). + dismissed: HashMap, + /// Increments each `advance()`. Triggers periodic dismissal reap. + advance_count: u32, + /// Pushed in from the audio layer via `set_recording()`. Gates + /// whether MeetingEnded fires. + is_recording: bool, + config: DetectorConfig, +} + +/// Reap expired dismissal entries every N advances. At 1s cadence +/// that's once per minute; cheap. +const REAP_EVERY_N_ADVANCES: u32 = 60; + +impl DetectorState { + pub fn new(config: DetectorConfig) -> Self { + Self { + phase: Phase::Idle, + dismissed: HashMap::new(), + advance_count: 0, + is_recording: false, + config, + } + } + + /// Update the recording flag. Called from the audio layer when + /// recording starts/stops. + pub fn set_recording(&mut self, recording: bool) { + self.is_recording = recording; + } + + /// Advance the state machine with a new snapshot. Returns an event + /// if a detected/ended transition just fired. + pub fn advance(&mut self, now: Instant, snapshot: &MicSnapshot) -> Option { + self.advance_count = self.advance_count.wrapping_add(1); + if self.advance_count % REAP_EVERY_N_ADVANCES == 0 { + self.reap_dismissed(now); + } + + let candidate = matcher::pick_best( + snapshot + .active_bundles + .iter() + .filter(|b| !self.is_dismissed(b, now)) + .map(String::as_str), + ) + .map(|s| s.to_string()); + + match self.phase.clone() { + Phase::Idle => { + if let Some(bundle) = candidate { + info!("detection: Idle → Sustaining"); + debug!("detection: Idle → Sustaining({})", bundle); + self.phase = Phase::Sustaining { + bundle, + since: now, + }; + } + None + } + Phase::Sustaining { bundle, since } => { + // Upgrade to a strictly-higher-priority candidate if + // one has appeared since we entered Sustaining. + if let Some(next) = candidate.as_deref() { + if next != bundle + && matcher::priority_of(next) < matcher::priority_of(&bundle) + { + info!("detection: Sustaining → Sustaining (priority upgrade)"); + debug!("detection: Sustaining({}) → Sustaining({}) (upgraded)", bundle, next); + self.phase = Phase::Sustaining { + bundle: next.to_string(), + since: now, + }; + return None; + } + } + if !snapshot.contains(&bundle) { + // Lost the sustain candidate before threshold — restart or go idle. + if let Some(next) = candidate { + info!("detection: Sustaining → Sustaining (switched)"); + debug!("detection: Sustaining({}) → Sustaining({}) (switched)", bundle, next); + self.phase = Phase::Sustaining { + bundle: next, + since: now, + }; + } else { + info!("detection: Sustaining → Idle (flicker, under threshold)"); + debug!("detection: Sustaining({}) dropped", bundle); + self.phase = Phase::Idle; + } + return None; + } + let threshold = if matcher::is_known(&bundle) { + self.config.known_sustain_duration + } else { + self.config.unknown_sustain_duration + }; + if now.saturating_duration_since(since) >= threshold { + let display_name = matcher::display_name(&bundle).to_string(); + info!( + "detection: Sustaining → Detected ({:?} threshold); firing MeetingDetected {}", + threshold, display_name + ); + debug!("detection: Detected bundle={}", bundle); + self.phase = Phase::Detected { + bundle: bundle.clone(), + }; + return Some(DetectionEvent::MeetingDetected(DetectedMeeting { + display_name, + bundle_id: bundle, + })); + } + None + } + Phase::Detected { bundle } => { + if snapshot.contains(&bundle) { + return None; + } + // Mic released — enter Ending and start counting. + info!("detection: Detected → Ending (mic released)"); + debug!("detection: Detected({}) → Ending", bundle); + self.phase = Phase::Ending { + bundle, + silence_since: now, + reacquire_since: None, + }; + None + } + Phase::Ending { + bundle, + silence_since, + reacquire_since, + } => { + if snapshot.contains(&bundle) { + // Bundle came back. Start — or continue — tracking + // a sustained reacquire. Only flip back to Detected + // after it's held the mic continuously for + // `ending_reacquire_confirm` (absorbs sub-Ns flicker). + let reacquire_since = reacquire_since.unwrap_or(now); + if now.saturating_duration_since(reacquire_since) + >= self.config.ending_reacquire_confirm + { + info!("detection: Ending → Detected (mic reacquired, sustained)"); + debug!("detection: Ending({}) → Detected", bundle); + self.phase = Phase::Detected { bundle }; + } else { + debug!( + "detection: Ending reacquire-debouncing ({}ms / {}ms)", + now.saturating_duration_since(reacquire_since).as_millis(), + self.config.ending_reacquire_confirm.as_millis() + ); + self.phase = Phase::Ending { + bundle, + silence_since, + reacquire_since: Some(reacquire_since), + }; + } + return None; + } + // Still silent. If we'd been tracking a reacquire, drop + // it; the silence_since clock continues. + if reacquire_since.is_some() { + self.phase = Phase::Ending { + bundle: bundle.clone(), + silence_since, + reacquire_since: None, + }; + } + if now.saturating_duration_since(silence_since) < self.config.end_silence { + return None; + } + // Sustained silence: fire end (if recording) and go idle. + // We do NOT auto-dismiss the bundle here — a new meeting in + // the same app after 30s of silence is a genuinely new + // session, and should re-detect normally. The dismissal + // cooldown only fires on explicit user action. + let display_name = matcher::display_name(&bundle).to_string(); + let meeting = DetectedMeeting { + display_name: display_name.clone(), + bundle_id: bundle.clone(), + }; + self.phase = Phase::Idle; + + if self.is_recording { + info!("detection: Ending → Idle; firing MeetingEnded {}", display_name); + debug!("detection: Ending({}) → Idle (fire end)", bundle); + Some(DetectionEvent::MeetingEnded(meeting)) + } else { + info!("detection: Ending → Idle; not recording, suppressing end-banner"); + debug!("detection: Ending({}) → Idle (suppressed)", bundle); + None + } + } + } + } + + /// Suppress further detected-events for this bundle for the cooldown window. + pub fn dismiss(&mut self, bundle_id: &str, now: Instant) { + let until = now + self.config.dismissal_cooldown; + self.dismissed.insert(bundle_id.to_string(), until); + debug!("detection: dismissed bundle for {:?}", self.config.dismissal_cooldown); + } + + fn is_dismissed(&self, bundle_id: &str, now: Instant) -> bool { + self.dismissed + .get(bundle_id) + .map(|until| *until > now) + .unwrap_or(false) + } + + fn reap_dismissed(&mut self, now: Instant) { + let before = self.dismissed.len(); + self.dismissed.retain(|_, until| *until > now); + let after = self.dismissed.len(); + if before != after { + debug!("detection: reaped {} expired dismissals ({} remain)", before - after, after); + } + } + + /// Produce a serializable snapshot of current phase + timing. + pub fn phase_snapshot(&self, now: Instant) -> DetectorPhaseSnapshot { + match &self.phase { + Phase::Idle => DetectorPhaseSnapshot { + phase: "idle", + display_name: None, + elapsed_ms: None, + remaining_ms: None, + is_recording: self.is_recording, + }, + Phase::Sustaining { bundle, since } => { + let threshold = if matcher::is_known(bundle) { + self.config.known_sustain_duration + } else { + self.config.unknown_sustain_duration + }; + let elapsed = now.saturating_duration_since(*since); + DetectorPhaseSnapshot { + phase: "sustaining", + display_name: Some(matcher::display_name(bundle).to_string()), + elapsed_ms: Some(elapsed.as_millis() as u64), + remaining_ms: Some(threshold.saturating_sub(elapsed).as_millis() as u64), + is_recording: self.is_recording, + } + } + Phase::Detected { bundle } => DetectorPhaseSnapshot { + phase: "detected", + display_name: Some(matcher::display_name(bundle).to_string()), + elapsed_ms: None, + remaining_ms: None, + is_recording: self.is_recording, + }, + Phase::Ending { + bundle, + silence_since, + .. + } => { + let elapsed = now.saturating_duration_since(*silence_since); + DetectorPhaseSnapshot { + phase: "ending", + display_name: Some(matcher::display_name(bundle).to_string()), + elapsed_ms: Some(elapsed.as_millis() as u64), + remaining_ms: Some( + self.config.end_silence.saturating_sub(elapsed).as_millis() as u64, + ), + is_recording: self.is_recording, + } + } + } + } + + #[cfg(test)] + fn is_idle(&self) -> bool { + matches!(self.phase, Phase::Idle) + } + + #[cfg(test)] + fn is_detected(&self) -> bool { + matches!(self.phase, Phase::Detected { .. }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot(bundles: &[&str]) -> MicSnapshot { + MicSnapshot { + active_bundles: bundles.iter().map(|s| s.to_string()).collect(), + } + } + + fn test_config() -> DetectorConfig { + DetectorConfig::DEFAULT + } + + #[test] + fn idle_stays_idle_with_no_active_bundles() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + assert_eq!(s.advance(t0, &snapshot(&[])), None); + assert!(s.is_idle()); + } + + #[test] + fn idle_ignores_blocklisted_bundles() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + assert_eq!(s.advance(t0, &snapshot(&["com.meetily.ai"])), None); + assert!(s.is_idle()); + } + + #[test] + fn known_app_detects_after_short_threshold() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + + assert_eq!(s.advance(t0, &snapshot(&["us.zoom.xos"])), None); + assert!(!s.is_idle()); + + let t1 = t0 + Duration::from_secs(9); + assert_eq!(s.advance(t1, &snapshot(&["us.zoom.xos"])), None); + + let t2 = t0 + Duration::from_secs(10); + let ev = s.advance(t2, &snapshot(&["us.zoom.xos"])); + assert!(matches!( + ev, + Some(DetectionEvent::MeetingDetected(ref m)) if m.bundle_id == "us.zoom.xos" && m.display_name == "Zoom" + )); + assert!(s.is_detected()); + } + + #[test] + fn unknown_app_waits_for_long_threshold() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + + s.advance(t0, &snapshot(&["com.niche.meetingtool"])); + + let t1 = t0 + Duration::from_secs(20); + assert_eq!(s.advance(t1, &snapshot(&["com.niche.meetingtool"])), None); + + let t2 = t0 + Duration::from_secs(29); + assert_eq!(s.advance(t2, &snapshot(&["com.niche.meetingtool"])), None); + + let t3 = t0 + Duration::from_secs(30); + let ev = s.advance(t3, &snapshot(&["com.niche.meetingtool"])); + assert!(matches!( + ev, + Some(DetectionEvent::MeetingDetected(ref m)) if m.display_name == "a meeting" + )); + } + + #[test] + fn flicker_under_sustain_drops_to_idle() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + + s.advance(t0, &snapshot(&["us.zoom.xos"])); + let t1 = t0 + Duration::from_secs(5); + assert_eq!(s.advance(t1, &snapshot(&[])), None); + assert!(s.is_idle()); + } + + #[test] + fn unknown_app_fires_generic_banner() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + + s.advance(t0, &snapshot(&["com.niche.meetingtool"])); + let t1 = t0 + Duration::from_secs(30); + let ev = s.advance(t1, &snapshot(&["com.niche.meetingtool"])); + match ev { + Some(DetectionEvent::MeetingDetected(m)) => { + assert_eq!(m.display_name, "a meeting"); + assert_eq!(m.bundle_id, "com.niche.meetingtool"); + } + other => panic!("expected MeetingDetected, got {:?}", other), + } + } + + #[test] + fn end_fires_after_silence_when_recording() { + let mut s = DetectorState::new(test_config()); + s.set_recording(true); + let t0 = Instant::now(); + + s.advance(t0, &snapshot(&["us.zoom.xos"])); + s.advance(t0 + Duration::from_secs(30), &snapshot(&["us.zoom.xos"])); + assert!(s.is_detected()); + + let t_released = t0 + Duration::from_secs(60); + assert_eq!(s.advance(t_released, &snapshot(&[])), None); + + assert_eq!( + s.advance(t_released + Duration::from_secs(29), &snapshot(&[])), + None + ); + + let ev = s.advance(t_released + Duration::from_secs(30), &snapshot(&[])); + assert!(matches!( + ev, + Some(DetectionEvent::MeetingEnded(ref m)) if m.bundle_id == "us.zoom.xos" + )); + assert!(s.is_idle()); + } + + #[test] + fn end_does_not_fire_when_not_recording() { + let mut s = DetectorState::new(test_config()); + // is_recording defaults to false + let t0 = Instant::now(); + + s.advance(t0, &snapshot(&["us.zoom.xos"])); + s.advance(t0 + Duration::from_secs(30), &snapshot(&["us.zoom.xos"])); + + let t_released = t0 + Duration::from_secs(60); + s.advance(t_released, &snapshot(&[])); + let ev = s.advance(t_released + Duration::from_secs(30), &snapshot(&[])); + assert_eq!(ev, None); + assert!(s.is_idle()); + } + + #[test] + fn ending_returns_to_detected_on_sustained_reacquire() { + let mut s = DetectorState::new(test_config()); + s.set_recording(true); + let t0 = Instant::now(); + + s.advance(t0, &snapshot(&["us.zoom.xos"])); + s.advance(t0 + Duration::from_secs(30), &snapshot(&["us.zoom.xos"])); + + // Release. + let t_release = t0 + Duration::from_secs(60); + s.advance(t_release, &snapshot(&[])); + + // Mic reacquired but only for 2s (under the 3s confirm) — stays Ending. + let t_reacq = t_release + Duration::from_secs(10); + s.advance(t_reacq, &snapshot(&["us.zoom.xos"])); + assert!(!s.is_detected()); + + // Still holding mic 3s after first reacquire — promote to Detected. + let t_confirmed = t_reacq + Duration::from_secs(3); + assert_eq!(s.advance(t_confirmed, &snapshot(&["us.zoom.xos"])), None); + assert!(s.is_detected()); + } + + #[test] + fn sub_3s_flicker_does_not_promote_from_ending() { + let mut s = DetectorState::new(test_config()); + s.set_recording(true); + let t0 = Instant::now(); + + s.advance(t0, &snapshot(&["us.zoom.xos"])); + s.advance(t0 + Duration::from_secs(30), &snapshot(&["us.zoom.xos"])); + + let t_release = t0 + Duration::from_secs(60); + s.advance(t_release, &snapshot(&[])); + + // Mic tick of 1s, then silent again, then another 1s tick later. + let t_flicker_start = t_release + Duration::from_secs(5); + s.advance(t_flicker_start, &snapshot(&["us.zoom.xos"])); + s.advance(t_flicker_start + Duration::from_secs(1), &snapshot(&[])); + s.advance(t_flicker_start + Duration::from_secs(3), &snapshot(&["us.zoom.xos"])); + // Short reacquires reset the reacquire clock; state stays Ending. + assert!(!s.is_detected()); + assert!(!s.is_idle()); + + // After another 30s of continuous silence from release, end fires. + let t_end = t_release + Duration::from_secs(30); + // Ensure we give silence before the end fires. + s.advance(t_end - Duration::from_secs(1), &snapshot(&[])); + let ev = s.advance(t_end, &snapshot(&[])); + assert!(matches!(ev, Some(DetectionEvent::MeetingEnded(_)))); + } + + #[test] + fn dismissal_suppresses_redetect_within_cooldown() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + + s.dismiss("us.zoom.xos", t0); + + let t_later = t0 + Duration::from_secs(300); + s.advance(t_later, &snapshot(&["us.zoom.xos"])); + let t_still_later = t_later + Duration::from_secs(60); + assert_eq!( + s.advance(t_still_later, &snapshot(&["us.zoom.xos"])), + None + ); + assert!(s.is_idle()); + } + + #[test] + fn dismissal_expires_after_cooldown() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + + s.dismiss("us.zoom.xos", t0); + + let t_later = t0 + Duration::from_secs(660); + s.advance(t_later, &snapshot(&["us.zoom.xos"])); + let t_sustained = t_later + Duration::from_secs(30); + let ev = s.advance(t_sustained, &snapshot(&["us.zoom.xos"])); + assert!(matches!(ev, Some(DetectionEvent::MeetingDetected(_)))); + } + + #[test] + fn natural_end_does_not_suppress_next_meeting() { + let mut s = DetectorState::new(test_config()); + s.set_recording(true); + let t0 = Instant::now(); + + s.advance(t0, &snapshot(&["us.zoom.xos"])); + s.advance(t0 + Duration::from_secs(30), &snapshot(&["us.zoom.xos"])); + let t_released = t0 + Duration::from_secs(60); + s.advance(t_released, &snapshot(&[])); + let ev_end = s.advance(t_released + Duration::from_secs(30), &snapshot(&[])); + assert!(matches!(ev_end, Some(DetectionEvent::MeetingEnded(_)))); + assert!(s.is_idle()); + + // Not recording for the second session to avoid interference, + // but that's unrelated — point is re-detect should fire. + s.set_recording(false); + let t_new = t_released + Duration::from_secs(120); + s.advance(t_new, &snapshot(&["us.zoom.xos"])); + let ev_new = s.advance(t_new + Duration::from_secs(30), &snapshot(&["us.zoom.xos"])); + assert!(matches!( + ev_new, + Some(DetectionEvent::MeetingDetected(ref m)) if m.bundle_id == "us.zoom.xos" + )); + } + + #[test] + fn higher_priority_app_wins_when_both_active() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + + s.advance(t0, &snapshot(&["com.google.Chrome", "us.zoom.xos"])); + let ev = s.advance( + t0 + Duration::from_secs(30), + &snapshot(&["com.google.Chrome", "us.zoom.xos"]), + ); + match ev { + Some(DetectionEvent::MeetingDetected(m)) => assert_eq!(m.bundle_id, "us.zoom.xos"), + other => panic!("expected MeetingDetected, got {:?}", other), + } + } + + #[test] + fn sustaining_upgrades_to_higher_priority_late_arrival() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + + // Chrome arrives first; sustain clock starts. + s.advance(t0, &snapshot(&["com.google.Chrome"])); + // 5s later, Zoom joins — should upgrade and reset the sustain clock. + let t5 = t0 + Duration::from_secs(5); + s.advance(t5, &snapshot(&["com.google.Chrome", "us.zoom.xos"])); + + // 10s after upgrade (t5+10 = t0+15): Zoom crosses its 10s known-app + // threshold. Chrome's clock would have been ignored. + let t15 = t5 + Duration::from_secs(10); + let ev = s.advance(t15, &snapshot(&["com.google.Chrome", "us.zoom.xos"])); + match ev { + Some(DetectionEvent::MeetingDetected(m)) => assert_eq!(m.bundle_id, "us.zoom.xos"), + other => panic!("expected MeetingDetected for Zoom, got {:?}", other), + } + } + + #[test] + fn phase_snapshot_idle() { + let s = DetectorState::new(test_config()); + let snap = s.phase_snapshot(Instant::now()); + assert_eq!(snap.phase, "idle"); + assert!(snap.display_name.is_none()); + assert!(!snap.is_recording); + } + + #[test] + fn phase_snapshot_sustaining_reports_remaining() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + s.advance(t0, &snapshot(&["us.zoom.xos"])); + let snap = s.phase_snapshot(t0 + Duration::from_secs(3)); + assert_eq!(snap.phase, "sustaining"); + assert_eq!(snap.display_name.as_deref(), Some("Zoom")); + // 10s threshold - 3s elapsed = 7000ms remaining + assert!(snap.remaining_ms.unwrap() <= 7_000 && snap.remaining_ms.unwrap() > 6_500); + } + + #[test] + fn dismissed_reaper_removes_expired_entries() { + let mut s = DetectorState::new(test_config()); + let t0 = Instant::now(); + s.dismiss("com.example.one", t0); + s.dismiss("com.example.two", t0); + assert_eq!(s.dismissed.len(), 2); + + // Jump past cooldown + past the reap cadence to trigger cleanup. + let t_past = t0 + Duration::from_secs(700); + for _ in 0..REAP_EVERY_N_ADVANCES { + s.advance(t_past, &snapshot(&[])); + } + assert_eq!(s.dismissed.len(), 0); + } +} diff --git a/frontend/src-tauri/src/detection/types.rs b/frontend/src-tauri/src/detection/types.rs new file mode 100644 index 0000000000..1e1b2c8d50 --- /dev/null +++ b/frontend/src-tauri/src/detection/types.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +/// A meeting detected by the mic-activity signal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DetectedMeeting { + pub bundle_id: String, + pub display_name: String, +} + +/// Events the detector emits as its internal state advances. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DetectionEvent { + /// A meeting has crossed the sustain threshold. + MeetingDetected(DetectedMeeting), + /// A previously-detected meeting has released the mic for the end-silence window. + MeetingEnded(DetectedMeeting), +} + +/// Snapshot of which non-Meetily apps are currently holding the mic. +#[derive(Debug, Clone, Default)] +pub struct MicSnapshot { + pub active_bundles: Vec, +} + +impl MicSnapshot { + pub fn contains(&self, bundle: &str) -> bool { + self.active_bundles.iter().any(|b| b == bundle) + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 965356c3c5..28ba2a715c 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -102,6 +102,7 @@ pub mod audio; pub mod config; pub mod console_utils; pub mod database; +pub mod detection; pub mod notifications; pub mod ollama; pub mod onboarding; @@ -495,6 +496,24 @@ pub fn run() { } }); + // Spawn meeting auto-detection (mic-activity) task. + // Recording state is pushed in from `audio::recording_commands` + // via `DetectionService::set_recording`, so detection stays + // decoupled from audio's internal flag implementation. + // + // macOS-only for now. The Windows (WASAPI) and Linux + // (libpulse-binding) samplers compile and are in the tree, + // but neither has been validated on real hardware. Gating + // the spawn call keeps the platform code shipping without + // running it for users. Follow-up PRs will flip these + // cfgs once each platform is validated end-to-end and a + // settings-level kill switch is in place. + #[cfg(target_os = "macos")] + { + let detection_service = detection::spawn(_app.handle().clone()); + _app.manage(detection_service); + } + // Set models directory to use app_data_dir (unified storage location) whisper_engine::commands::set_models_directory(&_app.handle()); @@ -712,6 +731,10 @@ pub fn run() { notifications::commands::test_notification_with_auto_consent, notifications::commands::get_notification_stats, notifications::commands::debug_show_notification, + #[cfg(target_os = "macos")] + detection::commands::dismiss_detected_meeting, + #[cfg(target_os = "macos")] + detection::commands::get_detection_state, // System audio capture commands audio::system_audio_commands::start_system_audio_capture_command, audio::system_audio_commands::list_system_audio_devices_command, @@ -759,6 +782,15 @@ pub fn run() { .run(|_app_handle, event| { if let tauri::RunEvent::Exit = event { log::info!("Application exiting, cleaning up resources..."); + + // Signal the detection poll task to exit before tauri + // starts dropping AppHandle / state. macOS-only — the + // service is never managed on other platforms. + #[cfg(target_os = "macos")] + if let Some(svc) = _app_handle.try_state::() { + svc.shutdown(); + } + tauri::async_runtime::block_on(async { // Clean up database connection and checkpoint WAL if let Some(app_state) = _app_handle.try_state::() { diff --git a/frontend/src-tauri/src/notifications/commands.rs b/frontend/src-tauri/src/notifications/commands.rs index 985f5df85c..2c69d0590e 100644 --- a/frontend/src-tauri/src/notifications/commands.rs +++ b/frontend/src-tauri/src/notifications/commands.rs @@ -28,6 +28,8 @@ pub enum DebugNotificationKind { MeetingReminder, SystemError, Test, + MeetingDetected, + MeetingEnded, } /// Initialize the notification manager (called during app setup) @@ -338,6 +340,13 @@ pub async fn debug_show_notification( None => Err(anyhow::anyhow!("Notification manager not initialized")), } } + DebugNotificationKind::MeetingDetected => { + show_meeting_detected_notification(&app, manager_state.inner(), "Zoom".to_string()) + .await + } + DebugNotificationKind::MeetingEnded => { + show_meeting_ended_notification(&app, manager_state.inner(), "Zoom".to_string()).await + } }; result.map_err(|e| format!("Failed to show debug notification: {}", e)) @@ -563,4 +572,69 @@ pub async fn show_meeting_reminder_notification( log_error!("Cannot show meeting reminder notification: manager not initialized"); Ok(()) } +} + +/// Show "Meeting detected" notification. Lazy-initializes the manager on +/// first call, matching `show_recording_started_notification`. +pub async fn show_meeting_detected_notification( + app_handle: &tauri::AppHandle, + manager_state: &NotificationManagerState, + app_name: String, +) -> Result<()> { + log_info!("Attempting to show meeting-detected notification for: {}", app_name); + + let manager_lock = manager_state.read().await; + if let Some(manager) = manager_lock.as_ref() { + return manager.show_meeting_detected(app_name).await; + } + drop(manager_lock); + + match initialize_notification_manager(app_handle.clone()).await { + Ok(manager) => { + let mut state_lock = manager_state.write().await; + *state_lock = Some(manager); + drop(state_lock); + let manager_lock = manager_state.read().await; + match manager_lock.as_ref() { + Some(manager) => manager.show_meeting_detected(app_name).await, + None => Ok(()), + } + } + Err(e) => { + log_error!("Failed to init notification manager for meeting-detected: {}", e); + Ok(()) + } + } +} + +/// Show "Meeting ended" notification. Same pattern as meeting-detected. +pub async fn show_meeting_ended_notification( + app_handle: &tauri::AppHandle, + manager_state: &NotificationManagerState, + app_name: String, +) -> Result<()> { + log_info!("Attempting to show meeting-ended notification for: {}", app_name); + + let manager_lock = manager_state.read().await; + if let Some(manager) = manager_lock.as_ref() { + return manager.show_meeting_ended(app_name).await; + } + drop(manager_lock); + + match initialize_notification_manager(app_handle.clone()).await { + Ok(manager) => { + let mut state_lock = manager_state.write().await; + *state_lock = Some(manager); + drop(state_lock); + let manager_lock = manager_state.read().await; + match manager_lock.as_ref() { + Some(manager) => manager.show_meeting_ended(app_name).await, + None => Ok(()), + } + } + Err(e) => { + log_error!("Failed to init notification manager for meeting-ended: {}", e); + Ok(()) + } + } } \ No newline at end of file diff --git a/frontend/src-tauri/src/notifications/manager.rs b/frontend/src-tauri/src/notifications/manager.rs index 69daf7eb3e..329eb992f9 100644 --- a/frontend/src-tauri/src/notifications/manager.rs +++ b/frontend/src-tauri/src/notifications/manager.rs @@ -189,6 +189,28 @@ impl NotificationManager { self.system_handler.show_notification(notification).await } + /// Show a "Meeting detected" notification (mic-activity detection). + pub async fn show_meeting_detected(&self, app_name: String) -> Result<()> { + let settings = self.settings.read().await; + if !settings.notification_preferences.show_meeting_detected { + return Ok(()); + } + drop(settings); + let notification = Notification::meeting_detected(app_name); + self.show_notification(notification).await + } + + /// Show a "Meeting ended" notification (mic-activity detection). + pub async fn show_meeting_ended(&self, app_name: String) -> Result<()> { + let settings = self.settings.read().await; + if !settings.notification_preferences.show_meeting_ended { + return Ok(()); + } + drop(settings); + let notification = Notification::meeting_ended(app_name); + self.show_notification(notification).await + } + /// Get current notification settings pub async fn get_settings(&self) -> NotificationSettings { self.settings.read().await.clone() @@ -301,6 +323,8 @@ impl NotificationManager { NotificationType::MeetingReminder(_) => settings.notification_preferences.show_meeting_reminders, NotificationType::SystemError(_) => settings.notification_preferences.show_system_errors, NotificationType::Test => true, // Always show test notifications + NotificationType::MeetingDetected(_) => settings.notification_preferences.show_meeting_detected, + NotificationType::MeetingEnded(_) => settings.notification_preferences.show_meeting_ended, } } diff --git a/frontend/src-tauri/src/notifications/settings.rs b/frontend/src-tauri/src/notifications/settings.rs index 0b22699ffd..fb9b32eae3 100644 --- a/frontend/src-tauri/src/notifications/settings.rs +++ b/frontend/src-tauri/src/notifications/settings.rs @@ -60,6 +60,19 @@ pub struct NotificationPreferences { /// Minutes before meeting to show reminder (0 = disabled) pub meeting_reminder_minutes: Vec, + + /// Show "Meeting detected" banner when mic-activity detection fires. + #[serde(default = "default_true")] + pub show_meeting_detected: bool, + + /// Show "Meeting ended" banner when mic-activity detection fires + /// during an active recording. + #[serde(default = "default_true")] + pub show_meeting_ended: bool, +} + +fn default_true() -> bool { + true } impl Default for NotificationSettings { @@ -89,6 +102,8 @@ impl Default for NotificationPreferences { show_meeting_reminders: true, show_system_errors: true, meeting_reminder_minutes: vec![15, 5], // 15 minutes and 5 minutes before + show_meeting_detected: true, + show_meeting_ended: true, } } } diff --git a/frontend/src-tauri/src/notifications/types.rs b/frontend/src-tauri/src/notifications/types.rs index dd469760a7..7a0ffbe31e 100644 --- a/frontend/src-tauri/src/notifications/types.rs +++ b/frontend/src-tauri/src/notifications/types.rs @@ -23,6 +23,14 @@ pub enum NotificationType { MeetingReminder(u64), // Duration in minutes SystemError(String), Test, // For testing notifications + /// Auto-detected meeting start — a non-Meetily app has held the mic + /// past the sustain threshold. Payload is the detected app's display + /// name (e.g. "Zoom", "a browser meeting", "a meeting"). + MeetingDetected(String), + /// Auto-detected meeting end — the previously-detected app released + /// the mic for the full end-silence window while Meetily was + /// recording. Payload is the detected app's display name. + MeetingEnded(String), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -196,4 +204,28 @@ impl Notification { .with_priority(NotificationPriority::Normal) .with_timeout(NotificationTimeout::Seconds(5)) } + + pub fn meeting_detected(app_name: impl Into) -> Self { + let app = app_name.into(); + let body = if app == "a meeting" || app == "a browser meeting" { + format!("Meeting detected — tap to start recording") + } else { + format!("Meeting detected — {}", app) + }; + + Notification::new("Meetily", body, NotificationType::MeetingDetected(app)) + .with_priority(NotificationPriority::High) + .with_timeout(NotificationTimeout::Seconds(10)) + } + + pub fn meeting_ended(app_name: impl Into) -> Self { + let app = app_name.into(); + Notification::new( + "Meetily", + "Meeting ended — tap to stop recording", + NotificationType::MeetingEnded(app), + ) + .with_priority(NotificationPriority::High) + .with_timeout(NotificationTimeout::Seconds(10)) + } } \ No newline at end of file diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx index 6b324990ee..0c1f17d524 100644 --- a/frontend/src/components/About.tsx +++ b/frontend/src/components/About.tsx @@ -22,7 +22,9 @@ type DebugNotificationKind = | 'transcription_complete' | 'meeting_reminder' | 'system_error' - | 'test'; + | 'test' + | 'meeting_detected' + | 'meeting_ended'; // `prefKey` matches a Rust NotificationPreferences field; omitted when a type isn't gated by preference. const DEBUG_NOTIFICATION_ITEMS: Array<{ @@ -38,6 +40,8 @@ const DEBUG_NOTIFICATION_ITEMS: Array<{ { kind: 'meeting_reminder', label: 'Meeting reminder (5 min)', prefKey: 'show_meeting_reminders' }, { kind: 'system_error', label: 'System error', prefKey: 'show_system_errors' }, { kind: 'test', label: 'Generic test notification' }, + { kind: 'meeting_detected', label: 'Meeting detected (auto)', prefKey: 'show_meeting_detected' }, + { kind: 'meeting_ended', label: 'Meeting ended (auto)', prefKey: 'show_meeting_ended' }, ];