1#![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};
30use time::{macros::format_description, OffsetDateTime};
31
32pub use fern;
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];
50const LOG_DATE_FORMAT: &str = "[year]-[month]-[day]_[hour]-[minute]-[second]";
51
52#[derive(Debug, thiserror::Error)]
53pub enum Error {
54 #[error(transparent)]
55 Tauri(#[from] tauri::Error),
56 #[error(transparent)]
57 Io(#[from] std::io::Error),
58 #[error(transparent)]
59 TimeFormat(#[from] time::error::Format),
60 #[error(transparent)]
61 InvalidFormatDescription(#[from] time::error::InvalidFormatDescription),
62 #[error("Internal logger disabled and cannot be acquired or attached")]
63 LoggerNotInitialized,
64}
65
66#[derive(Debug, Clone, Deserialize_repr, Serialize_repr)]
70#[repr(u16)]
71pub enum LogLevel {
72 Trace = 1,
76 Debug,
80 Info,
84 Warn,
88 Error,
92}
93
94impl From<LogLevel> for log::Level {
95 fn from(log_level: LogLevel) -> Self {
96 match log_level {
97 LogLevel::Trace => log::Level::Trace,
98 LogLevel::Debug => log::Level::Debug,
99 LogLevel::Info => log::Level::Info,
100 LogLevel::Warn => log::Level::Warn,
101 LogLevel::Error => log::Level::Error,
102 }
103 }
104}
105
106impl From<log::Level> for LogLevel {
107 fn from(log_level: log::Level) -> Self {
108 match log_level {
109 log::Level::Trace => LogLevel::Trace,
110 log::Level::Debug => LogLevel::Debug,
111 log::Level::Info => LogLevel::Info,
112 log::Level::Warn => LogLevel::Warn,
113 log::Level::Error => LogLevel::Error,
114 }
115 }
116}
117
118pub enum RotationStrategy {
119 KeepAll,
121 KeepOne,
123 KeepSome(usize),
125}
126
127#[derive(Debug, Clone)]
128pub enum TimezoneStrategy {
129 UseUtc,
130 UseLocal,
131}
132
133impl TimezoneStrategy {
134 pub fn get_now(&self) -> OffsetDateTime {
135 match self {
136 TimezoneStrategy::UseUtc => OffsetDateTime::now_utc(),
137 TimezoneStrategy::UseLocal => {
138 OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
139 } }
141 }
142}
143
144#[derive(Debug, Serialize, Clone)]
145struct RecordPayload {
146 message: String,
147 level: LogLevel,
148}
149
150pub enum TargetKind {
152 Stdout,
154 Stderr,
156 Folder {
160 path: PathBuf,
161 file_name: Option<String>,
162 },
163 LogDir { file_name: Option<String> },
174 Webview,
178 Dispatch(fern::Dispatch),
182}
183
184pub struct Target {
186 kind: TargetKind,
187 filters: Vec<Box<Filter>>,
188}
189
190impl Target {
191 #[inline]
192 pub const fn new(kind: TargetKind) -> Self {
193 Self {
194 kind,
195 filters: Vec::new(),
196 }
197 }
198
199 #[inline]
200 pub fn filter<F>(mut self, filter: F) -> Self
201 where
202 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
203 {
204 self.filters.push(Box::new(filter));
205 self
206 }
207}
208
209#[cfg(feature = "tracing")]
211fn emit_trace(
212 level: log::Level,
213 message: &String,
214 location: Option<&str>,
215 file: Option<&str>,
216 line: Option<u32>,
217 kv: &HashMap<&str, &str>,
218) {
219 macro_rules! emit_event {
220 ($level:expr) => {
221 tracing::event!(
222 target: WEBVIEW_TARGET,
223 $level,
224 message = %message,
225 location = location,
226 file,
227 line,
228 ?kv
229 )
230 };
231 }
232 match level {
233 log::Level::Error => emit_event!(tracing::Level::ERROR),
234 log::Level::Warn => emit_event!(tracing::Level::WARN),
235 log::Level::Info => emit_event!(tracing::Level::INFO),
236 log::Level::Debug => emit_event!(tracing::Level::DEBUG),
237 log::Level::Trace => emit_event!(tracing::Level::TRACE),
238 }
239}
240
241#[tauri::command]
242fn log(
243 level: LogLevel,
244 message: String,
245 location: Option<&str>,
246 file: Option<&str>,
247 line: Option<u32>,
248 key_values: Option<HashMap<String, String>>,
249) {
250 let level = log::Level::from(level);
251
252 let target = if let Some(location) = location {
253 format!("{WEBVIEW_TARGET}:{location}")
254 } else {
255 WEBVIEW_TARGET.to_string()
256 };
257
258 let mut builder = RecordBuilder::new();
259 builder.level(level).target(&target).file(file).line(line);
260
261 let key_values = key_values.unwrap_or_default();
262 let mut kv = HashMap::new();
263 for (k, v) in key_values.iter() {
264 kv.insert(k.as_str(), v.as_str());
265 }
266 builder.key_values(&kv);
267 #[cfg(feature = "tracing")]
268 emit_trace(level, &message, location, file, line, &kv);
269
270 logger().log(&builder.args(format_args!("{message}")).build());
271}
272
273pub struct Builder {
274 dispatch: fern::Dispatch,
275 rotation_strategy: RotationStrategy,
276 timezone_strategy: TimezoneStrategy,
277 max_file_size: u128,
278 targets: Vec<Target>,
279 is_skip_logger: bool,
280}
281
282impl Default for Builder {
283 fn default() -> Self {
284 #[cfg(desktop)]
285 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
286 let dispatch = fern::Dispatch::new().format(move |out, message, record| {
287 out.finish(
288 #[cfg(mobile)]
289 format_args!("[{}] {}", record.target(), message),
290 #[cfg(desktop)]
291 format_args!(
292 "{}[{}][{}] {}",
293 DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(),
294 record.target(),
295 record.level(),
296 message
297 ),
298 )
299 });
300 Self {
301 dispatch,
302 rotation_strategy: DEFAULT_ROTATION_STRATEGY,
303 timezone_strategy: DEFAULT_TIMEZONE_STRATEGY,
304 max_file_size: DEFAULT_MAX_FILE_SIZE,
305 targets: DEFAULT_LOG_TARGETS.into(),
306 is_skip_logger: false,
307 }
308 }
309}
310
311impl Builder {
312 pub fn new() -> Self {
313 Default::default()
314 }
315
316 pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self {
317 self.rotation_strategy = rotation_strategy;
318 self
319 }
320
321 pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self {
322 self.timezone_strategy = timezone_strategy.clone();
323
324 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
325 self.dispatch = self.dispatch.format(move |out, message, record| {
326 out.finish(format_args!(
327 "{}[{}][{}] {}",
328 timezone_strategy.get_now().format(&format).unwrap(),
329 record.level(),
330 record.target(),
331 message
332 ))
333 });
334 self
335 }
336
337 pub fn max_file_size(mut self, max_file_size: u128) -> Self {
338 self.max_file_size = max_file_size;
339 self
340 }
341
342 pub fn format<F>(mut self, formatter: F) -> Self
343 where
344 F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static,
345 {
346 self.dispatch = self.dispatch.format(formatter);
347 self
348 }
349
350 pub fn level(mut self, level_filter: impl Into<LevelFilter>) -> Self {
351 self.dispatch = self.dispatch.level(level_filter.into());
352 self
353 }
354
355 pub fn level_for(mut self, module: impl Into<Cow<'static, str>>, level: LevelFilter) -> Self {
356 self.dispatch = self.dispatch.level_for(module, level);
357 self
358 }
359
360 pub fn filter<F>(mut self, filter: F) -> Self
361 where
362 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
363 {
364 self.dispatch = self.dispatch.filter(filter);
365 self
366 }
367
368 pub fn clear_targets(mut self) -> Self {
370 self.targets.clear();
371 self
372 }
373
374 pub fn target(mut self, target: Target) -> Self {
382 self.targets.push(target);
383 self
384 }
385
386 pub fn skip_logger(mut self) -> Self {
398 self.is_skip_logger = true;
399 self
400 }
401
402 pub fn targets(mut self, targets: impl IntoIterator<Item = Target>) -> Self {
414 self.targets = Vec::from_iter(targets);
415 self
416 }
417
418 #[cfg(feature = "colored")]
419 pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self {
420 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
421
422 let timezone_strategy = self.timezone_strategy.clone();
423 self.format(move |out, message, record| {
424 out.finish(format_args!(
425 "{}[{}][{}] {}",
426 timezone_strategy.get_now().format(&format).unwrap(),
427 colors.color(record.level()),
428 record.target(),
429 message
430 ))
431 })
432 }
433
434 fn acquire_logger<R: Runtime>(
435 app_handle: &AppHandle<R>,
436 mut dispatch: fern::Dispatch,
437 rotation_strategy: RotationStrategy,
438 timezone_strategy: TimezoneStrategy,
439 max_file_size: u128,
440 targets: Vec<Target>,
441 ) -> Result<(log::LevelFilter, Box<dyn log::Log>), Error> {
442 let app_name = &app_handle.package_info().name;
443
444 for target in targets {
446 let mut target_dispatch = fern::Dispatch::new();
447 for filter in target.filters {
448 target_dispatch = target_dispatch.filter(filter);
449 }
450
451 let logger = match target.kind {
452 #[cfg(target_os = "android")]
453 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(android_logger::log),
454 #[cfg(target_os = "ios")]
455 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(move |record| {
456 let message = format!("{}", record.args());
457 unsafe {
458 ios::tauri_log(
459 match record.level() {
460 log::Level::Trace | log::Level::Debug => 1,
461 log::Level::Info => 2,
462 log::Level::Warn | log::Level::Error => 3,
463 },
464 objc2::rc::Retained::autorelease_ptr(
468 objc2_foundation::NSString::from_str(message.as_str()),
469 ) as _,
470 );
471 }
472 }),
473 #[cfg(desktop)]
474 TargetKind::Stdout => std::io::stdout().into(),
475 #[cfg(desktop)]
476 TargetKind::Stderr => std::io::stderr().into(),
477 TargetKind::Folder { path, file_name } => {
478 if !path.exists() {
479 fs::create_dir_all(&path)?;
480 }
481
482 fern::log_file(get_log_file_path(
483 &path,
484 file_name.as_deref().unwrap_or(app_name),
485 &rotation_strategy,
486 &timezone_strategy,
487 max_file_size,
488 )?)?
489 .into()
490 }
491 TargetKind::LogDir { file_name } => {
492 let path = app_handle.path().app_log_dir()?;
493 if !path.exists() {
494 fs::create_dir_all(&path)?;
495 }
496
497 fern::log_file(get_log_file_path(
498 &path,
499 file_name.as_deref().unwrap_or(app_name),
500 &rotation_strategy,
501 &timezone_strategy,
502 max_file_size,
503 )?)?
504 .into()
505 }
506 TargetKind::Webview => {
507 let app_handle = app_handle.clone();
508
509 fern::Output::call(move |record| {
510 let payload = RecordPayload {
511 message: record.args().to_string(),
512 level: record.level().into(),
513 };
514 let app_handle = app_handle.clone();
515 tauri::async_runtime::spawn(async move {
516 let _ = app_handle.emit("log://log", payload);
517 });
518 })
519 }
520 TargetKind::Dispatch(dispatch) => dispatch.into(),
521 };
522 target_dispatch = target_dispatch.chain(logger);
523
524 dispatch = dispatch.chain(target_dispatch);
525 }
526
527 Ok(dispatch.into_log())
528 }
529
530 fn plugin_builder<R: Runtime>() -> plugin::Builder<R> {
531 plugin::Builder::new("log").invoke_handler(tauri::generate_handler![log])
532 }
533
534 #[allow(clippy::type_complexity)]
535 pub fn split<R: Runtime>(
536 self,
537 app_handle: &AppHandle<R>,
538 ) -> Result<(TauriPlugin<R>, log::LevelFilter, Box<dyn log::Log>), Error> {
539 if self.is_skip_logger {
540 return Err(Error::LoggerNotInitialized);
541 }
542 let plugin = Self::plugin_builder();
543 let (max_level, log) = Self::acquire_logger(
544 app_handle,
545 self.dispatch,
546 self.rotation_strategy,
547 self.timezone_strategy,
548 self.max_file_size,
549 self.targets,
550 )?;
551
552 Ok((plugin.build(), max_level, log))
553 }
554
555 pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
556 Self::plugin_builder()
557 .setup(move |app_handle, _api| {
558 if !self.is_skip_logger {
559 let (max_level, log) = Self::acquire_logger(
560 app_handle,
561 self.dispatch,
562 self.rotation_strategy,
563 self.timezone_strategy,
564 self.max_file_size,
565 self.targets,
566 )?;
567 attach_logger(max_level, log)?;
568 }
569 Ok(())
570 })
571 .build()
572 }
573}
574
575pub fn attach_logger(
577 max_level: log::LevelFilter,
578 log: Box<dyn log::Log>,
579) -> Result<(), log::SetLoggerError> {
580 log::set_boxed_logger(log)?;
581 log::set_max_level(max_level);
582 Ok(())
583}
584
585fn rename_file_to_dated(
586 path: &impl AsRef<Path>,
587 dir: &impl AsRef<Path>,
588 file_name: &str,
589 timezone_strategy: &TimezoneStrategy,
590) -> Result<(), Error> {
591 let to = dir.as_ref().join(format!(
592 "{}_{}.log",
593 file_name,
594 timezone_strategy
595 .get_now()
596 .format(&time::format_description::parse(LOG_DATE_FORMAT).unwrap())
597 .unwrap(),
598 ));
599 if to.is_file() {
600 let mut to_bak = to.clone();
603 to_bak.set_file_name(format!(
604 "{}.bak",
605 to_bak.file_name().unwrap().to_string_lossy()
606 ));
607 fs::rename(&to, to_bak)?;
608 }
609 fs::rename(path, to)?;
610 Ok(())
611}
612
613fn get_log_file_path(
614 dir: &impl AsRef<Path>,
615 file_name: &str,
616 rotation_strategy: &RotationStrategy,
617 timezone_strategy: &TimezoneStrategy,
618 max_file_size: u128,
619) -> Result<PathBuf, Error> {
620 let path = dir.as_ref().join(format!("{file_name}.log"));
621
622 if path.exists() {
623 let log_size = File::open(&path)?.metadata()?.len() as u128;
624 if log_size > max_file_size {
625 match rotation_strategy {
626 RotationStrategy::KeepAll => {
627 rename_file_to_dated(&path, dir, file_name, timezone_strategy)?;
628 }
629 RotationStrategy::KeepSome(how_many) => {
630 let mut files = fs::read_dir(dir)?
631 .filter_map(|entry| {
632 let entry = entry.ok()?;
633 let path = entry.path();
634 let old_file_name = path.file_name()?.to_string_lossy().into_owned();
635 if old_file_name.starts_with(file_name) {
636 let date = old_file_name
637 .strip_prefix(file_name)?
638 .strip_prefix("_")?
639 .strip_suffix(".log")?;
640 Some((path, date.to_string()))
641 } else {
642 None
643 }
644 })
645 .collect::<Vec<_>>();
646 files.sort_by(|a, b| a.1.cmp(&b.1));
649 if files.len() > (*how_many - 2) {
652 files.truncate(files.len() + 2 - *how_many);
653 for (old_log_path, _) in files {
654 fs::remove_file(old_log_path)?;
655 }
656 }
657 rename_file_to_dated(&path, dir, file_name, timezone_strategy)?;
658 }
659 RotationStrategy::KeepOne => {
660 fs::remove_file(&path)?;
661 }
662 }
663 }
664 }
665 Ok(path)
666}