@@ -12,11 +12,12 @@ use tracing::{debug, error, info, warn}; |
| 12 | 12 | |
| 13 | 13 | use crate::config::{self, Config}; |
| 14 | 14 | use crate::dbus::NotificationsService; |
| 15 | | -use crate::ipc::{Command, IpcServer}; |
| 15 | +use crate::ipc::{Command, IpcRequest, IpcServer, Response}; |
| 16 | 16 | use crate::notification::{ |
| 17 | 17 | new_shared_store, CloseReason, History, Notification, NotificationEvent, |
| 18 | 18 | SharedNotificationStore, UrgencyTimeouts, |
| 19 | 19 | }; |
| 20 | +use crate::rules::RuleEngine; |
| 20 | 21 | use crate::ui::{PopupCommand, PopupEvent, PopupManager}; |
| 21 | 22 | |
| 22 | 23 | /// Get the path to the PID file |
@@ -91,16 +92,43 @@ impl Drop for PidGuard { |
| 91 | 92 | } |
| 92 | 93 | } |
| 93 | 94 | |
| 95 | +/// DND pause level |
| 96 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 97 | +pub enum PauseLevel { |
| 98 | + /// Show all notifications (not paused) |
| 99 | + ShowAll = 0, |
| 100 | + /// Only show critical notifications |
| 101 | + CriticalOnly = 1, |
| 102 | + /// Show no notifications |
| 103 | + ShowNone = 2, |
| 104 | +} |
| 105 | + |
| 106 | +impl From<u8> for PauseLevel { |
| 107 | + fn from(level: u8) -> Self { |
| 108 | + match level { |
| 109 | + 0 => PauseLevel::ShowAll, |
| 110 | + 1 => PauseLevel::CriticalOnly, |
| 111 | + _ => PauseLevel::ShowNone, |
| 112 | + } |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 94 | 116 | /// Daemon state |
| 95 | 117 | pub struct Daemon { |
| 96 | 118 | config: Arc<Config>, |
| 97 | 119 | ipc_server: IpcServer, |
| 98 | | - ipc_rx: Receiver<Command>, |
| 120 | + ipc_rx: Receiver<IpcRequest>, |
| 99 | 121 | dbus_service: Option<NotificationsService>, |
| 100 | 122 | notification_store: SharedNotificationStore, |
| 101 | 123 | notification_event_rx: tokio::sync::mpsc::Receiver<NotificationEvent>, |
| 102 | 124 | history: Arc<Mutex<History>>, |
| 103 | 125 | running: bool, |
| 126 | + /// Do Not Disturb mode |
| 127 | + paused: bool, |
| 128 | + /// Pause level (0=show all, 1=critical only, 2=show none) |
| 129 | + pause_level: PauseLevel, |
| 130 | + /// Rule engine for filtering/modifying notifications |
| 131 | + rule_engine: RuleEngine, |
| 104 | 132 | /// Channel to send commands to the UI thread |
| 105 | 133 | ui_cmd_tx: Option<std::sync::mpsc::Sender<PopupCommand>>, |
| 106 | 134 | /// Channel to receive events from the UI thread |
@@ -131,6 +159,9 @@ impl Daemon { |
| 131 | 159 | // Create history |
| 132 | 160 | let history = Arc::new(Mutex::new(History::new(config.history.max_length))); |
| 133 | 161 | |
| 162 | + // Create rule engine from config |
| 163 | + let rule_engine = RuleEngine::with_rules(config.rules.clone()); |
| 164 | + |
| 134 | 165 | let config = Arc::new(config); |
| 135 | 166 | |
| 136 | 167 | Ok(Self { |
@@ -142,6 +173,9 @@ impl Daemon { |
| 142 | 173 | notification_event_rx: event_rx, |
| 143 | 174 | history, |
| 144 | 175 | running: true, |
| 176 | + paused: false, |
| 177 | + pause_level: PauseLevel::ShowAll, |
| 178 | + rule_engine, |
| 145 | 179 | ui_cmd_tx: None, |
| 146 | 180 | ui_event_rx: None, |
| 147 | 181 | ui_thread: None, |
@@ -315,21 +349,52 @@ impl Daemon { |
| 315 | 349 | |
| 316 | 350 | /// Poll for IPC commands |
| 317 | 351 | async fn poll_ipc_commands(&mut self) { |
| 318 | | - while let Ok(cmd) = self.ipc_rx.try_recv() { |
| 319 | | - self.handle_ipc_command(cmd).await; |
| 352 | + while let Ok(request) = self.ipc_rx.try_recv() { |
| 353 | + let response = self.handle_ipc_command(request.command).await; |
| 354 | + // Send response back (ignore error if receiver dropped) |
| 355 | + let _ = request.response_tx.send(response); |
| 320 | 356 | } |
| 321 | 357 | } |
| 322 | 358 | |
| 323 | 359 | /// Handle a notification event (created, updated, closed) |
| 324 | 360 | async fn handle_notification_event(&mut self, event: NotificationEvent) { |
| 325 | 361 | match event { |
| 326 | | - NotificationEvent::Created(notification) => { |
| 362 | + NotificationEvent::Created(mut notification) => { |
| 327 | 363 | info!( |
| 328 | 364 | "Notification created: id={} summary=\"{}\"", |
| 329 | 365 | notification.id, notification.summary |
| 330 | 366 | ); |
| 331 | | - // Show popup |
| 332 | | - self.show_notification_popup(notification); |
| 367 | + |
| 368 | + // Process through rule engine (may modify or suppress) |
| 369 | + if self.rule_engine.process(&mut notification).is_none() { |
| 370 | + debug!( |
| 371 | + "Notification {} suppressed by rules", |
| 372 | + notification.id |
| 373 | + ); |
| 374 | + return; |
| 375 | + } |
| 376 | + |
| 377 | + // Check DND mode before showing popup |
| 378 | + let should_show = if !self.paused { |
| 379 | + true |
| 380 | + } else { |
| 381 | + match self.pause_level { |
| 382 | + PauseLevel::ShowAll => true, |
| 383 | + PauseLevel::CriticalOnly => { |
| 384 | + notification.hints.urgency == crate::notification::Urgency::Critical |
| 385 | + } |
| 386 | + PauseLevel::ShowNone => false, |
| 387 | + } |
| 388 | + }; |
| 389 | + |
| 390 | + if should_show { |
| 391 | + self.show_notification_popup(notification); |
| 392 | + } else { |
| 393 | + debug!( |
| 394 | + "Notification {} suppressed by DND (level={:?})", |
| 395 | + notification.id, self.pause_level |
| 396 | + ); |
| 397 | + } |
| 333 | 398 | } |
| 334 | 399 | NotificationEvent::Updated(notification) => { |
| 335 | 400 | info!( |
@@ -366,28 +431,33 @@ impl Daemon { |
| 366 | 431 | } |
| 367 | 432 | } |
| 368 | 433 | |
| 369 | | - /// Handle an IPC command |
| 370 | | - async fn handle_ipc_command(&mut self, cmd: Command) { |
| 434 | + /// Handle an IPC command and return a response |
| 435 | + async fn handle_ipc_command(&mut self, cmd: Command) -> Response { |
| 371 | 436 | debug!("Handling IPC command: {:?}", cmd); |
| 372 | 437 | match cmd { |
| 373 | 438 | Command::Status => { |
| 374 | 439 | let store = self.notification_store.lock().await; |
| 375 | 440 | let history = self.history.lock().await; |
| 376 | | - info!( |
| 377 | | - "Status: running, {} active notifications, {} in history", |
| 378 | | - store.count(), |
| 379 | | - history.len() |
| 380 | | - ); |
| 441 | + let status = serde_json::json!({ |
| 442 | + "running": true, |
| 443 | + "active_count": store.count(), |
| 444 | + "history_count": history.len(), |
| 445 | + "paused": self.paused, |
| 446 | + "pause_level": self.pause_level as u8, |
| 447 | + }); |
| 448 | + Response::ok_with_data(status) |
| 381 | 449 | } |
| 382 | 450 | Command::Reload => { |
| 383 | 451 | info!("Reloading config via IPC"); |
| 384 | | - if let Err(e) = self.handle_reload() { |
| 385 | | - error!("Failed to reload config: {}", e); |
| 452 | + match self.handle_reload() { |
| 453 | + Ok(_) => Response::ok_with_message("Configuration reloaded"), |
| 454 | + Err(e) => Response::error(format!("Failed to reload: {}", e)), |
| 386 | 455 | } |
| 387 | 456 | } |
| 388 | 457 | Command::Quit => { |
| 389 | 458 | info!("Quit requested via IPC"); |
| 390 | 459 | self.running = false; |
| 460 | + Response::ok_with_message("Shutting down") |
| 391 | 461 | } |
| 392 | 462 | Command::Close { id } => { |
| 393 | 463 | let target_id = if let Some(id) = id { |
@@ -400,6 +470,9 @@ impl Daemon { |
| 400 | 470 | |
| 401 | 471 | if let Some(id) = target_id { |
| 402 | 472 | info!("Close notification: {}", id); |
| 473 | + // Send close command to UI |
| 474 | + self.close_notification_popup(id, CloseReason::Closed); |
| 475 | + |
| 403 | 476 | let mut store = self.notification_store.lock().await; |
| 404 | 477 | if let Some(notification) = store.remove(id) { |
| 405 | 478 | let mut history = self.history.lock().await; |
@@ -412,12 +485,19 @@ impl Daemon { |
| 412 | 485 | } |
| 413 | 486 | } |
| 414 | 487 | } |
| 488 | + Response::ok_with_message(format!("Closed notification {}", id)) |
| 489 | + } else { |
| 490 | + Response::ok_with_message("No notifications to close") |
| 415 | 491 | } |
| 416 | 492 | } |
| 417 | 493 | Command::CloseAll => { |
| 418 | 494 | info!("Close all notifications"); |
| 495 | + // Send close-all to UI |
| 496 | + self.send_ui_command(PopupCommand::CloseAll); |
| 497 | + |
| 419 | 498 | let mut store = self.notification_store.lock().await; |
| 420 | 499 | let notifications = store.clear(); |
| 500 | + let count = notifications.len(); |
| 421 | 501 | let mut history = self.history.lock().await; |
| 422 | 502 | |
| 423 | 503 | for notification in notifications { |
@@ -431,54 +511,86 @@ impl Daemon { |
| 431 | 511 | } |
| 432 | 512 | } |
| 433 | 513 | } |
| 514 | + Response::ok_with_message(format!("Closed {} notifications", count)) |
| 434 | 515 | } |
| 435 | 516 | Command::HistoryPop => { |
| 436 | | - info!("History pop requested"); |
| 437 | 517 | let mut history = self.history.lock().await; |
| 438 | 518 | if let Some(notification) = history.pop() { |
| 439 | 519 | info!( |
| 440 | 520 | "Popped from history: id={} summary=\"{}\"", |
| 441 | 521 | notification.id, notification.summary |
| 442 | 522 | ); |
| 443 | | - // TODO: In sprint 3, re-display the notification |
| 523 | + // Re-display the notification |
| 524 | + drop(history); // Release lock before showing popup |
| 525 | + self.show_notification_popup(notification); |
| 526 | + Response::ok_with_message("Restored notification from history") |
| 444 | 527 | } else { |
| 445 | | - info!("History is empty"); |
| 528 | + Response::ok_with_message("History is empty") |
| 446 | 529 | } |
| 447 | 530 | } |
| 448 | 531 | Command::HistoryClear => { |
| 449 | | - info!("History clear requested"); |
| 450 | 532 | let mut history = self.history.lock().await; |
| 533 | + let count = history.len(); |
| 451 | 534 | history.clear(); |
| 535 | + Response::ok_with_message(format!("Cleared {} items from history", count)) |
| 452 | 536 | } |
| 453 | 537 | Command::SetPaused { paused, level } => { |
| 454 | | - info!("Set paused: {} (level {})", paused, level); |
| 455 | | - // TODO: Implement in sprint 4 |
| 538 | + self.paused = paused; |
| 539 | + self.pause_level = PauseLevel::from(level); |
| 540 | + info!( |
| 541 | + "DND mode: {} (level {:?})", |
| 542 | + if paused { "enabled" } else { "disabled" }, |
| 543 | + self.pause_level |
| 544 | + ); |
| 545 | + Response::ok_with_message(format!( |
| 546 | + "DND {}", |
| 547 | + if paused { "enabled" } else { "disabled" } |
| 548 | + )) |
| 456 | 549 | } |
| 457 | 550 | Command::IsPaused => { |
| 458 | | - info!("Is paused query"); |
| 459 | | - // TODO: Implement in sprint 4 |
| 551 | + let data = serde_json::json!({ |
| 552 | + "paused": self.paused, |
| 553 | + "level": self.pause_level as u8, |
| 554 | + }); |
| 555 | + Response::ok_with_data(data) |
| 460 | 556 | } |
| 461 | 557 | Command::Count => { |
| 462 | 558 | let store = self.notification_store.lock().await; |
| 463 | | - info!("Notification count: {}", store.count()); |
| 559 | + let data = serde_json::json!({ |
| 560 | + "count": store.count(), |
| 561 | + }); |
| 562 | + Response::ok_with_data(data) |
| 464 | 563 | } |
| 465 | 564 | Command::List => { |
| 466 | 565 | let store = self.notification_store.lock().await; |
| 467 | | - info!("Active notifications:"); |
| 468 | | - for notification in store.list() { |
| 469 | | - info!( |
| 470 | | - " id={} app=\"{}\" summary=\"{}\"", |
| 471 | | - notification.id, notification.app_name, notification.summary |
| 472 | | - ); |
| 473 | | - } |
| 566 | + let list: Vec<_> = store |
| 567 | + .list() |
| 568 | + .iter() |
| 569 | + .map(|n| { |
| 570 | + serde_json::json!({ |
| 571 | + "id": n.id, |
| 572 | + "app_name": n.app_name, |
| 573 | + "summary": n.summary, |
| 574 | + "body": n.body, |
| 575 | + "urgency": format!("{:?}", n.hints.urgency), |
| 576 | + }) |
| 577 | + }) |
| 578 | + .collect(); |
| 579 | + Response::ok_with_data(serde_json::json!(list)) |
| 474 | 580 | } |
| 475 | 581 | Command::RuleEnable { name } => { |
| 476 | | - info!("Rule enable: {}", name); |
| 477 | | - // TODO: Implement in sprint 4 |
| 582 | + if self.rule_engine.enable_rule(&name) { |
| 583 | + Response::ok_with_message(format!("Enabled rule '{}'", name)) |
| 584 | + } else { |
| 585 | + Response::error(format!("Rule '{}' not found", name)) |
| 586 | + } |
| 478 | 587 | } |
| 479 | 588 | Command::RuleDisable { name } => { |
| 480 | | - info!("Rule disable: {}", name); |
| 481 | | - // TODO: Implement in sprint 4 |
| 589 | + if self.rule_engine.disable_rule(&name) { |
| 590 | + Response::ok_with_message(format!("Disabled rule '{}'", name)) |
| 591 | + } else { |
| 592 | + Response::error(format!("Rule '{}' not found", name)) |
| 593 | + } |
| 482 | 594 | } |
| 483 | 595 | } |
| 484 | 596 | } |
@@ -488,6 +600,8 @@ impl Daemon { |
| 488 | 600 | match config::load(None) { |
| 489 | 601 | Ok(new_config) => { |
| 490 | 602 | info!("Reloaded configuration"); |
| 603 | + // Reload rules |
| 604 | + self.rule_engine = RuleEngine::with_rules(new_config.rules.clone()); |
| 491 | 605 | self.config = Arc::new(new_config); |
| 492 | 606 | } |
| 493 | 607 | Err(e) => { |