toast_logger_win/
toast_logger.rs

1use std::{
2    fmt, mem,
3    sync::{Mutex, OnceLock},
4};
5
6use log::Log;
7
8use crate::{BufferedRecord, Notification, Notifier, Result};
9
10type LogRecordFormatter =
11    dyn Fn(&mut dyn fmt::Write, &log::Record) -> fmt::Result + Send + Sync + 'static;
12type NotificationCreator =
13    dyn Fn(&[BufferedRecord]) -> Result<Notification> + Send + Sync + 'static;
14
15struct ToastLoggerConfig {
16    max_level: log::LevelFilter,
17    is_auto_flush: bool,
18    application_id: String,
19    formatter: Box<LogRecordFormatter>,
20    create_notification: Box<NotificationCreator>,
21}
22
23impl Default for ToastLoggerConfig {
24    fn default() -> Self {
25        Self {
26            max_level: log::LevelFilter::Error,
27            is_auto_flush: true,
28            application_id: Self::DEFAULT_APP_ID.into(),
29            formatter: Box::new(Self::default_formatter),
30            create_notification: Box::new(Notification::new_with_records),
31        }
32    }
33}
34
35impl ToastLoggerConfig {
36    // https://github.com/GitHub30/toast-notification-examples
37    const DEFAULT_APP_ID: &str =
38        r"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe";
39
40    fn default_formatter(buf: &mut dyn fmt::Write, record: &log::Record) -> fmt::Result {
41        write!(buf, "{}: {}", record.level(), record.args())
42    }
43
44    fn create_notifier(&self) -> Result<Notifier> {
45        Notifier::new_with_application_id(&self.application_id)
46    }
47}
48
49/// Builder for [`ToastLogger`].
50///
51/// # Examples
52/// ```no_run
53/// # use toast_logger_win::{Result, ToastLogger};
54/// # fn test() -> Result<()> {
55/// ToastLogger::builder().init()?;
56/// # Ok(())
57/// # }
58/// ```
59#[derive(Default)]
60pub struct ToastLoggerBuilder {
61    config: ToastLoggerConfig,
62}
63
64impl ToastLoggerBuilder {
65    fn new() -> Self {
66        Self::default()
67    }
68
69    /// Initialize the [`log`] crate to use the [`ToastLogger`]
70    /// with the configurations set to this builder.
71    pub fn init(&mut self) -> Result<()> {
72        ToastLogger::init(self.build_config())
73    }
74
75    #[deprecated(since = "0.2.0", note = "Use `init()` instead")]
76    pub fn init_logger(&mut self) -> Result<()> {
77        self.init()
78    }
79
80    /// Build a `ToastLogger`.
81    ///
82    /// The returned logger implements the [`Log`] trait
83    /// and can be installed manually or nested within another logger.
84    pub fn build(&mut self) -> Result<ToastLogger> {
85        ToastLogger::new(self.build_config())
86    }
87
88    fn build_config(&mut self) -> ToastLoggerConfig {
89        mem::take(&mut self.config)
90    }
91
92    /// Set the maximum level of logs to be displayed.
93    /// Logs above the specified level are discarded.
94    pub fn max_level(&mut self, level: log::LevelFilter) -> &mut Self {
95        self.config.max_level = level;
96        self
97    }
98
99    /// Set whether to show a toast notification on each logging,
100    /// or only when explicitly specified.
101    /// When this is set to `false`,
102    /// logs are appended to an internal buffer
103    /// without being shown,
104    /// until [`ToastLogger::flush()`] is called.
105    ///
106    /// The default value is `true`,
107    /// which shows a toast notification on each logging.
108    /// # Examples
109    /// ```no_run
110    /// # use toast_logger_win::{Result, ToastLogger};
111    /// # fn test() -> Result<()> {
112    /// ToastLogger::builder()
113    ///     .max_level(log::LevelFilter::Info)
114    ///     .auto_flush(false)
115    ///     .init()?;
116    /// log::info!("Test info log");
117    /// log::info!("Test info log 2");
118    /// ToastLogger::flush()?;  // Shows only one notification with both logs.
119    /// #  Ok(())
120    /// # }
121    /// ```
122    pub fn auto_flush(&mut self, is_auto_flush: bool) -> &mut Self {
123        self.config.is_auto_flush = is_auto_flush;
124        self
125    }
126
127    /// Set the application ID for the Toast Notification.
128    ///
129    /// This is the application ID passed to the Windows [`CreateToastNotifier`] API.
130    /// Please also see the [Application User Model ID][AUMID],
131    /// and the "[Find the Application User Model ID of an installed app]".
132    ///
133    /// [AUMID]: https://learn.microsoft.com/windows/win32/shell/appids
134    /// [`CreateToastNotifier`]: https://learn.microsoft.com/uwp/api/windows.ui.notifications.toastnotificationmanager.createtoastnotifier#windows-ui-notifications-toastnotificationmanager-createtoastnotifier(system-string)
135    /// [Find the Application User Model ID of an installed app]: https://learn.microsoft.com/windows/configuration/find-the-application-user-model-id-of-an-installed-app
136    pub fn application_id(&mut self, application_id: &str) -> &mut Self {
137        self.config.application_id = application_id.into();
138        self
139    }
140
141    // https://docs.rs/env_logger/0.11.8/env_logger/#using-a-custom-format
142    /// Set a custom formatter function
143    /// that writes [`log::Record`] to [`fmt::Write`].
144    ///
145    /// The default formatter writes the logs with their levels as prefixes.
146    /// # Examples
147    /// ```no_run
148    /// # use std::fmt;
149    /// # use toast_logger_win::{Result, ToastLogger};
150    /// # fn test() -> Result<()> {
151    /// ToastLogger::builder()
152    ///     .format(|buf: &mut dyn fmt::Write, record: &log::Record| {
153    ///         match record.level() {
154    ///             log::Level::Info => buf.write_fmt(*record.args()),
155    ///             _ => write!(buf, "{}: {}", record.level(), record.args()),
156    ///         }
157    ///     })
158    ///     .init()?;
159    /// # Ok(())
160    /// # }
161    /// ```
162    pub fn format<F>(&mut self, formatter: F) -> &mut Self
163    where
164        F: Fn(&mut dyn fmt::Write, &log::Record) -> fmt::Result + Send + Sync + 'static,
165    {
166        self.config.formatter = Box::new(formatter);
167        self
168    }
169
170    /// Set a custom function to create the [`Notification`].
171    /// # Examples
172    /// ```
173    /// # use toast_logger_win::{BufferedRecord, Notification, ToastLogger};
174    /// let builder = ToastLogger::builder()
175    ///     .create_notification(|records: &[BufferedRecord]| {
176    ///         let notification = Notification::new_with_records(records);
177    ///         // Change properties of `notification` as needed.
178    ///         notification
179    ///     });
180    /// ```
181    pub fn create_notification<F>(&mut self, create: F) -> &mut Self
182    where
183        F: Fn(&[BufferedRecord]) -> Result<Notification> + Send + Sync + 'static,
184    {
185        self.config.create_notification = Box::new(create);
186        self
187    }
188}
189
190/// [`log`] crate logger that
191/// implements [`log::Log`] trait.
192/// It sends the logging output to the [Windows Toast Notifications].
193///
194/// # Examples
195/// ```no_run
196/// # use toast_logger_win::{Result, ToastLogger};
197/// # pub fn test() -> Result<()> {
198///   ToastLogger::builder()
199///       .max_level(log::LevelFilter::Info)
200///       .init()?;
201///   log::info!("Test info log");  // Shows a Windows Toast Notification.
202/// #  Ok(())
203/// # }
204/// ```
205/// [Windows Toast Notifications]: https://learn.microsoft.com/windows/apps/design/shell/tiles-and-notifications/toast-notifications-overview
206pub struct ToastLogger {
207    config: ToastLoggerConfig,
208    notifier: Notifier,
209    records: Mutex<Vec<BufferedRecord>>,
210}
211
212static INSTANCE: OnceLock<ToastLogger> = OnceLock::new();
213
214impl ToastLogger {
215    /// Returns a [`ToastLoggerBuilder`] instance
216    /// that can build a [`ToastLogger`] with various configurations.
217    pub fn builder() -> ToastLoggerBuilder {
218        ToastLoggerBuilder::new()
219    }
220
221    fn init(config: ToastLoggerConfig) -> Result<()> {
222        log::set_max_level(config.max_level);
223        if INSTANCE.set(Self::new(config)?).is_err() {
224            panic!("ToastLogger already initialized.");
225        }
226        log::set_logger(INSTANCE.get().unwrap())?;
227        Ok(())
228    }
229
230    fn new(config: ToastLoggerConfig) -> Result<Self> {
231        let notifier = config.create_notifier()?;
232        Ok(Self {
233            config,
234            notifier,
235            records: Mutex::new(Vec::new()),
236        })
237    }
238
239    /// Flush the internal log buffer.
240    /// If the buffer is not empty,
241    /// this function shows one toast notification
242    /// by concatenating all logs in the buffer.
243    ///
244    /// Please see [`ToastLoggerBuilder::auto_flush()`] for more details.
245    pub fn flush() -> Result<()> {
246        let logger = INSTANCE.get().ok_or_else(|| crate::Error::NotInitialized)?;
247        logger.flush_result()
248    }
249
250    fn take_records(&self) -> Option<Vec<BufferedRecord>> {
251        let mut records = self.records.lock().unwrap();
252        if records.is_empty() {
253            return None;
254        }
255        Some(mem::take(&mut *records))
256    }
257
258    fn log_result(&self, record: &log::Record) -> Result<()> {
259        if !self.enabled(record.metadata()) {
260            return Ok(());
261        }
262
263        let mut text = String::new();
264        (self.config.formatter)(&mut text, record)?;
265        if text.is_empty() {
266            return Ok(());
267        }
268        let buffered_record = BufferedRecord::new_with_formatted_args(record, &text);
269
270        if self.config.is_auto_flush {
271            self.show_notification(&[buffered_record])?;
272            return Ok(());
273        }
274
275        // If not auto-flushing, append to the internal buffer.
276        let mut records = self.records.lock().unwrap();
277        records.push(buffered_record);
278        Ok(())
279    }
280
281    fn flush_result(&self) -> Result<()> {
282        if let Some(records) = self.take_records() {
283            return self.show_notification(&records);
284        }
285        Ok(())
286    }
287
288    fn show_notification(&self, records: &[BufferedRecord]) -> Result<()> {
289        let notification = (self.config.create_notification)(records)?;
290        self.notifier.show(&notification)?;
291        Ok(())
292    }
293}
294
295impl log::Log for ToastLogger {
296    fn enabled(&self, metadata: &log::Metadata) -> bool {
297        metadata.level() <= self.config.max_level
298    }
299
300    fn log(&self, record: &log::Record) {
301        if let Err(error) = self.log_result(record) {
302            eprintln!("Error while logging: {error}");
303        }
304    }
305
306    fn flush(&self) {
307        if let Err(error) = self.flush_result() {
308            eprintln!("Error flushing: {error}");
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn builder_default() {
319        let builder = ToastLogger::builder();
320        assert_eq!(builder.config.max_level, log::LevelFilter::Error);
321        assert!(builder.config.is_auto_flush);
322        assert_eq!(
323            builder.config.application_id,
324            ToastLoggerConfig::DEFAULT_APP_ID
325        );
326    }
327
328    #[test]
329    fn max_level() -> Result<()> {
330        let logger = ToastLogger::builder()
331            .max_level(log::LevelFilter::Info)
332            .auto_flush(false)
333            .build()?;
334        let info = log::Record::builder()
335            .level(log::Level::Info)
336            .args(format_args!("test"))
337            .build();
338        let debug = log::Record::builder()
339            .level(log::Level::Debug)
340            .args(format_args!("test"))
341            .build();
342        logger.log(&debug);
343        assert_eq!(logger.take_records(), None);
344        logger.log(&info);
345        assert_eq!(
346            logger.take_records().unwrap_or_default(),
347            [BufferedRecord {
348                level: log::Level::Info,
349                args: "INFO: test".into()
350            }]
351        );
352        Ok(())
353    }
354
355    #[test]
356    fn format() -> Result<()> {
357        let logger = ToastLogger::builder()
358            .max_level(log::LevelFilter::Info)
359            .auto_flush(false)
360            .format(|buf: &mut dyn fmt::Write, record: &log::Record| buf.write_fmt(*record.args()))
361            .build()?;
362        let info = log::Record::builder()
363            .level(log::Level::Info)
364            .args(format_args!("test"))
365            .build();
366        logger.log(&info);
367        assert_eq!(
368            logger.take_records().unwrap_or_default(),
369            [BufferedRecord {
370                level: log::Level::Info,
371                args: "test".into()
372            }]
373        );
374        Ok(())
375    }
376}