steer_tui/
notifications.rs1use crate::error::Result;
6use notify_rust::Notification;
7use process_wrap::tokio::{ProcessGroup, TokioCommandWrap};
8use std::fmt;
9use std::str::FromStr;
10use std::time::Duration;
11use tokio::time::sleep;
12use tracing::debug;
13
14#[derive(Debug, Clone, Copy)]
16pub enum NotificationSound {
17 ProcessingComplete,
19 ToolApproval,
21 Error,
23}
24
25impl FromStr for NotificationSound {
26 type Err = ();
27
28 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
29 match s {
30 "ProcessingComplete" => Ok(NotificationSound::ProcessingComplete),
31 "ToolApproval" => Ok(NotificationSound::ToolApproval),
32 "Error" => Ok(NotificationSound::Error),
33 _ => Err(()),
34 }
35 }
36}
37
38impl fmt::Display for NotificationSound {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 let s = match self {
41 NotificationSound::ProcessingComplete => "ProcessingComplete",
42 NotificationSound::ToolApproval => "ToolApproval",
43 NotificationSound::Error => "Error",
44 };
45 write!(f, "{s}")
46 }
47}
48
49fn get_sound_name(sound_type: NotificationSound) -> &'static str {
51 #[cfg(target_os = "macos")]
52 {
53 match sound_type {
54 NotificationSound::ProcessingComplete => "Glass", NotificationSound::ToolApproval => "Ping", NotificationSound::Error => "Basso", }
58 }
59
60 #[cfg(target_os = "linux")]
61 {
62 match sound_type {
63 NotificationSound::ProcessingComplete => "message-new-instant", NotificationSound::ToolApproval => "dialog-warning", NotificationSound::Error => "dialog-error", }
67 }
68
69 #[cfg(target_os = "windows")]
70 {
71 match sound_type {
73 NotificationSound::ProcessingComplete => "default",
74 NotificationSound::ToolApproval => "default",
75 NotificationSound::Error => "default",
76 }
77 }
78
79 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
80 {
81 "default"
82 }
83}
84
85pub fn show_notification_with_sound(
87 title: &str,
88 message: &str,
89 sound_type: Option<NotificationSound>,
90) -> Result<()> {
91 let mut notification = Notification::new();
92 notification
93 .summary(title)
94 .body(message)
95 .appname("steer")
96 .timeout(5000);
97
98 if let Some(sound) = sound_type {
100 notification.sound_name(get_sound_name(sound));
101 }
102
103 #[cfg(target_os = "linux")]
104 {
105 notification.icon("terminal").timeout(5000);
106 }
107
108 notification.show()?;
109 Ok(())
110}
111
112#[derive(Debug, Clone)]
114pub struct NotificationConfig {
115 pub enable_sound: bool,
116 pub enable_desktop_notification: bool,
117}
118
119impl Default for NotificationConfig {
120 fn default() -> Self {
121 Self {
122 enable_sound: true,
123 enable_desktop_notification: true,
124 }
125 }
126}
127
128impl NotificationConfig {
129 pub fn from_env() -> Self {
131 Self {
132 enable_sound: std::env::var("STEER_NOTIFICATION_SOUND")
133 .map(|v| v != "false" && v != "0")
134 .unwrap_or(true),
135 enable_desktop_notification: std::env::var("STEER_NOTIFICATION_DESKTOP")
136 .map(|v| v != "false" && v != "0")
137 .unwrap_or(true),
138 }
139 }
140}
141
142async fn trigger_notification_subprocess(
149 title: &str,
150 message: &str,
151 sound: Option<NotificationSound>,
152) -> Result<()> {
153 let current_exe = std::env::current_exe()?;
154 let mut args = vec![
155 "notify".to_string(),
156 "--title".to_string(),
157 title.to_string(),
158 "--message".to_string(),
159 message.to_string(),
160 ];
161
162 if let Some(sound_type) = sound {
163 args.push("--sound".to_string());
164 args.push(sound_type.to_string());
165 }
166
167 let mut child = TokioCommandWrap::with_new(current_exe, |command| {
168 command.args(args);
169 })
170 .wrap(ProcessGroup::leader())
171 .spawn()?;
172
173 tokio::spawn(async move {
174 sleep(Duration::from_secs(2)).await;
175 match child.start_kill() {
176 Ok(_) => {}
177 Err(e) => {
178 debug!("Failed to kill notification subprocess: {}", e);
179 }
180 }
181 });
182
183 Ok(())
184}
185
186pub async fn notify_with_sound(
188 config: &NotificationConfig,
189 sound: NotificationSound,
190 message: &str,
191) {
192 notify_with_title_and_sound(config, sound, "Steer", message).await;
193}
194
195pub async fn notify_with_title_and_sound(
197 config: &NotificationConfig,
198 sound: NotificationSound,
199 title: &str,
200 message: &str,
201) {
202 if config.enable_desktop_notification {
203 let sound_option = if config.enable_sound {
204 Some(sound)
205 } else {
206 None
207 };
208 match trigger_notification_subprocess(title, message, sound_option).await {
209 Ok(_) => {}
210 Err(e) => {
211 debug!("Failed to trigger notification subprocess: {}", e);
212 }
213 }
214 }
215}