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 && (
+
+ )}
)}
@@ -255,7 +349,51 @@ export function NotificationSettingsView() {
{field.state.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"),
+ }
}
});
}