Skip to content

Commit 6dfe9e7

Browse files
refactor: centralize notification policy with explicit skip conditions (#3513)
Co-authored-by: yujonglee <yujonglee.dev@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 6b9b4d3 commit 6dfe9e7

File tree

7 files changed

+499
-111
lines changed

7 files changed

+499
-111
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/notification-interface/src/lib.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::BTreeSet;
2+
13
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
24
pub enum NotificationEvent {
35
Confirm,
@@ -6,6 +8,63 @@ pub enum NotificationEvent {
68
Timeout,
79
}
810

11+
#[derive(Debug, Clone, PartialEq, Eq)]
12+
pub enum NotificationKey {
13+
MicStarted { apps: BTreeSet<String> },
14+
MicStopped { apps: BTreeSet<String> },
15+
CalendarEvent { event_id: String },
16+
Custom(String),
17+
}
18+
19+
impl NotificationKey {
20+
pub fn mic_started(app_bundle_ids: impl IntoIterator<Item = String>) -> Self {
21+
Self::MicStarted {
22+
apps: app_bundle_ids.into_iter().collect(),
23+
}
24+
}
25+
26+
pub fn mic_stopped(app_bundle_ids: impl IntoIterator<Item = String>) -> Self {
27+
Self::MicStopped {
28+
apps: app_bundle_ids.into_iter().collect(),
29+
}
30+
}
31+
32+
pub fn calendar_event(event_id: impl Into<String>) -> Self {
33+
Self::CalendarEvent {
34+
event_id: event_id.into(),
35+
}
36+
}
37+
38+
pub fn to_dedup_key(&self) -> String {
39+
match self {
40+
Self::MicStarted { apps } => {
41+
let sorted: Vec<_> = apps.iter().cloned().collect();
42+
format!("mic-started:{}", sorted.join(","))
43+
}
44+
Self::MicStopped { apps } => {
45+
let sorted: Vec<_> = apps.iter().cloned().collect();
46+
format!("mic-stopped:{}", sorted.join(","))
47+
}
48+
Self::CalendarEvent { event_id } => {
49+
format!("event:{event_id}")
50+
}
51+
Self::Custom(s) => s.clone(),
52+
}
53+
}
54+
}
55+
56+
impl From<String> for NotificationKey {
57+
fn from(s: String) -> Self {
58+
Self::Custom(s)
59+
}
60+
}
61+
62+
impl From<&str> for NotificationKey {
63+
fn from(s: &str) -> Self {
64+
Self::Custom(s.to_string())
65+
}
66+
}
67+
968
#[derive(
1069
Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize, specta::Type,
1170
)]

plugins/detect/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ specta-typescript = { workspace = true }
2222
[dependencies]
2323
hypr-detect = { workspace = true, features = ["mic", "list", "language", "sleep"] }
2424
hypr-host = { workspace = true }
25+
hypr-notification-interface = { workspace = true }
26+
tauri-plugin-listener = { workspace = true }
2527

2628
tauri = { workspace = true, features = ["specta", "test"] }
2729
tauri-plugin-windows = { workspace = true }

plugins/detect/src/ext.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,19 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager<R>> Detect<'a, R, M> {
1313
}
1414

1515
pub fn list_default_ignored_bundle_ids(&self) -> Vec<String> {
16-
crate::handler::default_ignored_bundle_ids()
16+
crate::policy::default_ignored_bundle_ids()
1717
}
1818

1919
pub async fn set_ignored_bundle_ids(&self, bundle_ids: Vec<String>) {
2020
let state = self.manager.state::<crate::SharedState>();
2121
let mut state_guard = state.lock().await;
22-
state_guard.ignored_bundle_ids = bundle_ids;
22+
state_guard.policy.user_ignored_bundle_ids = bundle_ids;
2323
}
2424

2525
pub async fn set_respect_do_not_disturb(&self, enabled: bool) {
2626
let state = self.manager.state::<crate::SharedState>();
2727
let mut state_guard = state.lock().await;
28-
state_guard.respect_do_not_disturb = enabled;
28+
state_guard.policy.respect_dnd = enabled;
2929
}
3030
}
3131

plugins/detect/src/handler.rs

Lines changed: 92 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,28 @@
11
use tauri::{AppHandle, EventTarget, Manager, Runtime};
2+
use tauri_plugin_listener::ListenerPluginExt;
23
use tauri_plugin_windows::WindowImpl;
34
use tauri_specta::Event;
45

