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