tauri_plugin_log/
lib.rs

1// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5//! Logging for Tauri applications.
6
7#![doc(
8    html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png",
9    html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
10)]
11
12use fern::{Filter, FormatCallback};
13use log::{LevelFilter, Record};
14use serde::Serialize;
15use serde_repr::{Deserialize_repr, Serialize_repr};
16use std::borrow::Cow;
17use std::{
18    fmt::Arguments,
19    fs::{self, File},
20    iter::FromIterator,
21    path::{Path, PathBuf},
22};
23use tauri::{
24    plugin::{self, TauriPlugin},
25    Manager, Runtime,
26};
27use tauri::{AppHandle, Emitter};
28use time::{macros::format_description, OffsetDateTime};
29
30pub use fern;
31pub use log;
32
33mod commands;
34
35pub const WEBVIEW_TARGET: &str = "webview";
36
37#[cfg(target_os = "ios")]
38mod ios {
39    swift_rs::swift!(pub fn tauri_log(
40      level: u8, message: *const std::ffi::c_void
41    ));
42}
43
44const DEFAULT_MAX_FILE_SIZE: u128 = 40000;
45const DEFAULT_ROTATION_STRATEGY: RotationStrategy = RotationStrategy::KeepOne;
46const DEFAULT_TIMEZONE_STRATEGY: TimezoneStrategy = TimezoneStrategy::UseUtc;
47const DEFAULT_LOG_TARGETS: [Target; 2] = [
48    Target::new(TargetKind::Stdout),
49    Target::new(TargetKind::LogDir { file_name: None }),
50];
51const LOG_DATE_FORMAT: &str = "[year]-[month]-[day]_[hour]-[minute]-[second]";
52
53#[derive(Debug, thiserror::Error)]
54pub enum Error {
55    #[error(transparent)]
56    Tauri(#[from] tauri::Error),
57    #[error(transparent)]
58    Io(#[from] std::io::Error),
59    #[error(transparent)]
60    TimeFormat(#[from] time::error::Format),
61    #[error(transparent)]
62    InvalidFormatDescription(#[from] time::error::InvalidFormatDescription),
63    #[error("Internal logger disabled and cannot be acquired or attached")]
64    LoggerNotInitialized,
65}
66
67/// An enum representing the available verbosity levels of the logger.
68///
69/// It is very similar to the [`log::Level`], but serializes to unsigned ints instead of strings.
70#[derive(Debug, Clone, Deserialize_repr, Serialize_repr)]
71#[repr(u16)]
72pub enum LogLevel {
73    /// The "trace" level.
74    ///
75    /// Designates very low priority, often extremely verbose, information.
76    Trace = 1,
77    /// The "debug" level.
78    ///
79    /// Designates lower priority information.
80    Debug,
81    /// The "info" level.
82    ///
83    /// Designates useful information.
84    Info,
85    /// The "warn" level.
86    ///
87    /// Designates hazardous situations.
88    Warn,
89    /// The "error" level.
90    ///
91    /// Designates very serious errors.
92    Error,
93}
94
95impl From<LogLevel> for log::Level {
96    fn from(log_level: LogLevel) -> Self {
97        match log_level {
98            LogLevel::Trace => log::Level::Trace,
99            LogLevel::Debug => log::Level::Debug,
100            LogLevel::Info => log::Level::Info,
101            LogLevel::Warn => log::Level::Warn,
102            LogLevel::Error => log::Level::Error,
103        }
104    }
105}
106
107impl From<log::Level> for LogLevel {
108    fn from(log_level: log::Level) -> Self {
109        match log_level {
110            log::Level::Trace => LogLevel::Trace,
111            log::Level::Debug => LogLevel::Debug,
112            log::Level::Info => LogLevel::Info,
113            log::Level::Warn => LogLevel::Warn,
114            log::Level::Error => LogLevel::Error,
115        }
116    }
117}
118
119pub enum RotationStrategy {
120    /// Will keep all the logs, renaming them to include the date.
121    KeepAll,
122    /// Will only keep the most recent log up to its maximal size.
123    KeepOne,
124    /// Will keep some of the most recent logs, renaming them to include the date.
125    KeepSome(usize),
126}
127
128#[derive(Debug, Clone)]
129pub enum TimezoneStrategy {
130    UseUtc,
131    UseLocal,
132}
133
134impl TimezoneStrategy {
135    pub fn get_now(&self) -> OffsetDateTime {
136        match self {
137            TimezoneStrategy::UseUtc => OffsetDateTime::now_utc(),
138            TimezoneStrategy::UseLocal => {
139                OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
140            } // Fallback to UTC since Rust cannot determine local timezone
141        }
142    }
143}
144
145#[derive(Debug, Serialize, Clone)]
146struct RecordPayload {
147    message: String,
148    level: LogLevel,
149}
150
151/// An enum representing the available targets of the logger.
152pub enum TargetKind {
153    /// Print logs to stdout.
154    Stdout,
155    /// Print logs to stderr.
156    Stderr,
157    /// Write logs to the given directory.
158    ///
159    /// The plugin will ensure the directory exists before writing logs.
160    Folder {
161        path: PathBuf,
162        file_name: Option<String>,
163    },
164    /// Write logs to the OS specific logs directory.
165    ///
166    /// ### Platform-specific
167    ///
168    /// |Platform   | Value                                                                                     | Example                                                     |
169    /// | --------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
170    /// | Linux     | `$XDG_DATA_HOME/{bundleIdentifier}/logs` or `$HOME/.local/share/{bundleIdentifier}/logs`  | `/home/alice/.local/share/com.tauri.dev/logs`               |
171    /// | macOS/iOS | `{homeDir}/Library/Logs/{bundleIdentifier}`                                               | `/Users/Alice/Library/Logs/com.tauri.dev`                   |
172    /// | Windows   | `{FOLDERID_LocalAppData}/{bundleIdentifier}/logs`                                         | `C:\Users\Alice\AppData\Local\com.tauri.dev\logs`           |
173    /// | Android   | `{ConfigDir}/logs`                                                                        | `/data/data/com.tauri.dev/files/logs`                       |
174    LogDir { file_name: Option<String> },
175    /// Forward logs to the webview (via the `log://log` event).
176    ///
177    /// This requires the webview to subscribe to log events, via this plugins `attachConsole` function.
178    Webview,
179    /// Send logs to a [`fern::Dispatch`]
180    ///
181    /// You can use this to construct arbitrary log targets.
182    Dispatch(fern::Dispatch),
183}
184
185/// A log target.
186pub struct Target {
187    kind: TargetKind,
188    filters: Vec<Box<Filter>>,
189}
190
191impl Target {
192    #[inline]
193    pub const fn new(kind: TargetKind) -> Self {
194        Self {
195            kind,
196            filters: Vec::new(),
197        }
198    }
199
200    #[inline]
201    pub fn filter<F>(mut self, filter: F) -> Self
202    where
203        F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
204    {
205        self.filters.push(Box::new(filter));
206        self
207    }
208}
209
210pub struct Builder {
211    dispatch: fern::Dispatch,
212    rotation_strategy: RotationStrategy,
213    timezone_strategy: TimezoneStrategy,
214    max_file_size: u128,
215    targets: Vec<Target>,
216    is_skip_logger: bool,
217}
218
219impl Default for Builder {
220    fn default() -> Self {
221        #[cfg(desktop)]
222        let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
223        let dispatch = fern::Dispatch::new().format(move |out, message, record| {
224            out.finish(
225                #[cfg(mobile)]
226                format_args!("[{}] {}", record.target(), message),
227                #[cfg(desktop)]
228                format_args!(
229                    "{}[{}][{}] {}",
230                    DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(),
231                    record.target(),
232                    record.level(),
233                    message
234                ),
235            )
236        });
237        Self {
238            dispatch,
239            rotation_strategy: DEFAULT_ROTATION_STRATEGY,
240            timezone_strategy: DEFAULT_TIMEZONE_STRATEGY,
241            max_file_size: DEFAULT_MAX_FILE_SIZE,
242            targets: DEFAULT_LOG_TARGETS.into(),
243            is_skip_logger: false,
244        }
245    }
246}
247
248impl Builder {
249    pub fn new() -> Self {
250        Default::default()
251    }
252
253    pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self {
254        self.rotation_strategy = rotation_strategy;
255        self
256    }
257
258    pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self {
259        self.timezone_strategy = timezone_strategy.clone();
260
261        let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
262        self.dispatch = self.dispatch.format(move |out, message, record| {
263            out.finish(format_args!(
264                "{}[{}][{}] {}",
265                timezone_strategy.get_now().format(&format).unwrap(),
266                record.level(),
267                record.target(),
268                message
269            ))
270        });
271        self
272    }
273
274    pub fn max_file_size(mut self, max_file_size: u128) -> Self {
275        self.max_file_size = max_file_size;
276        self
277    }
278
279    pub fn format<F>(mut self, formatter: F) -> Self
280    where
281        F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static,
282    {
283        self.dispatch = self.dispatch.format(formatter);
284        self
285    }
286
287    pub fn level(mut self, level_filter: impl Into<LevelFilter>) -> Self {
288        self.dispatch = self.dispatch.level(level_filter.into());
289        self
290    }
291
292    pub fn level_for(mut self, module: impl Into<Cow<'static, str>>, level: LevelFilter) -> Self {
293        self.dispatch = self.dispatch.level_for(module, level);
294        self
295    }
296
297    pub fn filter<F>(mut self, filter: F) -> Self
298    where
299        F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
300    {
301        self.dispatch = self.dispatch.filter(filter);
302        self
303    }
304
305    /// Removes all targets. Useful to ignore the default targets and reconfigure them.
306    pub fn clear_targets(mut self) -> Self {
307        self.targets.clear();
308        self
309    }
310
311    /// Adds a log target to the logger.
312    ///
313    /// ```rust
314    /// use tauri_plugin_log::{Target, TargetKind};
315    /// tauri_plugin_log::Builder::new()
316    ///     .target(Target::new(TargetKind::Webview));
317    /// ```
318    pub fn target(mut self, target: Target) -> Self {
319        self.targets.push(target);
320        self
321    }
322
323    /// Skip the creation and global registration of a logger
324    ///
325    /// If you wish to use your own global logger, you must call `skip_logger` so that the plugin does not attempt to set a second global logger. In this configuration, no logger will be created and the plugin's `log` command will rely on the result of `log::logger()`. You will be responsible for configuring the logger yourself and any included targets will be ignored. If ever initializing the plugin multiple times, such as if registering the plugin while testing, call this method to avoid panicking when registering multiple loggers. For interacting with `tracing`, you can leverage the `tracing-log` logger to forward logs to `tracing` or enable the `tracing` feature for this plugin to emit events directly to the tracing system. Both scenarios require calling this method.
326    /// ```rust
327    /// static LOGGER: SimpleLogger = SimpleLogger;
328    ///
329    /// log::set_logger(&SimpleLogger)?;
330    /// log::set_max_level(LevelFilter::Info);
331    /// tauri_plugin_log::Builder::new()
332    ///     .skip_logger();
333    /// ```
334    pub fn skip_logger(mut self) -> Self {
335        self.is_skip_logger = true;
336        self
337    }
338
339    /// Replaces the targets of the logger.
340    ///
341    /// ```rust
342    /// use tauri_plugin_log::{Target, TargetKind, WEBVIEW_TARGET};
343    /// tauri_plugin_log::Builder::new()
344    ///     .targets([
345    ///         Target::new(TargetKind::Webview),
346    ///         Target::new(TargetKind::LogDir { file_name: Some("webview".into()) }).filter(|metadata| metadata.target().starts_with(WEBVIEW_TARGET)),
347    ///         Target::new(TargetKind::LogDir { file_name: Some("rust".into()) }).filter(|metadata| !metadata.target().starts_with(WEBVIEW_TARGET)),
348    ///     ]);
349    /// ```
350    pub fn targets(mut self, targets: impl IntoIterator<Item = Target>) -> Self {
351        self.targets = Vec::from_iter(targets);
352        self
353    }
354
355    #[cfg(feature = "colored")]
356    pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self {
357        let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
358
359        let timezone_strategy = self.timezone_strategy.clone();
360        self.format(move |out, message, record| {
361            out.finish(format_args!(
362                "{}[{}][{}] {}",
363                timezone_strategy.get_now().format(&format).unwrap(),
364                colors.color(record.level()),
365                record.target(),
366                message
367            ))
368        })
369    }
370
371    fn acquire_logger<R: Runtime>(
372        app_handle: &AppHandle<R>,
373        mut dispatch: fern::Dispatch,
374        rotation_strategy: RotationStrategy,
375        timezone_strategy: TimezoneStrategy,
376        max_file_size: u128,
377        targets: Vec<Target>,
378    ) -> Result<(log::LevelFilter, Box<dyn log::Log>), Error> {
379        let app_name = &app_handle.package_info().name;
380
381        // setup targets
382        for target in targets {
383            let mut target_dispatch = fern::Dispatch::new();
384            for filter in target.filters {
385                target_dispatch = target_dispatch.filter(filter);
386            }
387
388            let logger = match target.kind {
389                #[cfg(target_os = "android")]
390                TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(android_logger::log),
391                #[cfg(target_os = "ios")]
392                TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(move |record| {
393                    let message = format!("{}", record.args());
394                    unsafe {
395                        ios::tauri_log(
396                            match record.level() {
397                                log::Level::Trace | log::Level::Debug => 1,
398                                log::Level::Info => 2,
399                                log::Level::Warn | log::Level::Error => 3,
400                            },
401                            // The string is allocated in rust, so we must
402                            // autorelease it rust to give it to the Swift
403                            // runtime.
404                            objc2::rc::Retained::autorelease_ptr(
405                                objc2_foundation::NSString::from_str(message.as_str()),
406                            ) as _,
407                        );
408                    }
409                }),
410                #[cfg(desktop)]
411                TargetKind::Stdout => std::io::stdout().into(),
412                #[cfg(desktop)]
413                TargetKind::Stderr => std::io::stderr().into(),
414                TargetKind::Folder { path, file_name } => {
415                    if !path.exists() {
416                        fs::create_dir_all(&path)?;
417                    }
418
419                    fern::log_file(get_log_file_path(
420                        &path,
421                        file_name.as_deref().unwrap_or(app_name),
422                        &rotation_strategy,
423                        &timezone_strategy,
424                        max_file_size,
425                    )?)?
426                    .into()
427                }
428                TargetKind::LogDir { file_name } => {
429                    let path = app_handle.path().app_log_dir()?;
430                    if !path.exists() {
431                        fs::create_dir_all(&path)?;
432                    }
433
434                    fern::log_file(get_log_file_path(
435                        &path,
436                        file_name.as_deref().unwrap_or(app_name),
437                        &rotation_strategy,
438                        &timezone_strategy,
439                        max_file_size,
440                    )?)?
441                    .into()
442                }
443                TargetKind::Webview => {
444                    let app_handle = app_handle.clone();
445
446                    fern::Output::call(move |record| {
447                        let payload = RecordPayload {
448                            message: record.args().to_string(),
449                            level: record.level().into(),
450                        };
451                        let app_handle = app_handle.clone();
452                        tauri::async_runtime::spawn(async move {
453                            let _ = app_handle.emit("log://log", payload);
454                        });
455                    })
456                }
457                TargetKind::Dispatch(dispatch) => dispatch.into(),
458            };
459            target_dispatch = target_dispatch.chain(logger);
460
461            dispatch = dispatch.chain(target_dispatch);
462        }
463
464        Ok(dispatch.into_log())
465    }
466
467    fn plugin_builder<R: Runtime>() -> plugin::Builder<R> {
468        plugin::Builder::new("log").invoke_handler(tauri::generate_handler![commands::log])
469    }
470
471    #[allow(clippy::type_complexity)]
472    pub fn split<R: Runtime>(
473        self,
474        app_handle: &AppHandle<R>,
475    ) -> Result<(TauriPlugin<R>, log::LevelFilter, Box<dyn log::Log>), Error> {
476        if self.is_skip_logger {
477            return Err(Error::LoggerNotInitialized);
478        }
479        let plugin = Self::plugin_builder();
480        let (max_level, log) = Self::acquire_logger(
481            app_handle,
482            self.dispatch,
483            self.rotation_strategy,
484            self.timezone_strategy,
485            self.max_file_size,
486            self.targets,
487        )?;
488
489        Ok((plugin.build(), max_level, log))
490    }
491
492    pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
493        Self::plugin_builder()
494            .setup(move |app_handle, _api| {
495                if !self.is_skip_logger {
496                    let (max_level, log) = Self::acquire_logger(
497                        app_handle,
498                        self.dispatch,
499                        self.rotation_strategy,
500                        self.timezone_strategy,
501                        self.max_file_size,
502                        self.targets,
503                    )?;
504                    attach_logger(max_level, log)?;
505                }
506                Ok(())
507            })
508            .build()
509    }
510}
511
512/// Attaches the given logger
513pub fn attach_logger(
514    max_level: log::LevelFilter,
515    log: Box<dyn log::Log>,
516) -> Result<(), log::SetLoggerError> {
517    log::set_boxed_logger(log)?;
518    log::set_max_level(max_level);
519    Ok(())
520}
521
522fn rename_file_to_dated(
523    path: &impl AsRef<Path>,
524    dir: &impl AsRef<Path>,
525    file_name: &str,
526    timezone_strategy: &TimezoneStrategy,
527) -> Result<(), Error> {
528    let to = dir.as_ref().join(format!(
529        "{}_{}.log",
530        file_name,
531        timezone_strategy
532            .get_now()
533            .format(&time::format_description::parse(LOG_DATE_FORMAT).unwrap())
534            .unwrap(),
535    ));
536    if to.is_file() {
537        // designated rotated log file name already exists
538        // highly unlikely but defensively handle anyway by adding .bak to filename
539        let mut to_bak = to.clone();
540        to_bak.set_file_name(format!(
541            "{}.bak",
542            to_bak.file_name().unwrap().to_string_lossy()
543        ));
544        fs::rename(&to, to_bak)?;
545    }
546    fs::rename(path, to)?;
547    Ok(())
548}
549
550fn get_log_file_path(
551    dir: &impl AsRef<Path>,
552    file_name: &str,
553    rotation_strategy: &RotationStrategy,
554    timezone_strategy: &TimezoneStrategy,
555    max_file_size: u128,
556) -> Result<PathBuf, Error> {
557    let path = dir.as_ref().join(format!("{file_name}.log"));
558
559    if path.exists() {
560        let log_size = File::open(&path)?.metadata()?.len() as u128;
561        if log_size > max_file_size {
562            match rotation_strategy {
563                RotationStrategy::KeepAll => {
564                    rename_file_to_dated(&path, dir, file_name, timezone_strategy)?;
565                }
566                RotationStrategy::KeepSome(how_many) => {
567                    let mut files = fs::read_dir(dir)?
568                        .filter_map(|entry| {
569                            let entry = entry.ok()?;
570                            let path = entry.path();
571                            let old_file_name = path.file_name()?.to_string_lossy().into_owned();
572                            if old_file_name.starts_with(file_name) {
573                                let date = old_file_name
574                                    .strip_prefix(file_name)?
575                                    .strip_prefix("_")?
576                                    .strip_suffix(".log")?;
577                                Some((path, date.to_string()))
578                            } else {
579                                None
580                            }
581                        })
582                        .collect::<Vec<_>>();
583                    // Regular sorting, so the oldest files are first. Lexicographical
584                    // sorting is fine due to the date format.
585                    files.sort_by(|a, b| a.1.cmp(&b.1));
586                    // We want to make space for the file we will be soon renaming, AND
587                    // the file we will be creating. Thus we need to keep how_many - 2 files.
588                    if files.len() > (*how_many - 2) {
589                        files.truncate(files.len() + 2 - *how_many);
590                        for (old_log_path, _) in files {
591                            fs::remove_file(old_log_path)?;
592                        }
593                    }
594                    rename_file_to_dated(&path, dir, file_name, timezone_strategy)?;
595                }
596                RotationStrategy::KeepOne => {
597                    fs::remove_file(&path)?;
598                }
599            }
600        }
601    }
602    Ok(path)
603}