5-
use crate::{DetectEvent, SharedState, dnd};
6-
7-
pub(crate) fn default_ignored_bundle_ids() -> Vec<String> {
8-
let hyprnote = [
9-
"com.hyprnote.dev",
10-
"com.hyprnote.stable",
11-
"com.hyprnote.nightly",
12-
"com.hyprnote.staging",
13-
];
14-
15-
let dictation_apps = [
16-
"com.electron.wispr-flow",
17-
"com.seewillow.WillowMac",
18-
"com.superduper.superwhisper",
19-
"com.prakashjoshipax.VoiceInk",
20-
"com.goodsnooze.macwhisper",
21-
"com.descript.beachcube",
22-
"com.apple.VoiceMemos",
23-
"com.electron.aqua-voice",
24-
];
25-
26-
let ides = [
27-
"dev.warp.Warp-Stable",
28-
"com.exafunction.windsurf",
29-
"com.microsoft.VSCode",
30-
"com.todesktop.230313mzl4w4u92",
31-
];
32-
33-
let screen_recording = [
34-
"so.cap.desktop",
35-
"com.timpler.screenstudio",
36-
"com.loom.desktop",
37-
"com.obsproject.obs-studio",
38-
];
39-
40-
let ai_assistants = ["com.openai.chat", "com.anthropic.claudefordesktop"];
41-
42-
let other = [
43-
"com.raycast.macos",
44-
"com.apple.garageband10",
45-
"com.apple.Sound-Settings.extension",
46-
];
47-
48-
dictation_apps
49-
.into_iter()
50-
.chain(hyprnote)
51-
.chain(ides)
52-
.chain(screen_recording)
53-
.chain(ai_assistants)
54-
.chain(other)
55-
.map(String::from)
56-
.collect()
57-
}
6+
use crate::{
7+
DetectEvent, SharedState, dnd,
8+
policy::{MicEventType, PolicyContext},
9+
};
5810

