diff --git a/Cargo.lock b/Cargo.lock index e9454ce9d8..8e6d593eab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17607,11 +17607,11 @@ dependencies = [ "specta-typescript", "tauri", "tauri-plugin", - "tauri-plugin-listener", "tauri-plugin-windows", "tauri-specta", "thiserror 2.0.18", "tokio", + "tokio-util", "tracing", "uuid", ] diff --git a/apps/desktop/src/components/settings/lab/index.tsx b/apps/desktop/src/components/settings/lab/index.tsx index 050aa167da..ef96bef31f 100644 --- a/apps/desktop/src/components/settings/lab/index.tsx +++ b/apps/desktop/src/components/settings/lab/index.tsx @@ -4,6 +4,10 @@ import { arch, platform } from "@tauri-apps/plugin-os"; import { commands as openerCommands } from "@hypr/plugin-opener2"; import { commands as windowsCommands } from "@hypr/plugin-windows"; import { Button } from "@hypr/ui/components/ui/button"; +import { Switch } from "@hypr/ui/components/ui/switch"; + +import { useConfigValue } from "../../../config/use-config"; +import * as settings from "../../../store/tinybase/store/settings"; export function SettingsLab() { const handleOpenControlWindow = async () => { @@ -11,7 +15,7 @@ export function SettingsLab() { }; return ( -
+

Control Overlay

@@ -25,11 +29,40 @@ export function SettingsLab() {
+ +
); } +function MeetingReminderToggle() { + const value = useConfigValue("notification_in_meeting_reminder"); + const setValue = settings.UI.useSetValueCallback( + "notification_in_meeting_reminder", + (value: boolean) => value, + [], + settings.STORE_ID, + ); + + return ( +
+
+

In-Meeting Reminder

+

+ Get a nudge when an app like Zoom or Google Meet has been using your + mic for a few minutes without Hyprnote recording. Helps you never miss + capturing a meeting. +

+
+ setValue(checked)} + /> +
+ ); +} + function DownloadNightlyButton() { const platformName = platform(); const archQuery = useQuery({ diff --git a/apps/desktop/src/config/registry.ts b/apps/desktop/src/config/registry.ts index f20923713e..4e11888c3b 100644 --- a/apps/desktop/src/config/registry.ts +++ b/apps/desktop/src/config/registry.ts @@ -20,7 +20,8 @@ export type ConfigKey = | "telemetry_consent" | "current_llm_provider" | "current_llm_model" - | "timezone"; + | "timezone" + | "notification_in_meeting_reminder"; type ConfigValueType = (typeof CONFIG_REGISTRY)[K]["default"]; @@ -145,4 +146,9 @@ export const CONFIG_REGISTRY = { key: "timezone", default: undefined as string | undefined, }, + + notification_in_meeting_reminder: { + key: "notification_in_meeting_reminder", + default: true, + }, } satisfies Record; diff --git a/apps/desktop/src/contexts/listener.tsx b/apps/desktop/src/contexts/listener.tsx index 6073c7edb0..9353272f93 100644 --- a/apps/desktop/src/contexts/listener.tsx +++ b/apps/desktop/src/contexts/listener.tsx @@ -53,12 +53,20 @@ const useHandleDetectEvents = (store: ListenerStore) => { const stop = useStore(store, (state) => state.stop); const setMuted = useStore(store, (state) => state.setMuted); const notificationDetectEnabled = useConfigValue("notification_detect"); + const inMeetingReminderEnabled = useConfigValue( + "notification_in_meeting_reminder", + ); const notificationDetectEnabledRef = useRef(notificationDetectEnabled); useEffect(() => { notificationDetectEnabledRef.current = notificationDetectEnabled; }, [notificationDetectEnabled]); + const inMeetingReminderEnabledRef = useRef(inMeetingReminderEnabled); + useEffect(() => { + inMeetingReminderEnabledRef.current = inMeetingReminderEnabled; + }, [inMeetingReminderEnabled]); + useEffect(() => { let unlisten: (() => void) | undefined; let cancelled = false; @@ -103,6 +111,29 @@ const useHandleDetectEvents = (store: ListenerStore) => { } } else if (payload.type === "micMuted") { setMuted(payload.value); + } else if (payload.type === "micProlongedUsage") { + if (!inMeetingReminderEnabledRef.current) { + return; + } + + if (store.getState().live.status === "active") { + return; + } + + const minutes = Math.round(payload.duration_secs / 60); + const appName = payload.app.name; + + void notificationCommands.showNotification({ + key: payload.key, + title: "Meeting in progress?", + message: `${appName} has been using the mic for ${minutes} min. Start listening?`, + timeout: { secs: 15, nanos: 0 }, + event_id: null, + start_time: null, + participants: null, + event_details: null, + action_label: null, + }); } }) .then((fn) => { diff --git a/apps/desktop/src/store/tinybase/store/settings.ts b/apps/desktop/src/store/tinybase/store/settings.ts index d098c23242..27fadd2392 100644 --- a/apps/desktop/src/store/tinybase/store/settings.ts +++ b/apps/desktop/src/store/tinybase/store/settings.ts @@ -65,6 +65,10 @@ export const SETTINGS_MAPPING = { type: "string", path: ["general", "timezone"], }, + notification_in_meeting_reminder: { + type: "boolean", + path: ["notification", "in_meeting_reminder"], + }, }, tables: { ai_providers: { diff --git a/plugins/detect/Cargo.toml b/plugins/detect/Cargo.toml index fa2be52fd4..0f133a6c04 100644 --- a/plugins/detect/Cargo.toml +++ b/plugins/detect/Cargo.toml @@ -18,12 +18,12 @@ tauri-plugin = { workspace = true, features = ["build"] } [dev-dependencies] specta-typescript = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } [dependencies] hypr-detect = { workspace = true, features = ["mic", "list", "language", "sleep"] } hypr-host = { workspace = true } hypr-notification-interface = { workspace = true } -tauri-plugin-listener = { workspace = true } tauri = { workspace = true, features = ["specta", "test"] } tauri-plugin-windows = { workspace = true } @@ -36,4 +36,5 @@ thiserror = { workspace = true } uuid = { workspace = true, features = ["v4"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tokio-util = { workspace = true } tracing = { workspace = true } diff --git a/plugins/detect/js/bindings.gen.ts b/plugins/detect/js/bindings.gen.ts index 5fcb32be33..194e6a856b 100644 --- a/plugins/detect/js/bindings.gen.ts +++ b/plugins/detect/js/bindings.gen.ts @@ -79,7 +79,7 @@ detectEvent: "plugin:detect:detect-event" /** user-defined types **/ -export type DetectEvent = { type: "micStarted"; key: string; apps: InstalledApp[] } | { type: "micStopped"; apps: InstalledApp[] } | { type: "micMuted"; value: boolean } | { type: "sleepStateChanged"; value: boolean } +export type DetectEvent = { type: "micStarted"; key: string; apps: InstalledApp[] } | { type: "micStopped"; apps: InstalledApp[] } | { type: "micMuted"; value: boolean } | { type: "sleepStateChanged"; value: boolean } | { type: "micProlongedUsage"; key: string; app: InstalledApp; duration_secs: number } export type InstalledApp = { id: string; name: string } /** tauri-specta globals **/ diff --git a/plugins/detect/src/commands.rs b/plugins/detect/src/commands.rs index c74d8a1a4a..1eaf71d4d9 100644 --- a/plugins/detect/src/commands.rs +++ b/plugins/detect/src/commands.rs @@ -30,7 +30,7 @@ pub(crate) async fn set_ignored_bundle_ids( app: tauri::AppHandle, bundle_ids: Vec, ) -> Result<(), String> { - app.detect().set_ignored_bundle_ids(bundle_ids).await; + app.detect().set_ignored_bundle_ids(bundle_ids); Ok(()) } @@ -40,7 +40,7 @@ pub(crate) async fn set_respect_do_not_disturb( app: tauri::AppHandle, enabled: bool, ) -> Result<(), String> { - app.detect().set_respect_do_not_disturb(enabled).await; + app.detect().set_respect_do_not_disturb(enabled); Ok(()) } diff --git a/plugins/detect/src/env.rs b/plugins/detect/src/env.rs new file mode 100644 index 0000000000..bf42e5ab24 --- /dev/null +++ b/plugins/detect/src/env.rs @@ -0,0 +1,73 @@ +use tauri::{AppHandle, EventTarget, Runtime}; +use tauri_plugin_windows::WindowImpl; +use tauri_specta::Event; + +use crate::DetectEvent; + +pub(crate) trait Env: Clone + Send + Sync + 'static { + fn emit(&self, event: DetectEvent); + fn is_do_not_disturb(&self) -> bool; +} + +pub(crate) struct TauriEnv { + pub(crate) app_handle: AppHandle, +} + +impl Clone for TauriEnv { + fn clone(&self) -> Self { + Self { + app_handle: self.app_handle.clone(), + } + } +} + +impl Env for TauriEnv { + fn emit(&self, event: DetectEvent) { + let _ = event.emit_to( + &self.app_handle, + EventTarget::AnyLabel { + label: tauri_plugin_windows::AppWindow::Main.label(), + }, + ); + } + + fn is_do_not_disturb(&self) -> bool { + crate::dnd::is_do_not_disturb() + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[derive(Clone)] + pub(crate) struct TestEnv { + pub(crate) events: Arc>>, + dnd: Arc, + } + + impl TestEnv { + pub(crate) fn new() -> Self { + Self { + events: Arc::new(std::sync::Mutex::new(Vec::new())), + dnd: Arc::new(AtomicBool::new(false)), + } + } + + pub(crate) fn set_dnd(&self, value: bool) { + self.dnd.store(value, Ordering::Relaxed); + } + } + + impl Env for TestEnv { + fn emit(&self, event: DetectEvent) { + self.events.lock().unwrap().push(event); + } + + fn is_do_not_disturb(&self) -> bool { + self.dnd.load(Ordering::Relaxed) + } + } +} diff --git a/plugins/detect/src/events.rs b/plugins/detect/src/events.rs index cedb916d59..9fbec72757 100644 --- a/plugins/detect/src/events.rs +++ b/plugins/detect/src/events.rs @@ -22,25 +22,11 @@ common_event_derives! { MicMuteStateChanged { value: bool }, #[serde(rename = "sleepStateChanged")] SleepStateChanged { value: bool }, - } -} - -impl From for DetectEvent { - fn from(event: hypr_detect::DetectEvent) -> Self { - match event { - hypr_detect::DetectEvent::MicStarted(apps) => Self::MicStarted { - key: uuid::Uuid::new_v4().to_string(), - apps, - }, - hypr_detect::DetectEvent::MicStopped(apps) => Self::MicStopped { apps }, - #[cfg(all(target_os = "macos", feature = "zoom"))] - hypr_detect::DetectEvent::ZoomMuteStateChanged { value } => { - Self::MicMuteStateChanged { value } - } - #[cfg(all(target_os = "macos", feature = "sleep"))] - hypr_detect::DetectEvent::SleepStateChanged { value } => { - Self::SleepStateChanged { value } - } - } + #[serde(rename = "micProlongedUsage")] + MicProlongedUsage { + key: String, + app: hypr_detect::InstalledApp, + duration_secs: u64, + }, } } diff --git a/plugins/detect/src/ext.rs b/plugins/detect/src/ext.rs index 0e0d59275d..e7f5613c97 100644 --- a/plugins/detect/src/ext.rs +++ b/plugins/detect/src/ext.rs @@ -16,15 +16,18 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Detect<'a, R, M> { crate::policy::default_ignored_bundle_ids() } - pub async fn set_ignored_bundle_ids(&self, bundle_ids: Vec) { - let state = self.manager.state::(); - let mut state_guard = state.lock().await; - state_guard.policy.user_ignored_bundle_ids = bundle_ids; + pub fn set_ignored_bundle_ids(&self, bundle_ids: Vec) { + let state = self.manager.state::(); + let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner()); + for id in &bundle_ids { + state_guard.mic_usage_tracker.cancel_app(id); + } + state_guard.policy.user_ignored_bundle_ids = bundle_ids.into_iter().collect(); } - pub async fn set_respect_do_not_disturb(&self, enabled: bool) { - let state = self.manager.state::(); - let mut state_guard = state.lock().await; + pub fn set_respect_do_not_disturb(&self, enabled: bool) { + let state = self.manager.state::(); + let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner()); state_guard.policy.respect_dnd = enabled; } } diff --git a/plugins/detect/src/handler.rs b/plugins/detect/src/handler.rs index 1fe8ed9a73..ef87b540cb 100644 --- a/plugins/detect/src/handler.rs +++ b/plugins/detect/src/handler.rs @@ -1,82 +1,106 @@ -use tauri::{AppHandle, EventTarget, Manager, Runtime}; -use tauri_plugin_listener::ListenerPluginExt; -use tauri_plugin_windows::WindowImpl; -use tauri_specta::Event; +use tauri::{AppHandle, Manager, Runtime}; +use tokio_util::sync::CancellationToken; use crate::{ - DetectEvent, SharedState, dnd, + DetectEvent, ProcessorState, + env::{Env, TauriEnv}, + mic_usage_tracker, policy::{MicEventType, PolicyContext}, }; -pub async fn setup(app: &AppHandle) -> Result<(), Box> { - let app_handle = app.app_handle().clone(); - let callback = hypr_detect::new_callback(move |event| { - let app_handle_clone = app_handle.clone(); +pub fn setup(app: &AppHandle) -> Result<(), Box> { + let env = TauriEnv { + app_handle: app.app_handle().clone(), + }; + let processor = app.state::().inner().clone(); - match event { - hypr_detect::DetectEvent::MicStarted(apps) => { - tauri::async_runtime::spawn(async move { - handle_mic_started(&app_handle_clone, apps).await; - }); - } - hypr_detect::DetectEvent::MicStopped(apps) => { - tauri::async_runtime::spawn(async move { - handle_mic_stopped(&app_handle_clone, apps).await; - }); - } - #[cfg(all(target_os = "macos", feature = "zoom"))] - hypr_detect::DetectEvent::ZoomMuteStateChanged { value } => { - emit_to_main(&app_handle, DetectEvent::MicMuteStateChanged { value }); - } - #[cfg(all(target_os = "macos", feature = "sleep"))] - hypr_detect::DetectEvent::SleepStateChanged { value } => { - emit_to_main(&app_handle, DetectEvent::SleepStateChanged { value }); - } - } + let callback = hypr_detect::new_callback(move |event| { + let env = env.clone(); + let processor = processor.clone(); + tauri::async_runtime::spawn(async move { + handle_detect_event(&env, &processor, event); + }); }); - let state = app.state::(); - let mut state_guard = state.lock().await; - state_guard.detector.start(callback); - drop(state_guard); + let detector_state = app.state::(); + let mut detector = detector_state.lock().unwrap_or_else(|e| e.into_inner()); + detector.start(callback); + drop(detector); Ok(()) } -async fn handle_mic_started( - app_handle: &AppHandle, +pub(crate) fn handle_detect_event( + env: &E, + state: &ProcessorState, + event: hypr_detect::DetectEvent, +) { + match event { + hypr_detect::DetectEvent::MicStarted(apps) => { + handle_mic_started(env, state, apps); + } + hypr_detect::DetectEvent::MicStopped(apps) => { + handle_mic_stopped(env, state, apps); + } + #[cfg(all(target_os = "macos", feature = "zoom"))] + hypr_detect::DetectEvent::ZoomMuteStateChanged { value } => { + env.emit(DetectEvent::MicMuteStateChanged { value }); + } + #[cfg(all(target_os = "macos", feature = "sleep"))] + hypr_detect::DetectEvent::SleepStateChanged { value } => { + env.emit(DetectEvent::SleepStateChanged { value }); + } + } +} + +fn handle_mic_started( + env: &E, + state: &ProcessorState, apps: Vec, ) { - let is_listening = { - let listener_state = app_handle.listener().get_state().await; - matches!( - listener_state, - tauri_plugin_listener::State::Active | tauri_plugin_listener::State::Finalizing - ) - }; + let is_dnd = env.is_do_not_disturb(); - let state = app_handle.state::(); - let state_guard = state.lock().await; + let policy_result = { + let mut guard = state.lock().unwrap_or_else(|e| e.into_inner()); - let is_dnd = dnd::is_do_not_disturb(); + let to_track: Vec<_> = apps + .iter() + .filter(|app| { + guard.policy.should_track_app(&app.id) + && !guard.mic_usage_tracker.is_tracking(&app.id) + && !guard.mic_usage_tracker.is_in_cooldown(&app.id) + }) + .cloned() + .collect(); - let ctx = PolicyContext { - apps: &apps, - is_listening, - is_dnd, - event_type: MicEventType::Started, + for app in &to_track { + let token = CancellationToken::new(); + let generation = guard + .mic_usage_tracker + .start_tracking(app.id.clone(), token.clone()); + mic_usage_tracker::spawn_timer( + env.clone(), + state.clone(), + app.clone(), + generation, + token, + ); + } + + let ctx = PolicyContext { + apps: &apps, + is_dnd, + event_type: MicEventType::Started, + }; + guard.policy.evaluate(&ctx) }; - match state_guard.policy.evaluate(&ctx) { + match policy_result { Ok(result) => { - drop(state_guard); - emit_to_main( - app_handle, - DetectEvent::MicStarted { - key: result.dedup_key, - apps: result.filtered_apps, - }, - ); + env.emit(DetectEvent::MicStarted { + key: result.dedup_key, + apps: result.filtered_apps, + }); } Err(reason) => { tracing::info!(?reason, "skip_notification"); @@ -84,39 +108,33 @@ async fn handle_mic_started( } } -async fn handle_mic_stopped( - app_handle: &AppHandle, +fn handle_mic_stopped( + env: &E, + state: &ProcessorState, apps: Vec, ) { - let is_listening = { - let listener_state = app_handle.listener().get_state().await; - matches!( - listener_state, - tauri_plugin_listener::State::Active | tauri_plugin_listener::State::Finalizing - ) - }; + let is_dnd = env.is_do_not_disturb(); - let state = app_handle.state::(); - let state_guard = state.lock().await; + let policy_result = { + let mut guard = state.lock().unwrap_or_else(|e| e.into_inner()); - let is_dnd = dnd::is_do_not_disturb(); + for app in &apps { + guard.mic_usage_tracker.cancel_app(&app.id); + } - let ctx = PolicyContext { - apps: &apps, - is_listening, - is_dnd, - event_type: MicEventType::Stopped, + let ctx = PolicyContext { + apps: &apps, + is_dnd, + event_type: MicEventType::Stopped, + }; + guard.policy.evaluate(&ctx) }; - match state_guard.policy.evaluate(&ctx) { + match policy_result { Ok(result) => { - drop(state_guard); - emit_to_main( - app_handle, - DetectEvent::MicStopped { - apps: result.filtered_apps, - }, - ); + env.emit(DetectEvent::MicStopped { + apps: result.filtered_apps, + }); } Err(reason) => { tracing::info!(?reason, "skip_mic_stopped"); @@ -124,11 +142,408 @@ async fn handle_mic_stopped( } } -fn emit_to_main(app_handle: &AppHandle, event: DetectEvent) { - let _ = event.emit_to( - app_handle, - EventTarget::AnyLabel { - label: tauri_plugin_windows::AppWindow::Main.label(), - }, - ); +#[cfg(test)] +mod tests { + use super::*; + use crate::env::test_support::TestEnv; + use std::time::Duration; + + fn zoom() -> hypr_detect::InstalledApp { + hypr_detect::InstalledApp { + id: "us.zoom.xos".to_string(), + name: "zoom.us".to_string(), + } + } + + fn aqua_voice() -> hypr_detect::InstalledApp { + hypr_detect::InstalledApp { + id: "com.electron.aqua-voice".to_string(), + name: "Aqua Voice".to_string(), + } + } + + fn slack() -> hypr_detect::InstalledApp { + hypr_detect::InstalledApp { + id: "com.tinyspeck.slackmacgap".to_string(), + name: "Slack".to_string(), + } + } + + struct Harness { + env: TestEnv, + state: ProcessorState, + } + + impl Harness { + fn new() -> Self { + Self { + env: TestEnv::new(), + state: ProcessorState::default(), + } + } + + fn mic_started(&self, app: hypr_detect::InstalledApp) { + handle_detect_event( + &self.env, + &self.state, + hypr_detect::DetectEvent::MicStarted(vec![app]), + ); + } + + fn mic_stopped(&self, app: hypr_detect::InstalledApp) { + handle_detect_event( + &self.env, + &self.state, + hypr_detect::DetectEvent::MicStopped(vec![app]), + ); + } + + async fn settle(&self) { + for _ in 0..100 { + tokio::task::yield_now().await; + } + } + + async fn advance_secs(&self, secs: u64) { + self.settle().await; + tokio::time::advance(Duration::from_secs(secs)).await; + self.settle().await; + } + + fn take_events(&self) -> Vec { + std::mem::take(&mut self.env.events.lock().unwrap()) + } + } + + #[tokio::test(start_paused = true)] + async fn test_mic_started_emits_event() { + let h = Harness::new(); + + h.mic_started(zoom()); + + let events = h.take_events(); + assert_eq!(events.len(), 1, "expected one MicStarted event for zoom"); + assert!( + matches!( + &events[0], + DetectEvent::MicStarted { apps, .. } if apps[0].id == "us.zoom.xos" + ), + "expected MicStarted with zoom app" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_filtered_app_no_event() { + let h = Harness::new(); + + h.mic_started(aqua_voice()); + + assert!( + h.take_events().is_empty(), + "categorized app should not emit MicStarted" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_mic_prolonged_usage_timer() { + let h = Harness::new(); + + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(3 * 60).await; + + let events = h.take_events(); + assert_eq!(events.len(), 1, "expected MicProlongedUsage event"); + assert!( + matches!( + &events[0], + DetectEvent::MicProlongedUsage { app, duration_secs, .. } + if app.id == "us.zoom.xos" && *duration_secs == 180 + ), + "expected timer event for zoom after 3 minutes" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_cancel_before_timer() { + let h = Harness::new(); + + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(60).await; + h.mic_stopped(zoom()); + h.take_events(); + + h.advance_secs(3 * 60).await; + + assert!( + h.take_events().is_empty(), + "cancelled timer should not emit" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_user_ignored_app_no_timer() { + let h = Harness::new(); + + { + let mut guard = h.state.lock().unwrap(); + guard + .policy + .user_ignored_bundle_ids + .insert("us.zoom.xos".to_string()); + } + + h.mic_started(zoom()); + assert!( + h.take_events().is_empty(), + "user-ignored app should not emit MicStarted" + ); + + h.advance_secs(3 * 60).await; + assert!( + h.take_events().is_empty(), + "user-ignored app should not trigger timer" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_full_scenario_zoom_and_dictation() { + let h = Harness::new(); + + h.mic_started(zoom()); + assert_eq!(h.take_events().len(), 1, "zoom should emit MicStarted"); + + h.mic_started(aqua_voice()); + assert!( + h.take_events().is_empty(), + "dictation app should be filtered" + ); + + h.mic_stopped(aqua_voice()); + assert!( + h.take_events().is_empty(), + "dictation app stop should be filtered" + ); + + h.mic_stopped(zoom()); + assert_eq!(h.take_events().len(), 1, "zoom should emit MicStopped"); + } + + #[test] + fn test_on_timer_fired_emits() { + let env = TestEnv::new(); + let app = zoom(); + mic_usage_tracker::on_timer_fired(&env, &app, 180); + + let events = std::mem::take(&mut *env.events.lock().unwrap()); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0], + DetectEvent::MicProlongedUsage { duration_secs, .. } if *duration_secs == 180 + )); + } + + #[tokio::test(start_paused = true)] + async fn test_dnd_skips_mic_started_but_timer_still_fires() { + let h = Harness::new(); + h.env.set_dnd(true); + { + let mut guard = h.state.lock().unwrap(); + guard.policy.respect_dnd = true; + } + + h.mic_started(zoom()); + assert!(h.take_events().is_empty(), "DnD should suppress MicStarted"); + + h.advance_secs(3 * 60).await; + let events = h.take_events(); + assert_eq!( + events.len(), + 1, + "timer fires regardless of DnD (separate concern)" + ); + assert!(matches!(&events[0], DetectEvent::MicProlongedUsage { .. })); + } + + #[tokio::test(start_paused = true)] + async fn test_stop_and_restart_creates_new_timer() { + let h = Harness::new(); + + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(60).await; + h.mic_stopped(zoom()); + h.take_events(); + + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(2 * 60).await; + assert!( + h.take_events().is_empty(), + "new timer should not have fired yet (only 2 min since restart)" + ); + + h.advance_secs(60).await; + let events = h.take_events(); + assert_eq!(events.len(), 1, "timer should fire 3 min after restart"); + assert!(matches!( + &events[0], + DetectEvent::MicProlongedUsage { app, .. } if app.id == "us.zoom.xos" + )); + } + + #[tokio::test(start_paused = true)] + async fn test_duplicate_mic_started_no_timer_reset() { + let h = Harness::new(); + + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(60).await; + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(2 * 60).await; + let events = h.take_events(); + assert_eq!( + events.len(), + 1, + "timer fires 3 min from original start, not from duplicate" + ); + assert!(matches!(&events[0], DetectEvent::MicProlongedUsage { .. })); + } + + #[tokio::test(start_paused = true)] + async fn test_multiple_apps_independent_timers() { + let h = Harness::new(); + + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(60).await; + h.mic_started(slack()); + h.take_events(); + + h.mic_stopped(zoom()); + h.take_events(); + + h.advance_secs(2 * 60).await; + assert!( + h.take_events().is_empty(), + "zoom cancelled, slack not yet at 3 min" + ); + + h.advance_secs(60).await; + let events = h.take_events(); + assert_eq!(events.len(), 1, "only slack timer should fire"); + assert!(matches!( + &events[0], + DetectEvent::MicProlongedUsage { app, .. } + if app.id == "com.tinyspeck.slackmacgap" + ),); + } + + #[tokio::test(start_paused = true)] + async fn test_ignore_during_active_tracking_cancels_timer() { + let h = Harness::new(); + + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(60).await; + + { + let mut guard = h.state.lock().unwrap(); + guard.mic_usage_tracker.cancel_app("us.zoom.xos"); + guard + .policy + .user_ignored_bundle_ids + .insert("us.zoom.xos".to_string()); + } + + h.advance_secs(3 * 60).await; + assert!( + h.take_events().is_empty(), + "timer should be cancelled when app is added to ignore list" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_cooldown_suppresses_repeated_notifications() { + let h = Harness::new(); + + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(3 * 60).await; + assert_eq!(h.take_events().len(), 1, "first notification should fire"); + + h.mic_stopped(zoom()); + h.take_events(); + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(3 * 60).await; + assert!( + h.take_events().is_empty(), + "second notification suppressed by cooldown" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_cooldown_expires_after_one_hour() { + let h = Harness::new(); + + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(3 * 60).await; + assert_eq!(h.take_events().len(), 1, "first notification fires"); + + h.mic_stopped(zoom()); + h.take_events(); + + h.advance_secs(60 * 60).await; + + h.mic_started(zoom()); + h.take_events(); + + h.advance_secs(3 * 60).await; + let events = h.take_events(); + assert_eq!( + events.len(), + 1, + "notification fires again after cooldown expires" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_cooldown_is_per_app() { + let h = Harness::new(); + + h.mic_started(zoom()); + h.take_events(); + h.advance_secs(3 * 60).await; + assert_eq!(h.take_events().len(), 1, "zoom notification fires"); + + h.mic_started(slack()); + h.take_events(); + h.advance_secs(3 * 60).await; + let events = h.take_events(); + assert_eq!( + events.len(), + 1, + "slack notification fires despite zoom cooldown" + ); + assert!(matches!( + &events[0], + DetectEvent::MicProlongedUsage { app, .. } + if app.id == "com.tinyspeck.slackmacgap" + )); + } } diff --git a/plugins/detect/src/lib.rs b/plugins/detect/src/lib.rs index 6c0df4f463..58b51ee6da 100644 --- a/plugins/detect/src/lib.rs +++ b/plugins/detect/src/lib.rs @@ -1,12 +1,15 @@ +use std::sync::{Arc, Mutex}; + use tauri::Manager; -use tokio::sync::Mutex; mod commands; mod dnd; +mod env; mod error; mod events; mod ext; mod handler; +mod mic_usage_tracker; mod policy; pub use dnd::*; @@ -17,13 +20,13 @@ pub use policy::*; const PLUGIN_NAME: &str = "detect"; -pub type SharedState = Mutex; +pub(crate) type DetectorState = Mutex; +pub(crate) type ProcessorState = Arc>; #[derive(Default)] -pub struct State { - #[allow(dead_code)] - pub(crate) detector: hypr_detect::Detector, +pub(crate) struct Processor { pub(crate) policy: policy::MicNotificationPolicy, + pub(crate) mic_usage_tracker: mic_usage_tracker::MicUsageTracker, } fn make_specta_builder() -> tauri_specta::Builder { @@ -50,12 +53,12 @@ pub fn init() -> tauri::plugin::TauriPlugin { .setup(move |app, _api| { specta_builder.mount_events(app); - let state = SharedState::default(); - app.manage(state); + app.manage(DetectorState::default()); + app.manage(ProcessorState::default()); let app_handle = app.app_handle().clone(); tauri::async_runtime::spawn(async move { - handler::setup(&app_handle).await.unwrap(); + handler::setup(&app_handle).unwrap(); }); Ok(()) diff --git a/plugins/detect/src/mic_usage_tracker.rs b/plugins/detect/src/mic_usage_tracker.rs new file mode 100644 index 0000000000..c261cab55e --- /dev/null +++ b/plugins/detect/src/mic_usage_tracker.rs @@ -0,0 +1,220 @@ +use std::collections::HashMap; +use std::time::Duration; + +use tokio_util::sync::CancellationToken; + +use crate::{DetectEvent, ProcessorState, env::Env}; + +pub(crate) const MIC_ACTIVE_THRESHOLD: Duration = Duration::from_secs(3 * 60); +pub(crate) const COOLDOWN_DURATION: Duration = Duration::from_secs(60 * 60); + +struct TimerEntry { + generation: u64, + token: CancellationToken, +} + +pub struct MicUsageTracker { + timers: HashMap, + cooldowns: HashMap, + next_gen: u64, +} + +impl Default for MicUsageTracker { + fn default() -> Self { + Self { + timers: HashMap::new(), + cooldowns: HashMap::new(), + next_gen: 0, + } + } +} + +impl Drop for MicUsageTracker { + fn drop(&mut self) { + for (_, entry) in self.timers.drain() { + entry.token.cancel(); + } + } +} + +impl MicUsageTracker { + pub fn is_tracking(&self, app_id: &str) -> bool { + self.timers.contains_key(app_id) + } + + pub fn is_in_cooldown(&mut self, app_id: &str) -> bool { + match self.cooldowns.get(app_id) { + Some(&fired_at) => { + if tokio::time::Instant::now().duration_since(fired_at) < COOLDOWN_DURATION { + true + } else { + self.cooldowns.remove(app_id); + false + } + } + None => false, + } + } + + pub fn start_tracking(&mut self, app_id: String, token: CancellationToken) -> u64 { + let generation = self.next_gen; + self.next_gen += 1; + if let Some(old) = self.timers.insert(app_id, TimerEntry { generation, token }) { + old.token.cancel(); + } + generation + } + + pub fn cancel_app(&mut self, app_id: &str) { + if let Some(entry) = self.timers.remove(app_id) { + entry.token.cancel(); + tracing::info!(app_id = %app_id, "cancelled_mic_active_timer"); + } + } + + /// Removes the timer entry only if the generation matches, + /// preventing a stale timer from claiming an entry replaced by a newer one. + /// On success, sets a cooldown so the same app won't be re-tracked for a while. + pub fn claim(&mut self, app_id: &str, generation: u64) -> bool { + match self.timers.get(app_id) { + Some(entry) if entry.generation == generation => { + self.timers.remove(app_id); + self.cooldowns + .insert(app_id.to_string(), tokio::time::Instant::now()); + true + } + _ => false, + } + } +} + +pub(crate) fn on_timer_fired(env: &E, app: &hypr_detect::InstalledApp, duration_secs: u64) { + tracing::info!( + app_id = %app.id, + duration_secs, + "mic_prolonged_usage" + ); + + let key = uuid::Uuid::new_v4().to_string(); + env.emit(DetectEvent::MicProlongedUsage { + key, + app: app.clone(), + duration_secs, + }); +} + +pub(crate) fn spawn_timer( + env: E, + state: ProcessorState, + app: hypr_detect::InstalledApp, + generation: u64, + token: CancellationToken, +) { + let duration_secs = MIC_ACTIVE_THRESHOLD.as_secs(); + let app_id = app.id.clone(); + + tokio::spawn(async move { + tokio::select! { + _ = tokio::time::sleep(MIC_ACTIVE_THRESHOLD) => {} + _ = token.cancelled() => { return; } + } + + let claimed = { + let mut guard = state.lock().unwrap_or_else(|e| e.into_inner()); + guard.mic_usage_tracker.claim(&app_id, generation) + }; + + if claimed { + on_timer_fired(&env, &app, duration_secs); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_claim_matching_generation() { + let _rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = _rt.enter(); + + let mut tracker = MicUsageTracker::default(); + let token = CancellationToken::new(); + let generation = tracker.start_tracking("app.x".to_string(), token); + + assert!(tracker.claim("app.x", generation)); + assert!(!tracker.is_tracking("app.x")); + } + + #[test] + fn test_claim_stale_generation_rejected() { + let _rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = _rt.enter(); + + let mut tracker = MicUsageTracker::default(); + + let generation_0 = tracker.start_tracking("app.x".to_string(), CancellationToken::new()); + let generation_1 = tracker.start_tracking("app.x".to_string(), CancellationToken::new()); + + assert!(!tracker.claim("app.x", generation_0)); + assert!(tracker.is_tracking("app.x")); + + assert!(tracker.claim("app.x", generation_1)); + assert!(!tracker.is_tracking("app.x")); + } + + #[test] + fn test_claim_after_cancel_returns_false() { + let _rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = _rt.enter(); + + let mut tracker = MicUsageTracker::default(); + let generation = tracker.start_tracking("app.x".to_string(), CancellationToken::new()); + + tracker.cancel_app("app.x"); + assert!(!tracker.claim("app.x", generation)); + } + + #[test] + fn test_start_tracking_cancels_old_token() { + let mut tracker = MicUsageTracker::default(); + let token1 = CancellationToken::new(); + let token1_clone = token1.clone(); + + tracker.start_tracking("app.x".to_string(), token1); + assert!(!token1_clone.is_cancelled()); + + tracker.start_tracking("app.x".to_string(), CancellationToken::new()); + assert!(token1_clone.is_cancelled()); + } + + #[tokio::test(start_paused = true)] + async fn test_cooldown_blocks_retracking() { + let mut tracker = MicUsageTracker::default(); + + let generation = tracker.start_tracking("app.x".to_string(), CancellationToken::new()); + assert!(tracker.claim("app.x", generation)); + assert!(tracker.is_in_cooldown("app.x")); + + tokio::time::advance(Duration::from_secs(30 * 60)).await; + assert!( + tracker.is_in_cooldown("app.x"), + "still in cooldown at 30 min" + ); + + tokio::time::advance(Duration::from_secs(30 * 60)).await; + assert!( + !tracker.is_in_cooldown("app.x"), + "cooldown expired at 60 min" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_no_cooldown_without_claim() { + let mut tracker = MicUsageTracker::default(); + tracker.start_tracking("app.x".to_string(), CancellationToken::new()); + tracker.cancel_app("app.x"); + assert!(!tracker.is_in_cooldown("app.x")); + } +} diff --git a/plugins/detect/src/policy.rs b/plugins/detect/src/policy.rs index 3561046e7e..7826f3ffcf 100644 --- a/plugins/detect/src/policy.rs +++ b/plugins/detect/src/policy.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet}; use hypr_notification_interface::NotificationKey; @@ -8,9 +8,10 @@ pub enum MicEventType { Stopped, } +// We intentionally don't include the "already listening" reason here; that filtering should be done by the consumer side. + #[derive(Debug, Clone, PartialEq, Eq)] pub enum SkipReason { - HyprnoteListening, DoNotDisturb, AllAppsFiltered, } @@ -95,7 +96,6 @@ pub fn default_ignored_bundle_ids() -> Vec { pub struct PolicyContext<'a> { pub apps: &'a [hypr_detect::InstalledApp], - pub is_listening: bool, pub is_dnd: bool, pub event_type: MicEventType, } @@ -106,19 +106,23 @@ pub struct PolicyResult { } pub struct MicNotificationPolicy { - pub skip_when_listening: bool, pub respect_dnd: bool, pub ignored_categories: Vec, - pub user_ignored_bundle_ids: Vec, + pub user_ignored_bundle_ids: HashSet, } impl MicNotificationPolicy { - pub fn evaluate(&self, ctx: &PolicyContext) -> Result { - if self.skip_when_listening && ctx.is_listening { - return Err(SkipReason::HyprnoteListening); - } + pub fn should_track_app(&self, app_id: &str) -> bool { + AppCategory::find_category(app_id).is_none() + && !self.user_ignored_bundle_ids.contains(app_id) + } - if self.respect_dnd && ctx.is_dnd { + fn filter_apps( + &self, + apps: &[hypr_detect::InstalledApp], + is_dnd: bool, + ) -> Result, SkipReason> { + if self.respect_dnd && is_dnd { return Err(SkipReason::DoNotDisturb); } @@ -128,17 +132,11 @@ impl MicNotificationPolicy { .flat_map(|cat| cat.bundle_ids().iter().copied()) .collect(); - let filtered_apps: Vec<_> = ctx - .apps + let filtered_apps: Vec<_> = apps .iter() .filter(|app| { - if self.user_ignored_bundle_ids.contains(&app.id) { - return false; - } - if ignored_from_categories.contains(app.id.as_str()) { - return false; - } - true + !self.user_ignored_bundle_ids.contains(&app.id) + && !ignored_from_categories.contains(app.id.as_str()) }) .cloned() .collect(); @@ -147,6 +145,12 @@ impl MicNotificationPolicy { return Err(SkipReason::AllAppsFiltered); } + Ok(filtered_apps) + } + + pub fn evaluate(&self, ctx: &PolicyContext) -> Result { + let filtered_apps = self.filter_apps(ctx.apps, ctx.is_dnd)?; + let notification_key = match ctx.event_type { MicEventType::Started => { NotificationKey::mic_started(filtered_apps.iter().map(|a| a.id.clone())) @@ -166,10 +170,9 @@ impl MicNotificationPolicy { impl Default for MicNotificationPolicy { fn default() -> Self { Self { - skip_when_listening: true, respect_dnd: false, ignored_categories: AppCategory::all().to_vec(), - user_ignored_bundle_ids: Vec::new(), + user_ignored_bundle_ids: HashSet::new(), } } } @@ -203,62 +206,4 @@ mod tests { ); assert_eq!(AppCategory::find_category("com.zoom.us"), None); } - - #[test] - fn test_skip_when_listening() { - let policy = MicNotificationPolicy { - skip_when_listening: true, - ignored_categories: vec![], - ..Default::default() - }; - - let apps = vec![hypr_detect::InstalledApp { - id: "com.zoom.us".to_string(), - name: "Zoom".to_string(), - }]; - - let ctx = PolicyContext { - apps: &apps, - is_listening: true, - is_dnd: false, - event_type: MicEventType::Started, - }; - - assert!(matches!( - policy.evaluate(&ctx), - Err(SkipReason::HyprnoteListening) - )); - - let ctx_not_listening = PolicyContext { - apps: &apps, - is_listening: false, - is_dnd: false, - event_type: MicEventType::Started, - }; - - assert!(policy.evaluate(&ctx_not_listening).is_ok()); - } - - #[test] - fn test_skip_when_listening_disabled() { - let policy = MicNotificationPolicy { - skip_when_listening: false, - ignored_categories: vec![], - ..Default::default() - }; - - let apps = vec![hypr_detect::InstalledApp { - id: "com.zoom.us".to_string(), - name: "Zoom".to_string(), - }]; - - let ctx = PolicyContext { - apps: &apps, - is_listening: true, - is_dnd: false, - event_type: MicEventType::Started, - }; - - assert!(policy.evaluate(&ctx).is_ok()); - } }