1#![doc(
13 html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png",
14 html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
15)]
16
17use fern::{Filter, FormatCallback};
18use log::{LevelFilter, Record};
19use serde::Serialize;
20use serde_repr::{Deserialize_repr, Serialize_repr};
21use std::borrow::Cow;
22use std::fs::OpenOptions;
23use std::io::Write;
24use std::{
25 fmt::Arguments,
26 fs::{self, File},
27 iter::FromIterator,
28 path::{Path, PathBuf},
29};
30use tauri::{
31 plugin::{self, TauriPlugin},
32 Manager, Runtime,
33};
34use tauri::{AppHandle, Emitter};
35use time::{macros::format_description, OffsetDateTime};
36
37pub use fern;
38pub use log;
39
40mod commands;
41
42pub const WEBVIEW_TARGET: &str = "webview";
43
44#[cfg(target_os = "ios")]
45mod ios {
46 swift_rs::swift!(pub fn tauri_log(
47 level: u8, message: *const std::ffi::c_void
48 ));
49}
50
51const DEFAULT_MAX_FILE_SIZE: u64 = 40_000;
52const DEFAULT_ROTATION_STRATEGY: RotationStrategy = RotationStrategy::KeepOne;
53const DEFAULT_TIMEZONE_STRATEGY: TimezoneStrategy = TimezoneStrategy::UseUtc;
54const DEFAULT_FILE_OPEN_STRATEGY: FileOpenStrategy = FileOpenStrategy::Append;
55const DEFAULT_LOG_TARGETS: [Target; 2] = [
56 Target::new(TargetKind::Stdout),
57 Target::new(TargetKind::LogDir { file_name: None }),
58];
59const LOG_DATE_FORMAT: &[time::format_description::FormatItem<'_>] =
60 format_description!("[year]-[month]-[day]_[hour]-[minute]-[second]");
61
62#[derive(Debug, thiserror::Error)]
63pub enum Error {
64 #[error(transparent)]
65 Tauri(#[from] tauri::Error),
66 #[error(transparent)]
67 Io(#[from] std::io::Error),
68 #[error(transparent)]
69 TimeFormat(#[from] time::error::Format),
70 #[error(transparent)]
71 InvalidFormatDescription(#[from] time::error::InvalidFormatDescription),
72 #[error("Internal logger disabled and cannot be acquired or attached")]
73 LoggerNotInitialized,
74}
75
76#[derive(Debug, Clone, Deserialize_repr, Serialize_repr)]
80#[repr(u16)]
81pub enum LogLevel {
82 Trace = 1,
86 Debug,
90 Info,
94 Warn,
98 Error,
102}
103
104impl From<LogLevel> for log::Level {
105 fn from(log_level: LogLevel) -> Self {
106 match log_level {
107 LogLevel::Trace => log::Level::Trace,
108 LogLevel::Debug => log::Level::Debug,
109 LogLevel::Info => log::Level::Info,
110 LogLevel::Warn => log::Level::Warn,
111 LogLevel::Error => log::Level::Error,
112 }
113 }
114}
115
116impl From<log::Level> for LogLevel {
117 fn from(log_level: log::Level) -> Self {
118 match log_level {
119 log::Level::Trace => LogLevel::Trace,
120 log::Level::Debug => LogLevel::Debug,
121 log::Level::Info => LogLevel::Info,
122 log::Level::Warn => LogLevel::Warn,
123 log::Level::Error => LogLevel::Error,
124 }
125 }
126}
127
128#[derive(Debug, Clone)]
129pub enum RotationStrategy {
130 KeepAll,
132 KeepOne,
134 KeepSome(usize),
136}
137
138#[derive(Debug, Clone)]
139pub enum TimezoneStrategy {
140 UseUtc,
141 UseLocal,
142}
143
144impl TimezoneStrategy {
145 pub fn get_now(&self) -> OffsetDateTime {
146 match self {
147 TimezoneStrategy::UseUtc => OffsetDateTime::now_utc(),
148 TimezoneStrategy::UseLocal => {
149 OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
150 } }
152 }
153}
154
155#[derive(Debug, Clone, PartialEq)]
156pub enum FileOpenStrategy {
157 Append,
159 Rotate,
161}
162
163struct RotatingFile {
165 dir: PathBuf,
166 file_name: String,
167 path: PathBuf,
168 max_size: u64,
170 current_size: u64,
172 rotation_strategy: RotationStrategy,
173 timezone_strategy: TimezoneStrategy,
174 file_open_strategy: FileOpenStrategy,
175 inner: Option<File>,
176 buffer: Vec<u8>,
177}
178
179impl RotatingFile {
180 pub fn new(
181 dir: impl AsRef<Path>,
182 file_name: String,
183 max_size: u64,
184 rotation_strategy: RotationStrategy,
185 timezone_strategy: TimezoneStrategy,
186 file_open_strategy: FileOpenStrategy,
187 ) -> Result<Self, Error> {
188 let dir = dir.as_ref().to_path_buf();
189 let path = dir.join(&file_name).with_extension("log");
190
191 let mut rotator = Self {
192 dir,
193 file_name,
194 path,
195 max_size,
196 current_size: 0,
197 rotation_strategy,
198 timezone_strategy,
199 file_open_strategy,
200 inner: None,
201 buffer: Vec::new(),
202 };
203
204 rotator.open_file()?;
205 if rotator.current_size >= rotator.max_size
206 || (rotator.current_size > 0 && rotator.file_open_strategy == FileOpenStrategy::Rotate)
207 {
208 rotator.rotate()?;
209 }
210 if let RotationStrategy::KeepSome(keep_count) = rotator.rotation_strategy {
211 rotator.remove_old_files(keep_count)?;
212 }
213
214 Ok(rotator)
215 }
216
217 fn open_file(&mut self) -> Result<(), Error> {
218 let file = OpenOptions::new()
219 .create(true)
220 .append(true)
221 .open(&self.path)?;
222 self.current_size = file.metadata()?.len();
223 self.inner = Some(file);
224 Ok(())
225 }
226
227 fn rotate(&mut self) -> Result<(), Error> {
228 if let Some(mut file) = self.inner.take() {
229 let _ = file.flush();
230 }
231 if self.path.exists() {
232 match self.rotation_strategy {
233 RotationStrategy::KeepAll => {
234 self.rename_file_to_dated()?;
235 }
236 RotationStrategy::KeepSome(keep_count) => {
237 self.remove_old_files(keep_count - 1)?;
240 self.rename_file_to_dated()?;
241 }
242 RotationStrategy::KeepOne => {
243 fs::remove_file(&self.path)?;
244 }
245 }
246 }
247 self.open_file()?;
248 Ok(())
249 }
250
251 fn remove_old_files(&self, keep_count: usize) -> Result<(), Error> {
254 let mut files = fs::read_dir(&self.dir)?
255 .filter_map(|entry| {
256 let entry = entry.ok()?;
257 let path = entry.path();
258 let old_file_name = path.file_name()?.to_string_lossy().into_owned();
259 if old_file_name.starts_with(&self.file_name)
260 && old_file_name != format!("{}.log", self.file_name)
262 {
263 let date = old_file_name
264 .strip_prefix(&self.file_name)?
265 .strip_prefix("_")?
266 .strip_suffix(".log")?;
267 Some((path, date.to_string()))
268 } else {
269 None
270 }
271 })
272 .collect::<Vec<_>>();
273
274 files.sort_by(|a, b| a.1.cmp(&b.1));
275
276 if files.len() > keep_count {
277 let files_to_remove = files.len() - keep_count;
278 for (old_log_path, _) in files.iter().take(files_to_remove) {
279 fs::remove_file(old_log_path)?;
280 }
281 }
282 Ok(())
283 }
284
285 fn rename_file_to_dated(&self) -> Result<(), Error> {
286 let to = self.dir.join(format!(
287 "{}_{}.log",
288 self.file_name,
289 self.timezone_strategy
290 .get_now()
291 .format(LOG_DATE_FORMAT)
292 .unwrap(),
293 ));
294 if to.is_file() {
295 let mut to_bak = to.clone();
298 to_bak.set_file_name(format!(
299 "{}.bak",
300 to_bak.file_name().unwrap().to_string_lossy()
301 ));
302 fs::rename(&to, to_bak)?;
303 }
304 fs::rename(&self.path, &to)?;
305 Ok(())
306 }
307}
308
309impl Write for RotatingFile {
310 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
311 self.buffer.extend_from_slice(buf);
312 Ok(buf.len())
313 }
314
315 fn flush(&mut self) -> std::io::Result<()> {
316 if self.buffer.is_empty() {
317 return Ok(());
318 }
319 if self.inner.is_none() {
320 self.open_file().map_err(std::io::Error::other)?;
321 }
322
323 if self.current_size != 0 && self.current_size + (self.buffer.len() as u64) > self.max_size
324 {
325 self.rotate().map_err(std::io::Error::other)?;
326 }
327
328 if let Some(file) = self.inner.as_mut() {
329 file.write_all(&self.buffer)?;
330 self.current_size += self.buffer.len() as u64;
331 file.flush()?;
332 }
333 self.buffer.clear();
334 Ok(())
335 }
336}
337
338#[derive(Debug, Serialize, Clone)]
339struct RecordPayload {
340 message: String,
341 level: LogLevel,
342}
343
344pub enum TargetKind {
346 Stdout,
348 Stderr,
350 Folder {
354 path: PathBuf,
355 file_name: Option<String>,
356 },
357 LogDir { file_name: Option<String> },
368 Webview,
372 Dispatch(fern::Dispatch),
376}
377
378type Formatter = dyn Fn(FormatCallback, &Arguments, &Record) + Send + Sync + 'static;
379
380pub struct Target {
382 kind: TargetKind,
383 filters: Vec<Box<Filter>>,
384 formatter: Option<Box<Formatter>>,
385}
386
387impl Target {
388 #[inline]
389 pub const fn new(kind: TargetKind) -> Self {
390 Self {
391 kind,
392 filters: Vec::new(),
393 formatter: None,
394 }
395 }
396
397 #[inline]
398 pub fn filter<F>(mut self, filter: F) -> Self
399 where
400 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
401 {
402 self.filters.push(Box::new(filter));
403 self
404 }
405
406 #[inline]
407 pub fn format<F>(mut self, formatter: F) -> Self
408 where
409 F: Fn(FormatCallback, &Arguments, &Record) + Send + Sync + 'static,
410 {
411 self.formatter.replace(Box::new(formatter));
412 self
413 }
414}
415
416pub struct Builder {
417 dispatch: fern::Dispatch,
418 rotation_strategy: RotationStrategy,
419 timezone_strategy: TimezoneStrategy,
420 file_open_strategy: FileOpenStrategy,
421 max_file_size: u128,
422 targets: Vec<Target>,
423 is_skip_logger: bool,
424}
425
426impl Default for Builder {
427 fn default() -> Self {
428 #[cfg(desktop)]
429 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
430 let dispatch = fern::Dispatch::new().format(move |out, message, record| {
431 out.finish(
432 #[cfg(mobile)]
433 format_args!("[{}] {}", record.target(), message),
434 #[cfg(desktop)]
435 format_args!(
436 "{}[{}][{}] {}",
437 DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(),
438 record.target(),
439 record.level(),
440 message
441 ),
442 )
443 });
444 Self {
445 dispatch,
446 rotation_strategy: DEFAULT_ROTATION_STRATEGY,
447 timezone_strategy: DEFAULT_TIMEZONE_STRATEGY,
448 file_open_strategy: DEFAULT_FILE_OPEN_STRATEGY,
449 max_file_size: DEFAULT_MAX_FILE_SIZE as u128,
450 targets: DEFAULT_LOG_TARGETS.into(),
451 is_skip_logger: false,
452 }
453 }
454}
455
456impl Builder {
457 pub fn new() -> Self {
458 Default::default()
459 }
460
461 pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self {
465 self.rotation_strategy = rotation_strategy;
466 self
467 }
468
469 pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self {
474 self.timezone_strategy = timezone_strategy.clone();
475
476 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
477 self.dispatch = self.dispatch.format(move |out, message, record| {
478 out.finish(format_args!(
479 "{}[{}][{}] {}",
480 timezone_strategy.get_now().format(&format).unwrap(),
481 record.level(),
482 record.target(),
483 message
484 ))
485 });
486 self
487 }
488
489 pub fn file_open_strategy(mut self, file_open_strategy: FileOpenStrategy) -> Self {
493 self.file_open_strategy = file_open_strategy;
494 self
495 }
496
497 pub fn max_file_size(mut self, max_file_size: u128) -> Self {
504 self.max_file_size = max_file_size.min(u64::MAX as u128);
505 self
506 }
507
508 pub fn clear_format(mut self) -> Self {
512 self.dispatch = self.dispatch.format(|out, message, _record| {
513 out.finish(format_args!("{message}"));
514 });
515 self
516 }
517
518 pub fn format<F>(mut self, formatter: F) -> Self
550 where
551 F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static,
552 {
553 self.dispatch = self.dispatch.format(formatter);
554 self
555 }
556
557 pub fn level(mut self, level_filter: impl Into<LevelFilter>) -> Self {
564 self.dispatch = self.dispatch.level(level_filter.into());
565 self
566 }
567
568 pub fn level_for(mut self, module: impl Into<Cow<'static, str>>, level: LevelFilter) -> Self {
596 self.dispatch = self.dispatch.level_for(module, level);
597 self
598 }
599
600 pub fn filter<F>(mut self, filter: F) -> Self
616 where
617 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
618 {
619 self.dispatch = self.dispatch.filter(filter);
620 self
621 }
622
623 pub fn clear_targets(mut self) -> Self {
625 self.targets.clear();
626 self
627 }
628
629 pub fn target(mut self, target: Target) -> Self {
650 self.targets.push(target);
651 self
652 }
653
654 pub fn skip_logger(mut self) -> Self {
672 self.is_skip_logger = true;
673 self
674 }
675
676 pub fn targets(mut self, targets: impl IntoIterator<Item = Target>) -> Self {
701 self.targets = Vec::from_iter(targets);
702 self
703 }
704
705 #[cfg(feature = "colored")]
706 pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self {
707 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
708
709 let timezone_strategy = self.timezone_strategy.clone();
710 self.format(move |out, message, record| {
711 out.finish(format_args!(
712 "{}[{}][{}] {}",
713 timezone_strategy.get_now().format(&format).unwrap(),
714 colors.color(record.level()),
715 record.target(),
716 message
717 ))
718 })
719 }
720
721 fn acquire_logger<R: Runtime>(
722 app_handle: &AppHandle<R>,
723 mut dispatch: fern::Dispatch,
724 rotation_strategy: RotationStrategy,
725 timezone_strategy: TimezoneStrategy,
726 file_open_strategy: FileOpenStrategy,
727 max_file_size: u64,
728 targets: Vec<Target>,
729 ) -> Result<(log::LevelFilter, Box<dyn log::Log>), Error> {
730 let app_name = &app_handle.package_info().name;
731
732 for target in targets {
734 let mut target_dispatch = fern::Dispatch::new();
735 for filter in target.filters {
736 target_dispatch = target_dispatch.filter(filter);
737 }
738 if let Some(formatter) = target.formatter {
739 target_dispatch = target_dispatch.format(formatter);
740 }
741
742 let logger = match target.kind {
743 #[cfg(target_os = "android")]
744 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(android_logger::log),
745 #[cfg(target_os = "ios")]
746 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(move |record| {
747 let message = format!("{}", record.args());
748 unsafe {
749 ios::tauri_log(
750 match record.level() {
751 log::Level::Trace | log::Level::Debug => 1,
752 log::Level::Info => 2,
753 log::Level::Warn | log::Level::Error => 3,
754 },
755 objc2::rc::Retained::autorelease_ptr(
759 objc2_foundation::NSString::from_str(message.as_str()),
760 ) as _,
761 );
762 }
763 }),
764 #[cfg(desktop)]
765 TargetKind::Stdout => std::io::stdout().into(),
766 #[cfg(desktop)]
767 TargetKind::Stderr => std::io::stderr().into(),
768 TargetKind::Folder { path, file_name } => {
769 if !path.exists() {
770 fs::create_dir_all(&path)?;
771 }
772
773 let rotator = RotatingFile::new(
774 &path,
775 file_name.unwrap_or(app_name.clone()),
776 max_file_size,
777 rotation_strategy.clone(),
778 timezone_strategy.clone(),
779 file_open_strategy.clone(),
780 )?;
781 fern::Output::writer(Box::new(rotator), "\n")
782 }
783 TargetKind::LogDir { file_name } => {
784 let path = app_handle.path().app_log_dir()?;
785 if !path.exists() {
786 fs::create_dir_all(&path)?;
787 }
788
789 let rotator = RotatingFile::new(
790 &path,
791 file_name.unwrap_or(app_name.clone()),
792 max_file_size,
793 rotation_strategy.clone(),
794 timezone_strategy.clone(),
795 file_open_strategy.clone(),
796 )?;
797 fern::Output::writer(Box::new(rotator), "\n")
798 }
799 TargetKind::Webview => {
800 let app_handle = app_handle.clone();
801
802 fern::Output::call(move |record| {
803 let payload = RecordPayload {
804 message: record.args().to_string(),
805 level: record.level().into(),
806 };
807 let app_handle = app_handle.clone();
808 tauri::async_runtime::spawn(async move {
809 let _ = app_handle.emit("log://log", payload);
810 });
811 })
812 }
813 TargetKind::Dispatch(dispatch) => dispatch.into(),
814 };
815 target_dispatch = target_dispatch.chain(logger);
816
817 dispatch = dispatch.chain(target_dispatch);
818 }
819
820 Ok(dispatch.into_log())
821 }
822
823 fn plugin_builder<R: Runtime>() -> plugin::Builder<R> {
824 plugin::Builder::new("log").invoke_handler(tauri::generate_handler![commands::log])
825 }
826
827 #[allow(clippy::type_complexity)]
828 pub fn split<R: Runtime>(
829 self,
830 app_handle: &AppHandle<R>,
831 ) -> Result<(TauriPlugin<R>, log::LevelFilter, Box<dyn log::Log>), Error> {
832 if self.is_skip_logger {
833 return Err(Error::LoggerNotInitialized);
834 }
835 let plugin = Self::plugin_builder();
836 let (max_level, log) = Self::acquire_logger(
837 app_handle,
838 self.dispatch,
839 self.rotation_strategy,
840 self.timezone_strategy,
841 self.file_open_strategy,
842 self.max_file_size as u64,
843 self.targets,
844 )?;
845
846 Ok((plugin.build(), max_level, log))
847 }
848
849 pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
850 Self::plugin_builder()
851 .setup(move |app_handle, _api| {
852 if !self.is_skip_logger {
853 let (max_level, log) = Self::acquire_logger(
854 app_handle,
855 self.dispatch,
856 self.rotation_strategy,
857 self.timezone_strategy,
858 self.file_open_strategy,
859 self.max_file_size as u64,
860 self.targets,
861 )?;
862 attach_logger(max_level, log)?;
863 }
864 Ok(())
865 })
866 .build()
867 }
868}
869
870pub fn attach_logger(
872 max_level: log::LevelFilter,
873 log: Box<dyn log::Log>,
874) -> Result<(), log::SetLoggerError> {
875 log::set_boxed_logger(log)?;
876 log::set_max_level(max_level);
877 Ok(())
878}