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