You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tauri-plugins-workspace/plugins/log/src/lib.rs

460 lines
15 KiB

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use fern::FormatCallback;
use log::{logger, RecordBuilder};
use log::{LevelFilter, Record};
use serde::Serialize;
use serde_repr::{Deserialize_repr, Serialize_repr};
use std::borrow::Cow;
use std::collections::HashMap;
use std::{
fmt::Arguments,
fs::{self, File},
iter::FromIterator,
path::{Path, PathBuf},
};
use tauri::{
plugin::{self, TauriPlugin},
Manager, Runtime,
};
pub use fern;
use time::OffsetDateTime;
#[cfg(target_os = "ios")]
mod ios {
use cocoa::base::id;
use objc::*;
const UTF8_ENCODING: usize = 4;
pub struct NSString(pub id);
impl NSString {
pub fn new(s: &str) -> Self {
// Safety: objc runtime calls are unsafe
NSString(unsafe {
let ns_string: id = msg_send![class!(NSString), alloc];
let ns_string: id = msg_send![ns_string,
initWithBytes:s.as_ptr()
length:s.len()
encoding:UTF8_ENCODING];
// The thing is allocated in rust, the thing must be set to autorelease in rust to relinquish control
// or it can not be released correctly in OC runtime
let _: () = msg_send![ns_string, autorelease];
ns_string
})
}
}
swift_rs::swift!(pub fn tauri_log(
level: u8, message: *const std::ffi::c_void
));
}
const DEFAULT_MAX_FILE_SIZE: u128 = 40000;
const DEFAULT_ROTATION_STRATEGY: RotationStrategy = RotationStrategy::KeepOne;
const DEFAULT_TIMEZONE_STRATEGY: TimezoneStrategy = TimezoneStrategy::UseUtc;
const DEFAULT_LOG_TARGETS: [LogTarget; 2] = [LogTarget::Stdout, LogTarget::LogDir];
/// An enum representing the available verbosity levels of the logger.
///
/// It is very similar to the [`log::Level`], but serializes to unsigned ints instead of strings.
#[derive(Debug, Clone, Deserialize_repr, Serialize_repr)]
#[repr(u16)]
pub enum LogLevel {
/// The "trace" level.
///
/// Designates very low priority, often extremely verbose, information.
Trace = 1,
/// The "debug" level.
///
/// Designates lower priority information.
Debug,
/// The "info" level.
///
/// Designates useful information.
Info,
/// The "warn" level.
///
/// Designates hazardous situations.
Warn,
/// The "error" level.
///
/// Designates very serious errors.
Error,
}
impl From<LogLevel> for log::Level {
fn from(log_level: LogLevel) -> Self {
match log_level {
LogLevel::Trace => log::Level::Trace,
LogLevel::Debug => log::Level::Debug,
LogLevel::Info => log::Level::Info,
LogLevel::Warn => log::Level::Warn,
LogLevel::Error => log::Level::Error,
}
}
}
impl From<log::Level> for LogLevel {
fn from(log_level: log::Level) -> Self {
match log_level {
log::Level::Trace => LogLevel::Trace,
log::Level::Debug => LogLevel::Debug,
log::Level::Info => LogLevel::Info,
log::Level::Warn => LogLevel::Warn,
log::Level::Error => LogLevel::Error,
}
}
}
pub enum RotationStrategy {
KeepAll,
KeepOne,
}
#[derive(Debug, Clone)]
pub enum TimezoneStrategy {
UseUtc,
UseLocal,
}
impl TimezoneStrategy {
pub fn get_now(&self) -> OffsetDateTime {
match self {
TimezoneStrategy::UseUtc => OffsetDateTime::now_utc(),
TimezoneStrategy::UseLocal => {
OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
} // Fallback to UTC since Rust cannot determine local timezone
}
}
}
#[derive(Debug, Serialize, Clone)]
struct RecordPayload {
message: String,
level: LogLevel,
}
/// An enum representing the available targets of the logger.
pub enum LogTarget {
/// Print logs to stdout.
Stdout,
/// Print logs to stderr.
Stderr,
/// Write logs to the given directory.
///
/// The plugin will ensure the directory exists before writing logs.
Folder(PathBuf),
/// Write logs to the OS specififc logs directory.
///
/// ### Platform-specific
///
/// |Platform | Value | Example |
/// | ------- | --------------------------------------------- | ---------------------------------------------- |
/// | Linux | `{configDir}/{bundleIdentifier}` | `/home/alice/.config/com.tauri.dev` |
/// | macOS | `{homeDir}/Library/Logs/{bundleIdentifier}` | `/Users/Alice/Library/Logs/com.tauri.dev` |
/// | Windows | `{configDir}/{bundleIdentifier}` | `C:\Users\Alice\AppData\Roaming\com.tauri.dev` |
LogDir,
/// Forward logs to the webview (via the `log://log` event).
///
/// This requires the webview to subscribe to log events, via this plugins `attachConsole` function.
Webview,
}
#[tauri::command]
fn log(
level: LogLevel,
message: String,
location: Option<&str>,
file: Option<&str>,
line: Option<u32>,
key_values: Option<HashMap<String, String>>,
) {
let location = location.unwrap_or("webview");
let mut builder = RecordBuilder::new();
builder
.level(level.into())
.target(location)
.file(file)
.line(line);
let key_values = key_values.unwrap_or_default();
let mut kv = HashMap::new();
for (k, v) in key_values.iter() {
kv.insert(k.as_str(), v.as_str());
}
builder.key_values(&kv);
logger().log(&builder.args(format_args!("{message}")).build());
}
pub struct Builder {
dispatch: fern::Dispatch,
rotation_strategy: RotationStrategy,
timezone_strategy: TimezoneStrategy,
max_file_size: u128,
targets: Vec<LogTarget>,
}
impl Default for Builder {
fn default() -> Self {
#[cfg(desktop)]
let format =
time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
.unwrap();
let dispatch = fern::Dispatch::new().format(move |out, message, record| {
out.finish(
#[cfg(mobile)]
format_args!("[{}] {}", record.target(), message),
#[cfg(desktop)]
format_args!(
"{}[{}][{}] {}",
DEFAULT_TIMEZONE_STRATEGY.get_now().format(&format).unwrap(),
record.target(),
record.level(),
message
),
)
});
Self {
dispatch,
rotation_strategy: DEFAULT_ROTATION_STRATEGY,
timezone_strategy: DEFAULT_TIMEZONE_STRATEGY,
max_file_size: DEFAULT_MAX_FILE_SIZE,
targets: DEFAULT_LOG_TARGETS.into(),
}
}
}
impl Builder {
pub fn new() -> Self {
Default::default()
}
pub fn rotation_strategy(mut self, rotation_strategy: RotationStrategy) -> Self {
self.rotation_strategy = rotation_strategy;
self
}
pub fn timezone_strategy(mut self, timezone_strategy: TimezoneStrategy) -> Self {
self.timezone_strategy = timezone_strategy.clone();
let format =
time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
.unwrap();
self.dispatch = fern::Dispatch::new().format(move |out, message, record| {
out.finish(format_args!(
"{}[{}][{}] {}",
timezone_strategy.get_now().format(&format).unwrap(),
record.level(),
record.target(),
message
))
});
self
}
pub fn max_file_size(mut self, max_file_size: u128) -> Self {
self.max_file_size = max_file_size;
self
}
pub fn format<F>(mut self, formatter: F) -> Self
where
F: Fn(FormatCallback, &Arguments, &Record) + Sync + Send + 'static,
{
self.dispatch = self.dispatch.format(formatter);
self
}
pub fn level(mut self, level_filter: impl Into<LevelFilter>) -> Self {
self.dispatch = self.dispatch.level(level_filter.into());
self
}
pub fn level_for(mut self, module: impl Into<Cow<'static, str>>, level: LevelFilter) -> Self {
self.dispatch = self.dispatch.level_for(module, level);
self
}
pub fn filter<F>(mut self, filter: F) -> Self
where
F: Fn(&log::Metadata) -> bool + Send + Sync + 'static,
{
self.dispatch = self.dispatch.filter(filter);
self
}
pub fn target(mut self, target: LogTarget) -> Self {
self.targets.push(target);
self
}
pub fn targets(mut self, targets: impl IntoIterator<Item = LogTarget>) -> Self {
self.targets = Vec::from_iter(targets);
self
}
#[cfg(feature = "colored")]
pub fn with_colors(self, colors: fern::colors::ColoredLevelConfig) -> Self {
let format =
time::format_description::parse("[[[year]-[month]-[day]][[[hour]:[minute]:[second]]")
.unwrap();
let timezone_strategy = self.timezone_strategy.clone();
self.format(move |out, message, record| {
out.finish(format_args!(
"{}[{}][{}] {}",
timezone_strategy.get_now().format(&format).unwrap(),
colors.color(record.level()),
record.target(),
message
))
})
}
pub fn build<R: Runtime>(mut self) -> TauriPlugin<R> {
plugin::Builder::new("log")
.invoke_handler(tauri::generate_handler![log])
.setup(move |app_handle, _api| {
let app_name = &app_handle.package_info().name;
// setup targets
for target in &self.targets {
let logger = match target {
#[cfg(target_os = "android")]
LogTarget::Stdout | LogTarget::Stderr => {
fern::Output::call(android_logger::log)
}
#[cfg(target_os = "ios")]
LogTarget::Stdout | LogTarget::Stderr => {
fern::Output::call(move |record| {
let message = format!("{}", record.args());
unsafe {
ios::tauri_log(
match record.level() {
log::Level::Trace | log::Level::Debug => 1,
log::Level::Info => 2,
log::Level::Warn | log::Level::Error => 3,
},
ios::NSString::new(message.as_str()).0 as _,
);
}
})
}
#[cfg(desktop)]
LogTarget::Stdout => std::io::stdout().into(),
#[cfg(desktop)]
LogTarget::Stderr => std::io::stderr().into(),
LogTarget::Folder(path) => {
if !path.exists() {
fs::create_dir_all(path).unwrap();
}
fern::log_file(get_log_file_path(
&path,
app_name,
&self.rotation_strategy,
&self.timezone_strategy,
self.max_file_size,
)?)?
.into()
}
#[cfg(mobile)]
LogTarget::LogDir => continue,
#[cfg(desktop)]
LogTarget::LogDir => {
let path = app_handle.path().app_log_dir().unwrap();
if !path.exists() {
fs::create_dir_all(&path).unwrap();
}
fern::log_file(get_log_file_path(
&path,
app_name,
&self.rotation_strategy,
&self.timezone_strategy,
self.max_file_size,
)?)?
.into()
}
LogTarget::Webview => {
let app_handle = app_handle.clone();
fern::Output::call(move |record| {
let payload = RecordPayload {
message: record.args().to_string(),
level: record.level().into(),
};
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
app_handle.emit_all("log://log", payload).unwrap();
});
})
}
};
self.dispatch = self.dispatch.chain(logger);
}
self.dispatch.apply()?;
Ok(())
})
.build()
}
}
fn get_log_file_path(
dir: &impl AsRef<Path>,
app_name: &str,
rotation_strategy: &RotationStrategy,
timezone_strategy: &TimezoneStrategy,
max_file_size: u128,
) -> plugin::Result<PathBuf> {
let path = dir.as_ref().join(format!("{app_name}.log"));
if path.exists() {
let log_size = File::open(&path)?.metadata()?.len() as u128;
if log_size > max_file_size {
match rotation_strategy {
RotationStrategy::KeepAll => {
let to = dir.as_ref().join(format!(
"{}_{}.log",
app_name,
timezone_strategy
.get_now()
.format(
&time::format_description::parse(
"[year]-[month]-[day]_[hour]-[minute]-[second]"
)
.unwrap()
)
.unwrap(),
));
if to.is_file() {
// designated rotated log file name already exists
// highly unlikely but defensively handle anyway by adding .bak to filename
let mut to_bak = to.clone();
to_bak.set_file_name(format!(
"{}.bak",
to_bak.file_name().unwrap().to_string_lossy()
));
fs::rename(&to, to_bak)?;
}
fs::rename(&path, to)?;
}
RotationStrategy::KeepOne => {
fs::remove_file(&path)?;
}
}
}
}
Ok(path)
}