diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 6cf15c11..249377b0 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -12,8 +12,6 @@ use super::utils::{get_app_data_dir, get_exe_directory, normalize_path}; /// 单个分卷 zip 的大小上限(字节)。 const MAX_VOLUME_BYTES: u64 = 24_500_000; -/// 单个 entry 的 local header + 中央目录条目大小上界(不含文件名)。 -const ZIP_PER_ENTRY_HEADER_UPPER_BOUND: u64 = 128; /// EOCD 记录(zip 末尾)固定大小。 const ZIP_EOCD_BYTES: u64 = 22; /// 中央目录每条记录的固定字段大小(不含文件名)。 @@ -99,11 +97,42 @@ where true } -fn estimate_entry_upper_bound(entry: &ExportEntry) -> Option { - let file_size = entry.source_path.metadata().ok()?.len(); - let name_len = entry.archive_name.len() as u64; - // 文件名在 local header 和中央目录条目里都出现一次,所以 ×2。 - Some(file_size + ZIP_PER_ENTRY_HEADER_UPPER_BOUND + name_len.saturating_mul(2)) +/// zip 每条目的 local header 固定开销(30)+ DEFLATE 帧头 + 余量。 +/// 文件名部分在 local header 和中央目录各出现一次: +/// - local header 侧:包含在此常量余量内(本应用文件名短,够用) +/// - 中央目录侧:由 `entry_cd_bytes`(ZIP_CENTRAL_DIR_FIXED_BYTES + 文件名长度)计入 +const ZIP_LOCAL_HEADER_OVERHEAD: u64 = 64; + +/// 按扩展名估算压缩后**数据**大小的保守上界。 +/// +/// 注意:仅估计压缩数据,不含 zip local header / 中央目录等开销,调用侧自行加。 +fn estimate_compressed_upper_bound(path: &Path, file_size: u64) -> u64 { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + match ext.as_str() { + "png" | "jpg" | "jpeg" => file_size, // 已压缩,DEFLATE 无效 + "log" | "json" | "txt" | "toml" | "yaml" | "yml" | "xml" | "csv" => { + file_size.saturating_div(4) // 实测 10-25x,4x 留有足够余量 + } + _ => file_size, // 未知类型,不假设压缩 + } +} + +/// 用 flate2 预压缩文件到内存,返回 deflate 后字节数——与 zip crate 内部压缩同算法。 +/// +/// 只在保守估算触线时才调用(每卷最多一次),避免每个文件都压两遍。 +fn pre_compress_measure(path: &Path) -> io::Result { + use flate2::write::DeflateEncoder; + use flate2::Compression; + + let mut src = std::fs::File::open(path)?; + let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default()); + std::io::copy(&mut src, &mut encoder)?; + let compressed = encoder.finish()?; + Ok(compressed.len() as u64) } fn normalize_archive_path(path: &Path) -> String { @@ -595,20 +624,56 @@ fn export_logs_blocking( let mut central_dir_reserve: u64 = ZIP_EOCD_BYTES; while let Some(entry) = iter.peek() { - let est_delta = estimate_entry_upper_bound(entry).unwrap_or(u64::MAX); - let current_bytes = counter.load(Ordering::Relaxed); - let entry_cd_bytes = ZIP_CENTRAL_DIR_FIXED_BYTES + entry.archive_name.len() as u64; - let projected = current_bytes - .saturating_add(est_delta) - .saturating_add(central_dir_reserve) - .saturating_add(entry_cd_bytes); - // 单文件超过卷上限时,当前卷为空就让它独占一卷,保证不丢文件。 - if wrote_any && projected > MAX_VOLUME_BYTES { - break; + let entry_cd_bytes = + ZIP_CENTRAL_DIR_FIXED_BYTES + entry.archive_name.len() as u64; + let file_size = entry + .source_path + .metadata() + .ok() + .map(|m| m.len()) + .unwrap_or(u64::MAX); // metadata 失败用极大值,保守触发预压缩 + + // 两阶段容量检查:先用保守估算快速通过大多数文件, + // 估算触线时才实际预压缩一次拿精确值,避免每个文件都压两遍。 + let est_delta = estimate_compressed_upper_bound(&entry.source_path, file_size) + .saturating_add(ZIP_LOCAL_HEADER_OVERHEAD); + let current_total = counter + .load(Ordering::Relaxed) + .saturating_add(central_dir_reserve); + if wrote_any + && current_total + .saturating_add(est_delta) + .saturating_add(entry_cd_bytes) + > MAX_VOLUME_BYTES + { + match pre_compress_measure(&entry.source_path) { + Ok(exact_delta) => { + let exact_delta = + exact_delta.saturating_add(ZIP_LOCAL_HEADER_OVERHEAD); + if current_total + .saturating_add(exact_delta) + .saturating_add(entry_cd_bytes) + > MAX_VOLUME_BYTES + { + break; + } + } + Err(_) => { + // IO 错误打不开文件,保守切卷 + break; + } + } } + let entry = iter.next().expect("peek 已确认存在"); - if add_file_to_zip(&mut zip, &entry.source_path, &entry.archive_name, options) { - central_dir_reserve = central_dir_reserve.saturating_add(entry_cd_bytes); + if add_file_to_zip( + &mut zip, + &entry.source_path, + &entry.archive_name, + options, + ) { + central_dir_reserve = + central_dir_reserve.saturating_add(entry_cd_bytes); wrote_any = true; volume_file_count += 1; total_files_written += 1;