mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-12 04:33:29 +08:00
feat: add ecc2 desktop notifications
This commit is contained in:
289
ecc2/src/notifications.rs
Normal file
289
ecc2/src/notifications.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Local, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(not(test))]
|
||||
use anyhow::Context;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotificationEvent {
|
||||
SessionCompleted,
|
||||
SessionFailed,
|
||||
BudgetAlert,
|
||||
ApprovalRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct QuietHoursConfig {
|
||||
pub enabled: bool,
|
||||
pub start_hour: u8,
|
||||
pub end_hour: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DesktopNotificationConfig {
|
||||
pub enabled: bool,
|
||||
pub session_completed: bool,
|
||||
pub session_failed: bool,
|
||||
pub budget_alerts: bool,
|
||||
pub approval_requests: bool,
|
||||
pub quiet_hours: QuietHoursConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopNotifier {
|
||||
config: DesktopNotificationConfig,
|
||||
}
|
||||
|
||||
impl Default for QuietHoursConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
start_hour: 22,
|
||||
end_hour: 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QuietHoursConfig {
|
||||
pub fn sanitized(self) -> Self {
|
||||
let valid = self.start_hour <= 23 && self.end_hour <= 23;
|
||||
if valid {
|
||||
self
|
||||
} else {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self, now: DateTime<Local>) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
let quiet = self.clone().sanitized();
|
||||
if quiet.start_hour == quiet.end_hour {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hour = now.hour() as u8;
|
||||
if quiet.start_hour < quiet.end_hour {
|
||||
hour >= quiet.start_hour && hour < quiet.end_hour
|
||||
} else {
|
||||
hour >= quiet.start_hour || hour < quiet.end_hour
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DesktopNotificationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
session_completed: true,
|
||||
session_failed: true,
|
||||
budget_alerts: true,
|
||||
approval_requests: true,
|
||||
quiet_hours: QuietHoursConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopNotificationConfig {
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
quiet_hours: self.quiet_hours.sanitized(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allows(&self, event: NotificationEvent, now: DateTime<Local>) -> bool {
|
||||
let config = self.clone().sanitized();
|
||||
if !config.enabled || config.quiet_hours.is_active(now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match event {
|
||||
NotificationEvent::SessionCompleted => config.session_completed,
|
||||
NotificationEvent::SessionFailed => config.session_failed,
|
||||
NotificationEvent::BudgetAlert => config.budget_alerts,
|
||||
NotificationEvent::ApprovalRequest => config.approval_requests,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopNotifier {
|
||||
pub fn new(config: DesktopNotificationConfig) -> Self {
|
||||
Self {
|
||||
config: config.sanitized(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify(&self, event: NotificationEvent, title: &str, body: &str) -> bool {
|
||||
match self.try_notify(event, title, body, Local::now()) {
|
||||
Ok(sent) => sent,
|
||||
Err(error) => {
|
||||
tracing::warn!("Failed to send desktop notification: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_notify(
|
||||
&self,
|
||||
event: NotificationEvent,
|
||||
title: &str,
|
||||
body: &str,
|
||||
now: DateTime<Local>,
|
||||
) -> Result<bool> {
|
||||
if !self.config.allows(event, now) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let Some((program, args)) = notification_command(std::env::consts::OS, title, body) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
run_notification_command(&program, &args)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_command(platform: &str, title: &str, body: &str) -> Option<(String, Vec<String>)> {
|
||||
match platform {
|
||||
"macos" => Some((
|
||||
"osascript".to_string(),
|
||||
vec![
|
||||
"-e".to_string(),
|
||||
format!(
|
||||
"display notification \"{}\" with title \"{}\"",
|
||||
sanitize_osascript(body),
|
||||
sanitize_osascript(title)
|
||||
),
|
||||
],
|
||||
)),
|
||||
"linux" => Some((
|
||||
"notify-send".to_string(),
|
||||
vec![
|
||||
"--app-name".to_string(),
|
||||
"ECC 2.0".to_string(),
|
||||
title.trim().to_string(),
|
||||
body.trim().to_string(),
|
||||
],
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn run_notification_command(program: &str, args: &[String]) -> Result<()> {
|
||||
let status = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.with_context(|| format!("launch {program}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("{program} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_osascript(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', "")
|
||||
.replace('"', "\u{201C}")
|
||||
.replace('\n', " ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
notification_command, DesktopNotificationConfig, DesktopNotifier, NotificationEvent,
|
||||
QuietHoursConfig,
|
||||
};
|
||||
use chrono::{Local, TimeZone};
|
||||
|
||||
#[test]
|
||||
fn quiet_hours_support_cross_midnight_ranges() {
|
||||
let quiet_hours = QuietHoursConfig {
|
||||
enabled: true,
|
||||
start_hour: 22,
|
||||
end_hour: 8,
|
||||
};
|
||||
|
||||
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap()));
|
||||
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 7, 0, 0).unwrap()));
|
||||
assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 14, 0, 0).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_hours_support_same_day_ranges() {
|
||||
let quiet_hours = QuietHoursConfig {
|
||||
enabled: true,
|
||||
start_hour: 9,
|
||||
end_hour: 17,
|
||||
};
|
||||
|
||||
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 10, 0, 0).unwrap()));
|
||||
assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 18, 0, 0).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_preferences_respect_event_flags() {
|
||||
let mut config = DesktopNotificationConfig::default();
|
||||
config.session_completed = false;
|
||||
let now = Local.with_ymd_and_hms(2026, 4, 9, 12, 0, 0).unwrap();
|
||||
|
||||
assert!(!config.allows(NotificationEvent::SessionCompleted, now));
|
||||
assert!(config.allows(NotificationEvent::BudgetAlert, now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifier_skips_delivery_during_quiet_hours() {
|
||||
let mut config = DesktopNotificationConfig::default();
|
||||
config.quiet_hours = QuietHoursConfig {
|
||||
enabled: true,
|
||||
start_hour: 22,
|
||||
end_hour: 8,
|
||||
};
|
||||
let notifier = DesktopNotifier::new(config);
|
||||
|
||||
assert!(!notifier
|
||||
.try_notify(
|
||||
NotificationEvent::ApprovalRequest,
|
||||
"ECC 2.0: Approval needed",
|
||||
"worker-123 needs review",
|
||||
Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap(),
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_notifications_use_osascript() {
|
||||
let (program, args) =
|
||||
notification_command("macos", "ECC 2.0: Completed", "Task finished").unwrap();
|
||||
|
||||
assert_eq!(program, "osascript");
|
||||
assert_eq!(args[0], "-e");
|
||||
assert!(args[1].contains("display notification"));
|
||||
assert!(args[1].contains("ECC 2.0: Completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_notifications_use_notify_send() {
|
||||
let (program, args) =
|
||||
notification_command("linux", "ECC 2.0: Approval needed", "worker-123").unwrap();
|
||||
|
||||
assert_eq!(program, "notify-send");
|
||||
assert_eq!(args[0], "--app-name");
|
||||
assert_eq!(args[1], "ECC 2.0");
|
||||
assert_eq!(args[2], "ECC 2.0: Approval needed");
|
||||
assert_eq!(args[3], "worker-123");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user