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