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 = 40_000;
47const DEFAULT_ROTATION_STRATEGY: RotationStrategy = RotationStrategy::KeepOne;
48const DEFAULT_TIMEZONE_STRATEGY: TimezoneStrategy = TimezoneStrategy::UseUtc;
49const DEFAULT_FILE_OPEN_STRATEGY: FileOpenStrategy = FileOpenStrategy::Append;
50const DEFAULT_LOG_TARGETS: [Target; 2] = [
51 Target::new(TargetKind::Stdout),
52 Target::new(TargetKind::LogDir { file_name: None }),
53];
54const LOG_DATE_FORMAT: &[time::format_description::FormatItem<'_>] =
55 format_description!("[year]-[month]-[day]_[hour]-[minute]-[second]");
56
57#[derive(Debug, thiserror::Error)]
58pub enum Error {
59 #[error(transparent)]
60 Tauri(#[from] tauri::Error),
61 #[error(transparent)]
62 Io(#[from] std::io::Error),
63 #[error(transparent)]
64 TimeFormat(#[from] time::error::Format),
65 #[error(transparent)]
66 InvalidFormatDescription(#[from] time::error::InvalidFormatDescription),
67 #[error("Internal logger disabled and cannot be acquired or attached")]
68 LoggerNotInitialized,
69}
70
71#[derive(Debug, Clone, Deserialize_repr, Serialize_repr)]
75#[repr(u16)]
76pub enum LogLevel {
77 Trace = 1,
81 Debug,
85 Info,
89 Warn,
93 Error,
97}
98
99impl From<LogLevel> for log::Level {
100 fn from(log_level: LogLevel) -> Self {
101 match log_level {
102 LogLevel::Trace => log::Level::Trace,
103 LogLevel::Debug => log::Level::Debug,
104 LogLevel::Info => log::Level::Info,
105 LogLevel::Warn => log::Level::Warn,
106 LogLevel::Error => log::Level::Error,
107 }
108 }
109}
110
111impl From<log::Level> for LogLevel {
112 fn from(log_level: log::Level) -> Self {
113 match log_level {
114 log::Level::Trace => LogLevel::Trace,
115 log::Level::Debug => LogLevel::Debug,
116 log::Level::Info => LogLevel::Info,
117 log::Level::Warn => LogLevel::Warn,
118 log::Level::Error => LogLevel::Error,
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
124pub enum RotationStrategy {
125 KeepAll,
127 KeepOne,
129 KeepSome(usize),
131}
132
133#[derive(Debug, Clone)]
134pub enum TimezoneStrategy {
135 UseUtc,
136 UseLocal,
137}
138
139impl TimezoneStrategy {
140 pub fn get_now(&self) -> OffsetDateTime {
141 match self {
142 TimezoneStrategy::UseUtc => OffsetDateTime::now_utc(),
143 TimezoneStrategy::UseLocal => {
144 OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
145 } }
147 }
148}
149
150#[derive(Debug, Clone, PartialEq)]
151pub enum FileOpenStrategy {
152 Append,
154 Rotate,
156}
157
158struct RotatingFile {
160 dir: PathBuf,
161 file_name: String,
162 path: PathBuf,
163 max_size: u64,
165 current_size: u64,
167 rotation_strategy: RotationStrategy,
168 timezone_strategy: TimezoneStrategy,
169 file_open_strategy: FileOpenStrategy,
170 inner: Option<File>,
171 buffer: Vec<u8>,
172}
173
174impl RotatingFile {
175 pub fn new(
176 dir: impl AsRef<Path>,
177 file_name: String,
178 max_size: u64,
179 rotation_strategy: RotationStrategy,
180 timezone_strategy: TimezoneStrategy,
181 file_open_strategy: FileOpenStrategy,
182 ) -> Result<Self, Error> {
183 let dir = dir.as_ref().to_path_buf();
184 let path = dir.join(&file_name).with_extension("log");
185
186 let mut rotator = Self {
187 dir,
188 file_name,
189 path,
190 max_size,
191 current_size: 0,
192 rotation_strategy,
193 timezone_strategy,
194 file_open_strategy,
195 inner: None,
196 buffer: Vec::new(),
197 };
198
199 rotator.open_file()?;
200 if rotator.current_size >= rotator.max_size
201 || (rotator.current_size > 0 && rotator.file_open_strategy == FileOpenStrategy::Rotate)
202 {
203 rotator.rotate()?;
204 }
205 if let RotationStrategy::KeepSome(keep_count) = rotator.rotation_strategy {
206 rotator.remove_old_files(keep_count)?;
207 }
208
209 Ok(rotator)
210 }
211
212 fn open_file(&mut self) -> Result<(), Error> {
213 let file = OpenOptions::new()
214 .create(true)
215 .append(true)
216 .open(&self.path)?;
217 self.current_size = file.metadata()?.len();
218 self.inner = Some(file);
219 Ok(())
220 }
221
222 fn rotate(&mut self) -> Result<(), Error> {
223 if let Some(mut file) = self.inner.take() {
224 let _ = file.flush();
225 }
226 if self.path.exists() {
227 match self.rotation_strategy {
228 RotationStrategy::KeepAll => {
229 self.rename_file_to_dated()?;
230 }
231 RotationStrategy::KeepSome(keep_count) => {
232 self.remove_old_files(keep_count - 1)?;
235 self.rename_file_to_dated()?;
236 }
237 RotationStrategy::KeepOne => {
238 fs::remove_file(&self.path)?;
239 }
240 }
241 }
242 self.open_file()?;
243 Ok(())
244 }
245
246 fn remove_old_files(&self, keep_count: usize) -> Result<(), Error> {
249 let mut files = fs::read_dir(&self.dir)?
250 .filter_map(|entry| {
251 let entry = entry.ok()?;
252 let path = entry.path();
253 let old_file_name = path.file_name()?.to_string_lossy().into_owned();
254 if old_file_name.starts_with(&self.file_name)
255 && old_file_name != format!("{}.log", self.file_name)
257 {
258 let date = old_file_name
259 .strip_prefix(&self.file_name)?
260 .strip_prefix("_")?
261 .strip_suffix(".log")?;
262 Some((path, date.to_string()))
263 } else {
264 None
265 }
266 })
267 .collect::<Vec<_>>();
268
269 files.sort_by(|a, b| a.1.cmp(&b.1));
270
271 if files.len() > keep_count {
272 let files_to_remove = files.len() - keep_count;
273 for (old_log_path, _) in files.iter().take(files_to_remove) {
274 fs::remove_file(old_log_path)?;
275 }
276 }
277 Ok(())
278 }
279
280 fn rename_file_to_dated(&self) -> Result<(), Error> {
281 let to = self.dir.join(format!(
282 "{}_{}.log",
283 self.file_name,
284 self.timezone_strategy
285 .get_now()
286 .format(LOG_DATE_FORMAT)
287 .unwrap(),
288 ));
289 if to.is_file() {
290 let mut to_bak = to.clone();
293 to_bak.set_file_name(format!(
294 "{}.bak",
295 to_bak.file_name().unwrap().to_string_lossy()
296 ));
297 fs::rename(&to, to_bak)?;
298 }
299 fs::rename(&self.path, &to)?;
300 Ok(())
301 }
302}
303
304impl Write for RotatingFile {
305 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
306 self.buffer.extend_from_slice(buf);
307 Ok(buf.len())
308 }
309
310 fn flush(&mut self) -> std::io::Result<()> {
311 if self.buffer.is_empty() {
312 return Ok(());
313 }
314 if self.inner.is_none() {
315 self.open_file().map_err(std::io::Error::other)?;
316 }
317
318 if self.current_size != 0 && self.current_size + (self.buffer.len() as u64) > self.max_size
319 {
320 self.rotate().map_err(std::io::Error::other)?;
321 }
322
323 if let Some(file) = self.inner.as_mut() {
324 file.write_all(&self.buffer)?;
325 self.current_size += self.buffer.len() as u64;
326 file.flush()?;
327 }
328 self.buffer.clear();
329 Ok(())
330 }
331}
332
333#[derive(Debug, Serialize, Clone)]
334struct RecordPayload {
335 message: String,
336 level: LogLevel,
337}
338
339pub enum TargetKind {
341 Stdout,
343 Stderr,
345 Folder {
349 path: PathBuf,
350 file_name: Option<String>,
351 },
352 LogDir { file_name: Option<String> },
363 Webview,
367 Dispatch(fern::Dispatch),
371}
372
373type Formatter = dyn Fn(FormatCallback, &Arguments, &Record) + Send + Sync + 'static;
374
375pub struct Target {
377 kind: TargetKind,
378 filters: Vec<Box<Filter>>,
379 formatter: Option<Box<Formatter>>,
380}
381
382impl Target {
383 #[inline]
384 pub const fn new(kind: TargetKind) -> Self {
385 Self {
386 kind,
387 filters: Vec::new(),
388 formatter: None,
389 }
390 }
391
392 #[inline]
393 pub fn filter<F>(mut self, filter: F) -> Self
394 where
395 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
396 {
397 self.filters.push(Box::new(filter));
398 self
399 }
400
401 #[inline]
402 pub fn format<F>(mut self, formatter: F) -> Self
403 where
404 F: Fn(FormatCallback, &Arguments, &Record) + Send + Sync + 'static,
405 {
406 self.formatter.replace(Box::new(formatter));
407 self
408 }
409}
410
411pub struct Builder {
412 dispatch: fern::Dispatch,
413 rotation_strategy: RotationStrategy,
414 timezone_strategy: TimezoneStrategy,
415 file_open_strategy: FileOpenStrategy,
416 max_file_size: u128,
417 targets: Vec<Target>,
418 is_skip_logger: bool,
419}
420
421impl Default for Builder {
422 fn default() -> Self {
423 #[cfg(desktop)]
424 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
425 let dispatch = fern::Dispatch::new().format(move |out, message, record| {
426 out.finish(
427 #[cfg(mobile)]
428 format_args!("[{}] {}", record.target(), message),
429 #[cfg(desktop)]
430 format_args!(
431 "{}[{}][{}] {}",
432 DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(),
433 record.target(),
434 record.level(),
435 message
436 ),
437 )
438 });
439 Self {
440 dispatch,
441 rotation_strategy: DEFAULT_ROTATION_STRATEGY,
442 timezone_strategy: DEFAULT_TIMEZONE_STRATEGY,
443 file_open_strategy: DEFAULT_FILE_OPEN_STRATEGY,
444 max_file_size: DEFAULT_MAX_FILE_SIZE as u128,
445 targets: DEFAULT_LOG_TARGETS.into(),
446 is_skip_logger: false,
447 }
448 }
449}
450
451impl Builder {
452 pub fn new() -> Self {
453 Default::default()
454 }
455
456 pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self {
460 self.rotation_strategy = rotation_strategy;
461 self
462 }
463
464 pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self {
469 self.timezone_strategy = timezone_strategy.clone();
470
471 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
472 self.dispatch = self.dispatch.format(move |out, message, record| {
473 out.finish(format_args!(
474 "{}[{}][{}] {}",
475 timezone_strategy.get_now().format(&format).unwrap(),
476 record.level(),
477 record.target(),
478 message
479 ))
480 });
481 self
482 }
483
484 pub fn file_open_strategy(mut self, file_open_strategy: FileOpenStrategy) -> Self {
488 self.file_open_strategy = file_open_strategy;
489 self
490 }
491
492 pub fn max_file_size(mut self, max_file_size: u128) -> Self {
499 self.max_file_size = max_file_size.min(u64::MAX as u128);
500 self
501 }
502
503 pub fn clear_format(mut self) -> Self {
507 self.dispatch = self.dispatch.format(|out, message, _record| {
508 out.finish(format_args!("{message}"));
509 });
510 self
511 }
512
513 pub fn format<F>(mut self, formatter: F) -> Self
545 where
546 F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static,
547 {
548 self.dispatch = self.dispatch.format(formatter);
549 self
550 }
551
552 pub fn level(mut self, level_filter: impl Into<LevelFilter>) -> Self {
559 self.dispatch = self.dispatch.level(level_filter.into());
560 self
561 }
562
563 pub fn level_for(mut self, module: impl Into<Cow<'static, str>>, level: LevelFilter) -> Self {
591 self.dispatch = self.dispatch.level_for(module, level);
592 self
593 }
594
595 pub fn filter<F>(mut self, filter: F) -> Self
611 where
612 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
613 {
614 self.dispatch = self.dispatch.filter(filter);
615 self
616 }
617
618 pub fn clear_targets(mut self) -> Self {
620 self.targets.clear();
621 self
622 }
623
624 pub fn target(mut self, target: Target) -> Self {
645 self.targets.push(target);
646 self
647 }
648
649 pub fn skip_logger(mut self) -> Self {
661 self.is_skip_logger = true;
662 self
663 }
664
665 pub fn targets(mut self, targets: impl IntoIterator<Item = Target>) -> Self {
690 self.targets = Vec::from_iter(targets);
691 self
692 }
693
694 #[cfg(feature = "colored")]
695 pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self {
696 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
697
698 let timezone_strategy = self.timezone_strategy.clone();
699 self.format(move |out, message, record| {
700 out.finish(format_args!(
701 "{}[{}][{}] {}",
702 timezone_strategy.get_now().format(&format).unwrap(),
703 colors.color(record.level()),
704 record.target(),
705 message
706 ))
707 })
708 }
709
710 fn acquire_logger<R: Runtime>(
711 app_handle: &AppHandle<R>,
712 mut dispatch: fern::Dispatch,
713 rotation_strategy: RotationStrategy,
714 timezone_strategy: TimezoneStrategy,
715 file_open_strategy: FileOpenStrategy,
716 max_file_size: u64,
717 targets: Vec<Target>,
718 ) -> Result<(log::LevelFilter, Box<dyn log::Log>), Error> {
719 let app_name = &app_handle.package_info().name;
720
721 for target in targets {
723 let mut target_dispatch = fern::Dispatch::new();
724 for filter in target.filters {
725 target_dispatch = target_dispatch.filter(filter);
726 }
727 if let Some(formatter) = target.formatter {
728 target_dispatch = target_dispatch.format(formatter);
729 }
730
731 let logger = match target.kind {
732 #[cfg(target_os = "android")]
733 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(android_logger::log),
734 #[cfg(target_os = "ios")]
735 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(move |record| {
736 let message = format!("{}", record.args());
737 unsafe {
738 ios::tauri_log(
739 match record.level() {
740 log::Level::Trace | log::Level::Debug => 1,
741 log::Level::Info => 2,
742 log::Level::Warn | log::Level::Error => 3,
743 },
744 objc2::rc::Retained::autorelease_ptr(
748 objc2_foundation::NSString::from_str(message.as_str()),
749 ) as _,
750 );
751 }
752 }),
753 #[cfg(desktop)]
754 TargetKind::Stdout => std::io::stdout().into(),
755 #[cfg(desktop)]
756 TargetKind::Stderr => std::io::stderr().into(),
757 TargetKind::Folder { path, file_name } => {
758 if !path.exists() {
759 fs::create_dir_all(&path)?;
760 }
761
762 let rotator = RotatingFile::new(
763 &path,
764 file_name.unwrap_or(app_name.clone()),
765 max_file_size,
766 rotation_strategy.clone(),
767 timezone_strategy.clone(),
768 file_open_strategy.clone(),
769 )?;
770 fern::Output::writer(Box::new(rotator), "\n")
771 }
772 TargetKind::LogDir { file_name } => {
773 let path = app_handle.path().app_log_dir()?;
774 if !path.exists() {
775 fs::create_dir_all(&path)?;
776 }
777
778 let rotator = RotatingFile::new(
779 &path,
780 file_name.unwrap_or(app_name.clone()),
781 max_file_size,
782 rotation_strategy.clone(),
783 timezone_strategy.clone(),
784 file_open_strategy.clone(),
785 )?;
786 fern::Output::writer(Box::new(rotator), "\n")
787 }
788 TargetKind::Webview => {
789 let app_handle = app_handle.clone();
790
791 fern::Output::call(move |record| {
792 let payload = RecordPayload {
793 message: record.args().to_string(),
794 level: record.level().into(),
795 };
796 let app_handle = app_handle.clone();
797 tauri::async_runtime::spawn(async move {
798 let _ = app_handle.emit("log://log", payload);
799 });
800 })
801 }
802 TargetKind::Dispatch(dispatch) => dispatch.into(),
803 };
804 target_dispatch = target_dispatch.chain(logger);
805
806 dispatch = dispatch.chain(target_dispatch);
807 }
808
809 Ok(dispatch.into_log())
810 }
811
812 fn plugin_builder<R: Runtime>() -> plugin::Builder<R> {
813 plugin::Builder::new("log").invoke_handler(tauri::generate_handler![commands::log])
814 }
815
816 #[allow(clippy::type_complexity)]
817 pub fn split<R: Runtime>(
818 self,
819 app_handle: &AppHandle<R>,
820 ) -> Result<(TauriPlugin<R>, log::LevelFilter, Box<dyn log::Log>), Error> {
821 if self.is_skip_logger {
822 return Err(Error::LoggerNotInitialized);
823 }
824 let plugin = Self::plugin_builder();
825 let (max_level, log) = Self::acquire_logger(
826 app_handle,
827 self.dispatch,
828 self.rotation_strategy,
829 self.timezone_strategy,
830 self.file_open_strategy,
831 self.max_file_size as u64,
832 self.targets,
833 )?;
834
835 Ok((plugin.build(), max_level, log))
836 }
837
838 pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
839 Self::plugin_builder()
840 .setup(move |app_handle, _api| {
841 if !self.is_skip_logger {
842 let (max_level, log) = Self::acquire_logger(
843 app_handle,
844 self.dispatch,
845 self.rotation_strategy,
846 self.timezone_strategy,
847 self.file_open_strategy,
848 self.max_file_size as u64,
849 self.targets,
850 )?;
851 attach_logger(max_level, log)?;
852 }
853 Ok(())
854 })
855 .build()
856 }
857}
858
859pub fn attach_logger(
861 max_level: log::LevelFilter,
862 log: Box<dyn log::Log>,
863) -> Result<(), log::SetLoggerError> {
864 log::set_boxed_logger(log)?;
865 log::set_max_level(max_level);
866 Ok(())
867}