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}
62
63#[derive(Debug, Clone, Deserialize_repr, Serialize_repr)]
67#[repr(u16)]
68pub enum LogLevel {
69 Trace = 1,
73 Debug,
77 Info,
81 Warn,
85 Error,
89}
90
91impl From<LogLevel> for log::Level {
92 fn from(log_level: LogLevel) -> Self {
93 match log_level {
94 LogLevel::Trace => log::Level::Trace,
95 LogLevel::Debug => log::Level::Debug,
96 LogLevel::Info => log::Level::Info,
97 LogLevel::Warn => log::Level::Warn,
98 LogLevel::Error => log::Level::Error,
99 }
100 }
101}
102
103impl From<log::Level> for LogLevel {
104 fn from(log_level: log::Level) -> Self {
105 match log_level {
106 log::Level::Trace => LogLevel::Trace,
107 log::Level::Debug => LogLevel::Debug,
108 log::Level::Info => LogLevel::Info,
109 log::Level::Warn => LogLevel::Warn,
110 log::Level::Error => LogLevel::Error,
111 }
112 }
113}
114
115pub enum RotationStrategy {
116 KeepAll,
117 KeepOne,
118}
119
120#[derive(Debug, Clone)]
121pub enum TimezoneStrategy {
122 UseUtc,
123 UseLocal,
124}
125
126impl TimezoneStrategy {
127 pub fn get_now(&self) -> OffsetDateTime {
128 match self {
129 TimezoneStrategy::UseUtc => OffsetDateTime::now_utc(),
130 TimezoneStrategy::UseLocal => {
131 OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
132 } }
134 }
135}
136
137#[derive(Debug, Serialize, Clone)]
138struct RecordPayload {
139 message: String,
140 level: LogLevel,
141}
142
143pub enum TargetKind {
145 Stdout,
147 Stderr,
149 Folder {
153 path: PathBuf,
154 file_name: Option<String>,
155 },
156 LogDir { file_name: Option<String> },
166 Webview,
170}
171
172pub struct Target {
174 kind: TargetKind,
175 filters: Vec<Box<Filter>>,
176}
177
178impl Target {
179 #[inline]
180 pub const fn new(kind: TargetKind) -> Self {
181 Self {
182 kind,
183 filters: Vec::new(),
184 }
185 }
186
187 #[inline]
188 pub fn filter<F>(mut self, filter: F) -> Self
189 where
190 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
191 {
192 self.filters.push(Box::new(filter));
193 self
194 }
195}
196
197#[tauri::command]
198fn log(
199 level: LogLevel,
200 message: String,
201 location: Option<&str>,
202 file: Option<&str>,
203 line: Option<u32>,
204 key_values: Option<HashMap<String, String>>,
205) {
206 let level = log::Level::from(level);
207
208 let target = if let Some(location) = location {
209 format!("{WEBVIEW_TARGET}:{location}")
210 } else {
211 WEBVIEW_TARGET.to_string()
212 };
213
214 let mut builder = RecordBuilder::new();
215 builder.level(level).target(&target).file(file).line(line);
216
217 let key_values = key_values.unwrap_or_default();
218 let mut kv = HashMap::new();
219 for (k, v) in key_values.iter() {
220 kv.insert(k.as_str(), v.as_str());
221 }
222 builder.key_values(&kv);
223
224 logger().log(&builder.args(format_args!("{message}")).build());
225}
226
227pub struct Builder {
228 dispatch: fern::Dispatch,
229 rotation_strategy: RotationStrategy,
230 timezone_strategy: TimezoneStrategy,
231 max_file_size: u128,
232 targets: Vec<Target>,
233}
234
235impl Default for Builder {
236 fn default() -> Self {
237 #[cfg(desktop)]
238 let format =
239 time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
240 .unwrap();
241 let dispatch = fern::Dispatch::new().format(move |out, message, record| {
242 out.finish(
243 #[cfg(mobile)]
244 format_args!("[{}] {}", record.target(), message),
245 #[cfg(desktop)]
246 format_args!(
247 "{}[{}][{}] {}",
248 DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(),
249 record.target(),
250 record.level(),
251 message
252 ),
253 )
254 });
255 Self {
256 dispatch,
257 rotation_strategy: DEFAULT_ROTATION_STRATEGY,
258 timezone_strategy: DEFAULT_TIMEZONE_STRATEGY,
259 max_file_size: DEFAULT_MAX_FILE_SIZE,
260 targets: DEFAULT_LOG_TARGETS.into(),
261 }
262 }
263}
264
265impl Builder {
266 pub fn new() -> Self {
267 Default::default()
268 }
269
270 pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self {
271 self.rotation_strategy = rotation_strategy;
272 self
273 }
274
275 pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self {
276 self.timezone_strategy = timezone_strategy.clone();
277
278 let format =
279 time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
280 .unwrap();
281 self.dispatch = self.dispatch.format(move |out, message, record| {
282 out.finish(format_args!(
283 "{}[{}][{}] {}",
284 timezone_strategy.get_now().format(&format).unwrap(),
285 record.level(),
286 record.target(),
287 message
288 ))
289 });
290 self
291 }
292
293 pub fn max_file_size(mut self, max_file_size: u128) -> Self {
294 self.max_file_size = max_file_size;
295 self
296 }
297
298 pub fn format<F>(mut self, formatter: F) -> Self
299 where
300 F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static,
301 {
302 self.dispatch = self.dispatch.format(formatter);
303 self
304 }
305
306 pub fn level(mut self, level_filter: impl Into<LevelFilter>) -> Self {
307 self.dispatch = self.dispatch.level(level_filter.into());
308 self
309 }
310
311 pub fn level_for(mut self, module: impl Into<Cow<'static, str>>, level: LevelFilter) -> Self {
312 self.dispatch = self.dispatch.level_for(module, level);
313 self
314 }
315
316 pub fn filter<F>(mut self, filter: F) -> Self
317 where
318 F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
319 {
320 self.dispatch = self.dispatch.filter(filter);
321 self
322 }
323
324 pub fn clear_targets(mut self) -> Self {
326 self.targets.clear();
327 self
328 }
329
330 pub fn target(mut self, target: Target) -> Self {
338 self.targets.push(target);
339 self
340 }
341
342 pub fn targets(mut self, targets: impl IntoIterator<Item = Target>) -> Self {
355 self.targets = Vec::from_iter(targets);
356 self
357 }
358
359 #[cfg(feature = "colored")]
360 pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self {
361 let format =
362 time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
363 .unwrap();
364
365 let timezone_strategy = self.timezone_strategy.clone();
366 self.format(move |out, message, record| {
367 out.finish(format_args!(
368 "{}[{}][{}] {}",
369 timezone_strategy.get_now().format(&format).unwrap(),
370 colors.color(record.level()),
371 record.target(),
372 message
373 ))
374 })
375 }
376
377 fn acquire_logger<R: Runtime>(
378 app_handle: &AppHandle<R>,
379 mut dispatch: fern::Dispatch,
380 rotation_strategy: RotationStrategy,
381 timezone_strategy: TimezoneStrategy,
382 max_file_size: u128,
383 targets: Vec<Target>,
384 ) -> Result<(log::LevelFilter, Box<dyn log::Log>), Error> {
385 let app_name = &app_handle.package_info().name;
386
387 for target in targets {
389 let mut target_dispatch = fern::Dispatch::new();
390 for filter in target.filters {
391 target_dispatch = target_dispatch.filter(filter);
392 }
393
394 let logger = match target.kind {
395 #[cfg(target_os = "android")]
396 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(android_logger::log),
397 #[cfg(target_os = "ios")]
398 TargetKind::Stdout | TargetKind::Stderr => fern::Output::call(move |record| {
399 let message = format!("{}", record.args());
400 unsafe {
401 ios::tauri_log(
402 match record.level() {
403 log::Level::Trace | log::Level::Debug => 1,
404 log::Level::Info => 2,
405 log::Level::Warn | log::Level::Error => 3,
406 },
407 objc2::rc::Retained::autorelease_ptr(
411 objc2_foundation::NSString::from_str(message.as_str()),
412 ) as _,
413 );
414 }
415 }),
416 #[cfg(desktop)]
417 TargetKind::Stdout => std::io::stdout().into(),
418 #[cfg(desktop)]
419 TargetKind::Stderr => std::io::stderr().into(),
420 TargetKind::Folder { path, file_name } => {
421 if !path.exists() {
422 fs::create_dir_all(&path)?;
423 }
424
425 fern::log_file(get_log_file_path(
426 &path,
427 file_name.as_deref().unwrap_or(app_name),
428 &rotation_strategy,
429 &timezone_strategy,
430 max_file_size,
431 )?)?
432 .into()
433 }
434 #[cfg(mobile)]
435 TargetKind::LogDir { .. } => continue,
436 #[cfg(desktop)]
437 TargetKind::LogDir { file_name } => {
438 let path = app_handle.path().app_log_dir()?;
439 if !path.exists() {
440 fs::create_dir_all(&path)?;
441 }
442
443 fern::log_file(get_log_file_path(
444 &path,
445 file_name.as_deref().unwrap_or(app_name),
446 &rotation_strategy,
447 &timezone_strategy,
448 max_file_size,
449 )?)?
450 .into()
451 }
452 TargetKind::Webview => {
453 let app_handle = app_handle.clone();
454
455 fern::Output::call(move |record| {
456 let payload = RecordPayload {
457 message: record.args().to_string(),
458 level: record.level().into(),
459 };
460 let app_handle = app_handle.clone();
461 tauri::async_runtime::spawn(async move {
462 let _ = app_handle.emit("log://log", payload);
463 });
464 })
465 }
466 };
467 target_dispatch = target_dispatch.chain(logger);
468
469 dispatch = dispatch.chain(target_dispatch);
470 }
471
472 Ok(dispatch.into_log())
473 }
474
475 fn plugin_builder<R: Runtime>() -> plugin::Builder<R> {
476 plugin::Builder::new("log").invoke_handler(tauri::generate_handler![log])
477 }
478
479 #[allow(clippy::type_complexity)]
480 pub fn split<R: Runtime>(
481 self,
482 app_handle: &AppHandle<R>,
483 ) -> Result<(TauriPlugin<R>, log::LevelFilter, Box<dyn log::Log>), Error> {
484 let plugin = Self::plugin_builder();
485 let (max_level, log) = Self::acquire_logger(
486 app_handle,
487 self.dispatch,
488 self.rotation_strategy,
489 self.timezone_strategy,
490 self.max_file_size,
491 self.targets,
492 )?;
493
494 Ok((plugin.build(), max_level, log))
495 }
496
497 pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
498 Self::plugin_builder()
499 .setup(move |app_handle, _api| {
500 let (max_level, log) = Self::acquire_logger(
501 app_handle,
502 self.dispatch,
503 self.rotation_strategy,
504 self.timezone_strategy,
505 self.max_file_size,
506 self.targets,
507 )?;
508
509 attach_logger(max_level, log)?;
510
511 Ok(())
512 })
513 .build()
514 }
515}
516
517pub fn attach_logger(
519 max_level: log::LevelFilter,
520 log: Box<dyn log::Log>,
521) -> Result<(), log::SetLoggerError> {
522 log::set_boxed_logger(log)?;
523 log::set_max_level(max_level);
524 Ok(())
525}
526
527fn get_log_file_path(
528 dir: &impl AsRef<Path>,
529 file_name: &str,
530 rotation_strategy: &RotationStrategy,
531 timezone_strategy: &TimezoneStrategy,
532 max_file_size: u128,
533) -> Result<PathBuf, Error> {
534 let path = dir.as_ref().join(format!("{file_name}.log"));
535
536 if path.exists() {
537 let log_size = File::open(&path)?.metadata()?.len() as u128;
538 if log_size > max_file_size {
539 match rotation_strategy {
540 RotationStrategy::KeepAll => {
541 let to = dir.as_ref().join(format!(
542 "{}_{}.log",
543 file_name,
544 timezone_strategy
545 .get_now()
546 .format(&time::format_description::parse(
547 "[year]-[month]-[day]_[hour]-[minute]-[second]"
548 )?)?,
549 ));
550 if to.is_file() {
551 let mut to_bak = to.clone();
554 to_bak.set_file_name(format!(
555 "{}.bak",
556 to_bak
557 .file_name()
558 .map(|f| f.to_string_lossy())
559 .unwrap_or_default()
560 ));
561 fs::rename(&to, to_bak)?;
562 }
563 fs::rename(&path, to)?;
564 }
565 RotationStrategy::KeepOne => {
566 fs::remove_file(&path)?;
567 }
568 }
569 }
570 }
571
572 Ok(path)
573}