diff --git a/windows/tauri/design-qa-artifacts/windows-log-settings.png b/windows/tauri/design-qa-artifacts/windows-log-settings.png new file mode 100644 index 000000000..f0415fdba Binary files /dev/null and b/windows/tauri/design-qa-artifacts/windows-log-settings.png differ diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 8fc75becd..69368c939 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -546,8 +546,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -2608,10 +2610,12 @@ dependencies = [ name = "lithe-windows" version = "0.3.0" dependencies = [ + "chrono", "keyring", "lithe-core", "lithe-project", "lithe-terminal", + "regex", "serde", "serde_json", "tauri", @@ -2630,6 +2634,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "url", + "windows-sys 0.61.2", ] [[package]] diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index 97e987512..6f52ca4d8 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -9,12 +9,14 @@ license = "Apache-2.0" tauri-build = { version = "2", features = [] } [dependencies] +chrono = { version = "0.4", features = ["serde"] } lithe-core = { path = "../../../rust/lithe-core" } lithe-project = { path = "../crates/project" } lithe-terminal = { path = "../crates/terminal" } keyring = { version = "3.6.3", features = ["windows-native"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +regex = "1" tauri = { version = "2", features = ["common-controls-v6", "protocol-asset"] } tauri-plugin-clipboard-manager = "2" tauri-plugin-deep-link = "2" @@ -30,6 +32,7 @@ tauri-plugin-store = "2" tauri-plugin-updater = "2" tauri-plugin-window-state = "2" url = "2" +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } [profile.release] lto = "thin" diff --git a/windows/tauri/src-tauri/src/host.rs b/windows/tauri/src-tauri/src/host.rs index 7f8c3cf26..3910d075f 100644 --- a/windows/tauri/src-tauri/src/host.rs +++ b/windows/tauri/src-tauri/src/host.rs @@ -410,19 +410,6 @@ mod tests { } } -#[tauri::command] -pub fn frontend_trace(level: String, scope: String, message: String, payload: Option) { - eprintln!( - "[frontend][{level}][{scope}] {message} {}", - payload.unwrap_or(Value::Null) - ); -} - -#[tauri::command] -pub fn record_startup_milestone(milestone: String) { - eprintln!("[startup] {milestone}"); -} - #[tauri::command] pub fn get_system_theme(window: WebviewWindow) -> String { match window.theme() { diff --git a/windows/tauri/src-tauri/src/logging.rs b/windows/tauri/src-tauri/src/logging.rs new file mode 100644 index 000000000..67a97b8d7 --- /dev/null +++ b/windows/tauri/src-tauri/src/logging.rs @@ -0,0 +1,2681 @@ +//! Windows-owned application logging, retention, diagnostics, and settings commands. + +use chrono::{DateTime, Duration, FixedOffset, Local}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, VecDeque}; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; +use std::sync::{mpsc, Arc, Condvar, Mutex, RwLock}; +use std::thread; +use std::time::Duration as StdDuration; +use tauri::{AppHandle, Emitter, Manager, State}; +use tauri_plugin_opener::OpenerExt; + +const LOG_CONFIG_FILE: &str = "lithe-log-settings.json"; +const CUSTOM_LOG_SUBDIRECTORY: [&str; 2] = ["Lithe", "logs"]; +const MAX_LOG_FILE_BYTES: u64 = 10 * 1024 * 1024; +const MAX_LOG_FILES_PER_DAY: usize = 5; +const MAX_LOG_LINE_BYTES: usize = 4 * 1024; +const MAX_LOG_READ_BYTES: u64 = 2 * 1024 * 1024; +const LOG_RETENTION_HOURS: i64 = 30 * 24; +const LOG_QUEUE_CAPACITY: usize = 8_000; +const LOG_QUEUE_NORMAL_CAPACITY: usize = 6_000; +const DIRECTORY_OPERATION_TIMEOUT: StdDuration = StdDuration::from_secs(2); +const WRITER_CONTROL_TIMEOUT: StdDuration = StdDuration::from_secs(10); +const WRITER_FLUSH_INTERVAL: StdDuration = StdDuration::from_secs(1); +const TRUNCATED_LOG_NOTICE: &str = + "[Earlier log content omitted; open the log directory for the complete file.]\n"; +const CONTROL_PENDING: u8 = 0; +const CONTROL_EXECUTING: u8 = 1; +const CONTROL_CANCELLED: u8 = 2; +const CONTROL_COMPLETE: u8 = 3; + +thread_local! { + static IS_LOG_WRITER_THREAD: std::cell::Cell = const { std::cell::Cell::new(false) }; + static PANIC_HOOK_ACTIVE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +struct PanicHookGuard; + +impl PanicHookGuard { + fn enter() -> Option { + PANIC_HOOK_ACTIVE.with(|active| { + if active.replace(true) { + None + } else { + Some(Self) + } + }) + } +} + +impl Drop for PanicHookGuard { + fn drop(&mut self) { + PANIC_HOOK_ACTIVE.with(|active| active.set(false)); + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum LogLevel { + Debug, + Info, + Warn, + Error, +} + +impl LogLevel { + fn parse(value: &str) -> Self { + match value.to_ascii_lowercase().as_str() { + "debug" | "trace" => Self::Debug, + "warn" | "warning" => Self::Warn, + "error" | "fatal" => Self::Error, + _ => Self::Info, + } + } + + fn label(self) -> &'static str { + match self { + Self::Debug => "DEBUG", + Self::Info => "INFO", + Self::Warn => "WARN", + Self::Error => "ERROR", + } + } +} + +#[derive(Clone, Debug)] +struct PreparedEvent { + timestamp: DateTime, + level: LogLevel, + scope: String, + message: String, + fields: BTreeMap, + key: String, +} + +impl PreparedEvent { + fn new( + level: LogLevel, + scope: impl Into, + message: impl Into, + fields: BTreeMap, + sanitizer: &LogSanitizer, + ) -> Self { + let scope = sanitizer.sanitize_text(&scope.into()); + let message = sanitizer.sanitize_free_text(&message.into()); + let fields = fields + .into_iter() + .map(|(key, value)| { + let key = snake_case_key(&key); + let value = if is_sensitive_key(&key) { + "".to_string() + } else { + sanitizer.sanitize_field(&key, &value) + }; + (key, value) + }) + .collect::>(); + let key = format!("{}|{}|{}|{:?}", level.label(), scope, message, fields); + Self { + timestamp: Local::now().fixed_offset(), + level, + scope, + message, + fields, + key, + } + } +} + +#[derive(Debug)] +struct QueuedEvent { + event: PreparedEvent, + repeated: u64, +} + +#[derive(Default)] +struct DroppedLines { + debug: u64, + info: u64, +} + +struct QueueState { + events: VecDeque, + controls: VecDeque, + dropped: DroppedLines, + write_failures: u64, + shutdown: bool, +} + +impl Default for QueueState { + fn default() -> Self { + Self { + events: VecDeque::new(), + controls: VecDeque::new(), + dropped: DroppedLines::default(), + write_failures: 0, + shutdown: false, + } + } +} + +enum Control { + Switch { + writer: ActiveWriter, + previous_path: PathBuf, + configured_path: Option, + fallback_reason: Option, + ticket: ControlTicket, + reply: mpsc::Sender>, + }, + Clear { + ticket: ControlTicket, + reply: mpsc::Sender>, + }, + Flush { + reply: mpsc::Sender<()>, + }, + Shutdown { + reply: mpsc::Sender<()>, + }, +} + +#[derive(Clone)] +struct ControlTicket { + state: Arc, +} + +impl ControlTicket { + fn new() -> Self { + Self { + state: Arc::new(AtomicU8::new(CONTROL_PENDING)), + } + } + + fn begin(&self) -> bool { + self.state + .compare_exchange( + CONTROL_PENDING, + CONTROL_EXECUTING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + fn cancel(&self) -> bool { + self.state + .compare_exchange( + CONTROL_PENDING, + CONTROL_CANCELLED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + fn complete(&self) { + self.state.store(CONTROL_COMPLETE, Ordering::Release); + } + + fn is_executing_or_complete(&self) -> bool { + matches!( + self.state.load(Ordering::Acquire), + CONTROL_EXECUTING | CONTROL_COMPLETE + ) + } +} + +fn wait_for_control( + receiver: mpsc::Receiver, + ticket: &ControlTicket, + operation: &str, +) -> Result { + wait_for_control_timeout(receiver, ticket, operation, WRITER_CONTROL_TIMEOUT) +} + +fn wait_for_control_timeout( + receiver: mpsc::Receiver, + ticket: &ControlTicket, + operation: &str, + timeout: StdDuration, +) -> Result { + match receiver.recv_timeout(timeout) { + Ok(result) => Ok(result), + Err(mpsc::RecvTimeoutError::Disconnected) => { + Err(format!("Log writer stopped while {operation}")) + } + Err(mpsc::RecvTimeoutError::Timeout) => { + if ticket.cancel() { + return Err(format!("Timed out while {operation}")); + } + if ticket.is_executing_or_complete() { + // Once execution begins, the operation must reach one definitive + // result; returning early would allow persisted and runtime paths to diverge. + receiver + .recv() + .map_err(|_| format!("Log writer stopped while {operation}")) + } else { + Err(format!("Timed out while {operation}")) + } + } + } +} + +struct QueuedControl { + control: Control, + pending_events: usize, +} + +#[derive(Clone, Debug, Serialize)] +pub struct LogSettingsSnapshot { + pub default_path: String, + pub configured_path: Option, + pub effective_path: String, + pub fallback_reason: Option, + pub diagnostic_enabled: bool, +} + +#[derive(Clone, Debug)] +struct RuntimeState { + default_path: PathBuf, + configured_path: Option, + effective_path: PathBuf, + fallback_reason: Option, + diagnostic_enabled: bool, + active_file_path: PathBuf, +} + +impl RuntimeState { + fn snapshot(&self) -> LogSettingsSnapshot { + LogSettingsSnapshot { + default_path: display_path(&self.default_path), + configured_path: self.configured_path.as_ref().map(|path| display_path(path)), + effective_path: display_path(&self.effective_path), + fallback_reason: self.fallback_reason.clone(), + diagnostic_enabled: self.diagnostic_enabled, + } + } +} + +struct SharedState { + app: Option, + queue: Mutex, + queue_ready: Condvar, + writer: Mutex, + runtime: RwLock, + panic_path: RwLock, + writer_alive: AtomicBool, + sanitizer: LogSanitizer, +} + +#[derive(Clone, Debug, Serialize)] +pub struct LogDirectoryChangeResult { + pub settings: LogSettingsSnapshot, + pub previous_custom_path: Option, + pub previous_log_bytes: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ClearLogResult { + pub deleted_files: usize, + pub freed_bytes: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct LitheLogFileResponse { + pub path: String, + pub content: String, + pub target_line: usize, + pub truncated: bool, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +struct HostLogConfig { + custom_root: Option, +} + +#[derive(Default)] +struct StartupNotes { + config_warning: Option, + cleanup_warnings: Vec, +} + +pub struct LogManager { + shared: Arc, + config_path: PathBuf, + session_id: String, + config_update: Mutex<()>, + pending_previous_custom: Mutex>, + degraded_reason: Option, +} + +impl LogManager { + pub fn initialize(app: &AppHandle) -> Result, String> { + let default_path = app + .path() + .app_log_dir() + .map_err(|error| format!("Unable to resolve the default log directory: {error}"))?; + let config_directory = app.path().app_config_dir().map_err(|error| { + format!("Unable to resolve the log configuration directory: {error}") + })?; + let config_path = config_directory.join(LOG_CONFIG_FILE); + fs::create_dir_all(&default_path) + .map_err(|error| format!("Unable to create the default log directory: {error}"))?; + + let (config, config_warning) = read_host_config(&config_path); + let configured_path = config + .custom_root + .as_deref() + .map(PathBuf::from) + .map(|root| derive_custom_log_path(&root)); + let (effective_path, fallback_reason) = match configured_path.as_ref() { + Some(path) => match probe_directory_with_timeout(path.clone()) { + Ok(()) => (path.clone(), None), + Err(reason) => (default_path.clone(), Some(reason)), + }, + None => (default_path.clone(), None), + }; + + let mut notes = StartupNotes { + config_warning, + cleanup_warnings: Vec::new(), + }; + for path in unique_paths([default_path.clone(), effective_path.clone()]) { + match cleanup_expired_logs_with_timeout(path.clone(), None) { + Ok(warnings) => notes.cleanup_warnings.extend(warnings), + Err(reason) => notes + .cleanup_warnings + .push(format!("directory={} reason={reason}", display_path(&path))), + } + } + + let session_id = create_session_id(); + let writer = ActiveWriter::open(effective_path.clone(), session_id.clone())?; + let active_file_path = writer.path().to_path_buf(); + let panic_path = panic_sidecar_path(&effective_path, &session_id); + let shared = Arc::new(SharedState { + app: Some(app.clone()), + queue: Mutex::new(QueueState::default()), + queue_ready: Condvar::new(), + writer: Mutex::new(writer), + runtime: RwLock::new(RuntimeState { + default_path, + configured_path, + effective_path, + fallback_reason: fallback_reason.clone(), + diagnostic_enabled: false, + active_file_path, + }), + panic_path: RwLock::new(panic_path), + writer_alive: AtomicBool::new(true), + sanitizer: LogSanitizer::new(), + }); + + let manager = Arc::new(Self { + shared: shared.clone(), + config_path, + session_id, + config_update: Mutex::new(()), + pending_previous_custom: Mutex::new(None), + degraded_reason: None, + }); + spawn_writer(shared, manager.session_id.clone())?; + manager.install_panic_hook(); + + let mut start_fields = BTreeMap::new(); + start_fields.insert("version".into(), app.package_info().version.to_string()); + start_fields.insert("platform".into(), "windows".into()); + manager.emit(LogLevel::Info, "session", "session started", start_fields); + if let Some(reason) = fallback_reason { + let snapshot = manager.snapshot(); + manager.emit( + LogLevel::Warn, + "log.writer", + "fallback_to_default", + BTreeMap::from([ + ("reason".into(), reason), + ( + "configured_dir".into(), + snapshot.configured_path.unwrap_or_default(), + ), + ("effective_dir".into(), snapshot.effective_path), + ]), + ); + } + if let Some(warning) = notes.config_warning { + manager.emit( + LogLevel::Warn, + "log.config", + "invalid log configuration; using defaults", + BTreeMap::from([("details".into(), warning)]), + ); + } + for warning in notes.cleanup_warnings { + manager.emit( + LogLevel::Warn, + "log.cleanup", + "cleanup skipped", + BTreeMap::from([("details".into(), warning)]), + ); + } + let previous_panics = + count_previous_panic_sidecars(&manager.effective_path(), &manager.session_id); + if previous_panics > 0 { + manager.emit( + LogLevel::Warn, + "log.writer", + "previous session panic logs found", + BTreeMap::from([( + "previous_session_panics".into(), + previous_panics.to_string(), + )]), + ); + } + Ok(manager) + } + + pub fn degraded(app: &AppHandle, reason: String) -> Arc { + let default_path = app + .path() + .app_log_dir() + .unwrap_or_else(|_| std::env::temp_dir().join("Lithe").join("logs")); + let config_path = app + .path() + .app_config_dir() + .unwrap_or_else(|_| std::env::temp_dir().join("Lithe")) + .join(LOG_CONFIG_FILE); + Self::degraded_with_paths(default_path, config_path, reason) + } + + fn degraded_with_paths( + default_path: PathBuf, + config_path: PathBuf, + reason: String, + ) -> Arc { + let session_id = create_session_id(); + let unavailable_path = default_path.join("lithe.unavailable.log"); + let shared = Arc::new(SharedState { + app: None, + queue: Mutex::new(QueueState { + shutdown: true, + ..QueueState::default() + }), + queue_ready: Condvar::new(), + writer: Mutex::new(ActiveWriter::disabled( + default_path.clone(), + session_id.clone(), + )), + runtime: RwLock::new(RuntimeState { + default_path: default_path.clone(), + configured_path: None, + effective_path: default_path.clone(), + fallback_reason: Some("logging_unavailable".into()), + diagnostic_enabled: false, + active_file_path: unavailable_path, + }), + panic_path: RwLock::new(PathBuf::new()), + writer_alive: AtomicBool::new(false), + sanitizer: LogSanitizer::new(), + }); + Arc::new(Self { + shared, + config_path, + session_id, + config_update: Mutex::new(()), + pending_previous_custom: Mutex::new(None), + degraded_reason: Some(reason), + }) + } + + pub fn snapshot(&self) -> LogSettingsSnapshot { + self.shared + .runtime + .read() + .map(|state| state.snapshot()) + .unwrap_or_else(|poisoned| poisoned.into_inner().snapshot()) + } + + fn effective_path(&self) -> PathBuf { + self.shared + .runtime + .read() + .map(|state| state.effective_path.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().effective_path.clone()) + } + + fn emit( + &self, + level: LogLevel, + scope: impl Into, + message: impl Into, + fields: BTreeMap, + ) { + let diagnostic_enabled = self + .shared + .runtime + .read() + .map(|state| state.diagnostic_enabled) + .unwrap_or(false); + if level == LogLevel::Debug && !diagnostic_enabled { + return; + } + let event = PreparedEvent::new(level, scope, message, fields, &self.shared.sanitizer); + if let Some(reason) = &self.degraded_reason { + let reason = self.shared.sanitizer.sanitize_free_text(reason); + eprint!( + "[logging unavailable: {reason}] {}", + format_event_line(&event, &self.session_id) + ); + return; + } + enqueue_event(&self.shared, event); + } + + pub fn emit_json(&self, level: &str, scope: String, message: String, payload: Option) { + let fields = payload_fields(payload); + self.emit(LogLevel::parse(level), scope, message, fields); + } + + pub fn set_diagnostic_enabled(&self, enabled: bool) -> LogSettingsSnapshot { + if let Ok(mut state) = self.shared.runtime.write() { + state.diagnostic_enabled = enabled; + } + self.emit( + LogLevel::Info, + "log.settings", + if enabled { + "diagnostic logging enabled" + } else { + "diagnostic logging disabled" + }, + BTreeMap::new(), + ); + self.snapshot() + } + + pub fn set_custom_root( + &self, + custom_root: Option, + ) -> Result { + self.ensure_available()?; + let _update_guard = self + .config_update + .lock() + .map_err(|_| "Log configuration update is unavailable".to_string())?; + let target_path = custom_root + .as_ref() + .map(|root| derive_custom_log_path(root)) + .unwrap_or_else(|| { + self.shared + .runtime + .read() + .map(|state| state.default_path.clone()) + .unwrap_or_default() + }); + probe_directory_with_timeout(target_path.clone()) + .map_err(|reason| format!("Log directory is not writable: {reason}"))?; + + let previous_configured_path = self + .shared + .runtime + .read() + .map(|state| state.configured_path.clone()) + .unwrap_or_default(); + let current_effective_path = self.effective_path(); + if previous_configured_path.as_ref() == custom_root.as_ref().map(|_| &target_path) + && current_effective_path == target_path + { + return Ok(LogDirectoryChangeResult { + settings: self.snapshot(), + previous_custom_path: None, + previous_log_bytes: 0, + }); + } + + let prepared_writer = ActiveWriter::open(target_path.clone(), self.session_id.clone())?; + let previous_config = read_host_config(&self.config_path).0; + let next_config = HostLogConfig { + custom_root: custom_root.as_ref().map(|path| display_path(path)), + }; + if let Err(error) = write_host_config(&self.config_path, &next_config) { + cleanup_aborted_writer(prepared_writer, custom_root.as_deref()); + return Err(error); + } + + if let Err(error) = + self.switch_writer(prepared_writer, custom_root.as_ref().map(|_| target_path)) + { + let _ = write_host_config(&self.config_path, &previous_config); + return Err(error); + } + + let previous_log_bytes = previous_configured_path + .as_ref() + .map(|path| managed_log_stats(path).1) + .unwrap_or(0); + if let Ok(mut pending) = self.pending_previous_custom.lock() { + *pending = previous_configured_path.clone(); + } + Ok(LogDirectoryChangeResult { + settings: self.snapshot(), + previous_custom_path: previous_configured_path + .as_ref() + .map(|path| display_path(path)), + previous_log_bytes, + }) + } + + fn switch_writer( + &self, + writer: ActiveWriter, + configured_path: Option, + ) -> Result<(), String> { + let (reply_tx, reply_rx) = mpsc::channel(); + let ticket = ControlTicket::new(); + self.push_control(Control::Switch { + writer, + previous_path: self.effective_path(), + configured_path, + fallback_reason: None, + ticket: ticket.clone(), + reply: reply_tx, + })?; + wait_for_control(reply_rx, &ticket, "switching the log writer")? + } + + pub fn resolve_previous_custom_cleanup(&self, delete: bool) -> Result { + self.ensure_available()?; + let path = self + .pending_previous_custom + .lock() + .map_err(|_| "Previous log directory state is unavailable".to_string())? + .take(); + if !delete { + return Ok(ClearLogResult { + deleted_files: 0, + freed_bytes: 0, + }); + } + path.map(|path| delete_managed_logs(&path, None)) + .transpose()? + .ok_or_else(|| "There is no previous custom log directory to clean".to_string()) + } + + pub fn clear_current_logs(&self) -> Result { + self.ensure_available()?; + let (reply_tx, reply_rx) = mpsc::channel(); + let ticket = ControlTicket::new(); + self.push_control(Control::Clear { + ticket: ticket.clone(), + reply: reply_tx, + })?; + wait_for_control(reply_rx, &ticket, "clearing logs")? + } + + pub fn read_current_log(&self) -> Result { + self.ensure_available()?; + self.flush(); + let path = self + .shared + .runtime + .read() + .map_err(|_| "Log runtime state is unavailable".to_string())? + .active_file_path + .clone(); + let mut file = + File::open(&path).map_err(|error| format!("Unable to open the log file: {error}"))?; + let length = file + .metadata() + .map_err(|error| format!("Unable to inspect the log file: {error}"))? + .len(); + let truncated = length > MAX_LOG_READ_BYTES; + if truncated { + file.seek(SeekFrom::End(-(MAX_LOG_READ_BYTES as i64))) + .map_err(|error| format!("Unable to seek in the log file: {error}"))?; + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|error| format!("Unable to read the log file: {error}"))?; + if truncated { + if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') { + bytes.drain(..=newline); + } + } + let body = String::from_utf8_lossy(&bytes); + let content = if truncated { + format!("{TRUNCATED_LOG_NOTICE}{body}") + } else { + body.into_owned() + }; + let target_line = content.lines().count().max(1); + Ok(LitheLogFileResponse { + path: display_path(&path), + content, + target_line, + truncated, + }) + } + + fn flush(&self) { + if self.degraded_reason.is_some() { + return; + } + let (reply_tx, reply_rx) = mpsc::channel(); + if self + .push_control(Control::Flush { reply: reply_tx }) + .is_ok() + { + let _ = reply_rx.recv_timeout(WRITER_CONTROL_TIMEOUT); + } + } + + pub fn shutdown(&self) { + self.emit( + LogLevel::Info, + "session", + "session ended", + BTreeMap::from([("reason".into(), "normal".into())]), + ); + let (reply_tx, reply_rx) = mpsc::channel(); + if self + .push_control(Control::Shutdown { reply: reply_tx }) + .is_ok() + { + let _ = reply_rx.recv_timeout(WRITER_CONTROL_TIMEOUT); + } + } + + fn ensure_available(&self) -> Result<(), String> { + self.degraded_reason + .as_ref() + .map(|reason| Err(format!("Application file logging is unavailable: {reason}"))) + .unwrap_or(Ok(())) + } + + fn push_control(&self, control: Control) -> Result<(), String> { + let mut queue = self + .shared + .queue + .lock() + .map_err(|_| "Log writer queue is unavailable".to_string())?; + if queue.shutdown { + return Err("Log writer has stopped".to_string()); + } + let pending_events = queue.events.len(); + queue.controls.push_back(QueuedControl { + control, + pending_events, + }); + self.shared.queue_ready.notify_one(); + Ok(()) + } + + fn install_panic_hook(self: &Arc) { + let weak = Arc::downgrade(self); + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if let Some(manager) = weak.upgrade() { + manager.write_panic(info); + } + previous(info); + })); + } + + fn write_panic(&self, info: &std::panic::PanicHookInfo<'_>) { + let Some(_guard) = PanicHookGuard::enter() else { + return; + }; + let message = info + .payload() + .downcast_ref::<&str>() + .copied() + .or_else(|| info.payload().downcast_ref::().map(String::as_str)) + .unwrap_or("Rust panic"); + let mut fields = BTreeMap::new(); + if let Some(location) = info.location() { + fields.insert( + "location".into(), + format!( + "{}:{}:{}", + location.file(), + location.line(), + location.column() + ), + ); + } + let backtrace = std::backtrace::Backtrace::force_capture() + .to_string() + .lines() + .filter(|line| !line.trim().is_empty()) + .take(8) + .collect::>() + .join("\n"); + if !backtrace.is_empty() { + fields.insert("backtrace".into(), backtrace); + } + self.write_panic_event_unchecked(message, fields); + } + + #[cfg(test)] + fn write_panic_event(&self, message: &str, fields: BTreeMap) { + let Some(_guard) = PanicHookGuard::enter() else { + return; + }; + self.write_panic_event_unchecked(message, fields); + } + + fn write_panic_event_unchecked(&self, message: &str, fields: BTreeMap) { + let event = PreparedEvent::new( + LogLevel::Error, + "runtime.rust.panic", + message, + fields, + &self.shared.sanitizer, + ); + let line = format_event_line(&event, &self.session_id); + let is_writer_thread = IS_LOG_WRITER_THREAD.with(|flag| flag.get()); + if !is_writer_thread && self.shared.writer_alive.load(Ordering::Acquire) { + if let Ok(mut writer) = self.shared.writer.try_lock() { + if writer.write_preformatted(&line, true).is_ok() { + return; + } + } + } + let panic_path = self + .shared + .panic_path + .read() + .map(|path| path.clone()) + .unwrap_or_default(); + if panic_path.as_os_str().is_empty() { + return; + } + if let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open(panic_path) + { + let _ = file.write_all(line.as_bytes()); + let _ = file.flush(); + } + } +} + +fn spawn_writer(shared: Arc, session_id: String) -> Result<(), String> { + thread::Builder::new() + .name("lithe-log-writer".into()) + .spawn(move || writer_loop(shared, session_id)) + .map(|_| ()) + .map_err(|error| format!("Unable to start the application log writer: {error}")) +} + +fn writer_loop(shared: Arc, session_id: String) { + IS_LOG_WRITER_THREAD.with(|flag| flag.set(true)); + loop { + let work = { + let mut queue = match shared.queue.lock() { + Ok(queue) => queue, + Err(poisoned) => poisoned.into_inner(), + }; + if queue.controls.is_empty() && queue.events.is_empty() && !queue.shutdown { + let result = shared + .queue_ready + .wait_timeout(queue, WRITER_FLUSH_INTERVAL); + queue = match result { + Ok((queue, _)) => queue, + Err(poisoned) => poisoned.into_inner().0, + }; + } + take_writer_work(&mut queue) + }; + + match work { + WriterWork::Event(queued, dropped, write_failures) => { + if dropped.debug + dropped.info > 0 { + let event = PreparedEvent::new( + LogLevel::Warn, + "log.writer", + "log lines dropped", + BTreeMap::from([ + ("debug".into(), dropped.debug.to_string()), + ("info".into(), dropped.info.to_string()), + ]), + &shared.sanitizer, + ); + write_event(&shared, &event, &session_id, true); + } + if write_failures > 0 { + let event = PreparedEvent::new( + LogLevel::Warn, + "log.writer", + "write failures recovered", + BTreeMap::from([("write_failures".into(), write_failures.to_string())]), + &shared.sanitizer, + ); + write_event(&shared, &event, &session_id, true); + } + write_event( + &shared, + &queued.event, + &session_id, + queued.event.level >= LogLevel::Warn, + ); + if queued.repeated > 0 { + let event = PreparedEvent::new( + queued.event.level, + "log.writer", + "last message repeated", + BTreeMap::from([("count".into(), queued.repeated.to_string())]), + &shared.sanitizer, + ); + write_event( + &shared, + &event, + &session_id, + queued.event.level >= LogLevel::Warn, + ); + } + } + WriterWork::Control(control) => match control { + Control::Switch { + mut writer, + previous_path, + configured_path, + fallback_reason, + ticket, + reply, + } => { + if !ticket.begin() { + cleanup_aborted_writer(writer, None); + let _ = reply.send(Err("Log directory switch was cancelled".into())); + continue; + } + let result = (|| { + if let Ok(mut current) = shared.writer.lock() { + let switching = PreparedEvent::new( + LogLevel::Info, + "log.writer", + "switching log directory", + BTreeMap::from([( + "effective_dir".into(), + display_path(&writer.directory), + )]), + &shared.sanitizer, + ); + let _ = current.write_preformatted( + &format_event_line(&switching, &session_id), + true, + ); + std::mem::swap(&mut *current, &mut writer); + let switched = PreparedEvent::new( + LogLevel::Info, + "log.writer", + "log directory switched; session continues", + BTreeMap::from([( + "previous_directory".into(), + display_path(&previous_path), + )]), + &shared.sanitizer, + ); + let _ = current.write_preformatted( + &format_event_line(&switched, &session_id), + true, + ); + let mut runtime = shared + .runtime + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + runtime.configured_path = configured_path; + runtime.effective_path = current.directory.clone(); + runtime.active_file_path = current.path().to_path_buf(); + runtime.fallback_reason = fallback_reason; + if let Ok(mut panic_path) = shared.panic_path.write() { + *panic_path = panic_sidecar_path(¤t.directory, &session_id); + } + Ok(()) + } else { + Err("Log writer is unavailable".to_string()) + } + })(); + if result.is_err() { + cleanup_aborted_writer(writer, None); + } + ticket.complete(); + let _ = reply.send(result); + } + Control::Clear { ticket, reply } => { + if !ticket.begin() { + let _ = reply.send(Err("Log clearing was cancelled".into())); + continue; + } + let result = clear_active_writer(&shared, &session_id); + ticket.complete(); + let _ = reply.send(result); + } + Control::Flush { reply } => { + flush_or_fallback(&shared, &session_id); + let _ = reply.send(()); + } + Control::Shutdown { reply } => { + if let Ok(mut writer) = shared.writer.lock() { + let _ = writer.flush(); + } + shared.writer_alive.store(false, Ordering::Release); + if let Ok(mut queue) = shared.queue.lock() { + queue.shutdown = true; + } + let _ = reply.send(()); + break; + } + }, + WriterWork::Flush => { + flush_or_fallback(&shared, &session_id); + } + WriterWork::Stop => break, + } + } + shared.writer_alive.store(false, Ordering::Release); +} + +enum WriterWork { + Event(QueuedEvent, DroppedLines, u64), + Control(Control), + Flush, + Stop, +} + +fn take_writer_work(queue: &mut QueueState) -> WriterWork { + if queue + .controls + .front() + .is_some_and(|control| control.pending_events == 0) + { + if let Some(control) = queue.controls.pop_front() { + return WriterWork::Control(control.control); + } + } + if let Some(event) = queue.events.pop_front() { + for control in &mut queue.controls { + control.pending_events = control.pending_events.saturating_sub(1); + } + let dropped = std::mem::take(&mut queue.dropped); + let write_failures = std::mem::take(&mut queue.write_failures); + return WriterWork::Event(event, dropped, write_failures); + } + if queue.shutdown { + WriterWork::Stop + } else { + WriterWork::Flush + } +} + +fn enqueue_event(shared: &Arc, event: PreparedEvent) { + let mut direct = None; + { + let mut queue = match shared.queue.lock() { + Ok(queue) => queue, + Err(poisoned) => poisoned.into_inner(), + }; + if queue.shutdown { + return; + } + if let Some(last) = queue.events.back_mut() { + if last.event.key == event.key { + last.repeated = last.repeated.saturating_add(1); + return; + } + } + + let capacity = if event.level >= LogLevel::Warn { + LOG_QUEUE_CAPACITY + } else { + LOG_QUEUE_NORMAL_CAPACITY + }; + if queue.events.len() >= capacity { + match event.level { + LogLevel::Debug => { + queue.dropped.debug = queue.dropped.debug.saturating_add(1); + return; + } + LogLevel::Info => { + if !evict_oldest(&mut queue, LogLevel::Debug) + && !evict_oldest(&mut queue, LogLevel::Info) + { + queue.dropped.info = queue.dropped.info.saturating_add(1); + return; + } + } + LogLevel::Warn | LogLevel::Error => { + if !evict_oldest(&mut queue, LogLevel::Debug) + && !evict_oldest(&mut queue, LogLevel::Info) + { + direct = Some(event.clone()); + } + } + } + } + if direct.is_none() { + queue.events.push_back(QueuedEvent { event, repeated: 0 }); + shared.queue_ready.notify_one(); + } + } + if let Some(event) = direct { + let session_id = shared + .runtime + .read() + .map(|state| { + state + .active_file_path + .file_name() + .and_then(|name| name.to_str()) + .and_then(parse_log_file_name) + .map(|parsed| parsed.session_id) + .unwrap_or_else(|| "unknown".into()) + }) + .unwrap_or_else(|_| "unknown".into()); + let _ = write_event(shared, &event, &session_id, true); + } +} + +fn evict_oldest(queue: &mut QueueState, level: LogLevel) -> bool { + if let Some(index) = queue + .events + .iter() + .position(|entry| entry.event.level == level) + { + if let Some(removed) = queue.events.remove(index) { + match removed.event.level { + LogLevel::Debug => queue.dropped.debug = queue.dropped.debug.saturating_add(1), + LogLevel::Info => queue.dropped.info = queue.dropped.info.saturating_add(1), + _ => {} + } + } + true + } else { + false + } +} + +fn write_event( + shared: &Arc, + event: &PreparedEvent, + session_id: &str, + flush: bool, +) -> bool { + let line = format_event_line(event, session_id); + let result = shared.writer.lock().map_err(|_| ()).and_then(|mut writer| { + let previous_path = writer.path().to_path_buf(); + writer.write_preformatted(&line, flush).map_err(|_| ())?; + let next_path = writer.path().to_path_buf(); + if previous_path != next_path { + if let Ok(mut runtime) = shared.runtime.write() { + runtime.active_file_path = next_path; + } + } + Ok(()) + }); + if result.is_err() { + if fallback_runtime_to_default(shared, session_id, Some(&line)) { + return true; + } + if let Ok(mut queue) = shared.queue.lock() { + queue.write_failures = queue.write_failures.saturating_add(1); + } + false + } else { + true + } +} + +fn flush_or_fallback(shared: &Arc, session_id: &str) { + let failed = shared + .writer + .lock() + .map(|mut writer| writer.flush().is_err()) + .unwrap_or(true); + if failed { + let _ = fallback_runtime_to_default(shared, session_id, None); + } +} + +fn fallback_runtime_to_default( + shared: &Arc, + session_id: &str, + pending_line: Option<&str>, +) -> bool { + let (default_path, effective_path, configured_path) = match shared.runtime.read() { + Ok(runtime) if runtime.effective_path != runtime.default_path => ( + runtime.default_path.clone(), + runtime.effective_path.clone(), + runtime.configured_path.clone(), + ), + _ => return false, + }; + let mut fallback_writer = match ActiveWriter::open(default_path.clone(), session_id.to_string()) + { + Ok(writer) => writer, + Err(_) => return false, + }; + let fallback_event = PreparedEvent::new( + LogLevel::Warn, + "log.writer", + "fallback_to_default", + BTreeMap::from([ + ("reason".into(), "not_writable".into()), + ("configured_dir".into(), display_path(&effective_path)), + ("effective_dir".into(), display_path(&default_path)), + ]), + &shared.sanitizer, + ); + if fallback_writer + .write_preformatted(&format_event_line(&fallback_event, session_id), true) + .is_err() + { + return false; + } + if let Some(line) = pending_line { + if fallback_writer.write_preformatted(line, true).is_err() { + return false; + } + } + let active_file_path = fallback_writer.path().to_path_buf(); + if let Ok(mut writer) = shared.writer.lock() { + *writer = fallback_writer; + } else { + return false; + } + let snapshot = { + let mut runtime = match shared.runtime.write() { + Ok(runtime) => runtime, + Err(poisoned) => poisoned.into_inner(), + }; + runtime.configured_path = configured_path; + runtime.effective_path = default_path.clone(); + runtime.active_file_path = active_file_path; + runtime.fallback_reason = Some("not_writable".into()); + runtime.snapshot() + }; + if let Ok(mut panic_path) = shared.panic_path.write() { + *panic_path = panic_sidecar_path(&default_path, session_id); + } + if let Some(app) = &shared.app { + let _ = app.emit("lithe-log-runtime-fallback", snapshot); + } + true +} + +fn clear_active_writer( + shared: &Arc, + session_id: &str, +) -> Result { + let mut writer = shared + .writer + .lock() + .map_err(|_| "Log writer is unavailable".to_string())?; + writer.close()?; + let result = delete_managed_logs(&writer.directory, None)?; + *writer = ActiveWriter::open(writer.directory.clone(), session_id.to_string())?; + let event = PreparedEvent::new( + LogLevel::Info, + "log.cleared", + "logs cleared", + BTreeMap::from([ + ("deleted_files".into(), result.deleted_files.to_string()), + ("freed_bytes".into(), result.freed_bytes.to_string()), + ]), + &shared.sanitizer, + ); + writer.write_preformatted(&format_event_line(&event, session_id), true)?; + if let Ok(mut runtime) = shared.runtime.write() { + runtime.active_file_path = writer.path().to_path_buf(); + } + Ok(result) +} + +struct ActiveWriter { + directory: PathBuf, + session_id: String, + created_date: String, + segment: u16, + bytes_written: u64, + path: PathBuf, + file: Option>, +} + +impl ActiveWriter { + fn disabled(directory: PathBuf, session_id: String) -> Self { + Self { + path: directory.join("lithe.unavailable.log"), + directory, + session_id, + created_date: Local::now().format("%Y-%m-%d").to_string(), + segment: 0, + bytes_written: 0, + file: None, + } + } + + fn open(directory: PathBuf, session_id: String) -> Result { + fs::create_dir_all(&directory) + .map_err(|error| format!("Unable to create the log directory: {error}"))?; + let now = Local::now(); + let created_date = now.format("%Y-%m-%d").to_string(); + enforce_daily_limit(&directory, &created_date, None)?; + let segment = next_segment_number(&directory, &created_date, &session_id); + let path = regular_log_path(&directory, &session_id, segment, now); + let file = open_log_file(&path)?; + Ok(Self { + directory, + session_id, + created_date, + segment, + bytes_written: file.metadata().map(|metadata| metadata.len()).unwrap_or(0), + path, + file: Some(BufWriter::new(file)), + }) + } + + fn path(&self) -> &Path { + &self.path + } + + fn write_preformatted(&mut self, line: &str, flush: bool) -> Result<(), String> { + let today = Local::now().format("%Y-%m-%d").to_string(); + if self.created_date != today + || self.bytes_written.saturating_add(line.len() as u64) > MAX_LOG_FILE_BYTES + { + self.rotate()?; + } + let file = self + .file + .as_mut() + .ok_or_else(|| "Log file is closed".to_string())?; + file.write_all(line.as_bytes()) + .map_err(|error| format!("Unable to write the log file: {error}"))?; + self.bytes_written = self.bytes_written.saturating_add(line.len() as u64); + if flush { + file.flush() + .map_err(|error| format!("Unable to flush the log file: {error}"))?; + } + Ok(()) + } + + fn rotate(&mut self) -> Result<(), String> { + self.close()?; + let now = Local::now(); + let date = now.format("%Y-%m-%d").to_string(); + self.segment = if date == self.created_date { + self.segment.saturating_add(1) + } else { + 1 + }; + self.created_date = date; + enforce_daily_limit(&self.directory, &self.created_date, None)?; + self.path = regular_log_path(&self.directory, &self.session_id, self.segment, now); + self.file = Some(BufWriter::new(open_log_file(&self.path)?)); + self.bytes_written = 0; + Ok(()) + } + + fn close(&mut self) -> Result<(), String> { + if let Some(mut file) = self.file.take() { + file.flush() + .map_err(|error| format!("Unable to flush the log file: {error}"))?; + } + Ok(()) + } + + fn flush(&mut self) -> Result<(), String> { + if let Some(file) = self.file.as_mut() { + file.flush() + .map_err(|error| format!("Unable to flush the log file: {error}"))?; + } + Ok(()) + } +} + +fn open_log_file(path: &Path) -> Result { + OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|error| format!("Unable to open the log file: {error}")) +} + +fn regular_log_path( + directory: &Path, + session_id: &str, + segment: u16, + now: DateTime, +) -> PathBuf { + let timestamp = now.format("%Y-%m-%dT%H-%M-%S%.3f%z"); + directory.join(format!("lithe.{timestamp}.{session_id}.{segment:03}.log")) +} + +fn panic_sidecar_path(directory: &Path, session_id: &str) -> PathBuf { + let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S%.3f%z"); + directory.join(format!("lithe.panic.{timestamp}.{session_id}.log")) +} + +#[derive(Clone, Debug)] +struct ParsedLogFile { + timestamp: DateTime, + session_id: String, + panic: bool, +} + +fn log_file_regex() -> &'static Regex { + static REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r"^lithe\.(?:(panic)\.)?(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}[+-]\d{4})\.([A-Fa-f0-9]+)(?:\.(\d{3}))?\.log$", + ) + .expect("the managed log filename regex is valid") + }) +} + +fn parse_log_file_name(name: &str) -> Option { + let captures = log_file_regex().captures(name)?; + let timestamp = + DateTime::parse_from_str(captures.get(2)?.as_str(), "%Y-%m-%dT%H-%M-%S%.3f%z").ok()?; + Some(ParsedLogFile { + timestamp, + session_id: captures.get(3)?.as_str().to_string(), + panic: captures.get(1).is_some(), + }) +} + +fn cleanup_expired_logs_with_timeout( + directory: PathBuf, + active_path: Option, +) -> Result, String> { + run_with_timeout(DIRECTORY_OPERATION_TIMEOUT, move || { + cleanup_expired_logs(&directory, active_path.as_deref()) + })? +} + +fn cleanup_expired_logs( + directory: &Path, + active_path: Option<&Path>, +) -> Result, String> { + if !directory.exists() { + return Ok(Vec::new()); + } + let mut warnings = Vec::new(); + let now = Local::now().fixed_offset(); + let entries = fs::read_dir(directory) + .map_err(|error| format!("Unable to inspect the log directory: {error}"))?; + for entry in entries.flatten() { + let path = entry.path(); + if active_path == Some(path.as_path()) || !path.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with("lithe.") || !name.ends_with(".log") { + continue; + } + let Some(parsed) = parse_log_file_name(name) else { + warnings.push(format!("skipped_unparseable file={name}")); + continue; + }; + let age = now.signed_duration_since(parsed.timestamp); + if age >= Duration::hours(LOG_RETENTION_HOURS) { + if let Err(error) = fs::remove_file(&path) { + warnings.push(format!("delete_failed file={name} error={error}")); + } + } + } + Ok(warnings) +} + +fn enforce_daily_limit( + directory: &Path, + date: &str, + active_path: Option<&Path>, +) -> Result<(), String> { + let mut files = managed_log_files(directory) + .into_iter() + .filter(|(path, parsed)| { + !parsed.panic + && parsed.timestamp.format("%Y-%m-%d").to_string() == date + && active_path != Some(path.as_path()) + }) + .collect::>(); + files.sort_by(|left, right| left.1.timestamp.cmp(&right.1.timestamp)); + while files.len() >= MAX_LOG_FILES_PER_DAY { + let (path, _) = files.remove(0); + fs::remove_file(path) + .map_err(|error| format!("Unable to enforce the daily log limit: {error}"))?; + } + Ok(()) +} + +fn managed_log_files(directory: &Path) -> Vec<(PathBuf, ParsedLogFile)> { + fs::read_dir(directory) + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let parsed = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(parse_log_file_name)?; + Some((path, parsed)) + }) + .collect() +} + +fn managed_log_stats(directory: &Path) -> (usize, u64) { + managed_log_files(directory) + .into_iter() + .fold((0usize, 0u64), |(count, bytes), (path, _)| { + let size = path.metadata().map(|metadata| metadata.len()).unwrap_or(0); + (count + 1, bytes.saturating_add(size)) + }) +} + +fn delete_managed_logs( + directory: &Path, + active_path: Option<&Path>, +) -> Result { + let files = managed_log_files(directory); + let mut result = ClearLogResult { + deleted_files: 0, + freed_bytes: 0, + }; + for (path, _) in files { + if active_path == Some(path.as_path()) { + continue; + } + let size = path.metadata().map(|metadata| metadata.len()).unwrap_or(0); + fs::remove_file(&path) + .map_err(|error| format!("Unable to delete {}: {error}", display_path(&path)))?; + result.deleted_files += 1; + result.freed_bytes = result.freed_bytes.saturating_add(size); + } + Ok(result) +} + +fn count_previous_panic_sidecars(directory: &Path, current_session: &str) -> usize { + managed_log_files(directory) + .into_iter() + .filter(|(_, parsed)| parsed.panic && parsed.session_id != current_session) + .count() +} + +fn next_segment_number(directory: &Path, date: &str, session_id: &str) -> u16 { + managed_log_files(directory) + .into_iter() + .filter_map(|(path, parsed)| { + if parsed.panic + || parsed.session_id != session_id + || parsed.timestamp.format("%Y-%m-%d").to_string() != date + { + return None; + } + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| log_file_regex().captures(name)) + .and_then(|captures| { + captures + .get(4) + .and_then(|value| value.as_str().parse().ok()) + }) + }) + .max() + .unwrap_or(0u16) + .saturating_add(1) +} + +fn derive_custom_log_path(root: &Path) -> PathBuf { + CUSTOM_LOG_SUBDIRECTORY + .iter() + .fold(root.to_path_buf(), |path, segment| path.join(segment)) +} + +fn cleanup_aborted_writer(mut writer: ActiveWriter, custom_root: Option<&Path>) { + let file_path = writer.path().to_path_buf(); + let directory = writer.directory.clone(); + let _ = writer.close(); + drop(writer); + let _ = fs::remove_file(file_path); + let _ = fs::remove_dir(&directory); + if let Some(root) = custom_root { + let _ = fs::remove_dir(root.join(CUSTOM_LOG_SUBDIRECTORY[0])); + } +} + +fn probe_directory_with_timeout(directory: PathBuf) -> Result<(), String> { + run_with_timeout(DIRECTORY_OPERATION_TIMEOUT, move || { + probe_directory(&directory) + })? +} + +fn run_with_timeout( + timeout: StdDuration, + task: impl FnOnce() -> T + Send + 'static, +) -> Result { + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let _ = sender.send(task()); + }); + receiver + .recv_timeout(timeout) + .map_err(|_| "timed_out".to_string()) +} + +fn probe_directory(directory: &Path) -> Result<(), String> { + fs::create_dir_all(directory).map_err(|error| classify_directory_error(&error))?; + let probe_name = format!( + ".lithe-log-probe-{}-{}", + std::process::id(), + PROBE_ID.fetch_add(1, Ordering::Relaxed) + ); + let probe_path = directory.join(probe_name); + File::create(&probe_path) + .and_then(|mut file| file.write_all(b"probe")) + .map_err(|error| classify_directory_error(&error))?; + fs::remove_file(probe_path).map_err(|error| classify_directory_error(&error)) +} + +static PROBE_ID: AtomicU64 = AtomicU64::new(1); + +fn classify_directory_error(error: &std::io::Error) -> String { + match error.kind() { + std::io::ErrorKind::PermissionDenied => "not_writable".into(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::NotConnected + | std::io::ErrorKind::TimedOut => "unavailable".into(), + _ => "not_writable".into(), + } +} + +fn read_host_config(path: &Path) -> (HostLogConfig, Option) { + let Ok(file) = File::open(path) else { + return (HostLogConfig::default(), None); + }; + match serde_json::from_reader(BufReader::new(file)) { + Ok(config) => (config, None), + Err(error) => (HostLogConfig::default(), Some(error.to_string())), + } +} + +fn write_host_config(path: &Path, config: &HostLogConfig) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "Log configuration path has no parent directory".to_string())?; + fs::create_dir_all(parent) + .map_err(|error| format!("Unable to create the log configuration directory: {error}"))?; + let temporary = path.with_extension("json.tmp"); + let bytes = serde_json::to_vec_pretty(config) + .map_err(|error| format!("Unable to serialize the log configuration: {error}"))?; + fs::write(&temporary, bytes) + .map_err(|error| format!("Unable to write the log configuration: {error}"))?; + replace_file_atomically(&temporary, path) +} + +#[cfg(target_os = "windows")] +fn replace_file_atomically(source: &Path, destination: &Path) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // MoveFileExW provides the replace-existing primitive needed to avoid a + // missing-config window between deleting the old file and renaming the new one. + let moved = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + Err(format!( + "Unable to commit the log configuration: {}", + std::io::Error::last_os_error() + )) + } else { + Ok(()) + } +} + +#[cfg(not(target_os = "windows"))] +fn replace_file_atomically(source: &Path, destination: &Path) -> Result<(), String> { + fs::rename(source, destination) + .map_err(|error| format!("Unable to commit the log configuration: {error}")) +} + +fn unique_paths(paths: impl IntoIterator) -> Vec { + let mut result = Vec::new(); + for path in paths { + if !result.iter().any(|existing| existing == &path) { + result.push(path); + } + } + result +} + +fn payload_fields(payload: Option) -> BTreeMap { + let Some(Value::Object(payload)) = payload else { + return BTreeMap::new(); + }; + payload + .into_iter() + .map(|(key, value)| (snake_case_key(&key), compact_json(&value))) + .collect() +} + +fn compact_json(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + _ => serde_json::to_string(value).unwrap_or_else(|_| "[unserializable]".into()), + } +} + +fn format_event_line(event: &PreparedEvent, session_id: &str) -> String { + let mut line = format!( + "{} {:<5} [session={}] [scope={}] {}", + event.timestamp.format("%Y-%m-%dT%H:%M:%S%.3f%:z"), + event.level.label(), + session_id, + event.scope, + event.message + ); + for (key, value) in &event.fields { + line.push(' '); + line.push_str(key); + line.push('='); + line.push_str(&format_log_value(value)); + } + line = truncate_log_line(line); + line.push('\n'); + line +} + +fn format_log_value(value: &str) -> String { + if !value.is_empty() + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._/-:+<>".contains(character)) + { + return value.to_string(); + } + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t"); + format!("\"{escaped}\"") +} + +fn truncate_log_line(mut line: String) -> String { + const MARKER: &str = " truncated=true"; + if line.len() <= MAX_LOG_LINE_BYTES { + return line; + } + let limit = MAX_LOG_LINE_BYTES.saturating_sub(MARKER.len()); + while line.len() > limit { + line.pop(); + } + line.push_str(MARKER); + line +} + +fn snake_case_key(value: &str) -> String { + let mut result = String::with_capacity(value.len()); + for (index, character) in value.chars().enumerate() { + if character.is_ascii_uppercase() { + if index > 0 && !result.ends_with('_') { + result.push('_'); + } + result.push(character.to_ascii_lowercase()); + } else if character.is_ascii_alphanumeric() || character == '_' { + result.push(character.to_ascii_lowercase()); + } else if !result.ends_with('_') { + result.push('_'); + } + } + result.trim_matches('_').to_string() +} + +fn is_sensitive_key(key: &str) -> bool { + [ + "token", + "authorization", + "password", + "secret", + "api_key", + "cookie", + ] + .iter() + .any(|sensitive| key.contains(sensitive)) +} + +struct LogSanitizer { + user_profile_patterns: Vec, +} + +impl LogSanitizer { + fn new() -> Self { + let user_profile_patterns = std::env::var("USERPROFILE") + .ok() + .filter(|value| !value.trim().is_empty()) + .into_iter() + .flat_map(|profile| [profile.clone(), profile.replace('\\', "/")]) + .filter_map(|profile| Regex::new(&format!("(?i){}", regex::escape(&profile))).ok()) + .collect(); + Self { + user_profile_patterns, + } + } + + fn sanitize_text(&self, value: &str) -> String { + let mut sanitized = value + .replace("\r\n", "\\n") + .replace('\n', "\\n") + .replace('\r', "\\r"); + for pattern in &self.user_profile_patterns { + sanitized = pattern + .replace_all(&sanitized, "") + .into_owned(); + } + sanitized = authorization_header_regex() + .replace_all(&sanitized, "$1=") + .into_owned(); + sanitized = sensitive_assignment_regex() + .replace_all(&sanitized, "$1=") + .into_owned(); + sanitized = sensitive_query_regex() + .replace_all(&sanitized, "$1=") + .into_owned(); + sanitized = credential_shape_regex() + .replace_all(&sanitized, "") + .into_owned(); + sanitized + } + + fn sanitize_free_text(&self, value: &str) -> String { + redact_absolute_paths(&self.sanitize_text(value)) + } + + fn sanitize_field(&self, key: &str, value: &str) -> String { + let sanitized = self.sanitize_text(value); + if allows_platform_absolute_path(key) { + sanitized + } else { + redact_absolute_paths(&sanitized) + } + } +} + +fn authorization_header_regex() -> &'static Regex { + static REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?i)\b(authorization)\s*[:=]\s*(?:bearer|basic)\s+[^\s,;]+") + .expect("the authorization header regex is valid") + }) +} + +fn sensitive_assignment_regex() -> &'static Regex { + static REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r"(?i)\b(token|authorization|password|secret|api[_-]?key|cookie)\s*[:=]\s*[^\s,;]+", + ) + .expect("the sensitive assignment regex is valid") + }) +} + +fn sensitive_query_regex() -> &'static Regex { + static REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?i)([?&](?:token|key|api_key|access_token|auth))=[^&#\s]+") + .expect("the sensitive query regex is valid") + }) +} + +fn credential_shape_regex() -> &'static Regex { + static REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r"(?i)\b(?:gh[pousr]_[A-Za-z0-9_]{8,}|github_pat_[A-Za-z0-9_]{8,}|sk-[A-Za-z0-9_-]{8,})\b", + ) + .expect("the credential shape regex is valid") + }) +} + +fn windows_absolute_path_regex() -> &'static Regex { + static REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r#"(?i)(?:\"(?:[A-Z]:[\\/]|\\\\)[^\"]+\"|'(?:[A-Z]:[\\/]|\\\\)[^']+'|\b[A-Z]:[\\/][^\s\"'<>|,;]+|\\\\[^\s\"'<>|,;]+)"#, + ) + .expect("the Windows absolute path regex is valid") + }) +} + +fn redact_absolute_paths(value: &str) -> String { + windows_absolute_path_regex() + .replace_all(value, "") + .into_owned() +} + +fn allows_platform_absolute_path(key: &str) -> bool { + matches!( + key, + "directory" + | "configured_dir" + | "effective_dir" + | "default_path" + | "configured_path" + | "effective_path" + | "previous_directory" + ) +} + +fn create_session_id() -> String { + let timestamp = Local::now().timestamp_micros() as u64; + let mixed = timestamp + ^ ((std::process::id() as u64) << 24) + ^ SESSION_ID.fetch_add(1, Ordering::Relaxed); + format!("{:08x}", mixed as u32) +} + +static SESSION_ID: AtomicU64 = AtomicU64::new(1); + +fn display_path(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +#[tauri::command] +pub fn get_log_settings(manager: State<'_, Arc>) -> LogSettingsSnapshot { + manager.snapshot() +} + +#[tauri::command] +pub async fn set_log_directory( + parent_path: Option, + manager: State<'_, Arc>, +) -> Result { + let manager = manager.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + manager.set_custom_root( + parent_path + .filter(|path| !path.trim().is_empty()) + .map(PathBuf::from), + ) + }) + .await + .map_err(|error| format!("Log directory task failed: {error}"))? +} + +#[tauri::command] +pub fn set_diagnostic_logging( + enabled: bool, + manager: State<'_, Arc>, +) -> LogSettingsSnapshot { + manager.set_diagnostic_enabled(enabled) +} + +#[tauri::command] +pub async fn read_lithe_log( + manager: State<'_, Arc>, +) -> Result { + let manager = manager.inner().clone(); + tauri::async_runtime::spawn_blocking(move || manager.read_current_log()) + .await + .map_err(|error| format!("Read log task failed: {error}"))? +} + +#[tauri::command] +pub async fn clear_lithe_logs( + manager: State<'_, Arc>, +) -> Result { + let manager = manager.inner().clone(); + tauri::async_runtime::spawn_blocking(move || manager.clear_current_logs()) + .await + .map_err(|error| format!("Clear log task failed: {error}"))? +} + +#[tauri::command] +pub async fn resolve_previous_log_cleanup( + delete: bool, + manager: State<'_, Arc>, +) -> Result { + let manager = manager.inner().clone(); + tauri::async_runtime::spawn_blocking(move || manager.resolve_previous_custom_cleanup(delete)) + .await + .map_err(|error| format!("Previous log cleanup task failed: {error}"))? +} + +#[tauri::command] +pub fn open_log_directory( + app: AppHandle, + manager: State<'_, Arc>, +) -> Result<(), String> { + manager.ensure_available()?; + app.opener() + .open_path(display_path(&manager.effective_path()), None::<&str>) + .map_err(|error| format!("Unable to open the log directory: {error}")) +} + +#[tauri::command] +pub fn frontend_trace( + level: String, + scope: String, + message: String, + payload: Option, + manager: State<'_, Arc>, +) { + manager.emit_json(&level, scope, message, payload); +} + +#[tauri::command] +pub fn record_startup_milestone(milestone: String, manager: State<'_, Arc>) { + manager.emit(LogLevel::Info, "startup", milestone, BTreeMap::new()); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temporary_directory(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "lithe-log-tests-{name}-{}-{}", + std::process::id(), + SESSION_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + path + } + + fn test_manager(directory: &Path) -> Arc { + let session_id = "deadbeef".to_string(); + let writer = ActiveWriter::open(directory.to_path_buf(), session_id.clone()).unwrap(); + let active_file_path = writer.path().to_path_buf(); + let shared = Arc::new(SharedState { + app: None, + queue: Mutex::new(QueueState::default()), + queue_ready: Condvar::new(), + writer: Mutex::new(writer), + runtime: RwLock::new(RuntimeState { + default_path: directory.to_path_buf(), + configured_path: None, + effective_path: directory.to_path_buf(), + fallback_reason: None, + diagnostic_enabled: false, + active_file_path, + }), + panic_path: RwLock::new(panic_sidecar_path(directory, &session_id)), + writer_alive: AtomicBool::new(true), + sanitizer: LogSanitizer::new(), + }); + Arc::new(LogManager { + shared, + config_path: directory.join(LOG_CONFIG_FILE), + session_id, + config_update: Mutex::new(()), + pending_previous_custom: Mutex::new(None), + degraded_reason: None, + }) + } + + #[test] + fn formats_single_line_and_redacts_sensitive_values() { + let sanitizer = LogSanitizer::new(); + let event = PreparedEvent::new( + LogLevel::Info, + "runtime.test", + "line one\nline two token=secret-value", + BTreeMap::from([("apiKey".into(), "secret".into())]), + &sanitizer, + ); + let line = format_event_line(&event, "abcd1234"); + assert_eq!(line.lines().count(), 1); + assert!(line.contains("line one\\nline two")); + assert!(line.contains("api_key=")); + assert!(!line.contains("secret-value")); + } + + #[test] + fn redacts_authorization_headers_token_shapes_and_free_text_paths() { + let sanitizer = LogSanitizer::new(); + let event = PreparedEvent::new( + LogLevel::Error, + "runtime.console", + "Authorization: Bearer ghp_1234567890abcdef failed at D:\\work\\customer-x\\main.rs and \\\\server\\share\\secret.txt", + BTreeMap::from([ + ("reason".into(), "github_pat_1234567890abcdef".into()), + ("directory".into(), "E:/Lithe/logs".into()), + ]), + &sanitizer, + ); + let line = format_event_line(&event, "abcd1234"); + assert!(!line.contains("ghp_")); + assert!(!line.contains("github_pat_")); + assert!(!line.contains("customer-x")); + assert!(!line.contains("server")); + assert!(line.contains("")); + assert!(line.contains("directory=E:/Lithe/logs")); + } + + #[test] + fn cleanup_uses_filename_timestamp_and_keeps_unparseable_files() { + let directory = temporary_directory("cleanup"); + let old = directory.join("lithe.2020-01-01T00-00-00.000+0000.deadbeef.001.log"); + let unknown = directory.join("lithe.unknown.log"); + fs::write(&old, "old").unwrap(); + fs::write(&unknown, "unknown").unwrap(); + let warnings = cleanup_expired_logs(&directory, None).unwrap(); + assert!(!old.exists()); + assert!(unknown.exists()); + assert!(warnings + .iter() + .any(|warning| warning.contains("lithe.unknown.log"))); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn daily_limit_excludes_panic_sidecars() { + let directory = temporary_directory("daily-limit"); + for index in 0..MAX_LOG_FILES_PER_DAY { + let path = directory.join(format!( + "lithe.2026-08-18T00-00-0{index}.000+0000.deadbeef.{:03}.log", + index + 1 + )); + fs::write(path, "regular").unwrap(); + } + let panic = directory.join("lithe.panic.2026-08-18T00-00-09.000+0000.deadbeef.log"); + fs::write(&panic, "panic").unwrap(); + enforce_daily_limit(&directory, "2026-08-18", None).unwrap(); + assert_eq!( + managed_log_files(&directory) + .into_iter() + .filter(|(_, parsed)| !parsed.panic) + .count(), + MAX_LOG_FILES_PER_DAY - 1 + ); + assert!(panic.exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn writer_rotates_at_ten_megabytes() { + let directory = temporary_directory("size-rotation"); + let mut writer = ActiveWriter::open(directory.clone(), "deadbeef".into()).unwrap(); + let original_path = writer.path().to_path_buf(); + writer.bytes_written = MAX_LOG_FILE_BYTES; + writer.write_preformatted("next line\n", true).unwrap(); + assert_ne!(writer.path(), original_path); + assert_eq!(writer.segment, 2); + drop(writer); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn writer_rotates_at_midnight_and_resets_the_segment() { + let directory = temporary_directory("midnight-rotation"); + let mut writer = ActiveWriter::open(directory.clone(), "deadbeef".into()).unwrap(); + writer.created_date = "2000-01-01".into(); + writer.segment = 9; + writer.write_preformatted("new day\n", true).unwrap(); + assert_eq!( + writer.created_date, + Local::now().format("%Y-%m-%d").to_string() + ); + assert_eq!(writer.segment, 1); + drop(writer); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn probe_creates_the_derived_custom_directory() { + let root = temporary_directory("probe"); + let target = derive_custom_log_path(&root); + probe_directory(&target).unwrap(); + assert!(target.is_dir()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn bounded_directory_operation_reports_timeout() { + let result = run_with_timeout(StdDuration::from_millis(5), || { + thread::sleep(StdDuration::from_millis(50)); + "late" + }); + assert_eq!(result.unwrap_err(), "timed_out"); + } + + #[test] + fn degraded_manager_keeps_commands_safe_when_file_logging_cannot_initialize() { + let directory = temporary_directory("degraded-manager"); + let manager = LogManager::degraded_with_paths( + directory.clone(), + directory.join(LOG_CONFIG_FILE), + "test initialization failure".into(), + ); + assert_eq!( + manager.snapshot().fallback_reason.as_deref(), + Some("logging_unavailable") + ); + assert!(manager.read_current_log().is_err()); + assert!(manager.clear_current_logs().is_err()); + assert!(manager + .set_custom_root(Some(directory.join("custom"))) + .is_err()); + assert!(manager.set_diagnostic_enabled(true).diagnostic_enabled); + manager.emit_json("error", "runtime.console".into(), "still safe".into(), None); + drop(manager); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn control_commands_wait_for_events_already_in_the_queue() { + let sanitizer = LogSanitizer::new(); + let event = PreparedEvent::new( + LogLevel::Info, + "test", + "before flush", + BTreeMap::new(), + &sanitizer, + ); + let (reply, _) = mpsc::channel(); + let mut queue = QueueState::default(); + queue.events.push_back(QueuedEvent { event, repeated: 0 }); + queue.controls.push_back(QueuedControl { + control: Control::Flush { reply }, + pending_events: 1, + }); + + assert!(matches!( + take_writer_work(&mut queue), + WriterWork::Event(..) + )); + assert!(matches!( + take_writer_work(&mut queue), + WriterWork::Control(Control::Flush { .. }) + )); + } + + #[test] + fn normal_queue_pressure_evicts_debug_before_info() { + let directory = temporary_directory("queue-pressure"); + let manager = test_manager(&directory); + for index in 0..LOG_QUEUE_NORMAL_CAPACITY { + let event = PreparedEvent::new( + LogLevel::Debug, + "queue", + format!("debug {index}"), + BTreeMap::new(), + &manager.shared.sanitizer, + ); + enqueue_event(&manager.shared, event); + } + let info = PreparedEvent::new( + LogLevel::Info, + "queue", + "important info", + BTreeMap::new(), + &manager.shared.sanitizer, + ); + enqueue_event(&manager.shared, info); + let queue = manager.shared.queue.lock().unwrap(); + assert_eq!(queue.events.len(), LOG_QUEUE_NORMAL_CAPACITY); + assert_eq!(queue.dropped.debug, 1); + assert!(queue + .events + .iter() + .any(|entry| entry.event.message == "important info")); + drop(queue); + drop(manager); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn saturated_warning_queue_direct_writes_errors() { + let directory = temporary_directory("critical-direct-write"); + let manager = test_manager(&directory); + for index in 0..LOG_QUEUE_CAPACITY { + let event = PreparedEvent::new( + LogLevel::Warn, + "queue", + format!("warning {index}"), + BTreeMap::new(), + &manager.shared.sanitizer, + ); + enqueue_event(&manager.shared, event); + } + let critical = PreparedEvent::new( + LogLevel::Error, + "queue", + "critical direct write", + BTreeMap::new(), + &manager.shared.sanitizer, + ); + enqueue_event(&manager.shared, critical); + let active = manager + .shared + .runtime + .read() + .unwrap() + .active_file_path + .clone(); + assert!(fs::read_to_string(active) + .unwrap() + .contains("critical direct write")); + drop(manager); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn write_failure_is_reported_after_the_writer_recovers() { + let directory = temporary_directory("write-recovery"); + let manager = test_manager(&directory); + *manager.shared.writer.lock().unwrap() = + ActiveWriter::disabled(directory.clone(), manager.session_id.clone()); + let failed = PreparedEvent::new( + LogLevel::Info, + "test", + "will fail", + BTreeMap::new(), + &manager.shared.sanitizer, + ); + assert!(!write_event( + &manager.shared, + &failed, + &manager.session_id, + true + )); + + let recovered = ActiveWriter::open(directory.clone(), manager.session_id.clone()).unwrap(); + let recovered_path = recovered.path().to_path_buf(); + *manager.shared.writer.lock().unwrap() = recovered; + manager.shared.runtime.write().unwrap().active_file_path = recovered_path.clone(); + spawn_writer(manager.shared.clone(), manager.session_id.clone()).unwrap(); + manager.emit(LogLevel::Info, "test", "writer recovered", BTreeMap::new()); + manager.shutdown(); + assert!(fs::read_to_string(recovered_path) + .unwrap() + .contains("write failures recovered")); + drop(manager); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn runtime_write_failure_falls_back_to_default_and_retries_the_line() { + let root = temporary_directory("runtime-fallback"); + let custom = root.join("custom"); + let default = root.join("default"); + fs::create_dir_all(&custom).unwrap(); + fs::create_dir_all(&default).unwrap(); + let manager = test_manager(&custom); + { + let mut runtime = manager.shared.runtime.write().unwrap(); + runtime.default_path = default.clone(); + runtime.configured_path = Some(custom.clone()); + runtime.effective_path = custom.clone(); + } + *manager.shared.writer.lock().unwrap() = + ActiveWriter::disabled(custom, manager.session_id.clone()); + let event = PreparedEvent::new( + LogLevel::Warn, + "runtime.test", + "retry after fallback", + BTreeMap::new(), + &manager.shared.sanitizer, + ); + assert!(write_event( + &manager.shared, + &event, + &manager.session_id, + true + )); + let runtime = manager.shared.runtime.read().unwrap().clone(); + assert_eq!(runtime.effective_path, default); + assert!(runtime.configured_path.is_some()); + assert_eq!(runtime.fallback_reason.as_deref(), Some("not_writable")); + let content = fs::read_to_string(runtime.active_file_path).unwrap(); + assert!(content.contains("fallback_to_default")); + assert!(content.contains("retry after fallback")); + drop(manager); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn panic_falls_back_to_sidecar_while_the_main_writer_is_locked() { + let directory = temporary_directory("panic-sidecar"); + let manager = test_manager(&directory); + let writer_guard = manager.shared.writer.lock().unwrap(); + let worker = { + let manager = manager.clone(); + thread::spawn(move || manager.write_panic_event("locked writer panic", BTreeMap::new())) + }; + worker.join().unwrap(); + drop(writer_guard); + let sidecar = manager.shared.panic_path.read().unwrap().clone(); + let content = fs::read_to_string(sidecar).unwrap(); + assert!(content.contains("locked writer panic")); + drop(manager); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn panic_on_the_writer_thread_skips_the_main_lock() { + let directory = temporary_directory("writer-panic-sidecar"); + let manager = test_manager(&directory); + let worker = { + let manager = manager.clone(); + thread::spawn(move || { + IS_LOG_WRITER_THREAD.with(|flag| flag.set(true)); + manager.write_panic_event("writer thread panic", BTreeMap::new()); + }) + }; + worker.join().unwrap(); + let sidecar = manager.shared.panic_path.read().unwrap().clone(); + assert!(fs::read_to_string(sidecar) + .unwrap() + .contains("writer thread panic")); + drop(manager); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn concurrent_panics_finish_without_deadlocking() { + let directory = temporary_directory("concurrent-panic"); + let manager = test_manager(&directory); + let writer_guard = manager.shared.writer.lock().unwrap(); + let (finished_tx, finished_rx) = mpsc::channel(); + let workers = (0..2) + .map(|index| { + let manager = manager.clone(); + let finished_tx = finished_tx.clone(); + thread::spawn(move || { + manager + .write_panic_event(&format!("concurrent panic {index}"), BTreeMap::new()); + let _ = finished_tx.send(()); + }) + }) + .collect::>(); + for _ in 0..2 { + finished_rx + .recv_timeout(StdDuration::from_secs(1)) + .expect("panic logging must not deadlock"); + } + for worker in workers { + worker.join().unwrap(); + } + drop(writer_guard); + let sidecar = manager.shared.panic_path.read().unwrap().clone(); + let content = fs::read_to_string(sidecar).unwrap(); + assert!(content.contains("concurrent panic 0")); + assert!(content.contains("concurrent panic 1")); + drop(manager); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn panic_guard_resets_after_each_hook_invocation() { + let directory = temporary_directory("sequential-panics"); + let manager = test_manager(&directory); + manager.write_panic_event("first panic", BTreeMap::new()); + manager.write_panic_event("second panic", BTreeMap::new()); + let active = manager + .shared + .runtime + .read() + .unwrap() + .active_file_path + .clone(); + let content = fs::read_to_string(active).unwrap(); + assert!(content.contains("first panic")); + assert!(content.contains("second panic")); + drop(manager); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn directory_switch_updates_runtime_and_persists_only_the_parent() { + let directory = temporary_directory("directory-switch"); + let custom_root = directory.join("chosen-parent"); + let manager = test_manager(&directory); + let original_file = manager + .shared + .runtime + .read() + .unwrap() + .active_file_path + .clone(); + spawn_writer(manager.shared.clone(), manager.session_id.clone()).unwrap(); + + let result = manager.set_custom_root(Some(custom_root.clone())).unwrap(); + let expected = derive_custom_log_path(&custom_root); + assert_eq!( + result.settings.configured_path.as_deref(), + Some(display_path(&expected).as_str()) + ); + assert_eq!(result.settings.effective_path, display_path(&expected)); + assert_eq!( + read_host_config(&manager.config_path).0.custom_root, + Some(display_path(&custom_root)) + ); + let switched_file = manager + .shared + .runtime + .read() + .unwrap() + .active_file_path + .clone(); + assert!(fs::read_to_string(original_file) + .unwrap() + .contains("switching log directory")); + assert!(fs::read_to_string(switched_file) + .unwrap() + .contains("session continues")); + + manager.shutdown(); + drop(manager); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn persistence_failure_aborts_a_directory_switch() { + let directory = temporary_directory("directory-switch-failure"); + let manager = test_manager(&directory); + let original_effective = manager.snapshot().effective_path; + let blocked_parent = directory.join("blocked-parent"); + fs::write(&blocked_parent, "not a directory").unwrap(); + let manager = Arc::new(LogManager { + shared: manager.shared.clone(), + config_path: blocked_parent.join(LOG_CONFIG_FILE), + session_id: manager.session_id.clone(), + config_update: Mutex::new(()), + pending_previous_custom: Mutex::new(None), + degraded_reason: None, + }); + + let result = manager.set_custom_root(Some(directory.join("next-parent"))); + assert!(result.is_err()); + assert_eq!(manager.snapshot().effective_path, original_effective); + assert!(!directory.join("next-parent").join("Lithe").exists()); + drop(manager); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn timed_out_switch_is_cancelled_before_the_writer_can_execute_it() { + let directory = temporary_directory("cancelled-switch"); + let target = directory.join("target").join("Lithe").join("logs"); + let manager = test_manager(&directory); + let original = manager.snapshot().effective_path; + let writer = ActiveWriter::open(target.clone(), manager.session_id.clone()).unwrap(); + let ticket = ControlTicket::new(); + let (reply_tx, reply_rx) = mpsc::channel(); + manager + .push_control(Control::Switch { + writer, + previous_path: PathBuf::from(&original), + configured_path: Some(target), + fallback_reason: None, + ticket: ticket.clone(), + reply: reply_tx, + }) + .unwrap(); + let result = wait_for_control_timeout( + reply_rx, + &ticket, + "testing a directory switch", + StdDuration::from_millis(1), + ); + assert!(result.is_err()); + spawn_writer(manager.shared.clone(), manager.session_id.clone()).unwrap(); + thread::sleep(StdDuration::from_millis(25)); + assert_eq!(manager.snapshot().effective_path, original); + manager.shutdown(); + drop(manager); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn invalid_config_destination_does_not_replace_an_existing_config() { + let directory = temporary_directory("config-transaction"); + let config_path = directory.join(LOG_CONFIG_FILE); + let original = HostLogConfig { + custom_root: Some("D:/original".into()), + }; + write_host_config(&config_path, &original).unwrap(); + let blocked_parent = directory.join("blocked"); + fs::write(&blocked_parent, "not a directory").unwrap(); + let result = write_host_config( + &blocked_parent.join(LOG_CONFIG_FILE), + &HostLogConfig { + custom_root: Some("E:/next".into()), + }, + ); + assert!(result.is_err()); + assert_eq!( + read_host_config(&config_path).0.custom_root, + original.custom_root + ); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index db846796e..b662a4f5d 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -3,6 +3,7 @@ mod core; mod file_events; mod host; +mod logging; mod lsp; mod platform; mod run; @@ -18,7 +19,7 @@ use tauri::Manager; use tauri_plugin_window_state::StateFlags; fn main() { - tauri::Builder::default() + let application = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, arguments, _| { host::enqueue_cli_arguments(app, arguments); })) @@ -39,6 +40,17 @@ fn main() { .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { + match logging::LogManager::initialize(app.handle()) { + Ok(log_manager) => { + app.manage(log_manager); + } + Err(error) => { + // Logging is diagnostic infrastructure; an unavailable log + // directory or writer must never prevent the product from starting. + eprintln!("[logging] application file logging is unavailable: {error}"); + app.manage(logging::LogManager::degraded(app.handle(), error)); + } + } app.manage(Arc::new(FileWatcher::new(Arc::new( TauriFileChangeEmitter::new(app.handle().clone()), )))); @@ -73,8 +85,15 @@ fn main() { secure_storage::store_secure_secret, secure_storage::get_secure_secret, secure_storage::remove_secure_secret, - host::frontend_trace, - host::record_startup_milestone, + logging::get_log_settings, + logging::set_log_directory, + logging::set_diagnostic_logging, + logging::read_lithe_log, + logging::clear_lithe_logs, + logging::resolve_previous_log_cleanup, + logging::open_log_directory, + logging::frontend_trace, + logging::record_startup_milestone, host::get_system_theme, host::set_native_window_appearance, host::get_system_fonts, @@ -105,8 +124,16 @@ fn main() { run::run_start_process, run::run_stop_process, ]) - .run(tauri::generate_context!()) - .expect("error while running Lithe desktop shell"); + .build(tauri::generate_context!()) + .expect("error while building Lithe desktop shell"); + + application.run(|app, event| { + if matches!(event, tauri::RunEvent::Exit) { + if let Some(manager) = app.try_state::>() { + manager.shutdown(); + } + } + }); } fn window_state_flags() -> StateFlags { diff --git a/windows/tauri/src/features/command-palette/constants/settings-actions.tsx b/windows/tauri/src/features/command-palette/constants/settings-actions.tsx index de4eb3ab9..ff5b71faa 100644 --- a/windows/tauri/src/features/command-palette/constants/settings-actions.tsx +++ b/windows/tauri/src/features/command-palette/constants/settings-actions.tsx @@ -70,6 +70,7 @@ const settingsTabLabels: Record = { ai: "AI", keyboard: "Keybindings", language: "Editor", + logs: "Logs", collaboration: "Collaboration", enterprise: "Enterprise", advanced: "Advanced", diff --git a/windows/tauri/src/features/logging/fps-monitor.test.ts b/windows/tauri/src/features/logging/fps-monitor.test.ts new file mode 100644 index 000000000..006ffb4a4 --- /dev/null +++ b/windows/tauri/src/features/logging/fps-monitor.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { + FPS_ANOMALY_ENTER_DURATION_MS, + FpsAnomalyDetector, +} from "./fps-monitor"; + +describe("FPS anomaly detector", () => { + test("requires three seconds below the entry threshold and summarizes recovery", () => { + const detector = new FpsAnomalyDetector(); + let now = 0; + + for (let index = 0; index < FPS_ANOMALY_ENTER_DURATION_MS / 1_000; index += 1) { + now += 1_000; + expect(detector.addBucket(40, 1_000, now, false)).toEqual([]); + } + + now += 1_000; + const recovered = detector.addBucket(55, 1_000, now, false); + expect(recovered).toHaveLength(1); + expect(recovered[0]?.message).toBe("Frame drop recovered"); + expect(recovered[0]?.payload.fps_min).toBe(40); + expect(recovered[0]?.payload.duration_ms).toBe(4_000); + }); + + test("emits progress while an anomaly remains active", () => { + const detector = new FpsAnomalyDetector(); + let now = 0; + const events = []; + + for (let index = 0; index < 13; index += 1) { + now += 1_000; + events.push(...detector.addBucket(30, 1_000, now, false)); + } + + expect(events.some((event) => event.message === "Frame drop ongoing")).toBe(true); + }); + + test("diagnostic mode emits a ten second heartbeat", () => { + const detector = new FpsAnomalyDetector(); + let now = 0; + const events = []; + + for (let index = 0; index < 10; index += 1) { + now += 1_000; + events.push(...detector.addBucket(60, 1_000, now, true)); + } + + const heartbeat = events.find((event) => event.scope === "perf.heartbeat"); + expect(heartbeat?.level).toBe("debug"); + expect(heartbeat?.payload.fps_avg).toBe(60); + }); + + test("visibility pause resets the entry window", () => { + const detector = new FpsAnomalyDetector(); + detector.addBucket(30, 1_000, 1_000, false); + detector.addBucket(30, 1_000, 2_000, false); + detector.pause(); + + expect(detector.addBucket(30, 1_000, 3_000, false)).toEqual([]); + expect(detector.addBucket(55, 1_000, 4_000, false)).toEqual([]); + }); +}); diff --git a/windows/tauri/src/features/logging/fps-monitor.ts b/windows/tauri/src/features/logging/fps-monitor.ts new file mode 100644 index 000000000..0f7d02397 --- /dev/null +++ b/windows/tauri/src/features/logging/fps-monitor.ts @@ -0,0 +1,176 @@ +export const FPS_ANOMALY_ENTER_THRESHOLD = 45; +export const FPS_ANOMALY_EXIT_THRESHOLD = 50; +export const FPS_ANOMALY_ENTER_DURATION_MS = 3_000; +export const FPS_PROGRESS_INTERVAL_MS = 10_000; +export const FPS_DIAGNOSTIC_HEARTBEAT_MS = 10_000; + +export interface FpsLogEvent { + level: "debug" | "warn"; + scope: "perf.fps" | "perf.heartbeat"; + message: string; + payload: Record; +} + +interface FpsWindow { + frames: number; + elapsedMs: number; + minFps: number; +} + +function emptyWindow(): FpsWindow { + return { frames: 0, elapsedMs: 0, minFps: Number.POSITIVE_INFINITY }; +} + +function averageFps(window: FpsWindow) { + return window.elapsedMs > 0 ? (window.frames * 1_000) / window.elapsedMs : 0; +} + +function rounded(value: number) { + return Math.round(value * 10) / 10; +} + +export class FpsAnomalyDetector { + private candidate = emptyWindow(); + private anomaly: FpsWindow | null = null; + private anomalyStartedAt = 0; + private lastProgressAt = 0; + private heartbeat = emptyWindow(); + private heartbeatStartedAt: number | null = null; + + addBucket( + frames: number, + elapsedMs: number, + now: number, + diagnosticEnabled: boolean, + ): FpsLogEvent[] { + const events: FpsLogEvent[] = []; + const fps = elapsedMs > 0 ? (frames * 1_000) / elapsedMs : 0; + + if (diagnosticEnabled) { + if (this.heartbeatStartedAt === null) this.heartbeatStartedAt = now - elapsedMs; + this.addToWindow(this.heartbeat, frames, elapsedMs, fps); + if (now - this.heartbeatStartedAt >= FPS_DIAGNOSTIC_HEARTBEAT_MS) { + events.push({ + level: "debug", + scope: "perf.heartbeat", + message: "FPS diagnostic heartbeat", + payload: { + fps_avg: rounded(averageFps(this.heartbeat)), + fps_min: rounded(this.heartbeat.minFps), + duration_ms: Math.round(this.heartbeat.elapsedMs), + }, + }); + this.heartbeat = emptyWindow(); + this.heartbeatStartedAt = now; + } + } else { + this.heartbeat = emptyWindow(); + this.heartbeatStartedAt = null; + } + + if (this.anomaly) { + this.addToWindow(this.anomaly, frames, elapsedMs, fps); + if (fps >= FPS_ANOMALY_EXIT_THRESHOLD) { + events.push(this.anomalyEvent("Frame drop recovered", now)); + this.resetAnomaly(); + } else if (now - this.lastProgressAt >= FPS_PROGRESS_INTERVAL_MS) { + events.push(this.anomalyEvent("Frame drop ongoing", now)); + this.lastProgressAt = now; + } + return events; + } + + if (fps < FPS_ANOMALY_ENTER_THRESHOLD) { + if (this.candidate.elapsedMs === 0) this.anomalyStartedAt = now - elapsedMs; + this.addToWindow(this.candidate, frames, elapsedMs, fps); + if (this.candidate.elapsedMs >= FPS_ANOMALY_ENTER_DURATION_MS) { + this.anomaly = this.candidate; + this.candidate = emptyWindow(); + this.lastProgressAt = now; + } + } else { + this.candidate = emptyWindow(); + this.anomalyStartedAt = 0; + } + return events; + } + + pause() { + this.candidate = emptyWindow(); + this.heartbeat = emptyWindow(); + this.heartbeatStartedAt = null; + if (!this.anomaly) this.anomalyStartedAt = 0; + } + + private addToWindow(window: FpsWindow, frames: number, elapsedMs: number, fps: number) { + window.frames += frames; + window.elapsedMs += elapsedMs; + window.minFps = Math.min(window.minFps, fps); + } + + private anomalyEvent(message: string, now: number): FpsLogEvent { + const anomaly = this.anomaly ?? this.candidate; + return { + level: "warn", + scope: "perf.fps", + message, + payload: { + fps_avg: rounded(averageFps(anomaly)), + fps_min: rounded(anomaly.minFps), + duration_ms: Math.max(Math.round(anomaly.elapsedMs), Math.round(now - this.anomalyStartedAt)), + }, + }; + } + + private resetAnomaly() { + this.candidate = emptyWindow(); + this.anomaly = null; + this.anomalyStartedAt = 0; + this.lastProgressAt = 0; + } +} + +export function startFpsLogging( + emit: (event: FpsLogEvent) => void, + isDiagnosticEnabled: () => boolean, +) { + if (typeof window === "undefined" || typeof document === "undefined") return () => {}; + + const detector = new FpsAnomalyDetector(); + let frameId = 0; + let bucketStartedAt = performance.now(); + let frames = 0; + + const resetForVisibility = () => { + detector.pause(); + bucketStartedAt = performance.now(); + frames = 0; + }; + document.addEventListener("visibilitychange", resetForVisibility); + + const record = (now: number) => { + if (document.visibilityState !== "visible") { + detector.pause(); + bucketStartedAt = now; + frames = 0; + frameId = window.requestAnimationFrame(record); + return; + } + + frames += 1; + const elapsedMs = now - bucketStartedAt; + if (elapsedMs >= 1_000) { + const events = detector.addBucket(frames, elapsedMs, now, isDiagnosticEnabled()); + for (const event of events) emit(event); + bucketStartedAt = now; + frames = 0; + } + frameId = window.requestAnimationFrame(record); + }; + + frameId = window.requestAnimationFrame(record); + return () => { + window.cancelAnimationFrame(frameId); + document.removeEventListener("visibilitychange", resetForVisibility); + }; +} diff --git a/windows/tauri/src/features/logging/frontend-log-runtime.test.ts b/windows/tauri/src/features/logging/frontend-log-runtime.test.ts new file mode 100644 index 000000000..ea0de15a8 --- /dev/null +++ b/windows/tauri/src/features/logging/frontend-log-runtime.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { + sanitizeFrontendLogPayload, + serializeConsoleArguments, + setFrontendLogWorkspaceRoots, +} from "./frontend-log-runtime"; + +describe("frontend log serialization", () => { + test("never throws for circular objects", () => { + const value: Record = { message: "failure" }; + value.self = value; + expect(() => serializeConsoleArguments([value])).not.toThrow(); + expect(serializeConsoleArguments([value])).toContain("[circular]"); + }); + + test("summarizes errors and limits stack frames", () => { + const error = new Error("broken"); + error.stack = ["Error: broken", ...Array.from({ length: 20 }, (_, index) => `at frame${index}`)].join("\n"); + const serialized = serializeConsoleArguments([error]); + expect(serialized).toContain("Error: broken"); + expect(serialized).toContain("frame7"); + expect(serialized).not.toContain("frame12"); + }); + + test("truncates oversized payloads", () => { + const serialized = serializeConsoleArguments(["x".repeat(5_000)]); + expect(serialized.length).toBeLessThanOrEqual(3_000); + expect(serialized).toContain("[truncated]"); + }); + + test("relativizes registered workspace paths and shortens outside paths", () => { + setFrontendLogWorkspaceRoots(["D:/work/project"]); + expect( + sanitizeFrontendLogPayload({ + filePath: "D:\\work\\project\\src\\main.ts", + workspaceFolders: ["D:\\work\\project", "C:\\outside\\two"], + root: "C:\\Users\\person\\outside.txt", + }), + ).toEqual({ + filePath: "/src/main.ts", + workspaceFolders: ["", "two"], + root: "outside.txt", + }); + }); +}); diff --git a/windows/tauri/src/features/logging/frontend-log-runtime.ts b/windows/tauri/src/features/logging/frontend-log-runtime.ts new file mode 100644 index 000000000..b69c5d32d --- /dev/null +++ b/windows/tauri/src/features/logging/frontend-log-runtime.ts @@ -0,0 +1,292 @@ +import { invoke } from "@/platform/tauri-core"; +import { listen } from "@tauri-apps/api/event"; +import { getLogSettings, type LogSettingsSnapshot } from "./log-api"; +import { startFpsLogging, type FpsLogEvent } from "./fps-monitor"; + +export type FrontendLogLevel = "debug" | "info" | "warn" | "error"; + +interface FrontendLogInput { + level: FrontendLogLevel; + scope: string; + message: string; + payload?: Record | null; +} + +const FORWARD_FAILURE_THRESHOLD = 3; +const FORWARD_BREAKER_COOLDOWN_MS = 5_000; +const SERIALIZED_ARGUMENT_LIMIT = 1_024; +const SERIALIZED_TOTAL_LIMIT = 3_000; +const SERIALIZED_MAX_DEPTH = 3; +const ERROR_STACK_FRAMES = 8; + +let installed = false; +let diagnosticEnabled = false; +let serializing = false; +let consecutiveFailures = 0; +let suppressedFailures = 0; +let breakerOpenUntil = 0; +let halfOpenProbeInFlight = false; +let stopFpsLogging: (() => void) | null = null; +let initializationPromise: Promise | null = null; +let workspaceRoots: string[] = []; + +const PATH_FIELD_NAMES = new Set([ + "path", + "file_path", + "root_folder_path", + "root", + "tab_path", + "workspace", + "workspace_folders", + "workspace_root", + "working_directory", + "repository", + "repo_path", +]); + +const originalConsole = { + warn: console.warn.bind(console), + error: console.error.bind(console), +}; + +function snakeCaseKey(value: string) { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); +} + +function normalizePath(value: string) { + return value.replace(/\\/g, "/").replace(/\/+$/, ""); +} + +function sanitizeStructuredPath(value: string) { + const normalized = normalizePath(value); + const normalizedLower = normalized.toLowerCase(); + const root = workspaceRoots.find((candidate) => { + const rootLower = candidate.toLowerCase(); + return normalizedLower === rootLower || normalizedLower.startsWith(`${rootLower}/`); + }); + if (root) { + const relative = normalized.slice(root.length).replace(/^\/+/, ""); + return relative ? `/${relative}` : ""; + } + return normalized.split("/").filter(Boolean).pop() ?? ""; +} + +export function setFrontendLogWorkspaceRoots(roots: string[]) { + workspaceRoots = [...new Set(roots.map(normalizePath).filter(Boolean))].sort( + (left, right) => right.length - left.length, + ); +} + +export function sanitizeFrontendLogPayload(payload?: Record) { + if (!payload) return null; + return Object.fromEntries( + Object.entries(payload).map(([key, value]) => { + if (PATH_FIELD_NAMES.has(snakeCaseKey(key))) { + if (typeof value === "string") return [key, sanitizeStructuredPath(value)]; + if (Array.isArray(value)) { + return [ + key, + value.map((entry) => + typeof entry === "string" ? sanitizeStructuredPath(entry) : entry, + ), + ]; + } + } + return [key, value]; + }), + ); +} + +function truncate(value: string, limit: number) { + if (value.length <= limit) return value; + return `${value.slice(0, Math.max(0, limit - 18))}…[truncated]`; +} + +function serializeUnknown( + value: unknown, + depth: number, + seen: WeakSet, +): string { + try { + if (value instanceof Error) { + const stack = value.stack + ?.split(/\r?\n/) + .slice(0, ERROR_STACK_FRAMES + 1) + .join("\\n"); + return truncate(`${value.name}: ${value.message}${stack ? ` ${stack}` : ""}`, SERIALIZED_ARGUMENT_LIMIT); + } + if (typeof value === "string") return truncate(value, SERIALIZED_ARGUMENT_LIMIT); + if (value === null || value === undefined || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (typeof value === "bigint") return `${value}n`; + if (typeof value === "symbol" || typeof value === "function") return String(value); + if (depth >= SERIALIZED_MAX_DEPTH) return "[max-depth]"; + if (typeof value === "object") { + if (seen.has(value)) return "[circular]"; + seen.add(value); + if (typeof Element !== "undefined" && value instanceof Element) { + return `<${value.tagName.toLowerCase()}>`; + } + if (Array.isArray(value)) { + return `[${value.map((entry) => serializeUnknown(entry, depth + 1, seen)).join(",")}]`; + } + const entries = Object.entries(value as Record) + .slice(0, 20) + .map(([key, entry]) => `${key}:${serializeUnknown(entry, depth + 1, seen)}`); + return `{${entries.join(",")}}`; + } + } catch { + return "[unserializable]"; + } + return "[unserializable]"; +} + +export function serializeConsoleArguments(values: unknown[]) { + try { + const seen = new WeakSet(); + return truncate( + values.map((value) => serializeUnknown(value, 0, seen)).join(" "), + SERIALIZED_TOTAL_LIMIT, + ); + } catch { + return "[unserializable]"; + } +} + +function rawInvoke(input: FrontendLogInput) { + return invoke("frontend_trace", { + level: input.level, + scope: input.scope, + message: input.message, + payload: input.payload ?? null, + }); +} + +export function submitFrontendLog(input: FrontendLogInput): Promise { + if (input.level === "debug" && !diagnosticEnabled) return Promise.resolve(false); + + const now = Date.now(); + const breakerIsOpen = breakerOpenUntil > now; + if (breakerIsOpen) { + suppressedFailures += 1; + return Promise.resolve(false); + } + const isHalfOpenProbe = breakerOpenUntil > 0; + if (isHalfOpenProbe && halfOpenProbeInFlight) return Promise.resolve(false); + if (isHalfOpenProbe) halfOpenProbeInFlight = true; + + return rawInvoke(input) + .then(() => { + const recoveredFailures = consecutiveFailures + suppressedFailures; + consecutiveFailures = 0; + suppressedFailures = 0; + breakerOpenUntil = 0; + halfOpenProbeInFlight = false; + if (recoveredFailures > 0) { + void rawInvoke({ + level: "warn", + scope: "runtime.console", + message: "frontend log forwarding recovered", + payload: { failures: recoveredFailures }, + }).catch(() => {}); + } + return true; + }) + .catch(() => { + halfOpenProbeInFlight = false; + consecutiveFailures += 1; + if (consecutiveFailures >= FORWARD_FAILURE_THRESHOLD) { + breakerOpenUntil = Date.now() + FORWARD_BREAKER_COOLDOWN_MS; + } + return false; + }); +} + +function forwardConsole(level: "warn" | "error", args: unknown[]) { + if (serializing) return; + serializing = true; + try { + const message = serializeConsoleArguments(args); + void submitFrontendLog({ level, scope: "runtime.console", message }); + } finally { + serializing = false; + } +} + +function installConsoleProxy() { + console.warn = (...args: unknown[]) => { + originalConsole.warn(...args); + forwardConsole("warn", args); + }; + console.error = (...args: unknown[]) => { + originalConsole.error(...args); + forwardConsole("error", args); + }; +} + +function installUnhandledErrorCapture() { + window.addEventListener("error", (event) => { + void submitFrontendLog({ + level: "error", + scope: "runtime.js", + message: event.message || "Unhandled JavaScript error", + payload: { + filename: event.filename?.split(/[\\/]/).pop() ?? null, + line: event.lineno || null, + column: event.colno || null, + stack: event.error instanceof Error ? serializeConsoleArguments([event.error]) : null, + }, + }); + }); + window.addEventListener("unhandledrejection", (event) => { + void submitFrontendLog({ + level: "error", + scope: "runtime.js.rejection", + message: "Unhandled promise rejection", + payload: { reason: serializeConsoleArguments([event.reason]) }, + }); + }); +} + +function forwardFpsEvent(event: FpsLogEvent) { + void submitFrontendLog(event); +} + +export function setFrontendDiagnosticEnabled(enabled: boolean) { + diagnosticEnabled = enabled; +} + +export function isFrontendDiagnosticEnabled() { + return diagnosticEnabled; +} + +export function initializeFrontendLogging() { + if (installed) return initializationPromise ?? Promise.resolve(null); + installed = true; + installConsoleProxy(); + installUnhandledErrorCapture(); + stopFpsLogging = startFpsLogging(forwardFpsEvent, isFrontendDiagnosticEnabled); + void listen("lithe-log-runtime-fallback", (event) => { + diagnosticEnabled = event.payload.diagnostic_enabled; + window.dispatchEvent( + new CustomEvent("lithe-log-runtime-fallback", { detail: event.payload }), + ); + }).catch(() => {}); + initializationPromise = getLogSettings() + .then((snapshot) => { + diagnosticEnabled = snapshot.diagnostic_enabled; + return snapshot; + }) + .catch(() => null); + return initializationPromise; +} + +export function disposeFrontendLoggingForTests() { + stopFpsLogging?.(); + stopFpsLogging = null; +} diff --git a/windows/tauri/src/features/logging/log-api.ts b/windows/tauri/src/features/logging/log-api.ts new file mode 100644 index 000000000..b161660ac --- /dev/null +++ b/windows/tauri/src/features/logging/log-api.ts @@ -0,0 +1,50 @@ +import { invoke } from "@/platform/tauri-core"; + +export type LogFallbackReason = "unavailable" | "not_writable" | "logging_unavailable"; + +export interface LogSettingsSnapshot { + default_path: string; + configured_path: string | null; + effective_path: string; + fallback_reason: LogFallbackReason | null; + diagnostic_enabled: boolean; +} + +export interface LogDirectoryChangeResult { + settings: LogSettingsSnapshot; + previous_custom_path: string | null; + previous_log_bytes: number; +} + +export interface ClearLogResult { + deleted_files: number; + freed_bytes: number; +} + +export function getLogSettings() { + return invoke("get_log_settings"); +} + +export function setLogDirectory(parentPath: string | null) { + return invoke("set_log_directory", { + parentPath, + }); +} + +export function setDiagnosticLogging(enabled: boolean) { + return invoke("set_diagnostic_logging", { enabled }); +} + +export function clearLitheLogs() { + return invoke("clear_lithe_logs"); +} + +export function resolvePreviousLogCleanup(deleteLogs: boolean) { + return invoke("resolve_previous_log_cleanup", { + delete: deleteLogs, + }); +} + +export function openLogDirectory() { + return invoke("open_log_directory"); +} diff --git a/windows/tauri/src/features/logging/log-fallback-notification.tsx b/windows/tauri/src/features/logging/log-fallback-notification.tsx new file mode 100644 index 000000000..a3696fef9 --- /dev/null +++ b/windows/tauri/src/features/logging/log-fallback-notification.tsx @@ -0,0 +1,40 @@ +import { useEffect } from "react"; +import type { LogSettingsSnapshot } from "./log-api"; +import { initializeFrontendLogging } from "./frontend-log-runtime"; +import { useToast } from "@/features/layout/contexts/toast-context"; +import { useTranslation } from "@/i18n/locale-provider"; + +export function LogFallbackNotification() { + const { showToast } = useToast(); + const { t } = useTranslation(); + + useEffect(() => { + let lastSignature = ""; + const showFallback = (settings: LogSettingsSnapshot | null) => { + if (!settings?.fallback_reason) return; + const signature = `${settings.fallback_reason}:${settings.effective_path}`; + if (signature === lastSignature) return; + lastSignature = signature; + showToast({ + key: "log-directory-fallback", + type: "warning", + message: + settings.fallback_reason === "logging_unavailable" + ? t("settings.logs.unavailableNotification") + : t("settings.logs.fallbackNotification", { + path: settings.configured_path ?? settings.default_path, + }), + }); + }; + const handleRuntimeFallback = (event: Event) => { + showFallback((event as CustomEvent).detail); + }; + window.addEventListener("lithe-log-runtime-fallback", handleRuntimeFallback); + void initializeFrontendLogging().then(showFallback).catch(() => {}); + return () => { + window.removeEventListener("lithe-log-runtime-fallback", handleRuntimeFallback); + }; + }, [showToast, t]); + + return null; +} diff --git a/windows/tauri/src/features/settings/components/log-settings-panel.tsx b/windows/tauri/src/features/settings/components/log-settings-panel.tsx new file mode 100644 index 000000000..a449f66f0 --- /dev/null +++ b/windows/tauri/src/features/settings/components/log-settings-panel.tsx @@ -0,0 +1,358 @@ +import { open } from "@tauri-apps/plugin-dialog"; +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { + clearLitheLogs, + getLogSettings, + openLogDirectory, + resolvePreviousLogCleanup, + setDiagnosticLogging, + setLogDirectory, + type LogFallbackReason, + type LogSettingsSnapshot, +} from "@/features/logging/log-api"; +import { setFrontendDiagnosticEnabled } from "@/features/logging/frontend-log-runtime"; +import { useToast } from "@/features/layout/contexts/toast-context"; +import { + openLitheLogBuffer, + refreshOpenLitheLogBuffer, +} from "@/features/settings/services/lithe-log-service"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import { showConfirmDialog } from "@/ui/dialog"; +import Switch from "@/ui/switch"; +import { writeClipboardText } from "@/utils/clipboard"; + +function SettingsGroup({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function SettingsRow({ + label, + description, + children, +}: { + label: string; + description?: string; + children: ReactNode; +}) { + return ( +
+
+
{label}
+ {description ? ( +

+ {description} +

+ ) : null} +
+
{children}
+
+ ); +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function formatBytes(bytes: number) { + if (bytes < 1_024) return `${bytes} B`; + if (bytes < 1_024 * 1_024) return `${(bytes / 1_024).toFixed(1)} KB`; + return `${(bytes / (1_024 * 1_024)).toFixed(1)} MB`; +} + +export function LogSettingsPanel({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + const { showToast } = useToast(); + const [settings, setSettings] = useState(null); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + setSettings(await getLogSettings()); + } catch (error) { + showToast({ + message: t("settings.logs.loadFailed", { error: errorMessage(error) }), + type: "error", + }); + } + }, [showToast, t]); + + useEffect(() => { + void load(); + const handleRuntimeFallback = (event: Event) => { + setSettings((event as CustomEvent).detail); + }; + window.addEventListener("lithe-log-runtime-fallback", handleRuntimeFallback); + return () => { + window.removeEventListener("lithe-log-runtime-fallback", handleRuntimeFallback); + }; + }, [load]); + + const refreshOpenLogAfterMutation = async () => { + try { + await refreshOpenLitheLogBuffer(); + } catch (error) { + showToast({ + message: t("settings.logs.refreshFailed", { error: errorMessage(error) }), + type: "warning", + }); + } + }; + + const finishDirectoryChange = async ( + result: Awaited>, + ) => { + setSettings(result.settings); + await refreshOpenLogAfterMutation(); + if (!result.previous_custom_path || result.previous_log_bytes <= 0) return; + const shouldDelete = await showConfirmDialog( + t("settings.logs.previousDirectoryPrompt", { + path: result.previous_custom_path, + size: formatBytes(result.previous_log_bytes), + }), + { + title: t("settings.logs.previousDirectoryTitle"), + confirmLabel: t("settings.logs.deleteLogs"), + cancelLabel: t("settings.logs.keepLogs"), + }, + ); + try { + await resolvePreviousLogCleanup(shouldDelete); + } catch (error) { + showToast({ + message: t("settings.logs.cleanupFailed", { error: errorMessage(error) }), + type: "error", + }); + } + }; + + const chooseDirectory = async () => { + const selected = await open({ + directory: true, + multiple: false, + title: t("settings.logs.chooseDirectory"), + }); + if (!selected || Array.isArray(selected)) return; + setBusy(true); + try { + await finishDirectoryChange(await setLogDirectory(selected)); + showToast({ message: t("settings.logs.directoryChanged"), type: "success" }); + } catch (error) { + showToast({ + message: t("settings.logs.directoryChangeFailed", { error: errorMessage(error) }), + type: "error", + }); + } finally { + setBusy(false); + } + }; + + const restoreDefault = async () => { + setBusy(true); + try { + await finishDirectoryChange(await setLogDirectory(null)); + showToast({ message: t("settings.logs.defaultRestored"), type: "success" }); + } catch (error) { + showToast({ + message: t("settings.logs.directoryChangeFailed", { error: errorMessage(error) }), + type: "error", + }); + } finally { + setBusy(false); + } + }; + + const toggleDiagnostic = async (enabled: boolean) => { + setBusy(true); + try { + const snapshot = await setDiagnosticLogging(enabled); + setFrontendDiagnosticEnabled(snapshot.diagnostic_enabled); + setSettings(snapshot); + } catch (error) { + showToast({ + message: t("settings.logs.diagnosticFailed", { error: errorMessage(error) }), + type: "error", + }); + } finally { + setBusy(false); + } + }; + + const clearLogs = async () => { + const confirmed = await showConfirmDialog(t("settings.logs.clearPrompt"), { + title: t("settings.logs.clearTitle"), + confirmLabel: t("settings.logs.clearLogs"), + cancelLabel: t("settings.logs.cancel"), + }); + if (!confirmed) return; + setBusy(true); + try { + const result = await clearLitheLogs(); + await refreshOpenLogAfterMutation(); + showToast({ + message: t("settings.logs.cleared", { + count: result.deleted_files, + size: formatBytes(result.freed_bytes), + }), + type: "success", + }); + } catch (error) { + showToast({ + message: t("settings.logs.cleanupFailed", { error: errorMessage(error) }), + type: "error", + }); + } finally { + setBusy(false); + } + }; + + const openCurrentLog = async () => { + try { + await openLitheLogBuffer(); + onClose(); + } catch (error) { + showToast({ + message: t("settings.logs.openFailed", { error: errorMessage(error) }), + type: "error", + }); + } + }; + + const openDirectory = async () => { + if (!settings) return; + try { + await openLogDirectory(); + } catch (error) { + showToast({ + message: t("settings.logs.openDirectoryFailed", { error: errorMessage(error) }), + type: "error", + }); + } + }; + + const copyEffectivePath = async () => { + if (!settings) return; + try { + await writeClipboardText(settings.effective_path); + showToast({ message: t("settings.logs.pathCopied"), type: "success" }); + } catch (error) { + showToast({ + message: t("settings.logs.copyFailed", { error: errorMessage(error) }), + type: "error", + }); + } + }; + + const fallbackReason = settings?.fallback_reason + ? t(`settings.logs.fallback.${settings.fallback_reason as LogFallbackReason}`) + : null; + + return ( +
+ + +
+ + {settings?.effective_path ?? t("settings.logs.loading")} + +
+ + + +
+
+
+ + + {settings?.default_path ?? t("settings.logs.loading")} + + + +
+ + {settings?.configured_path ?? t("settings.logs.usingDefault")} + +
+ + +
+
+
+
+ + + + void toggleDiagnostic(enabled)} + size="sm" + /> + + + + +

