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::{
18 fmt::Arguments,
19 fs::{self, File},
20 iter::FromIterator,
21 path::{Path, PathBuf},
22};
23use tauri::{
24 plugin::{self, TauriPlugin},
25 Manager, Runtime,
26};
27use tauri::{AppHandle, Emitter};
28use time::{macros::format_description, OffsetDateTime};
29
30pub use fern;
31pub use log;
32
33mod commands;
34
35pub const WEBVIEW_TARGET: &str = "webview";
36
37#[cfg(target_os = "ios")]
38mod ios {
39 swift_rs::swift!(pub fn tauri_log(
40 level: u8, message: *const std::ffi::c_void
41 ));
42}
43
44const DEFAULT_MAX_FILE_SIZE: u128 = 40000;
45const DEFAULT_ROTATION_STRATEGY: RotationStrategy = RotationStrategy::KeepOne;
46const DEFAULT_TIMEZONE_STRATEGY: TimezoneStrategy = TimezoneStrategy::UseUtc;
47const DEFAULT_LOG_TARGETS: [Target; 2] = [
48 Target::new(TargetKind::Stdout),
49 Target::new(TargetKind::LogDir { file_name: None }),
50];
51const LOG_DATE_FORMAT: &str = "[year]-[month]-[day]_[hour]-[minute]-[second]";
52
53#[derive(Debug, thiserror::Error)]
54pub enum Error {
55 #[error(transparent)]
56 Tauri(#[from] tauri::Error),
57 #[error(transparent)]
58 Io(#[from] std::io::Error),
59 #[error(transparent)]
60 TimeFormat(#[from] time::error::Format),
61 #[error(transparent)]
62 InvalidFormatDescription(#[from] time::error::InvalidFormatDescription),
63 #[error("Internal logger disabled and cannot be acquired or attached")]
64 LoggerNotInitialized,
65}
66
67#[derive(Debug, Clone, Deserialize_repr, Serialize_repr)]
71#[repr(u16)]
72pub enum LogLevel {
73 Trace = 1,
77 Debug,
81 Info,
85 Warn,
89 Error,
93}
94
95impl From<LogLevel> for log::Level {
96 fn from(log_level: LogLevel) -> Self {
97 match log_level {
98 LogLevel::Trace => log::Level::Trace,
99 LogLevel::Debug => log::Level::Debug,
100 LogLevel::Info => log::Level::Info,
101 LogLevel::Warn => log::Level::Warn,
102 LogLevel::Error => log::Level::Error,
103 }
104 }
105}
106
107impl From<log::Level> for LogLevel {
108 fn from(log_level: log::Level) -> Self {
109 match log_level {
110 log::Level::Trace => LogLevel::Trace,
111 log::Level::Debug => LogLevel::Debug,
112 log::Level::Info => LogLevel::Info,
113 log::Level::Warn => LogLevel::Warn,
114 log::Level::Error => LogLevel::Error,
115 }
116 }
117}
118
119pub enum RotationStrategy {
120 KeepAll,
122 KeepOne,
124 KeepSome(usize),
126}
127
128#[derive(Debug, Clone)]
129pub enum TimezoneStrategy {
130 UseUtc,
131 UseLocal,
132}
133
134impl TimezoneStrategy {
135 pub fn get_now(&self) -> OffsetDateTime {
136 match self {
137 TimezoneStrategy::UseUtc => OffsetDateTime::now_utc(),
138 TimezoneStrategy::UseLocal => {
139 OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
140 } }
142 }
143}
144
145#[derive(Debug, Serialize, Clone)]
146struct RecordPayload {
147 message: String,
148 level: LogLevel,
149}
150
151pub enum TargetKind {
153 Stdout,
155 Stderr,
157 Folder {
161 path: PathBuf,
162 file_name: Option<String>,
163 },
164 LogDir { file_name: Option<String> },
175 Webview,
179 Dispatch(fern::Dispatch),
183}
184
185pub struct Target {
187 kind: TargetKind,
188 filters: Vec<Box<Filter>>,
189}
190
191impl Target {
192 #[inline]
193 pub const fn new(kind: TargetKind) -> Self {
194 Self {
195 kind,
196 filters: Vec::new(),
197 }
198 }
199
200 #[inline]
201 pub fn filter<F>(mut self, filter: F) -> Self
202 where
203 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
204 {
205 self.filters.push(Box::new(filter));
206 self
207 }
208}
209
210pub struct Builder {
211 dispatch: fern::Dispatch,
212 rotation_strategy: RotationStrategy,
213 timezone_strategy: TimezoneStrategy,
214 max_file_size: u128,
215 targets: Vec<Target>,
216 is_skip_logger: bool,
217}
218
219impl Default for Builder {
220 fn default() -> Self {
221 #[cfg(desktop)]
222 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
223 let dispatch = fern::Dispatch::new().format(move |out, message, record| {
224 out.finish(
225 #[cfg(mobile)]
226 format_args!("[{}] {}", record.target(), message),
227 #[cfg(desktop)]
228 format_args!(
229 "{}[{}][{}] {}",
230 DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(),
231 record.target(),
232 record.level(),
233 message
234 ),
235 )
236 });
237 Self {
238 dispatch,
239 rotation_strategy: DEFAULT_ROTATION_STRATEGY,
240 timezone_strategy: DEFAULT_TIMEZONE_STRATEGY,
241 max_file_size: DEFAULT_MAX_FILE_SIZE,
242 targets: DEFAULT_LOG_TARGETS.into(),
243 is_skip_logger: false,
244 }
245 }
246}
247
248impl Builder {
249 pub fn new() -> Self {
250 Default::default()
251 }
252
253 pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self {
254 self.rotation_strategy = rotation_strategy;
255 self
256 }
257
258 pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self {
259 self.timezone_strategy = timezone_strategy.clone();
260
261 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
262 self.dispatch = self.dispatch.format(move |out, message, record| {
263 out.finish(format_args!(
264 "{}[{}][{}] {}",
265 timezone_strategy.get_now().format(&format).unwrap(),
266 record.level(),
267 record.target(),
268 message
269 ))
270 });
271 self
272 }
273
274 pub fn max_file_size(mut self, max_file_size: u128) -> Self {
275 self.max_file_size = max_file_size;
276 self
277 }
278
279 pub fn format<F>(mut self, formatter: F) -> Self
280 where
281 F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static,
282 {
283 self.dispatch = self.dispatch.format(formatter);
284 self
285 }
286
287 pub fn level(mut self, level_filter: impl Into<LevelFilter>) -> Self {
288 self.dispatch = self.dispatch.level(level_filter.into());
289 self
290 }
291
292 pub fn level_for(mut self, module: impl Into<Cow<'static, str>>, level: LevelFilter) -> Self {
293 self.dispatch = self.dispatch.level_for(module, level);
294 self
295 }
296
297 pub fn filter<F>(mut self, filter: F) -> Self
298 where
299 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
300 {
301 self.dispatch = self.dispatch.filter(filter);
302 self
303 }
304
305 pub fn clear_targets(mut self) -> Self {
307 self.targets.clear();
308 self
309 }
310
311 pub fn target(mut self, target: Target) -> Self {
319 self.targets.push(target);
320 self
321 }
322
323 pub fn skip_logger(mut self) -> Self {
335 self.is_skip_logger = true;
336 self
337 }
338
339 pub fn targets(mut self, targets: impl IntoIterator<Item = Target>) -> Self {
351 self.targets = Vec::from_iter(targets);
352 self
353 }
354
355 #[cfg(feature = "colored")]
356 pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self {
357 let format = format_description!("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]");
358
359 let timezone_strategy = self.timezone_strategy.clone();
360 self.format(move |out, message, record| {
361 out.finish(format_args!(
362 "{}[{}][{}] {}",
363 timezone_strategy.get_now().format(&format).unwrap(),
364 colors.color(record.level()),
365 record.target(),
366 message
367 ))
368 })
369 }
370
371 fn acquire_logger<R: Runtime>(
372 app_handle: &AppHandle<R>,
373 mut dispatch: fern::Dispatch,
374 rotation_strategy: RotationStrategy,
375 timezone_strategy: TimezoneStrategy,
376 max_file_size: u128,
377 targets: Vec<Target>,
378 ) -> Result<(log::LevelFilter, Box<dyn log::Log>), Error> {
379 let app_name = &app_handle.package_info().name;
380
381 for target in targets {
383 let mut target_dispatch = fern::Dispatch::new();
384 for filter in target.filters {
385 target_dispatch = target_dispatch.filter(filter);
386 }
387
388 let logger = match target.kind {
389 #[cfg(target_os = "android")]
390 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(android_logger::log),
391 #[cfg(target_os = "ios")]
392 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(move |record| {
393 let message = format!("{}", record.args());
394 unsafe {
395 ios::tauri_log(
396 match record.level() {
397 log::Level::Trace | log::Level::Debug => 1,
398 log::Level::Info => 2,
399 log::Level::Warn | log::Level::Error => 3,
400 },
401 objc2::rc::Retained::autorelease_ptr(
405 objc2_foundation::NSString::from_str(message.as_str()),
406 ) as _,
407 );
408 }
409 }),
410 #[cfg(desktop)]
411 TargetKind::Stdout => std::io::stdout().into(),
412 #[cfg(desktop)]
413 TargetKind::Stderr => std::io::stderr().into(),
414 TargetKind::Folder { path, file_name } => {
415 if !path.exists() {
416 fs::create_dir_all(&path)?;
417 }
418
419 fern::log_file(get_log_file_path(
420 &path,
421 file_name.as_deref().unwrap_or(app_name),
422 &rotation_strategy,
423 &timezone_strategy,
424 max_file_size,
425 )?)?
426 .into()
427 }
428 TargetKind::LogDir { file_name } => {
429 let path = app_handle.path().app_log_dir()?;
430 if !path.exists() {
431 fs::create_dir_all(&path)?;
432 }
433
434 fern::log_file(get_log_file_path(
435 &path,
436 file_name.as_deref().unwrap_or(app_name),
437 &rotation_strategy,
438 &timezone_strategy,
439 max_file_size,
440 )?)?
441 .into()
442 }
443 TargetKind::Webview => {
444 let app_handle = app_handle.clone();
445
446 fern::Output::call(move |record| {
447 let payload = RecordPayload {
448 message: record.args().to_string(),
449 level: record.level().into(),
450 };
451 let app_handle = app_handle.clone();
452 tauri::async_runtime::spawn(async move {
453 let _ = app_handle.emit("log://log", payload);
454 });
455 })
456 }
457 TargetKind::Dispatch(dispatch) => dispatch.into(),
458 };
459 target_dispatch = target_dispatch.chain(logger);
460
461 dispatch = dispatch.chain(target_dispatch);
462 }
463
464 Ok(dispatch.into_log())
465 }
466
467 fn plugin_builder<R: Runtime>() -> plugin::Builder<R> {
468 plugin::Builder::new("log").invoke_handler(tauri::generate_handler![commands::log])
469 }
470
471 #[allow(clippy::type_complexity)]
472 pub fn split<R: Runtime>(
473 self,
474 app_handle: &AppHandle<R>,
475 ) -> Result<(TauriPlugin<R>, log::LevelFilter, Box<dyn log::Log>), Error> {
476 if self.is_skip_logger {
477 return Err(Error::LoggerNotInitialized);
478 }
479 let plugin = Self::plugin_builder();
480 let (max_level, log) = Self::acquire_logger(
481 app_handle,
482 self.dispatch,
483 self.rotation_strategy,
484 self.timezone_strategy,
485 self.max_file_size,
486 self.targets,
487 )?;
488
489 Ok((plugin.build(), max_level, log))
490 }
491
492 pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
493 Self::plugin_builder()
494 .setup(move |app_handle, _api| {
495 if !self.is_skip_logger {
496 let (max_level, log) = Self::acquire_logger(
497 app_handle,
498 self.dispatch,
499 self.rotation_strategy,
500 self.timezone_strategy,
501 self.max_file_size,
502 self.targets,
503 )?;
504 attach_logger(max_level, log)?;
505 }
506 Ok(())
507 })
508 .build()
509 }
510}
511
512pub fn attach_logger(
514 max_level: log::LevelFilter,
515 log: Box<dyn log::Log>,
516) -> Result<(), log::SetLoggerError> {
517 log::set_boxed_logger(log)?;
518 log::set_max_level(max_level);
519 Ok(())
520}
521
522fn rename_file_to_dated(
523 path: &impl AsRef<Path>,
524 dir: &impl AsRef<Path>,
525 file_name: &str,
526 timezone_strategy: &TimezoneStrategy,
527) -> Result<(), Error> {
528 let to = dir.as_ref().join(format!(
529 "{}_{}.log",
530 file_name,
531 timezone_strategy
532 .get_now()
533 .format(&time::format_description::parse(LOG_DATE_FORMAT).unwrap())
534 .unwrap(),
535 ));
536 if to.is_file() {
537 let mut to_bak = to.clone();
540 to_bak.set_file_name(format!(
541 "{}.bak",
542 to_bak.file_name().unwrap().to_string_lossy()
543 ));
544 fs::rename(&to, to_bak)?;
545 }
546 fs::rename(path, to)?;
547 Ok(())
548}
549
550fn get_log_file_path(
551 dir: &impl AsRef<Path>,
552 file_name: &str,
553 rotation_strategy: &RotationStrategy,
554 timezone_strategy: &TimezoneStrategy,
555 max_file_size: u128,
556) -> Result<PathBuf, Error> {
557 let path = dir.as_ref().join(format!("{file_name}.log"));
558
559 if path.exists() {
560 let log_size = File::open(&path)?.metadata()?.len() as u128;
561 if log_size > max_file_size {
562 match rotation_strategy {
563 RotationStrategy::KeepAll => {
564 rename_file_to_dated(&path, dir, file_name, timezone_strategy)?;
565 }
566 RotationStrategy::KeepSome(how_many) => {
567 let mut files = fs::read_dir(dir)?
568 .filter_map(|entry| {
569 let entry = entry.ok()?;
570 let path = entry.path();
571 let old_file_name = path.file_name()?.to_string_lossy().into_owned();
572 if old_file_name.starts_with(file_name) {
573 let date = old_file_name
574 .strip_prefix(file_name)?
575 .strip_prefix("_")?
576 .strip_suffix(".log")?;
577 Some((path, date.to_string()))
578 } else {
579 None
580 }
581 })
582 .collect::<Vec<_>>();
583 files.sort_by(|a, b| a.1.cmp(&b.1));
586 if files.len() > (*how_many - 2) {
589 files.truncate(files.len() + 2 - *how_many);
590 for (old_log_path, _) in files {
591 fs::remove_file(old_log_path)?;
592 }
593 }
594 rename_file_to_dated(&path, dir, file_name, timezone_strategy)?;
595 }
596 RotationStrategy::KeepOne => {
597 fs::remove_file(&path)?;
598 }
599 }
600 }
601 }
602 Ok(path)
603}