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