+ {t("settings.logs.retentionDescription")} +

+ + + +
+
+ ); +} diff --git a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx index b3cf8ceab..e12c8c97a 100644 --- a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx +++ b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx @@ -10,6 +10,7 @@ import { import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; import Switch from "@/ui/switch"; +import { LogSettingsPanel } from "./log-settings-panel"; export type MacSettingsCategory = | "general" @@ -18,12 +19,13 @@ export type MacSettingsCategory = | "terminal" | "lsp" | "ai" + | "logs" | "updates"; const controlClassName = "h-8 rounded-md border border-input bg-background px-2.5 text-foreground outline-none focus:border-primary"; -function SettingsGroup({ title, children }: { title: string; children: ReactNode }) { +export function SettingsGroup({ title, children }: { title: string; children: ReactNode }) { return (

@@ -34,7 +36,7 @@ function SettingsGroup({ title, children }: { title: string; children: ReactNode ); } -function SettingsRow({ +export function SettingsRow({ label, description, children, @@ -475,7 +477,13 @@ function UpdatesPanel() { ); } -export function MacSettingsPanel({ category }: { category: MacSettingsCategory }) { +export function MacSettingsPanel({ + category, + onClose, +}: { + category: MacSettingsCategory; + onClose: () => void; +}) { switch (category) { case "general": return ; @@ -489,6 +497,8 @@ export function MacSettingsPanel({ category }: { category: MacSettingsCategory } return ; case "ai": return ; + case "logs": + return ; case "updates": return ; } diff --git a/windows/tauri/src/features/settings/components/settings-dialog.tsx b/windows/tauri/src/features/settings/components/settings-dialog.tsx index dcdedd66e..1bfd48fd7 100644 --- a/windows/tauri/src/features/settings/components/settings-dialog.tsx +++ b/windows/tauri/src/features/settings/components/settings-dialog.tsx @@ -13,6 +13,7 @@ import { GearSixIcon, KeyboardIcon, MagicWandIcon, + FileTextIcon, TerminalWindowIcon, type Icon, } from "@/ui/icons"; @@ -36,6 +37,7 @@ const categories: CategoryItem[] = [ { id: "terminal", labelKey: "settings.tabs.terminal", icon: TerminalWindowIcon }, { id: "lsp", labelKey: "settings.tabs.lsp", icon: DatabaseIcon }, { id: "ai", labelKey: "settings.tabs.aiCommit", icon: MagicWandIcon }, + { id: "logs", labelKey: "settings.tabs.logs", icon: FileTextIcon }, { id: "updates", labelKey: "settings.tabs.updates", icon: ArrowClockwiseIcon }, ]; @@ -45,6 +47,7 @@ function categoryFromRequestedTab(tab: SettingsTab | null): MacSettingsCategory case "keyboard": case "terminal": case "ai": + case "logs": return tab; case "language": return "lsp"; @@ -125,7 +128,7 @@ const SettingsDialog = ({ isOpen, onClose }: SettingsDialogProps) => {

{t(activeItem.labelKey)}

- +
diff --git a/windows/tauri/src/features/settings/lib/settings-access.ts b/windows/tauri/src/features/settings/lib/settings-access.ts index 1819f1640..e71ae403e 100644 --- a/windows/tauri/src/features/settings/lib/settings-access.ts +++ b/windows/tauri/src/features/settings/lib/settings-access.ts @@ -28,7 +28,7 @@ export function resolveVisibleSettingsSection( tab: SettingsTab, access: SettingsAccess, ): SettingsSection { - if (tab === "language" || tab === "account" || tab === "ai") { + if (tab === "language" || tab === "account" || tab === "ai" || tab === "logs") { return "editor"; } diff --git a/windows/tauri/src/features/settings/lib/settings-search.ts b/windows/tauri/src/features/settings/lib/settings-search.ts index 9f8acb137..ed68f95bb 100644 --- a/windows/tauri/src/features/settings/lib/settings-search.ts +++ b/windows/tauri/src/features/settings/lib/settings-search.ts @@ -8,6 +8,7 @@ export const SETTINGS_SEARCH_TAB_LABELS: Record = { appearance: "Appearance", editor: "Editor", language: "Language", + logs: "Logs", "file-explorer": "Files", git: "Git", terminal: "Terminal", diff --git a/windows/tauri/src/features/settings/services/lithe-log-service.ts b/windows/tauri/src/features/settings/services/lithe-log-service.ts index 012a894d9..34dccd9f3 100644 --- a/windows/tauri/src/features/settings/services/lithe-log-service.ts +++ b/windows/tauri/src/features/settings/services/lithe-log-service.ts @@ -3,6 +3,7 @@ import { EDITOR_CONSTANTS } from "@/features/editor/config/constants"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useEditorStateStore } from "@/features/editor/stores/state.store"; import { usePaneStore } from "@/features/panes/stores/pane.store"; +import type { EditorContent } from "@/features/panes/types/pane-content.types"; interface LitheLogFile { path: string; @@ -22,7 +23,7 @@ function toLitheLogFile(response: LitheLogFileResponse): LitheLogFile { return { path: response.path, content: response.content, - targetLine: response.target_line, + targetLine: Math.max(0, response.target_line - 1), truncated: response.truncated, }; } @@ -67,8 +68,71 @@ function cacheLogViewState(bufferId: string, content: string, targetLine: number } } +function isLitheLogBuffer(buffer: unknown): buffer is EditorContent { + if (!buffer || typeof buffer !== "object") return false; + const candidate = buffer as Partial; + return ( + candidate.type === "editor" && + candidate.isVirtual === true && + candidate.readOnly === true && + candidate.language === "log" && + typeof candidate.name === "string" && + candidate.name.toLowerCase().startsWith("lithe.") && + candidate.name.toLowerCase().endsWith(".log") + ); +} + +function updateLogBuffer(buffer: EditorContent, logFile: LitheLogFile) { + useBufferStore.getState().actions.updateBuffer({ + ...buffer, + path: logFile.path, + name: getFileName(logFile.path), + content: logFile.content, + savedContent: logFile.content, + isDirty: false, + isVirtual: true, + readOnly: true, + language: "log", + tokens: [], + }); + cacheLogViewState(buffer.id, logFile.content, logFile.targetLine); +} + +async function readCurrentLitheLog() { + return toLitheLogFile(await invoke("read_lithe_log")); +} + +export async function refreshOpenLitheLogBuffer() { + const bufferStore = useBufferStore.getState(); + const openLogBuffers = bufferStore.buffers.filter(isLitheLogBuffer); + if (openLogBuffers.length === 0) return false; + + const target = + openLogBuffers.find((buffer) => buffer.id === bufferStore.activeBufferId) ?? openLogBuffers[0]; + const logFile = await readCurrentLitheLog(); + updateLogBuffer(target, logFile); + for (const duplicate of openLogBuffers) { + if (duplicate.id !== target.id) { + useBufferStore.getState().actions.closeBufferForce(duplicate.id); + } + } + return true; +} + export async function openLitheLogBuffer() { - const logFile = toLitheLogFile(await invoke("read_lithe_log")); + const logFile = await readCurrentLitheLog(); + const currentStore = useBufferStore.getState(); + const existingLogBuffers = currentStore.buffers.filter(isLitheLogBuffer); + const existing = + existingLogBuffers.find((buffer) => buffer.id === currentStore.activeBufferId) ?? + existingLogBuffers[0]; + if (existing) { + updateLogBuffer(existing, logFile); + for (const duplicate of existingLogBuffers) { + if (duplicate.id !== existing.id) currentStore.actions.closeBufferForce(duplicate.id); + } + } + const bufferId = useBufferStore.getState().actions.openContent({ type: "editor", path: logFile.path, @@ -81,18 +145,6 @@ export async function openLitheLogBuffer() { const nextBufferStore = useBufferStore.getState(); const openedBuffer = nextBufferStore.buffers.find((buffer) => buffer.id === bufferId); - if (openedBuffer?.type === "editor") { - nextBufferStore.actions.updateBuffer({ - ...openedBuffer, - content: logFile.content, - savedContent: logFile.content, - isDirty: false, - isVirtual: true, - readOnly: true, - language: "log", - }); - } - - cacheLogViewState(bufferId, logFile.content, logFile.targetLine); + if (openedBuffer?.type === "editor") updateLogBuffer(openedBuffer, logFile); return logFile; } diff --git a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts index c3613f502..c6ded59d6 100644 --- a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts +++ b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts @@ -7,6 +7,7 @@ export type SettingsTab = | "ai" | "keyboard" | "language" + | "logs" | "collaboration" | "enterprise" | "advanced" diff --git a/windows/tauri/src/features/window/stores/workspace-tabs.store.ts b/windows/tauri/src/features/window/stores/workspace-tabs.store.ts index a7599a2f1..73c1de672 100644 --- a/windows/tauri/src/features/window/stores/workspace-tabs.store.ts +++ b/windows/tauri/src/features/window/stores/workspace-tabs.store.ts @@ -4,6 +4,7 @@ import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; import { createSelectors } from "@/utils/zustand-selectors"; import { createSafeJSONStorage } from "@/utils/zustand-storage"; +import { setFrontendLogWorkspaceRoots } from "@/features/logging/frontend-log-runtime"; import { removeProjectTabItems } from "../utils/project-tab-close"; import { reorderProjectTabItems } from "../utils/project-tab-order"; import { renameRemoteProjectTabs } from "../utils/project-tab-remote"; @@ -181,3 +182,8 @@ const useWorkspaceTabsStoreBase = create()( ); export const useWorkspaceTabsStore = createSelectors(useWorkspaceTabsStoreBase); + +setFrontendLogWorkspaceRoots(useWorkspaceTabsStoreBase.getState().projectTabs.map((tab) => tab.path)); +useWorkspaceTabsStoreBase.subscribe((state) => { + setFrontendLogWorkspaceRoots(state.projectTabs.map((tab) => tab.path)); +}); diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 27c21aad3..06e83b1f9 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1743,6 +1743,7 @@ const catalogs = { "settings.tabs.advanced": "Advanced", "settings.tabs.lsp": "LSP", "settings.tabs.aiCommit": "AI & Commit", + "settings.tabs.logs": "Logs", "settings.tabs.updates": "Updates", "settings.mac.categories": "Settings categories", "settings.mac.restoreDefaults": "Restore Defaults", @@ -1831,6 +1832,62 @@ const catalogs = { "settings.mac.updateFailed": "The update check failed. Try again later.", "settings.mac.updateAvailable": "Version {version} is available.", "settings.mac.updateHint": "Lithe can check for new preview and stable releases.", + "settings.logs.locations": "Log Locations", + "settings.logs.effectivePath": "Current log location", + "settings.logs.effectivePathDescription": "The directory receiving logs in this session.", + "settings.logs.defaultPath": "Default log location", + "settings.logs.defaultPathDescription": "Lithe always maintains and cleans this application-owned directory.", + "settings.logs.customPath": "Custom log location", + "settings.logs.customPathDescription": "Choose a parent directory; Lithe writes to its Lithe/logs subdirectory.", + "settings.logs.usingDefault": "Using the default location", + "settings.logs.loading": "Loading…", + "settings.logs.openCurrent": "Open Current Log", + "settings.logs.openFailed": "Unable to open the current log: {error}", + "settings.logs.openDirectory": "Open Directory", + "settings.logs.openDirectoryFailed": "Unable to open the log directory: {error}", + "settings.logs.copyPath": "Copy Path", + "settings.logs.pathCopied": "Log path copied.", + "settings.logs.copyFailed": "Unable to copy the log path: {error}", + "settings.logs.choose": "Choose…", + "settings.logs.chooseDirectory": "Choose a Parent Directory for Lithe Logs", + "settings.logs.restoreDefault": "Restore Default", + "settings.logs.directoryChanged": "The log directory changed immediately.", + "settings.logs.defaultRestored": "The default log directory is active.", + "settings.logs.directoryChangeFailed": "Unable to change the log directory: {error}", + "settings.logs.loadFailed": "Unable to load log settings: {error}", + "settings.logs.previousDirectoryTitle": "Clean Up Previous Logs?", + "settings.logs.previousDirectoryPrompt": + "The previous directory {path} still contains about {size} of Lithe logs. Delete them?", + "settings.logs.deleteLogs": "Delete Logs", + "settings.logs.keepLogs": "Keep Logs", + "settings.logs.cleanupFailed": "Unable to clean up logs: {error}", + "settings.logs.diagnostics": "Diagnostics", + "settings.logs.diagnosticMode": "Diagnostic logging for this session", + "settings.logs.diagnosticModeDescription": + "Record DEBUG events and a low-frequency FPS heartbeat until Lithe restarts.", + "settings.logs.diagnosticFailed": "Unable to change diagnostic logging: {error}", + "settings.logs.retention": "Retention", + "settings.logs.retentionDescription": + "Logs rotate at 10 MB, keep the newest five regular files per day, and are removed after 30 days when Lithe starts.", + "settings.logs.clearCurrent": "Clear current directory logs", + "settings.logs.clearCurrentDescription": "Delete only log files managed by Lithe in the active directory.", + "settings.logs.clearLogs": "Clear Logs", + "settings.logs.clearTitle": "Clear Current Logs?", + "settings.logs.clearPrompt": + "Lithe will close the current log, delete every managed log in the active directory, and start a new log file.", + "settings.logs.cancel": "Cancel", + "settings.logs.cleared": "Deleted {count} log files and freed {size}.", + "settings.logs.refreshFailed": "Logs were cleared, but the open log tab could not refresh: {error}", + "settings.logs.fallback.unavailable": + "The configured directory is unavailable. This session is writing to the default location.", + "settings.logs.fallback.not_writable": + "The configured directory is not writable. This session is writing to the default location.", + "settings.logs.fallback.logging_unavailable": + "Application file logging is unavailable. Lithe is continuing with console diagnostics only.", + "settings.logs.fallbackNotification": + "The log directory {path} is unavailable; this session is using the default location.", + "settings.logs.unavailableNotification": + "Application file logging is unavailable; Lithe will continue without writing log files.", "settings.git.integration": "Integration", "settings.git.gitIntegration": "Git Integration", "settings.git.gitIntegrationDescription": @@ -5760,6 +5817,7 @@ const catalogs = { "settings.tabs.advanced": "高级", "settings.tabs.lsp": "LSP", "settings.tabs.aiCommit": "AI 与提交", + "settings.tabs.logs": "日志", "settings.tabs.updates": "更新", "settings.mac.categories": "设置分类", "settings.mac.restoreDefaults": "恢复默认设置", @@ -5843,6 +5901,62 @@ const catalogs = { "settings.mac.updateFailed": "检查更新失败,请稍后重试。", "settings.mac.updateAvailable": "版本 {version} 可用。", "settings.mac.updateHint": "Lithe 可以检查新的预览版和稳定版。", + "settings.logs.locations": "日志位置", + "settings.logs.effectivePath": "当前日志位置", + "settings.logs.effectivePathDescription": "本次会话实际写入日志的目录。", + "settings.logs.defaultPath": "默认日志位置", + "settings.logs.defaultPathDescription": "Lithe 始终维护并清理这个应用自有目录。", + "settings.logs.customPath": "自定义日志位置", + "settings.logs.customPathDescription": "选择父目录后,Lithe 会写入其中的 Lithe/logs 子目录。", + "settings.logs.usingDefault": "正在使用默认位置", + "settings.logs.loading": "正在加载…", + "settings.logs.openCurrent": "打开当前日志", + "settings.logs.openFailed": "无法打开当前日志:{error}", + "settings.logs.openDirectory": "打开目录", + "settings.logs.openDirectoryFailed": "无法打开日志目录:{error}", + "settings.logs.copyPath": "复制路径", + "settings.logs.pathCopied": "日志路径已复制。", + "settings.logs.copyFailed": "无法复制日志路径:{error}", + "settings.logs.choose": "选择…", + "settings.logs.chooseDirectory": "选择 Lithe 日志的父目录", + "settings.logs.restoreDefault": "恢复默认", + "settings.logs.directoryChanged": "日志目录已立即切换。", + "settings.logs.defaultRestored": "默认日志目录已生效。", + "settings.logs.directoryChangeFailed": "无法切换日志目录:{error}", + "settings.logs.loadFailed": "无法加载日志设置:{error}", + "settings.logs.previousDirectoryTitle": "清理旧日志?", + "settings.logs.previousDirectoryPrompt": + "旧目录 {path} 中仍有约 {size} 的 Lithe 日志,是否删除?", + "settings.logs.deleteLogs": "删除日志", + "settings.logs.keepLogs": "保留日志", + "settings.logs.cleanupFailed": "无法清理日志:{error}", + "settings.logs.diagnostics": "诊断", + "settings.logs.diagnosticMode": "本次会话启用诊断日志", + "settings.logs.diagnosticModeDescription": + "记录 DEBUG 事件和低频 FPS 心跳;Lithe 重启后自动恢复为 INFO。", + "settings.logs.diagnosticFailed": "无法切换诊断日志:{error}", + "settings.logs.retention": "保留策略", + "settings.logs.retentionDescription": + "单个日志达到 10 MB 后轮转,每天保留最新五个常规日志,并在 Lithe 启动时删除超过 30 天的日志。", + "settings.logs.clearCurrent": "清除当前目录日志", + "settings.logs.clearCurrentDescription": "只删除当前目录中由 Lithe 管理的日志文件。", + "settings.logs.clearLogs": "清除日志", + "settings.logs.clearTitle": "清除当前日志?", + "settings.logs.clearPrompt": + "Lithe 将关闭当前日志,删除生效目录中的全部托管日志,然后创建新的日志文件。", + "settings.logs.cancel": "取消", + "settings.logs.cleared": "已删除 {count} 个日志文件,释放 {size}。", + "settings.logs.refreshFailed": "日志已清除,但无法刷新已打开的日志标签:{error}", + "settings.logs.fallback.unavailable": + "配置的目录当前不可用,本次会话正在写入默认位置。", + "settings.logs.fallback.not_writable": + "配置的目录不可写,本次会话正在写入默认位置。", + "settings.logs.fallback.logging_unavailable": + "应用文件日志当前不可用,Lithe 将继续运行并仅保留控制台诊断。", + "settings.logs.fallbackNotification": + "日志目录 {path} 当前不可用,本次运行将写入默认位置。", + "settings.logs.unavailableNotification": + "应用文件日志当前不可用,Lithe 将继续运行但不会写入日志文件。", "settings.git.integration": "集成", "settings.git.gitIntegration": "Git 集成", "settings.git.gitIntegrationDescription": "启用 Git 仓库的源代码管理功能", diff --git a/windows/tauri/src/main.tsx b/windows/tauri/src/main.tsx index 5fdf04468..c76671484 100644 --- a/windows/tauri/src/main.tsx +++ b/windows/tauri/src/main.tsx @@ -4,6 +4,9 @@ import App from "./App.tsx"; import { recordStartupMilestone } from "./features/bootstrap/startup-performance.ts"; import { initializeFrontendTerminalSession } from "./features/terminal/utils/frontend-terminal-session.ts"; import { traceWindowOpen } from "./features/window/utils/window-open-diagnostics.ts"; +import { initializeFrontendLogging } from "./features/logging/frontend-log-runtime.ts"; + +void initializeFrontendLogging(); if (import.meta.env.VITE_REACT_SCAN === "true") { void import("react-scan").then(({ scan }) => scan({ enabled: true })); diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index 04a4d7df6..3122d3131 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -20,6 +20,7 @@ const nativeCommands = new Set([ "clipboard_get", "clipboard_paste", "clipboard_set", + "clear_lithe_logs", "close_terminal", "core_cancel", "core_execute", @@ -28,6 +29,7 @@ const nativeCommands = new Set([ "get_secure_secret", "frontend_trace", "get_bundled_extensions_path", + "get_log_settings", "get_monospace_fonts", "get_symlink_info", "get_system_fonts", @@ -35,11 +37,14 @@ const nativeCommands = new Set([ "list_shells", "lsp_resolve_java_launch", "move_file", + "open_log_directory", "open_file_external", "read_file_custom", "read_local_file", "record_startup_milestone", + "read_lithe_log", "remove_secure_secret", + "resolve_previous_log_cleanup", "rename_file", "run_discover_toolchains", "run_list_java_sources", @@ -50,6 +55,8 @@ const nativeCommands = new Set([ "run_write_generated", "run_write_stdin", "set_native_window_appearance", + "set_diagnostic_logging", + "set_log_directory", "set_project_root", "start_watching", "stop_watching", diff --git a/windows/tauri/src/utils/frontend-trace.ts b/windows/tauri/src/utils/frontend-trace.ts index 47460a333..731a5a135 100644 --- a/windows/tauri/src/utils/frontend-trace.ts +++ b/windows/tauri/src/utils/frontend-trace.ts @@ -1,29 +1,10 @@ -import { invoke } from "@/platform/tauri-core"; +import { + submitFrontendLog, + sanitizeFrontendLogPayload, + type FrontendLogLevel, +} from "@/features/logging/frontend-log-runtime"; -type TraceLevel = "debug" | "info" | "warn" | "error"; - -const FRONTEND_TRACE_ENABLED = import.meta.env.VITE_FRONTEND_TRACE === "true"; -const FRONTEND_BENCHMARK_TRACE_ENABLED = - import.meta.env.DEV || import.meta.env.VITE_FILE_OPEN_BENCHMARK === "1"; - -function shortPath(value: string) { - const normalized = value.replace(/[\\/]+$/, ""); - const parts = normalized.split(/[\\/]/); - return parts[parts.length - 1] || value; -} - -function sanitizePayload(payload?: Record) { - if (!payload) return null; - - return Object.fromEntries( - Object.entries(payload).map(([key, value]) => { - if (typeof value === "string" && /path/i.test(key)) { - return [key, shortPath(value)]; - } - return [key, value]; - }), - ); -} +type TraceLevel = FrontendLogLevel; export function frontendTrace( level: TraceLevel, @@ -31,13 +12,10 @@ export function frontendTrace( message: string, payload?: Record, ) { - const isBenchmarkTrace = scope.startsWith("bench:"); - if (!FRONTEND_TRACE_ENABLED && !(FRONTEND_BENCHMARK_TRACE_ENABLED && isBenchmarkTrace)) return; - - void invoke("frontend_trace", { + void submitFrontendLog({ level, scope, message, - payload: sanitizePayload(payload), - }).catch(() => {}); + payload: sanitizeFrontendLogPayload(payload), + }); } diff --git a/windows/tauri/src/workbench-app.tsx b/windows/tauri/src/workbench-app.tsx index 2ddf9e060..6f3b61d16 100644 --- a/windows/tauri/src/workbench-app.tsx +++ b/windows/tauri/src/workbench-app.tsx @@ -20,6 +20,7 @@ import { Toaster } from "./ui/sonner"; import { TooltipProvider } from "./ui/tooltip"; import { WindowResizeBorder } from "./features/window/components/window-resize-border"; import { DialogServiceProvider } from "@/ui/dialog"; +import { LogFallbackNotification } from "@/features/logging/log-fallback-notification"; function WorkbenchApp() { useAppBootstrap(); @@ -78,6 +79,7 @@ function WorkbenchApp() { +