diff --git a/apps/desktop/src/components/settings/general/notification.tsx b/apps/desktop/src/components/settings/general/notification.tsx index c9304e5891..5fec612098 100644 --- a/apps/desktop/src/components/settings/general/notification.tsx +++ b/apps/desktop/src/components/settings/general/notification.tsx @@ -11,6 +11,7 @@ import { import { commands as notificationCommands } from "@hypr/plugin-notification"; import { Badge } from "@hypr/ui/components/ui/badge"; import { Button } from "@hypr/ui/components/ui/button"; +import { Input } from "@hypr/ui/components/ui/input"; import { Switch } from "@hypr/ui/components/ui/switch"; import { cn } from "@hypr/utils"; @@ -29,6 +30,10 @@ export function NotificationSettingsView() { "notification_detect", "respect_dnd", "ignored_platforms", + "event_notify_before_minutes", + "event_notification_timeout_secs", + "mic_detection_delay_secs", + "mic_notification_timeout_secs", ] as const); useEffect(() => { @@ -101,12 +106,44 @@ export function NotificationSettingsView() { settings.STORE_ID, ); + const handleSetEventNotifyBeforeMinutes = settings.UI.useSetValueCallback( + "event_notify_before_minutes", + (value: number) => value, + [], + settings.STORE_ID, + ); + + const handleSetEventNotificationTimeoutSecs = settings.UI.useSetValueCallback( + "event_notification_timeout_secs", + (value: number) => value, + [], + settings.STORE_ID, + ); + + const handleSetMicDetectionDelaySecs = settings.UI.useSetValueCallback( + "mic_detection_delay_secs", + (value: number) => value, + [], + settings.STORE_ID, + ); + + const handleSetMicNotificationTimeoutSecs = settings.UI.useSetValueCallback( + "mic_notification_timeout_secs", + (value: number) => value, + [], + settings.STORE_ID, + ); + const form = useForm({ defaultValues: { notification_event: configs.notification_event, notification_detect: configs.notification_detect, respect_dnd: configs.respect_dnd, ignored_platforms: configs.ignored_platforms.map(bundleIdToName), + event_notify_before_minutes: configs.event_notify_before_minutes, + event_notification_timeout_secs: configs.event_notification_timeout_secs, + mic_detection_delay_secs: configs.mic_detection_delay_secs, + mic_notification_timeout_secs: configs.mic_notification_timeout_secs, }, listeners: { onChange: async ({ formApi }) => { @@ -120,6 +157,12 @@ export function NotificationSettingsView() { handleSetIgnoredPlatforms( JSON.stringify(value.ignored_platforms.map(nameToBundleId)), ); + handleSetEventNotifyBeforeMinutes(value.event_notify_before_minutes); + handleSetEventNotificationTimeoutSecs( + value.event_notification_timeout_secs, + ); + handleSetMicDetectionDelaySecs(value.mic_detection_delay_secs); + handleSetMicNotificationTimeoutSecs(value.mic_notification_timeout_secs); }, }); @@ -220,17 +263,68 @@ export function NotificationSettingsView() {
{(field) => ( -
-
-

Event notifications

-

- Get notified 5 minutes before calendar events start -

+
+
+
+

+ Event notifications +

+

+ Get notified before calendar events start +

+
+
- + + {field.state.value && ( +
+ + {(subField) => ( +
+ + Minutes before event + + + subField.handleChange(Number(e.target.value)) + } + /> +
+ )} +
+ + {(subField) => ( +
+ + Auto-dismiss after (seconds) + + + subField.handleChange(Number(e.target.value)) + } + /> +
+ )} +
+
+ )}
)} @@ -255,7 +349,51 @@ export function NotificationSettingsView() {
{field.state.value && ( -
+
+
+ + {(subField) => ( +
+ + Detection delay (seconds) + + + subField.handleChange(Number(e.target.value)) + } + /> +
+ )} +
+ + {(subField) => ( +
+ + Auto-dismiss after (seconds) + + + subField.handleChange(Number(e.target.value)) + } + /> +
+ )} +
+

Exclude apps from detection diff --git a/apps/desktop/src/components/settings/lab/index.tsx b/apps/desktop/src/components/settings/lab/index.tsx index cb9731b20e..9e555678a5 100644 --- a/apps/desktop/src/components/settings/lab/index.tsx +++ b/apps/desktop/src/components/settings/lab/index.tsx @@ -5,12 +5,8 @@ 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 { cn } from "@hypr/utils"; -import { useConfigValue } from "../../../config/use-config"; -import * as settings from "../../../store/tinybase/store/settings"; - export function SettingsLab() { const handleOpenControlWindow = async () => { await windowsCommands.windowShow({ type: "control" }); @@ -30,39 +26,11 @@ 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 nudged when a meeting app is using your mic without Hyprnote - recording. -

-
- setValue(checked)} - /> -
- ); -} - function DownloadButtons() { const platformName = platform(); const archQuery = useQuery({ diff --git a/apps/desktop/src/config/registry.ts b/apps/desktop/src/config/registry.ts index 77423f9501..d78b566e5d 100644 --- a/apps/desktop/src/config/registry.ts +++ b/apps/desktop/src/config/registry.ts @@ -22,7 +22,10 @@ export type ConfigKey = | "current_llm_model" | "timezone" | "week_start" - | "notification_in_meeting_reminder"; + | "event_notify_before_minutes" + | "event_notification_timeout_secs" + | "mic_detection_delay_secs" + | "mic_notification_timeout_secs"; type ConfigValueType = (typeof CONFIG_REGISTRY)[K]["default"]; @@ -153,8 +156,26 @@ export const CONFIG_REGISTRY = { default: undefined as "sunday" | "monday" | undefined, }, - notification_in_meeting_reminder: { - key: "notification_in_meeting_reminder", - default: true, + event_notify_before_minutes: { + key: "event_notify_before_minutes", + default: 5, + }, + + event_notification_timeout_secs: { + key: "event_notification_timeout_secs", + default: 30, + }, + + mic_detection_delay_secs: { + key: "mic_detection_delay_secs", + default: 0, + sideEffect: async (value: number, _) => { + await detectCommands.setMicDetectionDelay(value); + }, + }, + + mic_notification_timeout_secs: { + key: "mic_notification_timeout_secs", + default: 8, }, } satisfies Record; diff --git a/apps/desktop/src/contexts/listener.tsx b/apps/desktop/src/contexts/listener.tsx index 61eecc4723..e39a97c30b 100644 --- a/apps/desktop/src/contexts/listener.tsx +++ b/apps/desktop/src/contexts/listener.tsx @@ -53,8 +53,8 @@ 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 micNotificationTimeoutSecs = useConfigValue( + "mic_notification_timeout_secs", ); const notificationDetectEnabledRef = useRef(notificationDetectEnabled); @@ -62,23 +62,26 @@ const useHandleDetectEvents = (store: ListenerStore) => { notificationDetectEnabledRef.current = notificationDetectEnabled; }, [notificationDetectEnabled]); - const inMeetingReminderEnabledRef = useRef(inMeetingReminderEnabled); + const micNotificationTimeoutSecsRef = useRef(micNotificationTimeoutSecs); useEffect(() => { - inMeetingReminderEnabledRef.current = inMeetingReminderEnabled; - }, [inMeetingReminderEnabled]); + micNotificationTimeoutSecsRef.current = micNotificationTimeoutSecs; + }, [micNotificationTimeoutSecs]); useEffect(() => { let unlisten: (() => void) | undefined; let cancelled = false; - let notificationTimerId: ReturnType | undefined; detectEvents.detectEvent .listen(({ payload }) => { - if (payload.type === "micStarted") { + if (payload.type === "micDetected") { if (!notificationDetectEnabledRef.current) { return; } + if (store.getState().live.status === "active") { + return; + } + void getCurrentWindow() .isFocused() .then((isFocused) => { @@ -86,22 +89,30 @@ const useHandleDetectEvents = (store: ListenerStore) => { return; } - if (notificationTimerId) { - clearTimeout(notificationTimerId); - } - notificationTimerId = setTimeout(() => { - void notificationCommands.showNotification({ - key: payload.key, - title: "Mic Started", - message: "Mic started", - timeout: { secs: 8, nanos: 0 }, - event_id: null, - start_time: null, - participants: null, - event_details: null, - action_label: null, - }); - }, 2000); + const durationSecs = payload.duration_secs; + const title = + durationSecs > 0 ? "Meeting in progress?" : "Mic detected"; + const message = + durationSecs >= 60 + ? `Mic used for ${Math.round(durationSecs / 60)} minutes. Start listening?` + : durationSecs > 0 + ? `Mic used for ${durationSecs} seconds. Start listening?` + : "A meeting app is using your mic"; + + void notificationCommands.showNotification({ + key: payload.key, + title, + message, + timeout: { + secs: micNotificationTimeoutSecsRef.current, + nanos: 0, + }, + event_id: null, + start_time: null, + participants: null, + event_details: null, + action_label: null, + }); }); } else if (payload.type === "micStopped") { stop(); @@ -111,28 +122,6 @@ 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); - - void notificationCommands.showNotification({ - key: payload.key, - title: "Meeting in progress?", - message: `Mic used for ${minutes} minutes. Start listening?`, - timeout: { secs: 15, nanos: 0 }, - event_id: null, - start_time: null, - participants: null, - event_details: null, - action_label: null, - }); } }) .then((fn) => { @@ -149,9 +138,6 @@ const useHandleDetectEvents = (store: ListenerStore) => { return () => { cancelled = true; unlisten?.(); - if (notificationTimerId) { - clearTimeout(notificationTimerId); - } }; }, [stop, setMuted]); }; diff --git a/apps/desktop/src/services/event-notification/index.ts b/apps/desktop/src/services/event-notification/index.ts index 3c8aa13417..6b4c9d2307 100644 --- a/apps/desktop/src/services/event-notification/index.ts +++ b/apps/desktop/src/services/event-notification/index.ts @@ -4,13 +4,13 @@ import { type Participant, } from "@hypr/plugin-notification"; +import { CONFIG_REGISTRY } from "../../config/registry"; import type * as main from "../../store/tinybase/store/main"; import type * as settings from "../../store/tinybase/store/settings"; export const EVENT_NOTIFICATION_TASK_ID = "eventNotification"; export const EVENT_NOTIFICATION_INTERVAL = 30 * 1000; // 30 sec -const NOTIFY_WINDOW_MS = 5 * 60 * 1000; // 5 minutes before const NOTIFIED_EVENTS_TTL_MS = 10 * 60 * 1000; // 10 minutes TTL for cleanup export type NotifiedEventsMap = Map; @@ -65,6 +65,16 @@ export function checkEventNotifications( return; } + const notifyBeforeMinutes = + (settingsStore?.getValue("event_notify_before_minutes") as + | number + | undefined) ?? CONFIG_REGISTRY.event_notify_before_minutes.default; + const notificationTimeoutSecs = + (settingsStore?.getValue("event_notification_timeout_secs") as + | number + | undefined) ?? CONFIG_REGISTRY.event_notification_timeout_secs.default; + const notifyWindowMs = notifyBeforeMinutes * 60 * 1000; + const now = Date.now(); for (const [key, timestamp] of notifiedEvents) { @@ -81,7 +91,7 @@ export function checkEventNotifications( const timeUntilStart = startTime.getTime() - now; const notificationKey = `event-${eventId}-${startTime.getTime()}`; - if (timeUntilStart > 0 && timeUntilStart <= NOTIFY_WINDOW_MS) { + if (timeUntilStart > 0 && timeUntilStart <= notifyWindowMs) { if (notifiedEvents.has(notificationKey)) { return; } @@ -111,7 +121,7 @@ export function checkEventNotifications( key: notificationKey, title: title, message: `Starting in ${minutesUntil} minute${minutesUntil !== 1 ? "s" : ""}`, - timeout: { secs: 30, nanos: 0 }, + timeout: { secs: notificationTimeoutSecs, nanos: 0 }, event_id: eventId, start_time: Math.floor(startTime.getTime() / 1000), participants: participants, diff --git a/apps/desktop/src/store/tinybase/persister/settings/transform.ts b/apps/desktop/src/store/tinybase/persister/settings/transform.ts index 9ee109ecdc..da9bd1ee5e 100644 --- a/apps/desktop/src/store/tinybase/persister/settings/transform.ts +++ b/apps/desktop/src/store/tinybase/persister/settings/transform.ts @@ -77,6 +77,14 @@ function settingsToStoreValues(settings: unknown): Record { value = getByPath(settings, ["general", "ai_language"]); } else if (key === "spoken_languages") { value = getByPath(settings, ["general", "spoken_languages"]); + } else if (key === "mic_detection_delay_secs") { + const legacy = getByPath(settings, [ + "notification", + "in_meeting_reminder", + ]); + if (legacy === true) { + value = 180; + } } } diff --git a/apps/desktop/src/store/tinybase/store/settings.ts b/apps/desktop/src/store/tinybase/store/settings.ts index 14e8f3f834..16f2723e8f 100644 --- a/apps/desktop/src/store/tinybase/store/settings.ts +++ b/apps/desktop/src/store/tinybase/store/settings.ts @@ -69,9 +69,21 @@ export const SETTINGS_MAPPING = { type: "string", path: ["general", "week_start"], }, - notification_in_meeting_reminder: { - type: "boolean", - path: ["notification", "in_meeting_reminder"], + event_notify_before_minutes: { + type: "number", + path: ["notification", "event_notify_before_minutes"], + }, + event_notification_timeout_secs: { + type: "number", + path: ["notification", "event_notification_timeout_secs"], + }, + mic_detection_delay_secs: { + type: "number", + path: ["notification", "mic_detection_delay_secs"], + }, + mic_notification_timeout_secs: { + type: "number", + path: ["notification", "mic_notification_timeout_secs"], }, }, tables: { diff --git a/plugins/detect/js/bindings.gen.ts b/plugins/detect/js/bindings.gen.ts index 194e6a856b..f838f56439 100644 --- a/plugins/detect/js/bindings.gen.ts +++ b/plugins/detect/js/bindings.gen.ts @@ -61,6 +61,14 @@ async getCurrentLocaleIdentifier() : Promise> { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async setMicDetectionDelay(secs: number) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:detect|set_mic_detection_delay", { secs }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } @@ -79,7 +87,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 } | { type: "micProlongedUsage"; key: string; app: InstalledApp; duration_secs: number } +export type DetectEvent = { type: "micDetected"; key: string; apps: InstalledApp[]; duration_secs: number } | { type: "micStopped"; apps: InstalledApp[] } | { type: "micMuted"; value: boolean } | { type: "sleepStateChanged"; value: boolean } export type InstalledApp = { id: string; name: string } /** tauri-specta globals **/ diff --git a/plugins/detect/permissions/autogenerated/commands/set_mic_detection_delay.toml b/plugins/detect/permissions/autogenerated/commands/set_mic_detection_delay.toml new file mode 100644 index 0000000000..9f7d837aab --- /dev/null +++ b/plugins/detect/permissions/autogenerated/commands/set_mic_detection_delay.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-set-mic-detection-delay" +description = "Enables the set_mic_detection_delay command without any pre-configured scope." +commands.allow = ["set_mic_detection_delay"] + +[[permission]] +identifier = "deny-set-mic-detection-delay" +description = "Denies the set_mic_detection_delay command without any pre-configured scope." +commands.deny = ["set_mic_detection_delay"] diff --git a/plugins/detect/permissions/autogenerated/reference.md b/plugins/detect/permissions/autogenerated/reference.md index 413d1d2aba..8431a18847 100644 --- a/plugins/detect/permissions/autogenerated/reference.md +++ b/plugins/detect/permissions/autogenerated/reference.md @@ -11,6 +11,7 @@ Default permissions for the plugin - `allow-list-default-ignored-bundle-ids` - `allow-get-preferred-languages` - `allow-get-current-locale-identifier` +- `allow-set-mic-detection-delay` ## Permission Table @@ -206,6 +207,32 @@ Denies the set_ignored_bundle_ids command without any pre-configured scope. +`detect:allow-set-mic-detection-delay` + + + + +Enables the set_mic_detection_delay command without any pre-configured scope. + + + + + + + +`detect:deny-set-mic-detection-delay` + + + + +Denies the set_mic_detection_delay command without any pre-configured scope. + + + + + + + `detect:allow-set-quit-handler` diff --git a/plugins/detect/permissions/default.toml b/plugins/detect/permissions/default.toml index 1aafacdc9f..0cbfb85916 100644 --- a/plugins/detect/permissions/default.toml +++ b/plugins/detect/permissions/default.toml @@ -8,4 +8,5 @@ permissions = [ "allow-list-default-ignored-bundle-ids", "allow-get-preferred-languages", "allow-get-current-locale-identifier", + "allow-set-mic-detection-delay", ] diff --git a/plugins/detect/permissions/schemas/schema.json b/plugins/detect/permissions/schemas/schema.json index da9d35b9e2..2c251b2590 100644 --- a/plugins/detect/permissions/schemas/schema.json +++ b/plugins/detect/permissions/schemas/schema.json @@ -378,6 +378,18 @@ "const": "deny-set-ignored-bundle-ids", "markdownDescription": "Denies the set_ignored_bundle_ids command without any pre-configured scope." }, + { + "description": "Enables the set_mic_detection_delay command without any pre-configured scope.", + "type": "string", + "const": "allow-set-mic-detection-delay", + "markdownDescription": "Enables the set_mic_detection_delay command without any pre-configured scope." + }, + { + "description": "Denies the set_mic_detection_delay command without any pre-configured scope.", + "type": "string", + "const": "deny-set-mic-detection-delay", + "markdownDescription": "Denies the set_mic_detection_delay command without any pre-configured scope." + }, { "description": "Enables the set_quit_handler command without any pre-configured scope.", "type": "string", @@ -403,10 +415,10 @@ "markdownDescription": "Denies the set_respect_do_not_disturb command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-list-installed-applications`\n- `allow-list-mic-using-applications`\n- `allow-set-respect-do-not-disturb`\n- `allow-set-ignored-bundle-ids`\n- `allow-list-default-ignored-bundle-ids`\n- `allow-get-preferred-languages`\n- `allow-get-current-locale-identifier`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-list-installed-applications`\n- `allow-list-mic-using-applications`\n- `allow-set-respect-do-not-disturb`\n- `allow-set-ignored-bundle-ids`\n- `allow-list-default-ignored-bundle-ids`\n- `allow-get-preferred-languages`\n- `allow-get-current-locale-identifier`\n- `allow-set-mic-detection-delay`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-list-installed-applications`\n- `allow-list-mic-using-applications`\n- `allow-set-respect-do-not-disturb`\n- `allow-set-ignored-bundle-ids`\n- `allow-list-default-ignored-bundle-ids`\n- `allow-get-preferred-languages`\n- `allow-get-current-locale-identifier`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-list-installed-applications`\n- `allow-list-mic-using-applications`\n- `allow-set-respect-do-not-disturb`\n- `allow-set-ignored-bundle-ids`\n- `allow-list-default-ignored-bundle-ids`\n- `allow-get-preferred-languages`\n- `allow-get-current-locale-identifier`\n- `allow-set-mic-detection-delay`" } ] } diff --git a/plugins/detect/src/commands.rs b/plugins/detect/src/commands.rs index 1eaf71d4d9..f55e3e610b 100644 --- a/plugins/detect/src/commands.rs +++ b/plugins/detect/src/commands.rs @@ -44,6 +44,16 @@ pub(crate) async fn set_respect_do_not_disturb( Ok(()) } +#[tauri::command] +#[specta::specta] +pub(crate) async fn set_mic_detection_delay( + app: tauri::AppHandle, + secs: u64, +) -> Result<(), String> { + app.detect().set_mic_detection_delay(secs); + Ok(()) +} + #[cfg(target_os = "macos")] #[tauri::command] #[specta::specta] diff --git a/plugins/detect/src/events.rs b/plugins/detect/src/events.rs index 9fbec72757..0c6aef4f74 100644 --- a/plugins/detect/src/events.rs +++ b/plugins/detect/src/events.rs @@ -9,10 +9,11 @@ macro_rules! common_event_derives { common_event_derives! { #[serde(tag = "type")] pub enum DetectEvent { - #[serde(rename = "micStarted")] - MicStarted { + #[serde(rename = "micDetected")] + MicDetected { key: String, apps: Vec, + duration_secs: u64, }, #[serde(rename = "micStopped")] MicStopped { @@ -22,11 +23,5 @@ common_event_derives! { MicMuteStateChanged { value: bool }, #[serde(rename = "sleepStateChanged")] SleepStateChanged { value: bool }, - #[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 e7f5613c97..5a64f9d46e 100644 --- a/plugins/detect/src/ext.rs +++ b/plugins/detect/src/ext.rs @@ -30,6 +30,12 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Detect<'a, R, M> { let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner()); state_guard.policy.respect_dnd = enabled; } + + pub fn set_mic_detection_delay(&self, secs: u64) { + let state = self.manager.state::(); + let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner()); + state_guard.mic_detection_delay = std::time::Duration::from_secs(secs); + } } pub trait DetectPluginExt { diff --git a/plugins/detect/src/handler.rs b/plugins/detect/src/handler.rs index ef87b540cb..6e0758508b 100644 --- a/plugins/detect/src/handler.rs +++ b/plugins/detect/src/handler.rs @@ -58,11 +58,46 @@ fn handle_mic_started( state: &ProcessorState, apps: Vec, ) { - let is_dnd = env.is_do_not_disturb(); - - let policy_result = { - let mut guard = state.lock().unwrap_or_else(|e| e.into_inner()); + let mut guard = state.lock().unwrap_or_else(|e| e.into_inner()); + let delay = guard.mic_detection_delay; + if delay.is_zero() { + let is_dnd = env.is_do_not_disturb(); + let ctx = PolicyContext { + apps: &apps, + is_dnd, + event_type: MicEventType::Started, + }; + let policy_result = guard.policy.evaluate(&ctx); + + match policy_result { + Ok(result) => { + let uncooled: Vec<_> = result + .filtered_apps + .iter() + .filter(|app| !guard.mic_usage_tracker.is_in_cooldown(&app.id)) + .cloned() + .collect(); + if uncooled.is_empty() { + drop(guard); + return; + } + for app in &uncooled { + guard.mic_usage_tracker.set_cooldown(&app.id); + } + drop(guard); + env.emit(DetectEvent::MicDetected { + key: result.dedup_key, + apps: uncooled, + duration_secs: 0, + }); + } + Err(reason) => { + drop(guard); + tracing::info!(?reason, "skip_notification"); + } + } + } else { let to_track: Vec<_> = apps .iter() .filter(|app| { @@ -84,27 +119,10 @@ fn handle_mic_started( app.clone(), generation, token, + delay, ); } - - let ctx = PolicyContext { - apps: &apps, - is_dnd, - event_type: MicEventType::Started, - }; - guard.policy.evaluate(&ctx) - }; - - match policy_result { - Ok(result) => { - env.emit(DetectEvent::MicStarted { - key: result.dedup_key, - apps: result.filtered_apps, - }); - } - Err(reason) => { - tracing::info!(?reason, "skip_notification"); - } + drop(guard); } } @@ -182,6 +200,15 @@ mod tests { } } + fn with_delay(delay_secs: u64) -> Self { + let h = Self::new(); + { + let mut guard = h.state.lock().unwrap(); + guard.mic_detection_delay = Duration::from_secs(delay_secs); + } + h + } + fn mic_started(&self, app: hypr_detect::InstalledApp) { handle_detect_event( &self.env, @@ -216,19 +243,20 @@ mod tests { } #[tokio::test(start_paused = true)] - async fn test_mic_started_emits_event() { + async fn test_mic_detected_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_eq!(events.len(), 1, "expected one MicDetected event for zoom"); assert!( matches!( &events[0], - DetectEvent::MicStarted { apps, .. } if apps[0].id == "us.zoom.xos" + DetectEvent::MicDetected { apps, duration_secs, .. } + if apps[0].id == "us.zoom.xos" && *duration_secs == 0 ), - "expected MicStarted with zoom app" + "expected MicDetected with zoom app and duration_secs=0" ); } @@ -240,37 +268,86 @@ mod tests { assert!( h.take_events().is_empty(), - "categorized app should not emit MicStarted" + "categorized app should not emit MicDetected" ); } #[tokio::test(start_paused = true)] - async fn test_mic_prolonged_usage_timer() { - let h = Harness::new(); + async fn test_delayed_mic_detection_timer() { + let h = Harness::with_delay(3 * 60); h.mic_started(zoom()); - h.take_events(); + assert!( + h.take_events().is_empty(), + "delay>0 should not emit MicDetected immediately" + ); h.advance_secs(3 * 60).await; let events = h.take_events(); - assert_eq!(events.len(), 1, "expected MicProlongedUsage event"); + assert_eq!(events.len(), 1, "expected delayed MicDetected event"); assert!( matches!( &events[0], - DetectEvent::MicProlongedUsage { app, duration_secs, .. } - if app.id == "us.zoom.xos" && *duration_secs == 180 + DetectEvent::MicDetected { apps, duration_secs, .. } + if apps[0].id == "us.zoom.xos" && *duration_secs == 180 ), - "expected timer event for zoom after 3 minutes" + "expected MicDetected with zoom app and duration_secs=180" ); } #[tokio::test(start_paused = true)] - async fn test_cancel_before_timer() { + async fn test_zero_delay_emits_immediately() { let h = Harness::new(); h.mic_started(zoom()); - h.take_events(); + + let events = h.take_events(); + assert_eq!(events.len(), 1, "zero delay should emit immediately"); + assert!(matches!( + &events[0], + DetectEvent::MicDetected { duration_secs, .. } if *duration_secs == 0 + ),); + + h.advance_secs(3 * 60).await; + assert!( + h.take_events().is_empty(), + "zero delay should not spawn any timer" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_zero_delay_cooldown_suppresses_repeat() { + let h = Harness::new(); + + h.mic_started(zoom()); + assert_eq!(h.take_events().len(), 1, "first notification should fire"); + + h.mic_started(zoom()); + assert!( + h.take_events().is_empty(), + "second immediate notification suppressed by cooldown" + ); + + h.advance_secs(60 * 60).await; + + h.mic_started(zoom()); + assert_eq!( + h.take_events().len(), + 1, + "notification fires again after cooldown expires" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_cancel_before_timer() { + let h = Harness::with_delay(3 * 60); + + h.mic_started(zoom()); + assert!( + h.take_events().is_empty(), + "delay>0 should not emit immediately" + ); h.advance_secs(60).await; h.mic_stopped(zoom()); @@ -286,7 +363,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn test_user_ignored_app_no_timer() { - let h = Harness::new(); + let h = Harness::with_delay(3 * 60); { let mut guard = h.state.lock().unwrap(); @@ -299,7 +376,7 @@ mod tests { h.mic_started(zoom()); assert!( h.take_events().is_empty(), - "user-ignored app should not emit MicStarted" + "user-ignored app should not emit MicDetected" ); h.advance_secs(3 * 60).await; @@ -314,7 +391,12 @@ mod tests { let h = Harness::new(); h.mic_started(zoom()); - assert_eq!(h.take_events().len(), 1, "zoom should emit MicStarted"); + let events = h.take_events(); + assert_eq!(events.len(), 1, "zoom should emit MicDetected"); + assert!(matches!( + &events[0], + DetectEvent::MicDetected { duration_secs, .. } if *duration_secs == 0 + )); h.mic_started(aqua_voice()); assert!( @@ -335,20 +417,23 @@ mod tests { #[test] fn test_on_timer_fired_emits() { let env = TestEnv::new(); - let app = zoom(); - mic_usage_tracker::on_timer_fired(&env, &app, 180); + let result = crate::policy::PolicyResult { + filtered_apps: vec![zoom()], + dedup_key: "test-key".to_string(), + }; + mic_usage_tracker::on_timer_fired(&env, &result, 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 + DetectEvent::MicDetected { 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(); + async fn test_dnd_suppresses_delayed_notification() { + let h = Harness::with_delay(3 * 60); h.env.set_dnd(true); { let mut guard = h.state.lock().unwrap(); @@ -356,31 +441,47 @@ mod tests { } h.mic_started(zoom()); - assert!(h.take_events().is_empty(), "DnD should suppress MicStarted"); + assert!( + h.take_events().is_empty(), + "delay>0 should not emit immediately" + ); h.advance_secs(3 * 60).await; - let events = h.take_events(); - assert_eq!( - events.len(), - 1, - "timer fires regardless of DnD (separate concern)" + assert!( + h.take_events().is_empty(), + "DnD should suppress delayed notification" ); - assert!(matches!(&events[0], DetectEvent::MicProlongedUsage { .. })); } #[tokio::test(start_paused = true)] - async fn test_stop_and_restart_creates_new_timer() { + async fn test_dnd_suppresses_zero_delay() { 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()); - h.take_events(); + assert!( + h.take_events().is_empty(), + "DnD should suppress immediate notification" + ); + } + + #[tokio::test(start_paused = true)] + async fn test_stop_and_restart_creates_new_timer() { + let h = Harness::with_delay(3 * 60); + + h.mic_started(zoom()); + assert!(h.take_events().is_empty()); h.advance_secs(60).await; h.mic_stopped(zoom()); h.take_events(); h.mic_started(zoom()); - h.take_events(); + assert!(h.take_events().is_empty()); h.advance_secs(2 * 60).await; assert!( @@ -393,20 +494,20 @@ mod tests { 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" + DetectEvent::MicDetected { apps, .. } if apps[0].id == "us.zoom.xos" )); } #[tokio::test(start_paused = true)] async fn test_duplicate_mic_started_no_timer_reset() { - let h = Harness::new(); + let h = Harness::with_delay(3 * 60); h.mic_started(zoom()); - h.take_events(); + assert!(h.take_events().is_empty()); h.advance_secs(60).await; h.mic_started(zoom()); - h.take_events(); + assert!(h.take_events().is_empty()); h.advance_secs(2 * 60).await; let events = h.take_events(); @@ -415,19 +516,22 @@ mod tests { 1, "timer fires 3 min from original start, not from duplicate" ); - assert!(matches!(&events[0], DetectEvent::MicProlongedUsage { .. })); + assert!(matches!( + &events[0], + DetectEvent::MicDetected { duration_secs, .. } if *duration_secs == 180 + )); } #[tokio::test(start_paused = true)] async fn test_multiple_apps_independent_timers() { - let h = Harness::new(); + let h = Harness::with_delay(3 * 60); h.mic_started(zoom()); - h.take_events(); + assert!(h.take_events().is_empty()); h.advance_secs(60).await; h.mic_started(slack()); - h.take_events(); + assert!(h.take_events().is_empty()); h.mic_stopped(zoom()); h.take_events(); @@ -443,17 +547,17 @@ mod tests { assert_eq!(events.len(), 1, "only slack timer should fire"); assert!(matches!( &events[0], - DetectEvent::MicProlongedUsage { app, .. } - if app.id == "com.tinyspeck.slackmacgap" + DetectEvent::MicDetected { apps, .. } + if apps[0].id == "com.tinyspeck.slackmacgap" ),); } #[tokio::test(start_paused = true)] async fn test_ignore_during_active_tracking_cancels_timer() { - let h = Harness::new(); + let h = Harness::with_delay(3 * 60); h.mic_started(zoom()); - h.take_events(); + assert!(h.take_events().is_empty()); h.advance_secs(60).await; @@ -475,10 +579,10 @@ mod tests { #[tokio::test(start_paused = true)] async fn test_cooldown_suppresses_repeated_notifications() { - let h = Harness::new(); + let h = Harness::with_delay(3 * 60); h.mic_started(zoom()); - h.take_events(); + assert!(h.take_events().is_empty()); h.advance_secs(3 * 60).await; assert_eq!(h.take_events().len(), 1, "first notification should fire"); @@ -486,7 +590,7 @@ mod tests { h.mic_stopped(zoom()); h.take_events(); h.mic_started(zoom()); - h.take_events(); + assert!(h.take_events().is_empty()); h.advance_secs(3 * 60).await; assert!( @@ -497,10 +601,10 @@ mod tests { #[tokio::test(start_paused = true)] async fn test_cooldown_expires_after_one_hour() { - let h = Harness::new(); + let h = Harness::with_delay(3 * 60); h.mic_started(zoom()); - h.take_events(); + assert!(h.take_events().is_empty()); h.advance_secs(3 * 60).await; assert_eq!(h.take_events().len(), 1, "first notification fires"); @@ -511,7 +615,7 @@ mod tests { h.advance_secs(60 * 60).await; h.mic_started(zoom()); - h.take_events(); + assert!(h.take_events().is_empty()); h.advance_secs(3 * 60).await; let events = h.take_events(); @@ -524,15 +628,15 @@ mod tests { #[tokio::test(start_paused = true)] async fn test_cooldown_is_per_app() { - let h = Harness::new(); + let h = Harness::with_delay(3 * 60); h.mic_started(zoom()); - h.take_events(); + assert!(h.take_events().is_empty()); h.advance_secs(3 * 60).await; assert_eq!(h.take_events().len(), 1, "zoom notification fires"); h.mic_started(slack()); - h.take_events(); + assert!(h.take_events().is_empty()); h.advance_secs(3 * 60).await; let events = h.take_events(); assert_eq!( @@ -542,8 +646,8 @@ mod tests { ); assert!(matches!( &events[0], - DetectEvent::MicProlongedUsage { app, .. } - if app.id == "com.tinyspeck.slackmacgap" + DetectEvent::MicDetected { apps, .. } + if apps[0].id == "com.tinyspeck.slackmacgap" )); } } diff --git a/plugins/detect/src/lib.rs b/plugins/detect/src/lib.rs index 58b51ee6da..119fad5eec 100644 --- a/plugins/detect/src/lib.rs +++ b/plugins/detect/src/lib.rs @@ -1,4 +1,5 @@ use std::sync::{Arc, Mutex}; +use std::time::Duration; use tauri::Manager; @@ -27,6 +28,7 @@ pub(crate) type ProcessorState = Arc>; pub(crate) struct Processor { pub(crate) policy: policy::MicNotificationPolicy, pub(crate) mic_usage_tracker: mic_usage_tracker::MicUsageTracker, + pub(crate) mic_detection_delay: Duration, } fn make_specta_builder() -> tauri_specta::Builder { @@ -40,6 +42,7 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::list_default_ignored_bundle_ids::, commands::get_preferred_languages::, commands::get_current_locale_identifier::, + commands::set_mic_detection_delay::, ]) .events(tauri_specta::collect_events![DetectEvent]) .error_handling(tauri_specta::ErrorHandlingMode::Result) diff --git a/plugins/detect/src/mic_usage_tracker.rs b/plugins/detect/src/mic_usage_tracker.rs index fcaae22f1f..5bec5406c3 100644 --- a/plugins/detect/src/mic_usage_tracker.rs +++ b/plugins/detect/src/mic_usage_tracker.rs @@ -3,9 +3,12 @@ use std::time::Duration; use tokio_util::sync::CancellationToken; -use crate::{DetectEvent, ProcessorState, env::Env}; +use crate::{ + DetectEvent, ProcessorState, + env::Env, + policy::{MicEventType, PolicyContext, PolicyResult}, +}; -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 { @@ -56,6 +59,11 @@ impl MicUsageTracker { generation } + pub fn set_cooldown(&mut self, app_id: &str) { + self.cooldowns + .insert(app_id.to_string(), tokio::time::Instant::now()); + } + pub fn cancel_app(&mut self, app_id: &str) { if let Some(entry) = self.timers.remove(app_id) { entry.token.cancel(); @@ -79,17 +87,12 @@ impl MicUsageTracker { } } -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" - ); +pub(crate) fn on_timer_fired(env: &E, result: &PolicyResult, duration_secs: u64) { + tracing::info!(duration_secs, "mic_detection_fired"); - let key = uuid::Uuid::new_v4().to_string(); - env.emit(DetectEvent::MicProlongedUsage { - key, - app: app.clone(), + env.emit(DetectEvent::MicDetected { + key: result.dedup_key.clone(), + apps: result.filtered_apps.clone(), duration_secs, }); } @@ -100,13 +103,14 @@ pub(crate) fn spawn_timer( app: hypr_detect::InstalledApp, generation: u64, token: CancellationToken, + delay: Duration, ) { - let duration_secs = MIC_ACTIVE_THRESHOLD.as_secs(); + let duration_secs = delay.as_secs(); let app_id = app.id.clone(); tokio::spawn(async move { tokio::select! { - _ = tokio::time::sleep(MIC_ACTIVE_THRESHOLD) => {} + _ = tokio::time::sleep(delay) => {} _ = token.cancelled() => { return; } } @@ -116,7 +120,21 @@ pub(crate) fn spawn_timer( }; if claimed { - on_timer_fired(&env, &app, duration_secs); + let is_dnd = env.is_do_not_disturb(); + let policy_result = { + let guard = state.lock().unwrap_or_else(|e| e.into_inner()); + let ctx = PolicyContext { + apps: &[app], + is_dnd, + event_type: MicEventType::Started, + }; + guard.policy.evaluate(&ctx) + }; + + match policy_result { + Ok(result) => on_timer_fired(&env, &result, duration_secs), + Err(reason) => tracing::info!(?reason, "skip_delayed_notification"), + } } }); }