notify/
lib.rs

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
27/// The current log level, set by the RUST_LOG environment variable
28pub 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
39/// The current level to which messages should be notified. By
40/// default, only errors cause prompts. This is controlled via the
41/// NOTIFY environment variable, and can be set to none to only log
42/// to the terminal.
43pub 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
56/// The global Logger
57static LOGGER: OnceLock<NotifyLogger> = OnceLock::new();
58
59/// A lock to ensure only a single thread can log at a time.
60static LOCK: LazyLock<ReentrantMutex<()>> = LazyLock::new(ReentrantMutex::default);
61
62/// A custom Notifier that the LOGGER will use, if set.
63static NOTIFIER: OnceLock<Box<Notifier>> = OnceLock::new();
64
65/// A DBus Map
66type VariantMap<'a> = HashMap<&'a str, Variant<Box<dyn dbus::arg::RefArg>>>;
67
68/// A Notifier function, which accepts the Record and Level of each Log, and
69/// returns whether the logging was successful.
70type Notifier = dyn Fn(&Record, Level) -> bool + Send + Sync + 'static;
71
72/// Errors for the NotifyLogger.
73#[derive(Debug, Error)]
74pub enum Error {
75    /// Errors for when the console-fallback fails to receive
76    /// user input for an action.
77    #[error("Failed to query user: {0}")]
78    Dialog(#[from] dialoguer::Error),
79
80    /// Errors with the dbus crate
81    #[error("Failed to communicate with the user bus: {0}")]
82    Dbus(#[from] dbus::Error),
83
84    /// Miscellaneous errors returning an Errno.
85    #[error("System error: {0}")]
86    Errno(#[from] errno::Errno),
87
88    /// Errors initializing the Logger.
89    #[error("Failed to initialize logger")]
90    Init,
91
92    /// Errors setting the Notifier.
93    #[error("Failed to set notify logger")]
94    Set,
95
96    /// Errors setting the Notifier.
97    #[error("Unexpected response from the user-bus")]
98    Connection,
99}
100
101/// The urgency level of a Notification.
102/// The Desktop Environment is free to interpret this as it wants.
103/// This should, therefore, be seen as a suggestion.
104#[derive(Default, Clone, Copy, Debug, clap::ValueEnum)]
105pub enum Urgency {
106    Low,
107
108    #[default]
109    Normal,
110
111    Critical,
112}
113impl Urgency {
114    /// Get the Byte for this Urgency Level, to send across the Bus.
115    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
133/// Format a message so it can be displayed on the console.
134fn 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
147/// Convert the HTML tags defined by the Notification Spec to console formatting.
148fn 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
163/// Format style tags.
164fn 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
171/// Get actions from the console.
172fn 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
188/// Construct a Message to send across the User Bus.
189fn 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    // App Name
217    .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    // Replace ID and Icon are empty
227    .append2(0u32, "")
228    // Summary and Body
229    .append2(title, body)
230    // Actions and Hints
231    .append2(a, hints)
232    // Timeout
233    .append1(if let Some(timeout) = timeout {
234        timeout.as_millis() as i32
235    } else {
236        -1
237    })
238}
239
240/// Send a stateless Notification across the User Bus.
241/// If there is a failure sending the message, it will be sent
242/// to the terminal.
243pub 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
266/// Send a Notification across the User Bus with a set of Actions.
267/// The selected Action, if the user choose one, is returned.
268///
269/// Should an error preventing sending a Notification, the dialoguer
270/// crate will be used to prompt from the terminal.
271pub 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    // Format.
282    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        // Get a connection, and send the notification.
290        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        // The Bus will Broadcast the response through an ActionInvoked Member,
299        // with a pair containing the response, and the ID we got from the original call.
300        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        // Monitor the Bus.
311        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        // Watch the Bus for our desired notification.
324        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        // Wait until the callback fires.
349        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    // If we couldn't get a response, ask on the terminal, instead.
358    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
380/// Get the color that should be used for particular log level.
381pub 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
391/// Get the pretty name of a log level.
392pub 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
402/// Get the recommended notification urgency for each level.
403pub 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
413/// A log::Log implementation that is controlled by RUST_LOG for console logs,
414/// and NOTIFY for which of those should be promoted to Notifications.
415struct 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
478/// Initialize the NotifyLogger as the program's Logger.
479pub 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
489/// Initialize the NotifyLogger as the program's Logger, but print to
490/// stderr instead of stdout. Note that this only applies to direct logging,
491/// notification call fallback still prints to stdout.
492pub 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
502/// Set an optional Notifier function that is called instead of the
503/// notify() function defined here, such as to run the binary compiled
504/// by this crate in cases of SetUID, where we cannot communicate
505/// to the User Bus in this process.
506pub 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}