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());
- }
}