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::{LevelFilter, Record};
14use serde::Serialize;
15use serde_repr::{Deserialize_repr, Serialize_repr};
16use std::borrow::Cow;
17use std::fs::OpenOptions;
18use std::io::Write;
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;
33pub use log;
34
35mod commands;
36
37pub const WEBVIEW_TARGET: &str = "webview";
38
39#[cfg(target_os = "ios")]
40mod ios {
41 swift_rs::swift!(pub fn tauri_log(
42 level: u8, message: *const std::ffi::c_void
43 ));
44}
45
46const DEFAULT_MAX_FILE_SIZE: u64 = 40000;
47const DEFAULT_ROTATION_STRATEGY: RotationStrategy = RotationStrategy::KeepOne;
48const DEFAULT_TIMEZONE_STRATEGY: TimezoneStrategy = TimezoneStrategy::UseUtc;
49const DEFAULT_LOG_TARGETS: [Target; 2] = [
50 Target::new(TargetKind::Stdout),
51 Target::new(TargetKind::LogDir { file_name: None }),
52];
53const LOG_DATE_FORMAT: &[time::format_description::FormatItem<'_>] =
54 format_description!("[year]-[month]-[day]_[hour]-[minute]-[second]");
55
56#[derive(Debug, thiserror::Error)]
57pub enum Error {
58 #[error(transparent)]
59 Tauri(#[from] tauri::Error),
60 #[error(transparent)]
61 Io(#[from] std::io::Error),
62 #[error(transparent)]
63 TimeFormat(#[from] time::error::Format),
64 #[error(transparent)]
65 InvalidFormatDescription(#[from] time::error::InvalidFormatDescription),
66 #[error("Internal logger disabled and cannot be acquired or attached")]
67 LoggerNotInitialized,
68}
69
70#[derive(Debug, Clone, Deserialize_repr, Serialize_repr)]
74#[repr(u16)]
75pub enum LogLevel {
76 Trace = 1,
80 Debug,
84 Info,
88 Warn,
92 Error,
96}
97
98impl From<LogLevel> for log::Level {
99 fn from(log_level: LogLevel) -> Self {
100 match log_level {
101 LogLevel::Trace => log::Level::Trace,
102 LogLevel::Debug => log::Level::Debug,
103 LogLevel::Info => log::Level::Info,
104 LogLevel::Warn => log::Level::Warn,
105 LogLevel::Error => log::Level::Error,
106 }
107 }
108}
109
110impl From<log::Level> for LogLevel {
111 fn from(log_level: log::Level) -> Self {
112 match log_level {
113 log::Level::Trace => LogLevel::Trace,
114 log::Level::Debug => LogLevel::Debug,
115 log::Level::Info => LogLevel::Info,
116 log::Level::Warn => LogLevel::Warn,
117 log::Level::Error => LogLevel::Error,
118 }
119 }
120}
121
122#[derive(Debug, Clone)]
123pub enum RotationStrategy {
124 KeepAll,
126 KeepOne,
128 KeepSome(usize),
130}
131
132#[derive(Debug, Clone)]
133pub enum TimezoneStrategy {
134 UseUtc,
135 UseLocal,
136}
137
138impl TimezoneStrategy {
139 pub fn get_now(&self) -> OffsetDateTime {
140 match self {
141 TimezoneStrategy::UseUtc => OffsetDateTime::now_utc(),
142 TimezoneStrategy::UseLocal => {
143 OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
144 } }
146 }
147}
148
149struct RotatingFile {
151 dir: PathBuf,
152 file_name: String,
153 path: PathBuf,
154 max_size: u64,
155 current_size: u64,
156 rotation_strategy: RotationStrategy,
157 timezone_strategy: TimezoneStrategy,
158 inner: Option<File>,
159 buffer: Vec<u8>,
160}
161
162impl RotatingFile {
163 pub fn new(
164 dir: impl AsRef<Path>,
165 file_name: String,
166 max_size: u64,
167 rotation_strategy: RotationStrategy,
168 timezone_strategy: TimezoneStrategy,
169 ) -> Result<Self, Error> {
170 let dir = dir.as_ref().to_path_buf();
171 let path = dir.join(&file_name).with_extension("log");
172
173 let mut rotator = Self {
174 dir,
175 file_name,
176 path,
177 max_size,
178 current_size: 0,
179 rotation_strategy,
180 timezone_strategy,
181 inner: None,
182 buffer: Vec::new(),
183 };
184
185 rotator.open_file()?;
186 if rotator.current_size >= rotator.max_size {
187 rotator.rotate()?;
188 }
189 if let RotationStrategy::KeepSome(keep_count) = rotator.rotation_strategy {
190 rotator.remove_old_files(keep_count)?;
191 }
192
193 Ok(rotator)
194 }
195
196 fn open_file(&mut self) -> Result<(), Error> {
197 let file = OpenOptions::new()
198 .create(true)
199 .append(true)
200 .open(&self.path)?;
201 self.current_size = file.metadata()?.len();
202 self.inner = Some(file);
203 Ok(())
204 }
205
206 fn rotate(&mut self) -> Result<(), Error> {
207 if let Some(mut file) = self.inner.take() {
208 let _ = file.flush();
209 }
210 if self.path.exists() {
211 match self.rotation_strategy {
212 RotationStrategy::KeepAll => {
213 self.rename_file_to_dated()?;
214 }
215 RotationStrategy::KeepSome(keep_count) => {
216 self.remove_old_files(keep_count - 1)?;
219 self.rename_file_to_dated()?;
220 }
221 RotationStrategy::KeepOne => {
222 fs::remove_file(&self.path)?;
223 }
224 }
225 }
226 self.open_file()?;
227 Ok(())
228 }
229
230 fn remove_old_files(&self, keep_count: usize) -> Result<(), Error> {
233 let mut files = fs::read_dir(&self.dir)?
234 .filter_map(|entry| {
235 let entry = entry.ok()?;
236 let path = entry.path();
237 let old_file_name = path.file_name()?.to_string_lossy().into_owned();
238 if old_file_name.starts_with(&self.file_name)
239 && old_file_name != format!("{}.log", self.file_name)
241 {
242 let date = old_file_name
243 .strip_prefix(&self.file_name)?
244 .strip_prefix("_")?
245 .strip_suffix(".log")?;
246 Some((path, date.to_string()))
247 } else {
248 None
249 }
250 })
251 .collect::<Vec<_>>();
252
253 files.sort_by(|a, b| a.1.cmp(&b.1));
254
255 if files.len() > keep_count {
256 let files_to_remove = files.len() - keep_count;
257 for (old_log_path, _) in files.iter().take(files_to_remove) {
258 fs::remove_file(old_log_path)?;
259 }
260 }
261 Ok(())
262 }
263
264 fn rename_file_to_dated(&self) -> Result<(), Error> {
265 let to = self.dir.join(format!(
266 "{}_{}.log",
267 self.file_name,
268 self.timezone_strategy
269 .get_now()
270 .format(LOG_DATE_FORMAT)
271 .unwrap(),
272 ));
273 if to.is_file() {
274 let mut to_bak = to.clone();
277 to_bak.set_file_name(format!(
278 "{}.bak",
279 to_bak.file_name().unwrap().to_string_lossy()
280 ));
281 fs::rename(&to, to_bak)?;
282 }
283 fs::rename(&self.path, &to)?;
284 Ok(())
285 }
286}
287
288impl Write for RotatingFile {
289 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
290 self.buffer.extend_from_slice(buf);
291 Ok(buf.len())
292 }
293
294 fn flush(&mut self) -> std::io::Result<()> {
295 if self.buffer.is_empty() {
296 return Ok(());
297 }
298 if self.inner.is_none() {
299 self.open_file().map_err(std::io::Error::other)?;
300 }
301
302 if self.current_size != 0 && self.current_size + (self.buffer.len() as u64) > self.max_size
303 {
304 self.rotate().map_err(std::io::Error::other)?;
305 }
306
307 if let Some(file) = self.inner.as_mut() {
308 file.write_all(&self.buffer)?;
309 self.current_size += self.buffer.len() as u64;
310 file.flush()?;
311 }
312 self.buffer.clear();
313 Ok(())
314 }
315}
316
317#[derive(Debug, Serialize, Clone)]
318struct RecordPayload {
319 message: String,
320 level: LogLevel,
321}
322
323pub enum TargetKind {
325 Stdout,
327 Stderr,
329 Folder {
333 path: PathBuf,
334 file_name: Option<String>,
335 },
336 LogDir { file_name: Option<String> },
347 Webview,
351 Dispatch(fern::Dispatch),
355}
356
357type Formatter = dyn Fn(FormatCallback, &Arguments, &Record) + Send + Sync + 'static;
358
359pub struct Target {
361 kind: TargetKind,
362 filters: Vec<Box<Filter>>,
363 formatter: Option<Box<Formatter>>,
364}
365
366impl Target {
367 #[inline]
368 pub const fn new(kind: TargetKind) -> Self {
369 Self {
370 kind,
371 filters: Vec::new(),
372 formatter: None,
373 }
374 }
375
376 #[inline]
377 pub fn filter<F>(mut self, filter: F) -> Self
378 where
379 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
380 {
381 self.filters.push(Box::new(filter));
382 self
383 }
384
385 #[inline]
386 pub fn format<F>(mut self, formatter: F) -> Self
387 where
388 F: Fn(FormatCallback, &Arguments, &Record) + Send + Sync + 'static,
389 {
390 self.formatter.replace(Box::new(formatter));
391 self
392 }
393}
394
395pub struct Builder {
396 dispatch: fern::Dispatch,
397 rotation_strategy: RotationStrategy,
398 timezone_strategy: TimezoneStrategy,
399 max_file_size: u128,
400 targets: Vec<Target>,
401 is_skip_logger: bool,
402}
403
404impl Default for Builder {
405 fn default() -> Self {
406 #[cfg(desktop)]
407 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
408 let dispatch = fern::Dispatch::new().format(move |out, message, record| {
409 out.finish(
410 #[cfg(mobile)]
411 format_args!("[{}] {}", record.target(), message),
412 #[cfg(desktop)]
413 format_args!(
414 "{}[{}][{}] {}",
415 DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(),
416 record.target(),
417 record.level(),
418 message
419 ),
420 )
421 });
422 Self {
423 dispatch,
424 rotation_strategy: DEFAULT_ROTATION_STRATEGY,
425 timezone_strategy: DEFAULT_TIMEZONE_STRATEGY,
426 max_file_size: DEFAULT_MAX_FILE_SIZE as u128,
427 targets: DEFAULT_LOG_TARGETS.into(),
428 is_skip_logger: false,
429 }
430 }
431}
432
433impl Builder {
434 pub fn new() -> Self {
435 Default::default()
436 }
437
438 pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self {
439 self.rotation_strategy = rotation_strategy;
440 self
441 }
442
443 pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self {
444 self.timezone_strategy = timezone_strategy.clone();
445
446 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
447 self.dispatch = self.dispatch.format(move |out, message, record| {
448 out.finish(format_args!(
449 "{}[{}][{}] {}",
450 timezone_strategy.get_now().format(&format).unwrap(),
451 record.level(),
452 record.target(),
453 message
454 ))
455 });
456 self
457 }
458
459 pub fn max_file_size(mut self, max_file_size: u128) -> Self {
464 self.max_file_size = max_file_size.min(u64::MAX as u128);
465 self
466 }
467
468 pub fn clear_format(mut self) -> Self {
469 self.dispatch = self.dispatch.format(|out, message, _record| {
470 out.finish(format_args!("{message}"));
471 });
472 self
473 }
474
475 pub fn format<F>(mut self, formatter: F) -> Self
476 where
477 F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static,
478 {
479 self.dispatch = self.dispatch.format(formatter);
480 self
481 }
482
483 pub fn level(mut self, level_filter: impl Into<LevelFilter>) -> Self {
484 self.dispatch = self.dispatch.level(level_filter.into());
485 self
486 }
487
488 pub fn level_for(mut self, module: impl Into<Cow<'static, str>>, level: LevelFilter) -> Self {
489 self.dispatch = self.dispatch.level_for(module, level);
490 self
491 }
492
493 pub fn filter<F>(mut self, filter: F) -> Self
494 where
495 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
496 {
497 self.dispatch = self.dispatch.filter(filter);
498 self
499 }
500
501 pub fn clear_targets(mut self) -> Self {
503 self.targets.clear();
504 self
505 }
506
507 pub fn target(mut self, target: Target) -> Self {
515 self.targets.push(target);
516 self
517 }
518
519 pub fn skip_logger(mut self) -> Self {
531 self.is_skip_logger = true;
532 self
533 }
534
535 pub fn targets(mut self, targets: impl IntoIterator<Item = Target>) -> Self {
547 self.targets = Vec::from_iter(targets);
548 self
549 }
550
551 #[cfg(feature = "colored")]
552 pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self {
553 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
554
555 let timezone_strategy = self.timezone_strategy.clone();
556 self.format(move |out, message, record| {
557 out.finish(format_args!(
558 "{}[{}][{}] {}",
559 timezone_strategy.get_now().format(&format).unwrap(),
560 colors.color(record.level()),
561 record.target(),
562 message
563 ))
564 })
565 }
566
567 fn acquire_logger<R: Runtime>(
568 app_handle: &AppHandle<R>,
569 mut dispatch: fern::Dispatch,
570 rotation_strategy: RotationStrategy,
571 timezone_strategy: TimezoneStrategy,
572 max_file_size: u64,
573 targets: Vec<Target>,
574 ) -> Result<(log::LevelFilter, Box<dyn log::Log>), Error> {
575 let app_name = &app_handle.package_info().name;
576
577 for target in targets {
579 let mut target_dispatch = fern::Dispatch::new();
580 for filter in target.filters {
581 target_dispatch = target_dispatch.filter(filter);
582 }
583 if let Some(formatter) = target.formatter {
584 target_dispatch = target_dispatch.format(formatter);
585 }
586
587 let logger = match target.kind {
588 #[cfg(target_os = "android")]
589 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(android_logger::log),
590 #[cfg(target_os = "ios")]
591 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(move |record| {
592 let message = format!("{}", record.args());
593 unsafe {
594 ios::tauri_log(
595 match record.level() {
596 log::Level::Trace | log::Level::Debug => 1,
597 log::Level::Info => 2,
598 log::Level::Warn | log::Level::Error => 3,
599 },
600 objc2::rc::Retained::autorelease_ptr(
604 objc2_foundation::NSString::from_str(message.as_str()),
605 ) as _,
606 );
607 }
608 }),
609 #[cfg(desktop)]
610 TargetKind::Stdout => std::io::stdout().into(),
611 #[cfg(desktop)]
612 TargetKind::Stderr => std::io::stderr().into(),
613 TargetKind::Folder { path, file_name } => {
614 if !path.exists() {
615 fs::create_dir_all(&path)?;
616 }
617
618 let rotator = RotatingFile::new(
619 &path,
620 file_name.unwrap_or(app_name.clone()),
621 max_file_size,
622 rotation_strategy.clone(),
623 timezone_strategy.clone(),
624 )?;
625 fern::Output::writer(Box::new(rotator), "\n")
626 }
627 TargetKind::LogDir { file_name } => {
628 let path = app_handle.path().app_log_dir()?;
629 if !path.exists() {
630 fs::create_dir_all(&path)?;
631 }
632
633 let rotator = RotatingFile::new(
634 &path,
635 file_name.unwrap_or(app_name.clone()),
636 max_file_size,
637 rotation_strategy.clone(),
638 timezone_strategy.clone(),
639 )?;
640 fern::Output::writer(Box::new(rotator), "\n")
641 }
642 TargetKind::Webview => {
643 let app_handle = app_handle.clone();
644
645 fern::Output::call(move |record| {
646 let payload = RecordPayload {
647 message: record.args().to_string(),
648 level: record.level().into(),
649 };
650 let app_handle = app_handle.clone();
651 tauri::async_runtime::spawn(async move {
652 let _ = app_handle.emit("log://log", payload);
653 });
654 })
655 }
656 TargetKind::Dispatch(dispatch) => dispatch.into(),
657 };
658 target_dispatch = target_dispatch.chain(logger);
659
660 dispatch = dispatch.chain(target_dispatch);
661 }
662
663 Ok(dispatch.into_log())
664 }
665
666 fn plugin_builder<R: Runtime>() -> plugin::Builder<R> {
667 plugin::Builder::new("log").invoke_handler(tauri::generate_handler![commands::log])
668 }
669
670 #[allow(clippy::type_complexity)]
671 pub fn split<R: Runtime>(
672 self,
673 app_handle: &AppHandle<R>,
674 ) -> Result<(TauriPlugin<R>, log::LevelFilter, Box<dyn log::Log>), Error> {
675 if self.is_skip_logger {
676 return Err(Error::LoggerNotInitialized);
677 }
678 let plugin = Self::plugin_builder();
679 let (max_level, log) = Self::acquire_logger(
680 app_handle,
681 self.dispatch,
682 self.rotation_strategy,
683 self.timezone_strategy,
684 self.max_file_size as u64,
685 self.targets,
686 )?;
687
688 Ok((plugin.build(), max_level, log))
689 }
690
691 pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
692 Self::plugin_builder()
693 .setup(move |app_handle, _api| {
694 if !self.is_skip_logger {
695 let (max_level, log) = Self::acquire_logger(
696 app_handle,
697 self.dispatch,
698 self.rotation_strategy,
699 self.timezone_strategy,
700 self.max_file_size as u64,
701 self.targets,
702 )?;
703 attach_logger(max_level, log)?;
704 }
705 Ok(())
706 })
707 .build()
708 }
709}
710
711pub fn attach_logger(
713 max_level: log::LevelFilter,
714 log: Box<dyn log::Log>,
715) -> Result<(), log::SetLoggerError> {
716 log::set_boxed_logger(log)?;
717 log::set_max_level(max_level);
718 Ok(())
719}