1#![doc = include_str!("../README.md")]
2
3use console::{StyledObject, style};
4use dbus::{
5 Message, MessageType,
6 arg::Variant,
7 blocking::{BlockingSender, LocalConnection},
8 channel::MatchingReceiver,
9 message::MatchRule,
10};
11use heck::ToTitleCase;
12use log::{Level, Record};
13use nix::errno;
14use parking_lot::{Mutex, ReentrantMutex};
15use std::{
16 borrow::Cow,
17 collections::HashMap,
18 io::{IsTerminal, stdout},
19 sync::{
20 Arc, LazyLock, OnceLock,
21 atomic::{AtomicBool, Ordering},
22 },
23 time::{Duration, Instant},
24};
25use thiserror::Error;
26
27pub static LEVEL: LazyLock<log::Level> = LazyLock::new(|| match std::env::var("RUST_LOG") {
29 Ok(e) => match e.to_lowercase().as_str() {
30 "trace" => log::Level::Trace,
31 "warn" => log::Level::Warn,
32 "info" => log::Level::Info,
33 "debug" => log::Level::Debug,
34 _ => log::Level::Error,
35 },
36 Err(_) => log::Level::Error,
37});
38
39pub static PROMPT_LEVEL: LazyLock<Option<log::Level>> =
44 LazyLock::new(|| match std::env::var("NOTIFY") {
45 Ok(e) => match e.to_lowercase().as_str() {
46 "none" => None,
47 "trace" => Some(log::Level::Trace),
48 "warn" => Some(log::Level::Warn),
49 "info" => Some(log::Level::Info),
50 "debug" => Some(log::Level::Debug),
51 _ => Some(log::Level::Error),
52 },
53 Err(_) => Some(log::Level::Error),
54 });
55
56static LOGGER: OnceLock<NotifyLogger> = OnceLock::new();
58
59static LOCK: LazyLock<ReentrantMutex<()>> = LazyLock::new(ReentrantMutex::default);
61
62static NOTIFIER: OnceLock<Box<Notifier>> = OnceLock::new();
64
65type VariantMap<'a> = HashMap<&'a str, Variant<Box<dyn dbus::arg::RefArg>>>;
67
68type Notifier = dyn Fn(&Record, Level) -> bool + Send + Sync + 'static;
71
72#[derive(Debug, Error)]
74pub enum Error {
75 #[error("Failed to query user: {0}")]
78 Dialog(#[from] dialoguer::Error),
79
80 #[error("Failed to communicate with the user bus: {0}")]
82 Dbus(#[from] dbus::Error),
83
84 #[error("System error: {0}")]
86 Errno(#[from] errno::Errno),
87
88 #[error("Failed to initialize logger")]
90 Init,
91
92 #[error("Failed to set notify logger")]
94 Set,
95
96 #[error("Unexpected response from the user-bus")]
98 Connection,
99}
100
101#[derive(Default, Clone, Copy, Debug, clap::ValueEnum)]
105pub enum Urgency {
106 Low,
107
108 #[default]
109 Normal,
110
111 Critical,
112}
113impl Urgency {
114 fn byte(&self) -> u8 {
116 match self {
117 Urgency::Low => 0,
118 Urgency::Normal => 1,
119 Urgency::Critical => 2,
120 }
121 }
122}
123impl std::fmt::Display for Urgency {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 match self {
126 Urgency::Low => write!(f, "low"),
127 Urgency::Normal => write!(f, "normal"),
128 Urgency::Critical => write!(f, "critical"),
129 }
130 }
131}
132
133fn console_msg(title: &str, body: &str, urgency: Option<Urgency>) -> String {
135 let msg = format!(
136 "{}: {body}",
137 match urgency.unwrap_or_default() {
138 Urgency::Low => style(title).blue().bold(),
139 Urgency::Normal => style(title).bold(),
140 Urgency::Critical => style(title).red().bold(),
141 }
142 .force_styling(true),
143 );
144 console_format(&msg)
145}
146
147fn style_tag<F>(mut msg: String, open: &str, close: &str, style: F) -> String
149where
150 F: Fn(&str) -> StyledObject<&str>,
151{
152 while let Some(start) = msg.find(open)
153 && let Some(end) = msg.find(close)
154 {
155 let pre = &msg[..start];
156 let post = &msg[end + close.len()..];
157 let extract = style(&msg[start + open.len()..end]).force_styling(true);
158 msg = format!("{pre}{extract}{post}");
159 }
160 msg
161}
162
163fn console_format(content: &str) -> String {
165 let mut content = content.to_string();
166 content = style_tag(content, "<b>", "</b>", |tag: &str| style(tag).bold());
167 content = style_tag(content, "<i>", "</i>", |tag: &str| style(tag).italic());
168 content
169}
170
171fn console_actions(
173 title: &str,
174 body: &str,
175 urgency: Option<Urgency>,
176 actions: Vec<String>,
177) -> Result<String, Error> {
178 let msg = console_msg(title, body, urgency);
179 let _lock = LOCK.lock();
180 let result = dialoguer::Select::new()
181 .with_prompt(msg)
182 .default(0)
183 .items(&actions)
184 .interact()?;
185 Ok(actions[result].clone())
186}
187
188fn get_msg(
190 title: &str,
191 body: &str,
192 timeout: &Option<Duration>,
193 urgency: &Option<Urgency>,
194 actions: Option<&Vec<String>>,
195) -> Message {
196 let mut hints = VariantMap::new();
197 if let Some(urgency) = urgency {
198 hints.insert("urgency", Variant(Box::new(urgency.byte())));
199 }
200 hints.insert("sender-pid", Variant(Box::new(std::process::id() as i32)));
201
202 let a_placeholder = Vec::new();
203 let a = if let Some(a) = actions {
204 a
205 } else {
206 &a_placeholder
207 };
208
209 Message::new_method_call(
210 "org.freedesktop.Notifications",
211 "/org/freedesktop/Notifications",
212 "org.freedesktop.Notifications",
213 "Notify",
214 )
215 .unwrap()
216 .append1(
218 if let Ok(path) = std::env::current_exe()
219 && let Some(name) = path.file_name()
220 {
221 name.to_string_lossy().to_title_case()
222 } else {
223 "Notify".to_string()
224 },
225 )
226 .append2(0u32, "")
228 .append2(title, body)
230 .append2(a, hints)
232 .append1(if let Some(timeout) = timeout {
234 timeout.as_millis() as i32
235 } else {
236 -1
237 })
238}
239
240pub fn notify(
244 title: impl Into<Cow<'static, str>>,
245 body: impl Into<Cow<'static, str>>,
246 timeout: Option<Duration>,
247 urgency: Option<Urgency>,
248) -> Result<(), Error> {
249 let title = title.into();
250 let body = body.into();
251
252 let msg = get_msg(&title, &body, &timeout, &urgency, None);
253 let result = || -> Result<(), Error> {
254 let connection = LocalConnection::new_session()?;
255 connection.send_with_reply_and_block(msg, Duration::from_secs(1))?;
256 Ok(())
257 };
258
259 if result().is_err() {
260 let _lock = LOCK.lock();
261 println!("{}", console_msg(&title, &body, urgency))
262 }
263 Ok(())
264}
265
266pub fn action(
272 title: impl Into<Cow<'static, str>>,
273 body: impl Into<Cow<'static, str>>,
274 timeout: Option<Duration>,
275 urgency: Option<Urgency>,
276 actions: Vec<(String, String)>,
277) -> Result<String, Error> {
278 let title = title.into();
279 let body = body.into();
280
281 let mut a = Vec::<String>::new();
283 for (key, value) in actions.clone() {
284 a.push(key);
285 a.push(value);
286 }
287
288 let result = || -> Result<String, Error> {
289 let connection = LocalConnection::new_session()?;
291 let msg = get_msg(&title, &body, &timeout, &urgency, Some(&a));
292 let response = connection.send_with_reply_and_block(msg, Duration::from_secs(1))?;
293 let id = match response.get1::<u32>() {
294 Some(id) => id,
295 None => return Err(Error::Connection),
296 };
297
298 let found = Arc::<AtomicBool>::new(AtomicBool::new(false));
301 let action = Arc::<Mutex<String>>::default();
302 let found_clone = found.clone();
303 let action_clone = action.clone();
304 let rule = MatchRule::new()
305 .with_path("/org/freedesktop/Notifications")
306 .with_interface("org.freedesktop.Notifications")
307 .with_member("ActionInvoked")
308 .with_type(MessageType::Signal);
309
310 let monitor = LocalConnection::new_session()?;
312 let proxy = monitor.with_proxy(
313 "org.freedesktop.DBus",
314 "/org/freedesktop/DBus",
315 Duration::from_secs(1),
316 );
317 let _: () = proxy.method_call(
318 "org.freedesktop.DBus.Monitoring",
319 "BecomeMonitor",
320 (vec![rule.match_str()], 0u32),
321 )?;
322
323 monitor.start_receive(
325 MatchRule::new(),
326 Box::new(move |msg: Message, _conn: &LocalConnection| -> bool {
327 if !found_clone.load(Ordering::Relaxed) {
328 let (notif_id, action_key): (u32, String) = match msg.read2() {
329 Ok(v) => v,
330 Err(_) => {
331 return true;
332 }
333 };
334
335 if notif_id == id {
336 *action_clone.lock() = action_key;
337 found_clone.store(true, Ordering::Relaxed);
338 false
339 } else {
340 true
341 }
342 } else {
343 true
344 }
345 }),
346 );
347
348 let timeout = timeout.unwrap_or(Duration::from_secs(10));
350 let start = Instant::now();
351 while !found.load(Ordering::Relaxed) && start.elapsed() < timeout {
352 monitor.process(Duration::from_secs(1))?;
353 }
354 Ok(Arc::into_inner(action).unwrap().into_inner())
355 };
356
357 match result() {
359 Ok(result) => Ok(result),
360 Err(_) => {
361 let _lock = LOCK.lock();
362 let response = console_actions(
363 &title,
364 &body,
365 urgency,
366 actions.iter().map(|(_, v)| v.to_string()).collect(),
367 )?;
368
369 Ok(actions
370 .into_iter()
371 .find_map(|(k, v)| if v == response { Some(k) } else { None })
372 .iter()
373 .next()
374 .unwrap()
375 .to_string())
376 }
377 }
378}
379
380pub fn level_color(level: log::Level) -> StyledObject<&'static str> {
382 match level {
383 log::Level::Error => style("ERROR").red().bold().blink(),
384 log::Level::Warn => style("WARN").yellow().bold(),
385 log::Level::Info => style("INFO").green().bold(),
386 log::Level::Debug => style("DEBUG").blue().bold(),
387 log::Level::Trace => style("TRACE").cyan().bold(),
388 }
389}
390
391pub fn level_name(level: log::Level) -> &'static str {
393 match level {
394 log::Level::Error => "Error",
395 log::Level::Warn => "Warning",
396 log::Level::Info => "Info",
397 log::Level::Debug => "Debug",
398 log::Level::Trace => "Trace",
399 }
400}
401
402pub fn level_urgency(level: log::Level) -> Urgency {
404 match level {
405 log::Level::Error => Urgency::Critical,
406 log::Level::Warn => Urgency::Normal,
407 log::Level::Info => Urgency::Low,
408 log::Level::Debug => Urgency::Low,
409 log::Level::Trace => Urgency::Low,
410 }
411}
412
413struct NotifyLogger {
416 error: bool,
417}
418impl NotifyLogger {
419 const fn new(error: bool) -> Self {
420 Self { error }
421 }
422}
423
424impl log::Log for NotifyLogger {
425 fn enabled(&self, metadata: &log::Metadata) -> bool {
426 metadata.level() <= *LEVEL
427 }
428
429 fn log(&self, record: &log::Record) {
430 let level = record.level();
431 if !self.enabled(record.metadata()) {
432 return;
433 }
434
435 let mut msg = String::new();
436 msg.push_str(&format!(
437 "[{} {} {:?}] {}",
438 level_color(record.level()),
439 style(record.target()).bold().italic(),
440 std::thread::current().id(),
441 record.args()
442 ));
443
444 if !msg.ends_with('\n') {
445 msg.push('\n')
446 }
447
448 {
449 let _lock = LOCK.lock();
450 if self.error {
451 eprint!("{msg}");
452 } else {
453 print!("{msg}");
454 }
455 }
456
457 if let Some(prompt) = *PROMPT_LEVEL
458 && level <= prompt
459 && !stdout().is_terminal()
460 {
461 if let Some(cb) = NOTIFIER.get()
462 && cb(record, record.level())
463 {
464 } else {
465 let _ = notify(
466 format!("{}: {}", level_name(level), record.target()),
467 format!("{}", record.args()),
468 None,
469 Some(level_urgency(level)),
470 );
471 }
472 }
473 }
474
475 fn flush(&self) {}
476}
477
478pub fn init() -> Result<(), Error> {
480 if LOGGER.set(NotifyLogger::new(false)).is_ok() {
481 log::set_logger(LOGGER.get().unwrap()).map_err(|_| Error::Init)?;
482 log::set_max_level(log::LevelFilter::Trace);
483 Ok(())
484 } else {
485 Err(Error::Init)
486 }
487}
488
489pub fn init_error() -> Result<(), Error> {
493 if LOGGER.set(NotifyLogger::new(true)).is_ok() {
494 log::set_logger(LOGGER.get().unwrap()).map_err(|_| Error::Init)?;
495 log::set_max_level(log::LevelFilter::Trace);
496 Ok(())
497 } else {
498 Err(Error::Init)
499 }
500}
501
502pub fn set_notifier(function: Box<Notifier>) -> Result<(), Error> {
507 NOTIFIER.set(function).map_err(|_| Error::Set)
508}
509
510#[cfg(test)]
511mod tests {
512 use crate::{Error, action, notify};
513
514 #[test]
515 pub fn simple_notify() -> Result<(), Error> {
516 notify("Notify Test", "This is a notification test!", None, None)
517 }
518
519 #[test]
520 pub fn notify_action() -> Result<(), Error> {
521 assert!(
522 action(
523 "Notify Test",
524 "This is an action test!",
525 None,
526 None,
527 vec![("Test".to_string(), "Test".to_string())]
528 )? == "Test"
529 );
530 Ok(())
531 }
532}