5911
pub async fn setup<R: Runtime>(app: &AppHandle<R>) -> Result<(), Box<dyn std::error::Error>> {
6012
let app_handle = app.app_handle().clone();
6113
let callback = hypr_detect::new_callback(move |event| {
62-
let state = app_handle.state::<SharedState>();
14+
let app_handle_clone = app_handle.clone();
6315

6416
match event {
6517
hypr_detect::DetectEvent::MicStarted(apps) => {
66-
let state_guard = state.blocking_lock();
67-
68-
if state_guard.respect_do_not_disturb && dnd::is_do_not_disturb() {
69-
tracing::info!(reason = "respect_do_not_disturb", "skip_notification");
70-
return;
71-
}
72-
73-
let filtered_apps = filter_apps(apps, &state_guard.ignored_bundle_ids);
74-
drop(state_guard);
75-
76-
if filtered_apps.is_empty() {
77-
tracing::info!(reason = "all_apps_filtered", "skip_notification");
78-
return;
79-
}
80-
81-
emit_to_main(
82-
&app_handle,
83-
DetectEvent::MicStarted {
84-
key: uuid::Uuid::new_v4().to_string(),
85-
apps: filtered_apps,
86-
},
87-
);
18+
tauri::async_runtime::spawn(async move {
19+
handle_mic_started(&app_handle_clone, apps).await;
20+
});
8821
}
8922
hypr_detect::DetectEvent::MicStopped(apps) => {
90-
let state_guard = state.blocking_lock();
91-
92-
if state_guard.respect_do_not_disturb && dnd::is_do_not_disturb() {
93-
tracing::info!(reason = "respect_do_not_disturb", "skip_mic_stopped");
94-
return;
95-
}
96-
97-
let filtered_apps = filter_apps(apps, &state_guard.ignored_bundle_ids);
98-
drop(state_guard);
99-
100-
if filtered_apps.is_empty() {
101-
tracing::info!(reason = "all_apps_filtered", "skip_mic_stopped");
102-
return;
103-
}
104-
105-
emit_to_main(
106-
&app_handle,
107-
DetectEvent::MicStopped {
108-
apps: filtered_apps,
109-
},
110-
);
23+
tauri::async_runtime::spawn(async move {
24+
handle_mic_stopped(&app_handle_clone, apps).await;
25+
});
11126
}
11227
#[cfg(all(target_os = "macos", feature = "zoom"))]
11328
hypr_detect::DetectEvent::ZoomMuteStateChanged { value } => {
@@ -128,15 +43,87 @@ pub async fn setup<R: Runtime>(app: &AppHandle<R>) -> Result<(), Box<dyn std::er
12843
Ok(())
12944
}
13045

131-
fn filter_apps(
46+
async fn handle_mic_started<R: Runtime>(
47+
app_handle: &AppHandle<R>,
48+
apps: Vec<hypr_detect::InstalledApp>,
49+
) {
50+
let is_listening = {
51+
let listener_state = app_handle.listener().get_state().await;
52+
matches!(
53+
listener_state,
54+
tauri_plugin_listener::fsm::State::Active
55+
| tauri_plugin_listener::fsm::State::Finalizing
56+
)
57+
};
58+
59+
let state = app_handle.state::<SharedState>();
60+
let state_guard = state.lock().await;
61+
62+
let is_dnd = dnd::is_do_not_disturb();
63+
64+
let ctx = PolicyContext {
65+
apps: &apps,
66+
is_listening,
67+
is_dnd,
68+
event_type: MicEventType::Started,
69+
};
70+
71+
match state_guard.policy.evaluate(&ctx) {
72+
Ok(result) => {
73+
drop(state_guard);
74+
emit_to_main(
75+
app_handle,
76+
DetectEvent::MicStarted {
77+
key: result.dedup_key,
78+
apps: result.filtered_apps,
79+
},
80+
);
81+
}
82+
Err(reason) => {
83+
tracing::info!(?reason, "skip_notification");
84+
}
85+
}
86+
}
87+
88+
async fn handle_mic_stopped<R: Runtime>(
89+
app_handle: &AppHandle<R>,
13290
apps: Vec<hypr_detect::InstalledApp>,
133-
ignored_bundle_ids: &[String],
134-
) -> Vec<hypr_detect::InstalledApp> {
135-
let default_ignored = default_ignored_bundle_ids();
136-
apps.into_iter()
137-
.filter(|app| !ignored_bundle_ids.contains(&app.id))
138-
.filter(|app| !default_ignored.contains(&app.id))
139-
.collect()
91+
) {
92+
let is_listening = {
93+
let listener_state = app_handle.listener().get_state().await;
94+
matches!(
95+
listener_state,
96+
tauri_plugin_listener::fsm::State::Active
97+
| tauri_plugin_listener::fsm::State::Finalizing
98+
)
99+
};
100+
101+
let state = app_handle.state::<SharedState>();
102+
let state_guard = state.lock().await;
103+
104+
let is_dnd = dnd::is_do_not_disturb();
105+
106+
let ctx = PolicyContext {
107+
apps: &apps,
108+
is_listening,
109+
is_dnd,
110+
event_type: MicEventType::Stopped,
111+
};
112+
113+
match state_guard.policy.evaluate(&ctx) {
114+
Ok(result) => {
115+
drop(state_guard);
116+
emit_to_main(
117+
app_handle,
118+
DetectEvent::MicStopped {
119+
apps: result.filtered_apps,
120+
},
121+
);
122+
}
123+
Err(reason) => {
124+
tracing::info!(?reason, "skip_mic_stopped");
125+
}
126+
}
140127
}
141128

142129
fn emit_to_main<R: Runtime>(app_handle: &AppHandle<R>, event: DetectEvent) {

plugins/detect/src/lib.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,31 @@ mod error;
77
mod events;
88
mod ext;
99
mod handler;
10+
mod policy;
1011

1112
pub use dnd::*;
1213
pub use error::*;
1314
pub use events::*;
1415
pub use ext::*;
16+
pub use policy::*;
1517

1618
const PLUGIN_NAME: &str = "detect";
1719

1820
pub type SharedState = Mutex<State>;
1921

20-
#[derive(Default)]
2122
pub struct State {
2223
#[allow(dead_code)]
2324
pub(crate) detector: hypr_detect::Detector,
24-
pub(crate) ignored_bundle_ids: Vec<String>,
25-
pub(crate) respect_do_not_disturb: bool,
25+
pub(crate) policy: policy::MicNotificationPolicy,
26+
}
27+
28+
impl Default for State {
29+
fn default() -> Self {
30+
Self {
31+
detector: hypr_detect::Detector::default(),
32+
policy: policy::MicNotificationPolicy::default(),
33+
}
34+
}
2635
}
2736

2837
fn make_specta_builder<R: tauri::Runtime>() -> tauri_specta::Builder<R> {

0 commit comments

Comments
 (0)