From bde08f756e67bd4a0c64027a4d199ebe1bd460ed Mon Sep 17 00:00:00 2001 From: ocsin1 <2719912597@qq.com> Date: Sun, 26 Jul 2026 21:19:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B8=85=E7=90=86=E6=97=A7=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=97=B6=E5=90=8C=E6=AD=A5=E6=B8=85=E7=90=86=E6=97=A7?= =?UTF-8?q?=20on=5Ferror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按本次 MXU 启动时间区分新旧文件,清理旧 .log 时同步清理旧 on_error,并保护正在使用的日志。手动清理仅在全部任务停止时清除本次启动产生的 on_error,运行中自动保留;同时增加任务提交互斥、分类报告和文件系统测试。 --- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 4 + src-tauri/src/commands/file_ops.rs | 46 +- src-tauri/src/commands/log_cleanup.rs | 625 ++++++++++++++++++++++++++ src-tauri/src/commands/maa_agent.rs | 6 + src-tauri/src/commands/maa_core.rs | 4 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/types.rs | 2 + src-tauri/src/lib.rs | 7 +- src/App.tsx | 12 +- src/components/LogsPanel.tsx | 13 +- src/utils/logCleanup.ts | 27 ++ 12 files changed, 689 insertions(+), 60 deletions(-) create mode 100644 src-tauri/src/commands/log_cleanup.rs create mode 100644 src/utils/logCleanup.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a2586cd8..18216be4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2817,6 +2817,7 @@ dependencies = [ "bytes", "chrono", "clap", + "filetime", "flate2", "futures-util", "libc", @@ -2846,6 +2847,7 @@ dependencies = [ "tauri-plugin-log", "tauri-plugin-opener", "tauri-plugin-process", + "tempfile", "tokio", "tower-http 0.5.2", "urlencoding", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 87b1624b..dbfe1952 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -63,6 +63,10 @@ sysinfo = "0.32" machine-uid = "0.5" sha2 = "0.10" +[dev-dependencies] +filetime = "0.2" +tempfile = "3" + [profile.release] # 保留调试符号以生成 PDB 文件,便于崩溃分析 debug = true diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 0bd434c4..81693dc7 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -294,51 +294,7 @@ pub fn get_data_dir() -> Result { Ok(data_dir.to_string_lossy().to_string()) } -/// 删除 debug 目录中的 .log 文件,可选择排除一个当前正在使用的日志文件 -#[tauri::command] -pub fn clear_log_files(exclude_file_name: Option) -> Result { - let debug_dir = get_app_data_dir()?.join("debug"); - - if !debug_dir.exists() { - return Ok(0); - } - - let mut deleted = 0_u64; - let entries = std::fs::read_dir(&debug_dir) - .map_err(|e| format!("读取日志目录失败 [{}]: {}", debug_dir.display(), e))?; - - for entry in entries { - let entry = match entry { - Ok(entry) => entry, - Err(_) => continue, - }; - let path = entry.path(); - if !path.is_file() { - continue; - } - - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - - if !name.ends_with(".log") { - continue; - } - - if exclude_file_name.as_deref() == Some(name) { - continue; - } - - match std::fs::remove_file(&path) { - Ok(()) => deleted = deleted.saturating_add(1), - Err(e) => log::debug!("Failed to delete log file [{}]: {}", path.display(), e), - } - } - - Ok(deleted) -} - -/// 获取当前工作目录 +/// 获取当前工作目录。 #[tauri::command] pub fn get_cwd() -> Result { std::env::current_dir() diff --git a/src-tauri/src/commands/log_cleanup.rs b/src-tauri/src/commands/log_cleanup.rs new file mode 100644 index 00000000..9d6f3692 --- /dev/null +++ b/src-tauri/src/commands/log_cleanup.rs @@ -0,0 +1,625 @@ +//! 顶层日志文件与 MaaFramework on_error 产物的会话感知清理逻辑。 + +use serde::{Deserialize, Serialize}; +use std::ffi::OsStr; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; +use tauri::State; + +use super::types::MaaState; +use super::utils::get_app_data_dir; + +const ACTIVE_LOG_FILE_NAMES: [&str; 2] = ["mxu-tauri.log", "maafw.log"]; + +/// 进程级会话边界,用于区分本次启动与更早 MXU 会话产生的文件。 +#[derive(Debug)] +pub struct LogCleanupState { + process_started_at: SystemTime, +} + +impl LogCleanupState { + pub fn new(process_started_at: SystemTime) -> Self { + Self { process_started_at } + } +} + +/// 调用方请求的 MaaFramework on_error 目录清理策略。 +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum OnErrorScope { + #[default] + OldSessionOnly, + IncludeCurrentWhenIdle, +} + +/// 后端检查真实任务状态后实际采用的清理策略。 +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AppliedOnErrorScope { + OldSessionOnly, + AllExisting, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LogCleanupReport { + pub log_files_deleted: u64, + pub on_error_files_deleted: u64, + pub protected_files: u64, + pub failures: u64, + pub on_error_scope_applied: AppliedOnErrorScope, +} + +impl LogCleanupReport { + fn new(on_error_scope_applied: AppliedOnErrorScope) -> Self { + Self { + log_files_deleted: 0, + on_error_files_deleted: 0, + protected_files: 0, + failures: 0, + on_error_scope_applied, + } + } + + fn protect(&mut self) { + self.protected_files = self.protected_files.saturating_add(1); + } + + fn fail(&mut self) { + self.failures = self.failures.saturating_add(1); + } +} + +fn is_log_file(path: &Path) -> bool { + path.extension() == Some(OsStr::new("log")) +} + +fn is_protected_log_name(path: &Path, exclude_file_name: Option<&str>) -> bool { + let Some(name) = path.file_name() else { + return true; + }; + + ACTIVE_LOG_FILE_NAMES + .iter() + .any(|protected| name == OsStr::new(protected)) + || exclude_file_name.is_some_and(|excluded| name == OsStr::new(excluded)) +} + +fn remove_file_with_report( + path: &Path, + report: &mut LogCleanupReport, + remove_file: &dyn Fn(&Path) -> io::Result<()>, +) -> bool { + match remove_file(path) { + Ok(()) => true, + Err(error) => { + report.fail(); + log::warn!( + "Failed to delete cleanup target [{}]: {}", + path.display(), + error + ); + false + } + } +} + +fn collect_on_error_files( + directory: &Path, + files: &mut Vec<(PathBuf, SystemTime)>, + report: &mut LogCleanupReport, +) { + let directory_metadata = match fs::symlink_metadata(directory) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return, + Err(error) => { + report.fail(); + log::warn!( + "Failed to inspect on_error directory [{}]: {}", + directory.display(), + error + ); + return; + } + }; + + if directory_metadata.file_type().is_symlink() { + report.protect(); + return; + } + if !directory_metadata.is_dir() { + report.protect(); + return; + } + + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return, + Err(error) => { + report.fail(); + log::warn!( + "Failed to read on_error directory [{}]: {}", + directory.display(), + error + ); + return; + } + }; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + report.fail(); + log::warn!( + "Failed to read an entry in on_error directory [{}]: {}", + directory.display(), + error + ); + continue; + } + }; + let path = entry.path(); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + report.fail(); + report.protect(); + log::warn!( + "Failed to inspect on_error entry [{}]: {}", + path.display(), + error + ); + continue; + } + }; + + if metadata.file_type().is_symlink() { + report.protect(); + } else if metadata.is_dir() { + collect_on_error_files(&path, files, report); + } else if metadata.is_file() { + match metadata.modified() { + Ok(modified) => files.push((path, modified)), + Err(error) => { + report.fail(); + report.protect(); + log::warn!( + "Failed to read modification time for on_error file [{}]: {}", + path.display(), + error + ); + } + } + } else { + report.protect(); + } + } +} + +fn clear_log_files_in_directory( + debug_dir: &Path, + process_started_at: SystemTime, + exclude_file_name: Option<&str>, + on_error_scope_applied: AppliedOnErrorScope, + remove_file: &dyn Fn(&Path) -> io::Result<()>, +) -> Result { + let mut report = LogCleanupReport::new(on_error_scope_applied); + let entries = match fs::read_dir(debug_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(report), + Err(error) => { + return Err(format!( + "读取日志目录失败 [{}]: {}", + debug_dir.display(), + error + )) + } + }; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + report.fail(); + log::warn!( + "Failed to read an entry in log directory [{}]: {}", + debug_dir.display(), + error + ); + continue; + } + }; + let path = entry.path(); + if !is_log_file(&path) { + continue; + } + if is_protected_log_name(&path, exclude_file_name) { + report.protect(); + continue; + } + + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + report.fail(); + report.protect(); + log::warn!("Failed to inspect log file [{}]: {}", path.display(), error); + continue; + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + report.protect(); + continue; + } + + let modified = match metadata.modified() { + Ok(modified) => modified, + Err(error) => { + report.fail(); + report.protect(); + log::warn!( + "Failed to read modification time for log file [{}]: {}", + path.display(), + error + ); + continue; + } + }; + if modified >= process_started_at { + report.protect(); + continue; + } + + if remove_file_with_report(&path, &mut report, remove_file) { + report.log_files_deleted = report.log_files_deleted.saturating_add(1); + } + } + + // 删除前先对已有 on_error 文件做快照;遍历结束后新产生的文件不会被本次清理带走。 + let mut on_error_files = Vec::new(); + collect_on_error_files( + &debug_dir.join("on_error"), + &mut on_error_files, + &mut report, + ); + + for (path, modified) in on_error_files { + let should_delete = match on_error_scope_applied { + AppliedOnErrorScope::OldSessionOnly => modified < process_started_at, + AppliedOnErrorScope::AllExisting => true, + }; + if !should_delete { + report.protect(); + continue; + } + + if remove_file_with_report(&path, &mut report, remove_file) { + report.on_error_files_deleted = report.on_error_files_deleted.saturating_add(1); + } + } + + Ok(report) +} + +fn any_task_running(state: &MaaState) -> Result { + let instances = state + .instances + .lock() + .map_err(|error| format!("Failed to lock Maa instance state: {}", error))?; + Ok(instances.values().any(|instance| { + instance + .tasker + .as_ref() + .is_some_and(|tasker| tasker.running()) + })) +} + +fn resolve_on_error_scope( + requested_scope: OnErrorScope, + any_task_running: Option, +) -> AppliedOnErrorScope { + match (requested_scope, any_task_running) { + (OnErrorScope::IncludeCurrentWhenIdle, Some(false)) => AppliedOnErrorScope::AllExisting, + _ => AppliedOnErrorScope::OldSessionOnly, + } +} + +/// 删除旧会话的顶层 .log 文件及 MaaFramework on_error 产物。 +#[tauri::command] +pub fn clear_log_files( + cleanup_state: State<'_, LogCleanupState>, + maa_state: State<'_, Arc>, + exclude_file_name: Option, + on_error_scope: Option, +) -> Result { + let requested_scope = on_error_scope.unwrap_or_default(); + let mut state_failures = 0_u64; + + // 只有可能删除当前会话文件时才持续持有互斥锁;若请求已降级为旧会话清理, + // 则释放锁,避免在清理旧文件期间阻塞新任务启动。 + let mut task_submission_guard = None; + let applied_scope = if requested_scope == OnErrorScope::IncludeCurrentWhenIdle { + match maa_state.task_submission_cleanup_gate.lock() { + Ok(guard) => match any_task_running(&maa_state) { + Ok(false) => { + task_submission_guard = Some(guard); + resolve_on_error_scope(requested_scope, Some(false)) + } + Ok(true) => resolve_on_error_scope(requested_scope, Some(true)), + Err(error) => { + state_failures = state_failures.saturating_add(1); + log::warn!( + "Could not confirm task state; protecting current on_error files: {}", + error + ); + resolve_on_error_scope(requested_scope, None) + } + }, + Err(error) => { + state_failures = state_failures.saturating_add(1); + log::warn!( + "Could not lock task submission gate; protecting current on_error files: {}", + error + ); + resolve_on_error_scope(requested_scope, None) + } + } + } else { + resolve_on_error_scope(requested_scope, None) + }; + + let debug_dir = get_app_data_dir()?.join("debug"); + let mut report = clear_log_files_in_directory( + &debug_dir, + cleanup_state.process_started_at, + exclude_file_name.as_deref(), + applied_scope, + &|path| fs::remove_file(path), + )?; + report.failures = report.failures.saturating_add(state_failures); + + // 明确互斥锁的生命周期:当前会话文件删除完成后,新任务提交才能取得该锁。 + drop(task_submission_guard); + + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use filetime::{set_file_mtime, FileTime}; + use std::time::{Duration, UNIX_EPOCH}; + use tempfile::TempDir; + + fn session_start() -> SystemTime { + UNIX_EPOCH + Duration::from_secs(1_000_000) + } + + fn create_file(path: &Path, modified: SystemTime) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, b"test").unwrap(); + set_file_mtime(path, FileTime::from_system_time(modified)).unwrap(); + } + + fn run_cleanup( + debug_dir: &Path, + exclude_file_name: Option<&str>, + scope: AppliedOnErrorScope, + ) -> LogCleanupReport { + clear_log_files_in_directory( + debug_dir, + session_start(), + exclude_file_name, + scope, + &|path| fs::remove_file(path), + ) + .unwrap() + } + + #[test] + fn top_level_cleanup_deletes_only_old_unprotected_logs() { + let temp = TempDir::new().unwrap(); + let debug_dir = temp.path(); + let start = session_start(); + let old = start - Duration::from_secs(10); + let new = start + Duration::from_secs(10); + + create_file(&debug_dir.join("old.log"), old); + create_file(&debug_dir.join("new.log"), new); + create_file(&debug_dir.join("boundary.log"), start); + create_file(&debug_dir.join("frontend.log"), old); + create_file(&debug_dir.join("mxu-tauri.log"), old); + create_file(&debug_dir.join("maafw.log"), old); + create_file(&debug_dir.join("note.txt"), old); + fs::create_dir(debug_dir.join("directory.log")).unwrap(); + + let report = run_cleanup( + debug_dir, + Some("frontend.log"), + AppliedOnErrorScope::OldSessionOnly, + ); + + assert_eq!(report.log_files_deleted, 1); + assert_eq!(report.on_error_files_deleted, 0); + assert_eq!(report.protected_files, 6); + assert_eq!(report.failures, 0); + assert!(!debug_dir.join("old.log").exists()); + for name in [ + "new.log", + "boundary.log", + "frontend.log", + "mxu-tauri.log", + "maafw.log", + "note.txt", + "directory.log", + ] { + assert!(debug_dir.join(name).exists(), "{name} should be preserved"); + } + } + + #[test] + fn old_session_scope_recurses_and_preserves_current_on_error_files() { + let temp = TempDir::new().unwrap(); + let debug_dir = temp.path(); + let start = session_start(); + + create_file( + &debug_dir.join("on_error/old.png"), + start - Duration::from_secs(10), + ); + create_file( + &debug_dir.join("on_error/nested/current.json"), + start + Duration::from_secs(10), + ); + create_file(&debug_dir.join("on_error/boundary.png"), start); + + let report = run_cleanup(debug_dir, None, AppliedOnErrorScope::OldSessionOnly); + + assert_eq!(report.on_error_files_deleted, 1); + assert_eq!(report.protected_files, 2); + assert!(!debug_dir.join("on_error/old.png").exists()); + assert!(debug_dir.join("on_error/nested/current.json").exists()); + assert!(debug_dir.join("on_error/boundary.png").exists()); + assert!(debug_dir.join("on_error/nested").is_dir()); + } + + #[test] + fn all_existing_scope_removes_current_on_error_snapshot_without_removing_directories() { + let temp = TempDir::new().unwrap(); + let debug_dir = temp.path(); + let start = session_start(); + + for (name, modified) in [ + ("old.png", start - Duration::from_secs(10)), + ("boundary.png", start), + ("nested/current.json", start + Duration::from_secs(10)), + ] { + create_file(&debug_dir.join("on_error").join(name), modified); + } + + let report = run_cleanup(debug_dir, None, AppliedOnErrorScope::AllExisting); + + assert_eq!(report.on_error_files_deleted, 3); + assert_eq!(report.protected_files, 0); + assert!(debug_dir.join("on_error").is_dir()); + assert!(debug_dir.join("on_error/nested").is_dir()); + } + + #[test] + fn on_error_cleanup_uses_a_snapshot() { + let temp = TempDir::new().unwrap(); + let debug_dir = temp.path(); + let on_error_dir = debug_dir.join("on_error"); + create_file(&on_error_dir.join("existing.png"), session_start()); + + let late_file = on_error_dir.join("late.png"); + let report = clear_log_files_in_directory( + debug_dir, + session_start(), + None, + AppliedOnErrorScope::AllExisting, + &|path| { + create_file(&late_file, session_start()); + fs::remove_file(path) + }, + ) + .unwrap(); + + assert_eq!(report.on_error_files_deleted, 1); + assert!(late_file.exists()); + } + + #[test] + fn deletion_failures_are_counted_and_do_not_stop_other_targets() { + let temp = TempDir::new().unwrap(); + let debug_dir = temp.path(); + let old = session_start() - Duration::from_secs(10); + create_file(&debug_dir.join("fail.log"), old); + create_file(&debug_dir.join("on_error/delete.png"), old); + + let report = clear_log_files_in_directory( + debug_dir, + session_start(), + None, + AppliedOnErrorScope::OldSessionOnly, + &|path| { + if path.file_name() == Some(OsStr::new("fail.log")) { + Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")) + } else { + fs::remove_file(path) + } + }, + ) + .unwrap(); + + assert_eq!(report.log_files_deleted, 0); + assert_eq!(report.on_error_files_deleted, 1); + assert_eq!(report.failures, 1); + assert!(debug_dir.join("fail.log").exists()); + assert!(!debug_dir.join("on_error/delete.png").exists()); + } + + #[test] + fn missing_debug_directory_returns_an_empty_report() { + let temp = TempDir::new().unwrap(); + let missing = temp.path().join("missing"); + + let report = run_cleanup(&missing, None, AppliedOnErrorScope::OldSessionOnly); + + assert_eq!( + report, + LogCleanupReport::new(AppliedOnErrorScope::OldSessionOnly) + ); + } + + #[test] + fn requested_current_scope_is_conservatively_downgraded() { + assert_eq!( + resolve_on_error_scope(OnErrorScope::IncludeCurrentWhenIdle, Some(false)), + AppliedOnErrorScope::AllExisting + ); + assert_eq!( + resolve_on_error_scope(OnErrorScope::IncludeCurrentWhenIdle, Some(true)), + AppliedOnErrorScope::OldSessionOnly + ); + assert_eq!( + resolve_on_error_scope(OnErrorScope::IncludeCurrentWhenIdle, None), + AppliedOnErrorScope::OldSessionOnly + ); + assert_eq!( + resolve_on_error_scope(OnErrorScope::OldSessionOnly, Some(false)), + AppliedOnErrorScope::OldSessionOnly + ); + } + + #[test] + fn on_error_symlinks_are_preserved() { + let temp = TempDir::new().unwrap(); + let debug_dir = temp.path(); + let on_error_dir = debug_dir.join("on_error"); + fs::create_dir_all(&on_error_dir).unwrap(); + let target = temp.path().join("outside.png"); + create_file(&target, session_start()); + let link = on_error_dir.join("linked.png"); + + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &link).unwrap(); + #[cfg(windows)] + if std::os::windows::fs::symlink_file(&target, &link).is_err() { + return; + } + + let report = run_cleanup(debug_dir, None, AppliedOnErrorScope::AllExisting); + + assert_eq!(report.on_error_files_deleted, 0); + assert_eq!(report.protected_files, 1); + assert!(link.exists()); + assert!(target.exists()); + } +} diff --git a/src-tauri/src/commands/maa_agent.rs b/src-tauri/src/commands/maa_agent.rs index 50615e19..12bf427b 100644 --- a/src-tauri/src/commands/maa_agent.rs +++ b/src-tauri/src/commands/maa_agent.rs @@ -700,6 +700,12 @@ pub async fn start_tasks_impl( debug!("[start_tasks] No agent configs, skipping agent setup"); }; + // 相对于当前会话 on_error 清理,将任务提交与运行状态登记视为一个原子区间。 + let _task_submission_guard = maa_state + .task_submission_cleanup_gate + .lock() + .map_err(|e| format!("Failed to lock task submission gate: {}", e))?; + // 遥测:整批运行开始(仅首批;追加批次沿用已有 Transaction) // 必须在 post_task 之前,否则首个任务的开始回调会早于 Transaction 创建、丢掉它的 Span if reset_state { diff --git a/src-tauri/src/commands/maa_core.rs b/src-tauri/src/commands/maa_core.rs index af0ae572..46a3020b 100644 --- a/src-tauri/src/commands/maa_core.rs +++ b/src-tauri/src/commands/maa_core.rs @@ -857,6 +857,10 @@ pub fn run_task_impl( pipeline_override: &str, selected_task_id: Option<&str>, ) -> Result { + let _task_submission_guard = state + .task_submission_cleanup_gate + .lock() + .map_err(|e| format!("Failed to lock task submission gate: {}", e))?; let mut instances = state.instances.lock().map_err(|e| e.to_string())?; let instance = instances.get_mut(instance_id).ok_or("Instance not found")?; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f66dc69f..388bf049 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -20,6 +20,7 @@ pub mod utils; pub mod app_config; pub mod download; pub mod file_ops; +pub mod log_cleanup; pub mod maa_agent; pub mod maa_core; pub mod state; diff --git a/src-tauri/src/commands/types.rs b/src-tauri/src/commands/types.rs index 7fd5a8b6..e784130a 100644 --- a/src-tauri/src/commands/types.rs +++ b/src-tauri/src/commands/types.rs @@ -282,6 +282,8 @@ pub struct MaaState { pub lib_dir: Mutex>, pub resource_dir: Mutex>, pub instances: Mutex>, + /// 串行化任务提交与当前会话 on_error 文件清理,避免状态检查后的启动竞态。 + pub task_submission_cleanup_gate: Mutex<()>, /// 前置程序停止请求(用于中断等待退出) pub pre_action_stop_requests: Mutex>, /// Controller 连接池:相同配置的 Controller 复用同一个 MaaControllerHandle diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d1266cea..3fd86614 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,8 @@ use ws_broadcast::WsBroadcast; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let process_started_at = std::time::SystemTime::now(); + // 日志目录:exe 目录/debug/logs(与前端日志同目录) let logs_dir = commands::utils::get_logs_dir(); @@ -26,6 +28,9 @@ pub fn run() { commands::system::migrate_legacy_autostart(); tauri::Builder::default() + .manage(commands::log_cleanup::LogCleanupState::new( + process_started_at, + )) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) @@ -252,7 +257,7 @@ pub fn run() { commands::file_ops::local_file_exists, commands::file_ops::get_exe_dir, commands::file_ops::get_data_dir, - commands::file_ops::clear_log_files, + commands::log_cleanup::clear_log_files, commands::file_ops::get_cwd, commands::file_ops::check_exe_path, commands::file_ops::set_executable, diff --git a/src/App.tsx b/src/App.tsx index 58db3d16..e3599e84 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -67,7 +67,7 @@ import { mergeRuntimeLogs, persistRuntimeLogs, } from '@/utils/runtimeLogPersistence'; -import { getCurrentLogFileName } from '@/utils/logger'; +import { clearDiskLogFiles } from '@/utils/logCleanup'; import { isTauri, isValidWindowSize, @@ -733,12 +733,10 @@ function App() { if (store.autoClearLogsOnLaunch) { if (isTauri()) { try { - const deleted = await invoke('clear_log_files', { - excludeFileName: getCurrentLogFileName(), - }); - log.info('Auto-cleared log files on launch:', deleted); - } catch { - // ignore cleanup errors + const report = await clearDiskLogFiles(); + log.info('Auto-cleared log files on launch:', report); + } catch (error) { + log.warn('Failed to auto-clear log files on launch:', error); } } clearPersistedRuntimeLogs(); diff --git a/src/components/LogsPanel.tsx b/src/components/LogsPanel.tsx index 9019f151..e2b57544 100644 --- a/src/components/LogsPanel.tsx +++ b/src/components/LogsPanel.tsx @@ -10,14 +10,14 @@ import { import { useTranslation } from 'react-i18next'; import { Eraser, Copy, ChevronUp, ChevronDown, Archive } from 'lucide-react'; import clsx from 'clsx'; -import { invoke } from '@tauri-apps/api/core'; import { useAppStore, type LogType } from '@/stores/appStore'; import { ContextMenu, useContextMenu, type MenuItem } from './ContextMenu'; import { isTauri } from '@/utils/paths'; import { useExportLogs } from '@/utils/useExportLogs'; import { ExportLogsModal } from './settings/ExportLogsModal'; import { useIsMobile } from '@/hooks/useIsMobile'; -import { getCurrentLogFileName } from '@/utils/logger'; +import { loggers } from '@/utils/logger'; +import { clearDiskLogFiles } from '@/utils/logCleanup'; import { clearPersistedRuntimeLogs } from '@/utils/runtimeLogPersistence'; import { getAllLogsFromBackend } from '@/utils/logStdout'; import { loadPersistedRuntimeLogs, mergeRuntimeLogs } from '@/utils/runtimeLogPersistence'; @@ -91,11 +91,10 @@ export function LogsPanel() { const clearLogFiles = useCallback(async () => { if (!isTauri()) return; try { - await invoke('clear_log_files', { - excludeFileName: getCurrentLogFileName(), - }); - } catch { - // ignore cleanup errors + const report = await clearDiskLogFiles('includeCurrentWhenIdle'); + loggers.ui.info('Manual log cleanup completed:', report); + } catch (error) { + loggers.ui.warn('Manual log cleanup failed:', error); } }, []); diff --git a/src/utils/logCleanup.ts b/src/utils/logCleanup.ts new file mode 100644 index 00000000..91a7eaf7 --- /dev/null +++ b/src/utils/logCleanup.ts @@ -0,0 +1,27 @@ +import { invoke } from '@tauri-apps/api/core'; +import { getCurrentLogFileName } from './logger'; + +export type OnErrorCleanupScope = 'oldSessionOnly' | 'includeCurrentWhenIdle'; + +export interface ClearLogFilesArgs extends Record { + excludeFileName?: string | null; + onErrorScope?: OnErrorCleanupScope; +} + +export interface LogCleanupReport { + logFilesDeleted: number; + onErrorFilesDeleted: number; + protectedFiles: number; + failures: number; + onErrorScopeApplied: 'oldSessionOnly' | 'allExisting'; +} + +export async function clearDiskLogFiles( + onErrorScope?: OnErrorCleanupScope, +): Promise { + const args: ClearLogFilesArgs = { + excludeFileName: getCurrentLogFileName(), + ...(onErrorScope === undefined ? {} : { onErrorScope }), + }; + return invoke('clear_log_files', args); +}