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/iOS | `{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    /// | Android   | `{ConfigDir}/logs`                                                                        | `/data/data/com.tauri.dev/files/logs`                       |
168    LogDir { file_name: Option<String> },
169    /// Forward logs to the webview (via the `log://log` event).
170    ///
171    /// This requires the webview to subscribe to log events, via this plugins `attachConsole` function.
172    Webview,
173}
174
175/// A log target.
176pub struct Target {
177    kind: TargetKind,
178    filters: Vec<Box<Filter>>,
179}
180
181impl Target {
182    #[inline]
183    pub const fn new(kind: TargetKind) -> Self {
184        Self {
185            kind,
186            filters: Vec::new(),
187        }
188    }
189
190    #[inline]
191    pub fn filter<F>(mut self, filter: F) -> Self
192    where
193        F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
194    {
195        self.filters.push(Box::new(filter));
196        self
197    }
198}
199
200#[tauri::command]
201fn log(
202    level: LogLevel,
203    message: String,
204    location: Option<&str>,
205    file: Option<&str>,
206    line: Option<u32>,
207    key_values: Option<HashMap<String, String>>,
208) {
209    let level = log::Level::from(level);
210
211    let target = if let Some(location) = location {
212        format!("{WEBVIEW_TARGET}:{location}")
213    } else {
214        WEBVIEW_TARGET.to_string()
215    };
216
217    let mut builder = RecordBuilder::new();
218    builder.level(level).target(&target).file(file).line(line);
219
220    let key_values = key_values.unwrap_or_default();
221    let mut kv = HashMap::new();
222    for (k, v) in key_values.iter() {
223        kv.insert(k.as_str(), v.as_str());
224    }
225    builder.key_values(&kv);
226
227    logger().log(&builder.args(format_args!("{message}")).build());
228}
229
230pub struct Builder {
231    dispatch: fern::Dispatch,
232    rotation_strategy: RotationStrategy,
233    timezone_strategy: TimezoneStrategy,
234    max_file_size: u128,
235    targets: Vec<Target>,
236    is_skip_logger: bool,
237}
238
239impl Default for Builder {
240    fn default() -> Self {
241        #[cfg(desktop)]
242        let format =
243            time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
244                .unwrap();
245        let dispatch = fern::Dispatch::new().format(move |out, message, record| {
246            out.finish(
247                #[cfg(mobile)]
248                format_args!("[{}] {}", record.target(), message),
249                #[cfg(desktop)]
250                format_args!(
251                    "{}[{}][{}] {}",
252                    DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(),
253                    record.target(),
254                    record.level(),
255                    message
256                ),
257            )
258        });
259        Self {
260            dispatch,
261            rotation_strategy: DEFAULT_ROTATION_STRATEGY,
262            timezone_strategy: DEFAULT_TIMEZONE_STRATEGY,
263            max_file_size: DEFAULT_MAX_FILE_SIZE,
264            targets: DEFAULT_LOG_TARGETS.into(),
265            is_skip_logger: false,
266        }
267    }
268}
269
270impl Builder {
271    pub fn new() -> Self {
272        Default::default()
273    }
274
275    pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self {
276        self.rotation_strategy = rotation_strategy;
277        self
278    }
279
280    pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self {
281        self.timezone_strategy = timezone_strategy.clone();
282
283        let format =
284            time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
285                .unwrap();
286        self.dispatch = self.dispatch.format(move |out, message, record| {
287            out.finish(format_args!(
288                "{}[{}][{}] {}",
289                timezone_strategy.get_now().format(&format).unwrap(),
290                record.level(),
291                record.target(),
292                message
293            ))
294        });
295        self
296    }
297
298    pub fn max_file_size(mut self, max_file_size: u128) -> Self {
299        self.max_file_size = max_file_size;
300        self
301    }
302
303    pub fn format<F>(mut self, formatter: F) -> Self
304    where
305        F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static,
306    {
307        self.dispatch = self.dispatch.format(formatter);
308        self
309    }
310
311    pub fn level(mut self, level_filter: impl Into<LevelFilter>) -> Self {
312        self.dispatch = self.dispatch.level(level_filter.into());
313        self
314    }
315
316    pub fn level_for(mut self, module: impl Into<Cow<'static, str>>, level: LevelFilter) -> Self {
317        self.dispatch = self.dispatch.level_for(module, level);
318        self
319    }
320
321    pub fn filter<F>(mut self, filter: F) -> Self
322    where
323        F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
324    {
325        self.dispatch = self.dispatch.filter(filter);
326        self
327    }
328
329    /// Removes all targets. Useful to ignore the default targets and reconfigure them.
330    pub fn clear_targets(mut self) -> Self {
331        self.targets.clear();
332        self
333    }
334
335    /// Adds a log target to the logger.
336    ///
337    /// ```rust
338    /// use tauri_plugin_log::{Target, TargetKind};
339    /// tauri_plugin_log::Builder::new()
340    ///     .target(Target::new(TargetKind::Webview));
341    /// ```
342    pub fn target(mut self, target: Target) -> Self {
343        self.targets.push(target);
344        self
345    }
346
347    /// Skip the creation and global registration of a logger
348    ///
349    /// 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.
350    /// ```rust
351    /// static LOGGER: SimpleLogger = SimpleLogger;
352    ///
353    /// log::set_logger(&SimpleLogger)?;
354    /// log::set_max_level(LevelFilter::Info);
355    /// tauri_plugin_log::Builder::new()
356    ///     .skip_logger();
357    /// ```
358    pub fn skip_logger(mut self) -> Self {
359        self.is_skip_logger = true;
360        self
361    }
362
363    /// Adds a collection of targets to the logger.
364    ///
365    /// ```rust
366    /// use tauri_plugin_log::{Target, TargetKind, WEBVIEW_TARGET};
367    /// tauri_plugin_log::Builder::new()
368    ///     .clear_targets()
369    ///     .targets([
370    ///         Target::new(TargetKind::Webview),
371    ///         Target::new(TargetKind::LogDir { file_name: Some("webview".into()) }).filter(|metadata| metadata.target().starts_with(WEBVIEW_TARGET)),
372    ///         Target::new(TargetKind::LogDir { file_name: Some("rust".into()) }).filter(|metadata| !metadata.target().starts_with(WEBVIEW_TARGET)),
373    ///     ]);
374    /// ```
375    pub fn targets(mut self, targets: impl IntoIterator<Item = Target>) -> Self {
376        self.targets = Vec::from_iter(targets);
377        self
378    }
379
380    #[cfg(feature = "colored")]
381    pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self {
382        let format =
383            time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
384                .unwrap();
385
386        let timezone_strategy = self.timezone_strategy.clone();
387        self.format(move |out, message, record| {
388            out.finish(format_args!(
389                "{}[{}][{}] {}",
390                timezone_strategy.get_now().format(&format).unwrap(),
391                colors.color(record.level()),
392                record.target(),
393                message
394            ))
395        })
396    }
397
398    fn acquire_logger<R: Runtime>(
399        app_handle: &AppHandle<R>,
400        mut dispatch: fern::Dispatch,
401        rotation_strategy: RotationStrategy,
402        timezone_strategy: TimezoneStrategy,
403        max_file_size: u128,
404        targets: Vec<Target>,
405    ) -> Result<(log::LevelFilter, Box<dyn log::Log>), Error> {
406        let app_name = &app_handle.package_info().name;
407
408        // setup targets
409        for target in targets {
410            let mut target_dispatch = fern::Dispatch::new();
411            for filter in target.filters {
412                target_dispatch = target_dispatch.filter(filter);
413            }
414
415            let logger = match target.kind {
416                #[cfg(target_os = "android")]
417                TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(android_logger::log),
418                #[cfg(target_os = "ios")]
419                TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(move |record| {
420                    let message = format!("{}", record.args());
421                    unsafe {
422                        ios::tauri_log(
423                            match record.level() {
424                                log::Level::Trace | log::Level::Debug => 1,
425                                log::Level::Info => 2,
426                                log::Level::Warn | log::Level::Error => 3,
427                            },
428                            // The string is allocated in rust, so we must
429                            // autorelease it rust to give it to the Swift
430                            // runtime.
431                            objc2::rc::Retained::autorelease_ptr(
432                                objc2_foundation::NSString::from_str(message.as_str()),
433                            ) as _,
434                        );
435                    }
436                }),
437                #[cfg(desktop)]
438                TargetKind::Stdout => std::io::stdout().into(),
439                #[cfg(desktop)]
440                TargetKind::Stderr => std::io::stderr().into(),
441                TargetKind::Folder { path, file_name } => {
442                    if !path.exists() {
443                        fs::create_dir_all(&path)?;
444                    }
445
446                    fern::log_file(get_log_file_path(
447                        &path,
448                        file_name.as_deref().unwrap_or(app_name),
449                        &rotation_strategy,
450                        &timezone_strategy,
451                        max_file_size,
452                    )?)?
453                    .into()
454                }
455                TargetKind::LogDir { file_name } => {
456                    let path = app_handle.path().app_log_dir()?;
457                    if !path.exists() {
458                        fs::create_dir_all(&path)?;
459                    }
460
461                    fern::log_file(get_log_file_path(
462                        &path,
463                        file_name.as_deref().unwrap_or(app_name),
464                        &rotation_strategy,
465                        &timezone_strategy,
466                        max_file_size,
467                    )?)?
468                    .into()
469                }
470                TargetKind::Webview => {
471                    let app_handle = app_handle.clone();
472
473                    fern::Output::call(move |record| {
474                        let payload = RecordPayload {
475                            message: record.args().to_string(),
476                            level: record.level().into(),
477                        };
478                        let app_handle = app_handle.clone();
479                        tauri::async_runtime::spawn(async move {
480                            let _ = app_handle.emit("log://log", payload);
481                        });
482                    })
483                }
484            };
485            target_dispatch = target_dispatch.chain(logger);
486
487            dispatch = dispatch.chain(target_dispatch);
488        }
489
490        Ok(dispatch.into_log())
491    }
492
493    fn plugin_builder<R: Runtime>() -> plugin::Builder<R> {
494        plugin::Builder::new("log").invoke_handler(tauri::generate_handler![log])
495    }
496
497    #[allow(clippy::type_complexity)]
498    pub fn split<R: Runtime>(
499        self,
500        app_handle: &AppHandle<R>,
501    ) -> Result<(TauriPlugin<R>, log::LevelFilter, Box<dyn log::Log>), Error> {
502        if self.is_skip_logger {
503            return Err(Error::LoggerNotInitialized);
504        }
505        let plugin = Self::plugin_builder();
506        let (max_level, log) = Self::acquire_logger(
507            app_handle,
508            self.dispatch,
509            self.rotation_strategy,
510            self.timezone_strategy,
511            self.max_file_size,
512            self.targets,
513        )?;
514
515        Ok((plugin.build(), max_level, log))
516    }
517
518    pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
519        Self::plugin_builder()
520            .setup(move |app_handle, _api| {
521                if !self.is_skip_logger {
522                    let (max_level, log) = Self::acquire_logger(
523                        app_handle,
524                        self.dispatch,
525                        self.rotation_strategy,
526                        self.timezone_strategy,
527                        self.max_file_size,
528                        self.targets,
529                    )?;
530                    attach_logger(max_level, log)?;
531                }
532                Ok(())
533            })
534            .build()
535    }
536}
537
538/// Attaches the given logger
539pub fn attach_logger(
540    max_level: log::LevelFilter,
541    log: Box<dyn log::Log>,
542) -> Result<(), log::SetLoggerError> {
543    log::set_boxed_logger(log)?;
544    log::set_max_level(max_level);
545    Ok(())
546}
547
548fn get_log_file_path(
549    dir: &impl AsRef<Path>,
550    file_name: &str,
551    rotation_strategy: &RotationStrategy,
552    timezone_strategy: &TimezoneStrategy,
553    max_file_size: u128,
554) -> Result<PathBuf, Error> {
555    let path = dir.as_ref().join(format!("{file_name}.log"));
556
557    if path.exists() {
558        let log_size = File::open(&path)?.metadata()?.len() as u128;
559        if log_size > max_file_size {
560            match rotation_strategy {
561                RotationStrategy::KeepAll => {
562                    let to = dir.as_ref().join(format!(
563                        "{}_{}.log",
564                        file_name,
565                        timezone_strategy
566                            .get_now()
567                            .format(&time::format_description::parse(
568                                "[year]-[month]-[day]_[hour]-[minute]-[second]"
569                            )?)?,
570                    ));
571                    if to.is_file() {
572                        // designated rotated log file name already exists
573                        // highly unlikely but defensively handle anyway by adding .bak to filename
574                        let mut to_bak = to.clone();
575                        to_bak.set_file_name(format!(
576                            "{}.bak",
577                            to_bak
578                                .file_name()
579                                .map(|f| f.to_string_lossy())
580                                .unwrap_or_default()
581                        ));
582                        fs::rename(&to, to_bak)?;
583                    }
584                    fs::rename(&path, to)?;
585                }
586                RotationStrategy::KeepOne => {
587                    fs::remove_file(&path)?;
588                }
589            }
590        }
591    }
592
593    Ok(path)
594}