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};
30
31pub use fern;
32use time::OffsetDateTime;
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> },
168 Webview,
172}
173
174pub struct Target {
176 kind: TargetKind,
177 filters: Vec<Box<Filter>>,
178}
179
180impl Target {
181 #[inline]
182 pub const fn new(kind: TargetKind) -> Self {
183 Self {
184 kind,
185 filters: Vec::new(),
186 }
187 }
188
189 #[inline]
190 pub fn filter<F>(mut self, filter: F) -> Self
191 where
192 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
193 {
194 self.filters.push(Box::new(filter));
195 self
196 }
197}
198
199#[tauri::command]
200fn log(
201 level: LogLevel,
202 message: String,
203 location: Option<&str>,
204 file: Option<&str>,
205 line: Option<u32>,
206 key_values: Option<HashMap<String, String>>,
207) {
208 let level = log::Level::from(level);
209
210 let target = if let Some(location) = location {
211 format!("{WEBVIEW_TARGET}:{location}")
212 } else {
213 WEBVIEW_TARGET.to_string()
214 };
215
216 let mut builder = RecordBuilder::new();
217 builder.level(level).target(&target).file(file).line(line);
218
219 let key_values = key_values.unwrap_or_default();
220 let mut kv = HashMap::new();
221 for (k, v) in key_values.iter() {
222 kv.insert(k.as_str(), v.as_str());
223 }
224 builder.key_values(&kv);
225
226 logger().log(&builder.args(format_args!("{message}")).build());
227}
228
229pub struct Builder {
230 dispatch: fern::Dispatch,
231 rotation_strategy: RotationStrategy,
232 timezone_strategy: TimezoneStrategy,
233 max_file_size: u128,
234 targets: Vec<Target>,
235 is_skip_logger: bool,
236}
237
238impl Default for Builder {
239 fn default() -> Self {
240 #[cfg(desktop)]
241 let format =
242 time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
243 .unwrap();
244 let dispatch = fern::Dispatch::new().format(move |out, message, record| {
245 out.finish(
246 #[cfg(mobile)]
247 format_args!("[{}] {}", record.target(), message),
248 #[cfg(desktop)]
249 format_args!(
250 "{}[{}][{}] {}",
251 DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(),
252 record.target(),
253 record.level(),
254 message
255 ),
256 )
257 });
258 Self {
259 dispatch,
260 rotation_strategy: DEFAULT_ROTATION_STRATEGY,
261 timezone_strategy: DEFAULT_TIMEZONE_STRATEGY,
262 max_file_size: DEFAULT_MAX_FILE_SIZE,
263 targets: DEFAULT_LOG_TARGETS.into(),
264 is_skip_logger: false,
265 }
266 }
267}
268
269impl Builder {
270 pub fn new() -> Self {
271 Default::default()
272 }
273
274 pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self {
275 self.rotation_strategy = rotation_strategy;
276 self
277 }
278
279 pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self {
280 self.timezone_strategy = timezone_strategy.clone();
281
282 let format =
283 time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
284 .unwrap();
285 self.dispatch = self.dispatch.format(move |out, message, record| {
286 out.finish(format_args!(
287 "{}[{}][{}] {}",
288 timezone_strategy.get_now().format(&format).unwrap(),
289 record.level(),
290 record.target(),
291 message
292 ))
293 });
294 self
295 }
296
297 pub fn max_file_size(mut self, max_file_size: u128) -> Self {
298 self.max_file_size = max_file_size;
299 self
300 }
301
302 pub fn format<F>(mut self, formatter: F) -> Self
303 where
304 F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static,
305 {
306 self.dispatch = self.dispatch.format(formatter);
307 self
308 }
309
310 pub fn level(mut self, level_filter: impl Into<LevelFilter>) -> Self {
311 self.dispatch = self.dispatch.level(level_filter.into());
312 self
313 }
314
315 pub fn level_for(mut self, module: impl Into<Cow<'static, str>>, level: LevelFilter) -> Self {
316 self.dispatch = self.dispatch.level_for(module, level);
317 self
318 }
319
320 pub fn filter<F>(mut self, filter: F) -> Self
321 where
322 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
323 {
324 self.dispatch = self.dispatch.filter(filter);
325 self
326 }
327
328 pub fn clear_targets(mut self) -> Self {
330 self.targets.clear();
331 self
332 }
333
334 pub fn target(mut self, target: Target) -> Self {
342 self.targets.push(target);
343 self
344 }
345
346 pub fn skip_logger(mut self) -> Self {
358 self.is_skip_logger = true;
359 self
360 }
361
362 pub fn targets(mut self, targets: impl IntoIterator<Item = Target>) -> Self {
375 self.targets = Vec::from_iter(targets);
376 self
377 }
378
379 #[cfg(feature = "colored")]
380 pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self {
381 let format =
382 time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
383 .unwrap();
384
385 let timezone_strategy = self.timezone_strategy.clone();
386 self.format(move |out, message, record| {
387 out.finish(format_args!(
388 "{}[{}][{}] {}",
389 timezone_strategy.get_now().format(&format).unwrap(),
390 colors.color(record.level()),
391 record.target(),
392 message
393 ))
394 })
395 }
396
397 fn acquire_logger<R: Runtime>(
398 app_handle: &AppHandle<R>,
399 mut dispatch: fern::Dispatch,
400 rotation_strategy: RotationStrategy,
401 timezone_strategy: TimezoneStrategy,
402 max_file_size: u128,
403 targets: Vec<Target>,
404 ) -> Result<(log::LevelFilter, Box<dyn log::Log>), Error> {
405 let app_name = &app_handle.package_info().name;
406
407 for target in targets {
409 let mut target_dispatch = fern::Dispatch::new();
410 for filter in target.filters {
411 target_dispatch = target_dispatch.filter(filter);
412 }
413
414 let logger = match target.kind {
415 #[cfg(target_os = "android")]
416 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(android_logger::log),
417 #[cfg(target_os = "ios")]
418 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(move |record| {
419 let message = format!("{}", record.args());
420 unsafe {
421 ios::tauri_log(
422 match record.level() {
423 log::Level::Trace | log::Level::Debug => 1,
424 log::Level::Info => 2,
425 log::Level::Warn | log::Level::Error => 3,
426 },
427 objc2::rc::Retained::autorelease_ptr(
431 objc2_foundation::NSString::from_str(message.as_str()),
432 ) as _,
433 );
434 }
435 }),
436 #[cfg(desktop)]
437 TargetKind::Stdout => std::io::stdout().into(),
438 #[cfg(desktop)]
439 TargetKind::Stderr => std::io::stderr().into(),
440 TargetKind::Folder { path, file_name } => {
441 if !path.exists() {
442 fs::create_dir_all(&path)?;
443 }
444
445 fern::log_file(get_log_file_path(
446 &path,
447 file_name.as_deref().unwrap_or(app_name),
448 &rotation_strategy,
449 &timezone_strategy,
450 max_file_size,
451 )?)?
452 .into()
453 }
454 #[cfg(mobile)]
455 TargetKind::LogDir { .. } => continue,
456 #[cfg(desktop)]
457 TargetKind::LogDir { file_name } => {
458 let path = app_handle.path().app_log_dir()?;
459 if !path.exists() {
460 fs::create_dir_all(&path)?;
461 }
462
463 fern::log_file(get_log_file_path(
464 &path,
465 file_name.as_deref().unwrap_or(app_name),
466 &rotation_strategy,
467 &timezone_strategy,
468 max_file_size,
469 )?)?
470 .into()
471 }
472 TargetKind::Webview => {
473 let app_handle = app_handle.clone();
474
475 fern::Output::call(move |record| {
476 let payload = RecordPayload {
477 message: record.args().to_string(),
478 level: record.level().into(),
479 };
480 let app_handle = app_handle.clone();
481 tauri::async_runtime::spawn(async move {
482 let _ = app_handle.emit("log://log", payload);
483 });
484 })
485 }
486 };
487 target_dispatch = target_dispatch.chain(logger);
488
489 dispatch = dispatch.chain(target_dispatch);
490 }
491
492 Ok(dispatch.into_log())
493 }
494
495 fn plugin_builder<R: Runtime>() -> plugin::Builder<R> {
496 plugin::Builder::new("log").invoke_handler(tauri::generate_handler![log])
497 }
498
499 #[allow(clippy::type_complexity)]
500 pub fn split<R: Runtime>(
501 self,
502 app_handle: &AppHandle<R>,
503 ) -> Result<(TauriPlugin<R>, log::LevelFilter, Box<dyn log::Log>), Error> {
504 if self.is_skip_logger {
505 return Err(Error::LoggerNotInitialized);
506 }
507 let plugin = Self::plugin_builder();
508 let (max_level, log) = Self::acquire_logger(
509 app_handle,
510 self.dispatch,
511 self.rotation_strategy,
512 self.timezone_strategy,
513 self.max_file_size,
514 self.targets,
515 )?;
516
517 Ok((plugin.build(), max_level, log))
518 }
519
520 pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
521 Self::plugin_builder()
522 .setup(move |app_handle, _api| {
523 if !self.is_skip_logger {
524 let (max_level, log) = Self::acquire_logger(
525 app_handle,
526 self.dispatch,
527 self.rotation_strategy,
528 self.timezone_strategy,
529 self.max_file_size,
530 self.targets,
531 )?;
532 attach_logger(max_level, log)?;
533 }
534 Ok(())
535 })
536 .build()
537 }
538}
539
540pub fn attach_logger(
542 max_level: log::LevelFilter,
543 log: Box<dyn log::Log>,
544) -> Result<(), log::SetLoggerError> {
545 log::set_boxed_logger(log)?;
546 log::set_max_level(max_level);
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 let to = dir.as_ref().join(format!(
565 "{}_{}.log",
566 file_name,
567 timezone_strategy
568 .get_now()
569 .format(&time::format_description::parse(
570 "[year]-[month]-[day]_[hour]-[minute]-[second]"
571 )?)?,
572 ));
573 if to.is_file() {
574 let mut to_bak = to.clone();
577 to_bak.set_file_name(format!(
578 "{}.bak",
579 to_bak
580 .file_name()
581 .map(|f| f.to_string_lossy())
582 .unwrap_or_default()
583 ));
584 fs::rename(&to, to_bak)?;
585 }
586 fs::rename(&path, to)?;
587 }
588 RotationStrategy::KeepOne => {
589 fs::remove_file(&path)?;
590 }
591 }
592 }
593 }
594
595 Ok(path)
596}