brows3r_lib/notifications/
os.rs1use crate::{error::AppError, notifications::Severity, settings::SettingsHandle};
28
29pub trait OsNotifyChannel {
38 fn send(&self, title: &str, body: &str) -> Result<(), AppError>;
39}
40
41pub struct AppHandleChannel {
47 pub app: tauri::AppHandle,
48}
49
50impl OsNotifyChannel for AppHandleChannel {
51 fn send(&self, title: &str, body: &str) -> Result<(), AppError> {
52 use tauri_plugin_notification::NotificationExt;
53 self.app
54 .notification()
55 .builder()
56 .title(title)
57 .body(body)
58 .show()
59 .map_err(|e| AppError::Network {
60 source: format!("os notification plugin error: {e}"),
61 })
62 }
63}
64
65pub struct OsNotifier<C: OsNotifyChannel> {
71 channel: C,
72 settings: SettingsHandle,
73}
74
75impl<C: OsNotifyChannel> OsNotifier<C> {
76 pub fn new(channel: C, settings: SettingsHandle) -> Self {
77 Self { channel, settings }
78 }
79}
80
81impl OsNotifier<NoopOsChannel> {
82 pub fn noop() -> Self {
89 use crate::settings::SettingsHandle;
90 use std::path::PathBuf;
91 let settings = SettingsHandle::new(crate::settings::Settings::default(), PathBuf::new());
92 Self {
93 channel: NoopOsChannel,
94 settings,
95 }
96 }
97}
98
99impl<C: OsNotifyChannel> OsNotifier<C> {
100 pub async fn maybe_send(
106 &self,
107 notification: &crate::notifications::Notification,
108 terminal: bool,
109 ) -> Result<(), AppError> {
110 let os_enabled = {
112 let settings = self.settings.inner.lock().await;
113 settings.notifications.os_enabled
114 };
115 if !os_enabled {
116 return Ok(());
117 }
118
119 if notification.severity == Severity::Info {
121 return Ok(());
122 }
123
124 if !terminal {
126 return Ok(());
127 }
128
129 self.channel
130 .send(¬ification.title, ¬ification.message)
131 }
132}
133
134#[cfg(test)]
139impl<C: OsNotifyChannel> OsNotifier<C> {
140 pub fn inner_channel(&self) -> &C {
145 &self.channel
146 }
147}
148
149#[cfg(test)]
154pub struct MockOsChannel {
155 pub calls: std::sync::Mutex<Vec<(String, String)>>,
156 pub should_fail: bool,
158}
159
160#[cfg(test)]
161impl Default for MockOsChannel {
162 fn default() -> Self {
163 Self::new()
164 }
165}
166
167#[cfg(test)]
168impl MockOsChannel {
169 pub fn new() -> Self {
170 Self {
171 calls: std::sync::Mutex::new(Vec::new()),
172 should_fail: false,
173 }
174 }
175
176 pub fn failing() -> Self {
177 Self {
178 calls: std::sync::Mutex::new(Vec::new()),
179 should_fail: true,
180 }
181 }
182
183 pub fn call_count(&self) -> usize {
184 self.calls.lock().expect("lock poisoned").len()
185 }
186}
187
188#[cfg(test)]
189impl OsNotifyChannel for MockOsChannel {
190 fn send(&self, title: &str, body: &str) -> Result<(), AppError> {
191 if self.should_fail {
192 return Err(AppError::Network {
193 source: "mock plugin failure".to_string(),
194 });
195 }
196 self.calls
197 .lock()
198 .expect("lock poisoned")
199 .push((title.to_string(), body.to_string()));
200 Ok(())
201 }
202}
203
204pub struct NoopOsChannel;
213
214impl OsNotifyChannel for NoopOsChannel {
215 fn send(&self, _title: &str, _body: &str) -> Result<(), crate::error::AppError> {
216 Ok(())
217 }
218}
219
220#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::{
228 notifications::{Notification, NotificationCategory, Severity},
229 settings::{NotificationSettings, Settings, SettingsHandle},
230 };
231 use std::path::PathBuf;
232 use tokio::sync::Mutex;
233
234 fn make_settings(os_enabled: bool) -> SettingsHandle {
235 let settings = Settings {
236 notifications: NotificationSettings {
237 in_app: true,
238 os_enabled,
239 sound: false,
240 },
241 ..Settings::default()
242 };
243 SettingsHandle {
244 inner: std::sync::Arc::new(Mutex::new(settings)),
245 path: PathBuf::from("/dev/null"),
246 }
247 }
248
249 fn make_notification(severity: Severity) -> Notification {
250 Notification {
251 id: "test-id".to_string(),
252 severity,
253 category: NotificationCategory::Background,
254 title: "Upload complete".to_string(),
255 message: "my-file.txt uploaded".to_string(),
256 resource: Some("s3://bucket/my-file.txt".to_string()),
257 operation: Some("upload".to_string()),
258 timestamp: 1_000_000,
259 details: None,
260 }
261 }
262
263 #[tokio::test]
266 async fn no_call_when_os_disabled() {
267 let channel = MockOsChannel::new();
268 let notifier = OsNotifier::new(channel, make_settings(false));
269 let notif = make_notification(Severity::Success);
270 notifier
271 .maybe_send(¬if, true)
272 .await
273 .expect("should be ok");
274 assert_eq!(
275 notifier.channel.call_count(),
276 0,
277 "no OS notification when os_enabled=false"
278 );
279 }
280
281 #[tokio::test]
284 async fn no_call_for_info_severity() {
285 let channel = MockOsChannel::new();
286 let notifier = OsNotifier::new(channel, make_settings(true));
287 let notif = make_notification(Severity::Info);
288 notifier
289 .maybe_send(¬if, true)
290 .await
291 .expect("should be ok");
292 assert_eq!(
293 notifier.channel.call_count(),
294 0,
295 "no OS notification for Info severity"
296 );
297 }
298
299 #[tokio::test]
302 async fn no_call_when_not_terminal() {
303 let channel = MockOsChannel::new();
304 let notifier = OsNotifier::new(channel, make_settings(true));
305 let notif = make_notification(Severity::Success);
306 notifier
307 .maybe_send(¬if, false)
308 .await
309 .expect("should be ok");
310 assert_eq!(
311 notifier.channel.call_count(),
312 0,
313 "no OS notification when terminal=false"
314 );
315 }
316
317 #[tokio::test]
320 async fn sends_when_all_gates_pass_success() {
321 let channel = MockOsChannel::new();
322 let notifier = OsNotifier::new(channel, make_settings(true));
323 let notif = make_notification(Severity::Success);
324 notifier
325 .maybe_send(¬if, true)
326 .await
327 .expect("should be ok");
328 assert_eq!(
329 notifier.channel.call_count(),
330 1,
331 "OS notification should fire for Success+terminal+enabled"
332 );
333 }
334
335 #[tokio::test]
336 async fn sends_for_warning_severity() {
337 let channel = MockOsChannel::new();
338 let notifier = OsNotifier::new(channel, make_settings(true));
339 let notif = make_notification(Severity::Warning);
340 notifier
341 .maybe_send(¬if, true)
342 .await
343 .expect("should be ok");
344 assert_eq!(notifier.channel.call_count(), 1);
345 }
346
347 #[tokio::test]
348 async fn sends_for_error_severity() {
349 let channel = MockOsChannel::new();
350 let notifier = OsNotifier::new(channel, make_settings(true));
351 let notif = make_notification(Severity::Error);
352 notifier
353 .maybe_send(¬if, true)
354 .await
355 .expect("should be ok");
356 assert_eq!(notifier.channel.call_count(), 1);
357 }
358
359 #[tokio::test]
362 async fn plugin_failure_returns_err() {
363 let channel = MockOsChannel::failing();
364 let notifier = OsNotifier::new(channel, make_settings(true));
365 let notif = make_notification(Severity::Error);
366 let result = notifier.maybe_send(¬if, true).await;
367 assert!(
368 matches!(result, Err(AppError::Network { .. })),
369 "plugin failure should propagate as AppError::Network"
370 );
371 }
372}