949 lines
40 KiB
C#
949 lines
40 KiB
C#
using ICSharpCode.SharpZipLib.BZip2;
|
||||
|
|
using ICSharpCode.SharpZipLib.GZip;
|
|||
|
|
using ICSharpCode.SharpZipLib.Tar;
|
|||
|
|
using ICSharpCode.SharpZipLib.Zip;
|
|||
|
|
using PCL.Core.Logging;
|
|||
|
|
using PCL.Core.UI;
|
|||
|
|
using PCL.Core.Utils;
|
|||
|
|
using PCL.Core.Utils.Codecs;
|
|||
|
|
using PCL.Core.Utils.Exts;
|
|||
|
|
using PCL.Core.Utils.Hash;
|
|||
|
|
using PCL.Core.App.Localization;
|
|||
|
|
using System;
|
|||
|
|
using System.Collections.Generic;
|
|||
|
|
using System.IO;
|
|||
|
|
using System.Runtime.InteropServices;
|
|||
|
|
using System.Text;
|
|||
|
|
using System.Text.Json;
|
|||
|
|
using System.Text.Json.Nodes;
|
|||
|
|
using System.Threading;
|
|||
|
|
using System.Threading.Tasks;
|
|||
|
|
using System.Windows;
|
|||
|
|
using PCL.Core.App;
|
|||
|
|
|
|||
|
|
namespace PCL.Core.IO;
|
|||
|
|
|
|||
|
|
public static class Files {
|
|||
|
|
public static readonly JsonSerializerOptions PrettierJsonOptions = new(JsonCompat.SerializerOptions) {
|
|||
|
|
WriteIndented = true,
|
|||
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 在指定路径创建一个指向目标文件的 .lnk 快捷方式。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="shortcut">要创建的快捷方式完整路径,建议以 ".lnk" 结尾</param>
|
|||
|
|
/// <param name="target">被指向的目标文件或可执行程序路径</param>
|
|||
|
|
/// <param name="arguments">启动时的命令行参数</param>
|
|||
|
|
/// <param name="workingDirectory">快捷方式的起始目录</param>
|
|||
|
|
/// <param name="description">快捷方式说明</param>
|
|||
|
|
/// <param name="icon">自定义图标,格式 "图标文件路径,索引"</param>
|
|||
|
|
// Partly generated by o4-mini-high (20250719)
|
|||
|
|
public static void CreateShortcut(
|
|||
|
|
string shortcut,
|
|||
|
|
string target,
|
|||
|
|
string? arguments = null,
|
|||
|
|
string? workingDirectory = null,
|
|||
|
|
string? description = null,
|
|||
|
|
string? icon = null) {
|
|||
|
|
if (string.IsNullOrWhiteSpace(shortcut))
|
|||
|
|
throw new ArgumentException("shortcutPath 不能为空", nameof(shortcut));
|
|||
|
|
if (string.IsNullOrWhiteSpace(target))
|
|||
|
|
throw new ArgumentException("targetFilePath 不能为空", nameof(target));
|
|||
|
|
|
|||
|
|
// 确保目录存在
|
|||
|
|
var dir = Path.GetDirectoryName(shortcut);
|
|||
|
|
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
|||
|
|
|
|||
|
|
// 与 WshShell 交互
|
|||
|
|
var shellType = Type.GetTypeFromProgID("WScript.Shell", throwOnError: true)!;
|
|||
|
|
dynamic shell = Activator.CreateInstance(shellType)!;
|
|||
|
|
var link = shell.CreateShortcut(shortcut)!;
|
|||
|
|
|
|||
|
|
// 设置属性
|
|||
|
|
link.TargetPath = target;
|
|||
|
|
if (!string.IsNullOrEmpty(arguments)) link.Arguments = arguments;
|
|||
|
|
if (!string.IsNullOrEmpty(workingDirectory)) link.WorkingDirectory = workingDirectory;
|
|||
|
|
else link.WorkingDirectory = Path.GetDirectoryName(target) ?? Path.GetPathRoot(target);
|
|||
|
|
if (!string.IsNullOrEmpty(description)) link.Description = description;
|
|||
|
|
if (!string.IsNullOrEmpty(icon)) link.IconLocation = icon;
|
|||
|
|
|
|||
|
|
// 保存 .lnk 文件
|
|||
|
|
link.Save();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static bool ArePathsEqual(string path1, string path2) {
|
|||
|
|
var fullPath1 = Path.GetFullPath(path1).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|||
|
|
var fullPath2 = Path.GetFullPath(path2).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|||
|
|
return string.Equals(fullPath1, fullPath2, StringComparison.OrdinalIgnoreCase);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static async Task<bool> ExportAsZipArchiveAsync(
|
|||
|
|
IEnumerable<string> sourceFiles,
|
|||
|
|
string dialogTitle,
|
|||
|
|
string defaultFileName,
|
|||
|
|
string fileFilter,
|
|||
|
|
string defaultDirectory,
|
|||
|
|
string tempDirPrefix,
|
|||
|
|
CancellationToken cancellationToken = default) {
|
|||
|
|
var tempDirName = $"{tempDirPrefix}.tmp";
|
|||
|
|
var selectedPath = SystemDialogs.SelectSaveFile(dialogTitle, defaultFileName, fileFilter, defaultDirectory);
|
|||
|
|
|
|||
|
|
if (string.IsNullOrEmpty(selectedPath)) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
Directory.CreateDirectory(tempDirName);
|
|||
|
|
|
|||
|
|
if (File.Exists(selectedPath)) {
|
|||
|
|
File.Delete(selectedPath);
|
|||
|
|
LogWrapper.Info("Files", $"删除已有文件:{selectedPath}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await using var fileStream = new FileStream(selectedPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true);
|
|||
|
|
await using var zipStream = new ZipOutputStream(fileStream);
|
|||
|
|
foreach (var item in sourceFiles) {
|
|||
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|||
|
|
var itemFileName = Path.GetFileName(item);
|
|||
|
|
var tempPath = Path.Combine(tempDirName, itemFileName);
|
|||
|
|
|
|||
|
|
await CopyFileAsync(item, tempPath, cancellationToken);
|
|||
|
|
await using (var sourceStream = new FileStream(tempPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true)) {
|
|||
|
|
var entry = new ZipEntry(itemFileName);
|
|||
|
|
await zipStream.PutNextEntryAsync(entry, cancellationToken);
|
|||
|
|
await sourceStream.CopyToAsync(zipStream, cancellationToken);
|
|||
|
|
}
|
|||
|
|
File.Delete(tempPath);
|
|||
|
|
}
|
|||
|
|
await zipStream.FinishAsync(cancellationToken);
|
|||
|
|
LogWrapper.Info("Files", $"导出 Zip 成功:{selectedPath}");
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
} catch (Exception ex) {
|
|||
|
|
LogWrapper.Warn(ex, "Log", "导出 Zip 失败");
|
|||
|
|
return false;
|
|||
|
|
} finally {
|
|||
|
|
if (Directory.Exists(tempDirName)) {
|
|||
|
|
Directory.Delete(tempDirName, true);
|
|||
|
|
LogWrapper.Debug("Log", $"清理临时文件夹:{tempDirName}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#region 异步文件操作
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 复制文件,自动创建目标目录并覆盖已有文件。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="fromPath">源文件路径(完整或相对)</param>
|
|||
|
|
/// <param name="toPath">目标文件路径(完整或相对)</param>
|
|||
|
|
/// <param name="cancelToken">取消令牌</param>
|
|||
|
|
/// <exception cref="IOException">复制失败时抛出</exception>
|
|||
|
|
public static async Task CopyFileAsync(string fromPath, string toPath, CancellationToken cancelToken = default) {
|
|||
|
|
try {
|
|||
|
|
var fullFromPath = GetFullPath(fromPath);
|
|||
|
|
var fullToPath = GetFullPath(toPath);
|
|||
|
|
if (fullFromPath == fullToPath) return;
|
|||
|
|
|
|||
|
|
var directoryName = Path.GetDirectoryName(fullToPath);
|
|||
|
|
if (directoryName is null) {
|
|||
|
|
throw new InvalidOperationException("无法获取目标目录");
|
|||
|
|
}
|
|||
|
|
Directory.CreateDirectory(directoryName);
|
|||
|
|
|
|||
|
|
// 使用异步流复制
|
|||
|
|
const int bufferSize = 4096;
|
|||
|
|
await using var sourceStream = new FileStream(fullFromPath, FileMode.Open, FileAccess.Read,
|
|||
|
|
FileShare.ReadWrite, bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
|||
|
|
await using var destinationStream = new FileStream(fullToPath, FileMode.Create, FileAccess.Write,
|
|||
|
|
FileShare.Read, bufferSize, FileOptions.Asynchronous);
|
|||
|
|
await sourceStream.CopyToAsync(destinationStream, cancelToken);
|
|||
|
|
} catch (Exception ex) {
|
|||
|
|
throw new IOException($"复制文件出错:{fromPath} -> {toPath}", ex);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static async Task CopyDirectoryAsync(string sourceDir, string destDir, CancellationToken cancelToken = default) {
|
|||
|
|
Directory.CreateDirectory(destDir);
|
|||
|
|
|
|||
|
|
// 获取所有文件和子目录
|
|||
|
|
var files = Directory.GetFiles(sourceDir);
|
|||
|
|
var directories = Directory.GetDirectories(sourceDir);
|
|||
|
|
|
|||
|
|
// 限制并行度,防止资源耗尽
|
|||
|
|
var parallelOptions = new ParallelOptions {
|
|||
|
|
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount),
|
|||
|
|
CancellationToken = cancelToken
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 并行复制文件
|
|||
|
|
await Parallel.ForEachAsync(files, parallelOptions, async (file, ct) => {
|
|||
|
|
var destFile = Path.Combine(destDir, Path.GetFileName(file));
|
|||
|
|
await CopyFileAsync(file, destFile, ct);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 并行处理子目录
|
|||
|
|
await Parallel.ForEachAsync(directories, parallelOptions, async (subDir, ct) => {
|
|||
|
|
var destSubDir = Path.Combine(destDir, Path.GetFileName(subDir));
|
|||
|
|
await CopyDirectoryAsync(subDir, destSubDir, ct);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 读取文件为字节数组,支持读取被占用的文件。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="filePath">文件路径(完整或相对)</param>
|
|||
|
|
/// <param name="cancelToken">取消令牌</param>
|
|||
|
|
/// <returns>文件内容的字节数组,失败时返回空数组</returns>
|
|||
|
|
public static async Task<byte[]> ReadAllBytesOrEmptyAsync(string filePath, CancellationToken cancelToken = default) {
|
|||
|
|
try {
|
|||
|
|
var fullPath = GetFullPath(filePath);
|
|||
|
|
if (File.Exists(fullPath)) {
|
|||
|
|
// 使用 ReadAllBytesAsync
|
|||
|
|
return await File.ReadAllBytesAsync(fullPath, cancelToken);
|
|||
|
|
}
|
|||
|
|
throw new FileNotFoundException(fullPath);
|
|||
|
|
} catch (Exception ex) {
|
|||
|
|
LogWrapper.Warn(ex, $"读取文件出错:{filePath}");
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 读取文件为字符串。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="filePath">文件路径(完整或相对)</param>
|
|||
|
|
/// <param name="encoding">文件编码,默认为 UTF-8</param>
|
|||
|
|
/// <param name="cancelToken">取消令牌</param>
|
|||
|
|
/// <returns>文件内容的字符串,失败时返回空字符串</returns>
|
|||
|
|
public static async Task<string> ReadAllTextOrEmptyAsync(string filePath, Encoding? encoding = null, CancellationToken cancelToken = default) {
|
|||
|
|
try {
|
|||
|
|
var fullPath = GetFullPath(filePath);
|
|||
|
|
if (!File.Exists(fullPath)) throw new FileNotFoundException(fullPath);
|
|||
|
|
if (encoding is null) return await File.ReadAllTextAsync(fullPath, cancelToken);
|
|||
|
|
return await File.ReadAllTextAsync(fullPath, encoding, cancelToken);
|
|||
|
|
} catch (Exception ex) {
|
|||
|
|
LogWrapper.Warn(ex, $"读取文件出错:{filePath}");
|
|||
|
|
return "";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 从流中读取所有文本。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="stream">要读取的流</param>
|
|||
|
|
/// <param name="encoding">文件编码(可选,若为 null 则动态检测)</param>
|
|||
|
|
/// <param name="cancelToken">取消令牌</param>
|
|||
|
|
/// <returns>流内容的字符串,失败时返回空字符串</returns>
|
|||
|
|
public static async Task<string> ReadAllTextOrEmptyAsync(Stream stream, Encoding? encoding = null, CancellationToken cancelToken = default) {
|
|||
|
|
try {
|
|||
|
|
ArgumentNullException.ThrowIfNull(stream);
|
|||
|
|
using var memoryStream = new MemoryStream();
|
|||
|
|
await stream.CopyToAsync(memoryStream, cancelToken);
|
|||
|
|
// 使用 MemoryStream 的内部 buffer 避免再分配一次完整的 byte 数组以节省内存
|
|||
|
|
// 注:内部 buffer 长度可能大于实际数据长度
|
|||
|
|
var buffer = memoryStream.GetBuffer();
|
|||
|
|
var len = (int)memoryStream.Length;
|
|||
|
|
return (encoding ?? EncodingDetector.DetectEncoding(buffer)).GetString(buffer, 0, len);
|
|||
|
|
} catch (Exception ex) {
|
|||
|
|
LogWrapper.Warn(ex, $"读取流出错: {stream}");
|
|||
|
|
return string.Empty;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步读取文件到流。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="filePath">文件路径(完整或相对)</param>
|
|||
|
|
/// <param name="cancelToken">取消令牌</param>
|
|||
|
|
/// <returns>包含文件内容的 MemoryStream,失败时返回空的 MemoryStream</returns>
|
|||
|
|
public static async Task<MemoryStream> ReadFileToStreamOrEmptyAsync(string filePath, CancellationToken cancelToken = default) {
|
|||
|
|
try {
|
|||
|
|
var fullPath = GetFullPath(filePath);
|
|||
|
|
if (!File.Exists(fullPath))
|
|||
|
|
throw new FileNotFoundException(fullPath);
|
|||
|
|
|
|||
|
|
await using var fileStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true);
|
|||
|
|
var memoryStream = new MemoryStream();
|
|||
|
|
await fileStream.CopyToAsync(memoryStream, cancelToken);
|
|||
|
|
memoryStream.Position = 0; // 重置流位置以便后续读取
|
|||
|
|
return memoryStream;
|
|||
|
|
} catch (Exception ex) {
|
|||
|
|
LogWrapper.Warn(ex, $"读取文件到流出错:{filePath}");
|
|||
|
|
return new MemoryStream(); // 返回空的 MemoryStream
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 写入字符串到文件,支持追加或覆盖,自动创建目录。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="filePath">文件路径(完整或相对)</param>
|
|||
|
|
/// <param name="text">要写入的文本</param>
|
|||
|
|
/// <param name="append">追加到文件(true)或覆盖(false)</param>
|
|||
|
|
/// <param name="encoding">文件编码(可选),默认智能检测</param>
|
|||
|
|
/// <param name="cancelToken">取消令牌</param>
|
|||
|
|
public static async Task WriteFileAsync(string filePath, string text, bool append = false, Encoding? encoding = null, CancellationToken cancelToken = default) {
|
|||
|
|
var fullPath = GetFullPath(filePath);
|
|||
|
|
var directoryName = Path.GetDirectoryName(fullPath);
|
|||
|
|
if (directoryName is null) {
|
|||
|
|
throw new InvalidOperationException("无法获取目标目录");
|
|||
|
|
}
|
|||
|
|
Directory.CreateDirectory(directoryName);
|
|||
|
|
|
|||
|
|
if (append) {
|
|||
|
|
// 编码检测使用 stream 而不是完整读取内容以避免多余的内存占用
|
|||
|
|
await using (var fileStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
|||
|
|
encoding ??= EncodingDetector.DetectEncoding(fileStream);
|
|||
|
|
// 注:从此处开始,编码检测使用的 stream 已经销毁
|
|||
|
|
await File.AppendAllTextAsync(fullPath, text, encoding, cancelToken);
|
|||
|
|
} else {
|
|||
|
|
encoding ??= new UTF8Encoding(false); // 无 BOM 的 UTF-8
|
|||
|
|
await File.WriteAllTextAsync(fullPath, text, encoding, cancelToken);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 写入字节数组到文件,自动创建目录。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="filePath">文件路径(完整或相对)</param>
|
|||
|
|
/// <param name="content">要写入的字节数组</param>
|
|||
|
|
/// <param name="append">追加到文件(true)或覆盖(false),默认为 false</param>
|
|||
|
|
/// <param name="cancelToken">取消令牌</param>
|
|||
|
|
/// <returns>一个 Task,表示异步写入操作</returns>
|
|||
|
|
public static async Task WriteFileAsync(string filePath, byte[] content, bool append = false, CancellationToken cancelToken = default) {
|
|||
|
|
var fullPath = GetFullPath(filePath);
|
|||
|
|
var directoryName = Path.GetDirectoryName(fullPath);
|
|||
|
|
if (directoryName is null) {
|
|||
|
|
throw new InvalidOperationException("无法获取目标目录");
|
|||
|
|
}
|
|||
|
|
Directory.CreateDirectory(directoryName);
|
|||
|
|
|
|||
|
|
var fileMode = append ? FileMode.Append : FileMode.Create;
|
|||
|
|
await using var fileStream = new FileStream(fullPath, fileMode, FileAccess.Write, FileShare.Read);
|
|||
|
|
await fileStream.WriteAsync(content.AsMemory(), cancelToken);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 将流写入文件,自动创建目录。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="filePath">文件路径(完整或相对)</param>
|
|||
|
|
/// <param name="stream">要写入的流</param>
|
|||
|
|
/// <param name="cancelToken">取消令牌</param>
|
|||
|
|
/// <returns>写入是否成功</returns>
|
|||
|
|
public static async Task<bool> WriteFileAsync(string filePath, Stream? stream, CancellationToken cancelToken = default) {
|
|||
|
|
if (stream is null) return false;
|
|||
|
|
try {
|
|||
|
|
var fullPath = GetFullPath(filePath);
|
|||
|
|
var directoryName = Path.GetDirectoryName(fullPath);
|
|||
|
|
if (directoryName is null) {
|
|||
|
|
throw new InvalidOperationException("无法获取目标目录");
|
|||
|
|
}
|
|||
|
|
Directory.CreateDirectory(directoryName);
|
|||
|
|
|
|||
|
|
await using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
|||
|
|
fileStream.SetLength(0);
|
|||
|
|
await stream.CopyToAsync(fileStream, cancelToken).ConfigureAwait(false);
|
|||
|
|
return true;
|
|||
|
|
} catch (Exception ex) {
|
|||
|
|
LogWrapper.Warn(ex, "保存流出错");
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region 文件解压
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 尝试根据文件后缀名判断文件种类并解压,支持 zip、gz、tar、tar.gz 和 bzip2。
|
|||
|
|
/// 会尝试将 jar 文件以 zip 方式解压。不会清空目标目录,但会创建不存在的目录。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="compressFilePath">压缩文件路径</param>
|
|||
|
|
/// <param name="destDirectory">目标解压目录</param>
|
|||
|
|
/// <param name="progressIncrementHandler">进度更新回调,接收 0.0 到 1.0 的进度值</param>
|
|||
|
|
/// <param name="cancellationToken">取消操作的令牌</param>
|
|||
|
|
/// <returns>异步任务。</returns>
|
|||
|
|
/// <exception cref="ArgumentNullException">当 <paramref name="compressFilePath"/> 或 <paramref name="destDirectory"/> 为 null 或空时抛出</exception>
|
|||
|
|
/// <exception cref="NotSupportedException">当文件格式不受支持时抛出</exception>
|
|||
|
|
public static async Task ExtractFileAsync(string? compressFilePath, string? destDirectory, Action<double>? progressIncrementHandler = null,
|
|||
|
|
CancellationToken cancellationToken = default) {
|
|||
|
|
if (string.IsNullOrEmpty(compressFilePath)) {
|
|||
|
|
LogWrapper.Error(new ArgumentNullException(nameof(compressFilePath)), "压缩文件路径为空");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (string.IsNullOrEmpty(destDirectory)) {
|
|||
|
|
LogWrapper.Error(new ArgumentNullException(nameof(destDirectory)), "目标目录路径为空");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
Directory.CreateDirectory(destDirectory); // 创建目标目录(同步操作,因为通常很快且无异步版本)
|
|||
|
|
|
|||
|
|
if (compressFilePath.EndsWithF(".gz") || compressFilePath.EndsWithF(".tgz")) {
|
|||
|
|
await _ExtractGZipAsync(compressFilePath, destDirectory, progressIncrementHandler, cancellationToken).ConfigureAwait(false);
|
|||
|
|
} else if (compressFilePath.EndsWithF(".bz2")) {
|
|||
|
|
await _ExtractBZip2Async(compressFilePath, destDirectory, progressIncrementHandler, cancellationToken).ConfigureAwait(false);
|
|||
|
|
} else if (compressFilePath.EndsWithF(".tar")) {
|
|||
|
|
await _ExtractTarAsync(compressFilePath, destDirectory, progressIncrementHandler, cancellationToken).ConfigureAwait(false);
|
|||
|
|
} else if (compressFilePath.EndsWithF(".zip") || compressFilePath.EndsWithF(".jar")) {
|
|||
|
|
await _ExtractZipAsync(compressFilePath, destDirectory, progressIncrementHandler, cancellationToken).ConfigureAwait(false);
|
|||
|
|
} else {
|
|||
|
|
throw new NotSupportedException("不支持的压缩文件格式");
|
|||
|
|
}
|
|||
|
|
} catch (Exception ex) {
|
|||
|
|
LogWrapper.Error(ex, $"解压文件 {compressFilePath} 失败");
|
|||
|
|
throw;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步解压 GZip 文件(包括 .gz、.tgz 和 .tar.gz)。
|
|||
|
|
/// </summary>
|
|||
|
|
private static async Task _ExtractGZipAsync(
|
|||
|
|
string compressFilePath,
|
|||
|
|
string destDirectory,
|
|||
|
|
Action<double>? progressIncrementHandler,
|
|||
|
|
CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
var outputFileName = Path.GetFileName(compressFilePath).ToLower();
|
|||
|
|
var isTarGZip = outputFileName.EndsWithF(".tar.gz") || outputFileName.EndsWithF(".tgz");
|
|||
|
|
|
|||
|
|
if (isTarGZip)
|
|||
|
|
outputFileName = outputFileName
|
|||
|
|
.Replace(".tar.gz", "")
|
|||
|
|
.Replace(".tgz", "");
|
|||
|
|
else if (outputFileName.EndsWithF(".gz")) outputFileName = outputFileName.Replace(".gz", "");
|
|||
|
|
|
|||
|
|
var outputPath = _GetSafePath(destDirectory, outputFileName);
|
|||
|
|
|
|||
|
|
await using FileStream compressedFile = new(compressFilePath, FileMode.Open, FileAccess.Read);
|
|||
|
|
await using GZipInputStream gzipStream = new(compressedFile);
|
|||
|
|
|
|||
|
|
if (isTarGZip)
|
|||
|
|
{
|
|||
|
|
// 处理 .tgz / .tar.gz 文件
|
|||
|
|
await using TarInputStream tarStream = new(gzipStream, Encoding.UTF8);
|
|||
|
|
|
|||
|
|
await _ExtractTarStreamAsync(
|
|||
|
|
tarStream,
|
|||
|
|
destDirectory,
|
|||
|
|
progressIncrementHandler,
|
|||
|
|
cancellationToken)
|
|||
|
|
.ConfigureAwait(false);
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
// 处理普通 .gz 文件
|
|||
|
|
await using FileStream outputStream = new(outputPath, FileMode.Create, FileAccess.Write);
|
|||
|
|
|
|||
|
|
await gzipStream
|
|||
|
|
.CopyToAsync(outputStream, cancellationToken)
|
|||
|
|
.ConfigureAwait(false);
|
|||
|
|
|
|||
|
|
progressIncrementHandler?.Invoke(1.0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步解压 BZip2 文件。
|
|||
|
|
/// </summary>
|
|||
|
|
private static async Task _ExtractBZip2Async(
|
|||
|
|
string compressFilePath,
|
|||
|
|
string destDirectory,
|
|||
|
|
Action<double>? progressIncrementHandler,
|
|||
|
|
CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
var outputFileName = Path.GetFileName(compressFilePath)
|
|||
|
|
.ToLower()
|
|||
|
|
.Replace(".bz2", "");
|
|||
|
|
|
|||
|
|
var outputPath = _GetSafePath(destDirectory, outputFileName);
|
|||
|
|
|
|||
|
|
await using FileStream compressedFile = new(compressFilePath, FileMode.Open, FileAccess.Read);
|
|||
|
|
await using BZip2InputStream bzip2Stream = new(compressedFile);
|
|||
|
|
await using FileStream outputStream = new(outputPath, FileMode.Create, FileAccess.Write);
|
|||
|
|
|
|||
|
|
await bzip2Stream
|
|||
|
|
.CopyToAsync(outputStream, cancellationToken)
|
|||
|
|
.ConfigureAwait(false);
|
|||
|
|
|
|||
|
|
progressIncrementHandler?.Invoke(1.0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步解压 Tar 文件。
|
|||
|
|
/// </summary>
|
|||
|
|
private static async Task _ExtractTarAsync(
|
|||
|
|
string compressFilePath,
|
|||
|
|
string destDirectory,
|
|||
|
|
Action<double>? progressIncrementHandler,
|
|||
|
|
CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
await using FileStream compressedFile = new(compressFilePath, FileMode.Open, FileAccess.Read);
|
|||
|
|
await using TarInputStream tarStream = new(compressedFile, Encoding.UTF8);
|
|||
|
|
|
|||
|
|
await _ExtractTarStreamAsync(
|
|||
|
|
tarStream,
|
|||
|
|
destDirectory,
|
|||
|
|
progressIncrementHandler,
|
|||
|
|
cancellationToken)
|
|||
|
|
.ConfigureAwait(false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步解压 Tar 流中的内容到指定目录。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="tarStream">要解压的 Tar 输入流。</param>
|
|||
|
|
/// <param name="destDirectory">目标解压目录。</param>
|
|||
|
|
/// <param name="progressIncrementHandler">进度更新回调,接收 0.0 到 1.0 的进度值(基于条目计数)。</param>
|
|||
|
|
/// <param name="cancellationToken">用于取消操作的令牌。</param>
|
|||
|
|
/// <exception cref="OperationCanceledException">如果操作被取消。</exception>
|
|||
|
|
/// <exception cref="InvalidOperationException">如果路径不合法或解压失败。</exception>
|
|||
|
|
/// <exception cref="IOException">如果发生 IO 相关错误。</exception>
|
|||
|
|
private static async Task _ExtractTarStreamAsync(
|
|||
|
|
TarInputStream tarStream,
|
|||
|
|
string destDirectory,
|
|||
|
|
Action<double>? progressIncrementHandler,
|
|||
|
|
CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
var entries = new List<TarEntry>();
|
|||
|
|
long totalBytes = 0;
|
|||
|
|
|
|||
|
|
while (await _GetNextEntryAsync(tarStream, cancellationToken).ConfigureAwait(false) is { } entry)
|
|||
|
|
{
|
|||
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|||
|
|
|
|||
|
|
entries.Add(entry);
|
|||
|
|
totalBytes += entry.Size;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
long processedBytes = 0;
|
|||
|
|
|
|||
|
|
tarStream.Reset(); // 重置流以重新读取条目
|
|||
|
|
|
|||
|
|
foreach (var entry in entries)
|
|||
|
|
{
|
|||
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
var destinationPath = _GetSafePath(destDirectory, entry.Name);
|
|||
|
|
|
|||
|
|
if (entry.IsDirectory)
|
|||
|
|
{
|
|||
|
|
await _CreateDirectoryAsync(destinationPath, cancellationToken)
|
|||
|
|
.ConfigureAwait(false);
|
|||
|
|
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await _CreateDirectoryAsync(Path.GetDirectoryName(destinationPath)!, cancellationToken)
|
|||
|
|
.ConfigureAwait(false);
|
|||
|
|
|
|||
|
|
if (entry.Size == 0)
|
|||
|
|
{
|
|||
|
|
await File.Create(destinationPath).DisposeAsync();
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await using FileStream outputStream = new(destinationPath, FileMode.Create, FileAccess.Write);
|
|||
|
|
|
|||
|
|
await tarStream
|
|||
|
|
.CopyEntryContentsAsync(outputStream, cancellationToken)
|
|||
|
|
.ConfigureAwait(false);
|
|||
|
|
|
|||
|
|
processedBytes += entry.Size;
|
|||
|
|
progressIncrementHandler?.Invoke((double)processedBytes / totalBytes);
|
|||
|
|
}
|
|||
|
|
catch (IOException ex)
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException(
|
|||
|
|
$"Failed to extract entry {entry.Name}: {ex.Message}",
|
|||
|
|
ex);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static async Task<TarEntry?> _GetNextEntryAsync(
|
|||
|
|
TarInputStream tarStream,
|
|||
|
|
CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|||
|
|
|
|||
|
|
var task = Task.Run(tarStream.GetNextEntry, cts.Token);
|
|||
|
|
|
|||
|
|
if (await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(10), cts.Token)).ConfigureAwait(false) == task)
|
|||
|
|
return await task.ConfigureAwait(false);
|
|||
|
|
|
|||
|
|
throw new TimeoutException("Operation timed out while reading next Tar entry.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static string _GetSafePath(
|
|||
|
|
string destDirectory,
|
|||
|
|
string entryName)
|
|||
|
|
{
|
|||
|
|
var destinationRoot =
|
|||
|
|
Path.GetFullPath(destDirectory)
|
|||
|
|
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) +
|
|||
|
|
Path.DirectorySeparatorChar;
|
|||
|
|
|
|||
|
|
var fullPath = Path.GetFullPath(Path.Combine(destinationRoot, entryName));
|
|||
|
|
|
|||
|
|
return fullPath.StartsWith(destinationRoot, StringComparison.OrdinalIgnoreCase)
|
|||
|
|
? fullPath
|
|||
|
|
: throw new InvalidOperationException($"Invalid path detected: {entryName}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static async Task _CreateDirectoryAsync(
|
|||
|
|
string path,
|
|||
|
|
CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
await Task
|
|||
|
|
.Run(() => Directory.CreateDirectory(path), cancellationToken)
|
|||
|
|
.ConfigureAwait(false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步解压 Zip 文件(包括 .zip 和 .jar)。
|
|||
|
|
/// </summary>
|
|||
|
|
private static async Task _ExtractZipAsync(
|
|||
|
|
string compressFilePath,
|
|||
|
|
string destDirectory,
|
|||
|
|
Action<double>? progressIncrementHandler,
|
|||
|
|
CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
using ZipFile zipFile = new(compressFilePath);
|
|||
|
|
|
|||
|
|
var totalEntries = zipFile.Count;
|
|||
|
|
long currentEntry = 0;
|
|||
|
|
|
|||
|
|
foreach (ZipEntry entry in zipFile)
|
|||
|
|
{
|
|||
|
|
var destinationPath = _GetSafePath(destDirectory, entry.Name);
|
|||
|
|
|
|||
|
|
if (entry.IsDirectory)
|
|||
|
|
{
|
|||
|
|
Directory.CreateDirectory(destinationPath);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
|||
|
|
|
|||
|
|
await using var zipStream = zipFile.GetInputStream(entry);
|
|||
|
|
await using FileStream outputStream = new(destinationPath, FileMode.Create, FileAccess.Write);
|
|||
|
|
|
|||
|
|
await zipStream
|
|||
|
|
.CopyToAsync(outputStream, cancellationToken)
|
|||
|
|
.ConfigureAwait(false);
|
|||
|
|
|
|||
|
|
currentEntry++;
|
|||
|
|
progressIncrementHandler?.Invoke((double)currentEntry / totalEntries);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region 文件哈希计算
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步计算文件的指定哈希值。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="filePath">要计算哈希的文件路径</param>
|
|||
|
|
/// <param name="hashProvider">哈希算法提供者(如 MD5Provider、SHA1Provider 等)</param>
|
|||
|
|
/// <param name="ignoreIfBusy">是否忽略被占用的文件</param>
|
|||
|
|
/// <returns>哈希值(十六进制字符串),失败时返回空字符串</returns>
|
|||
|
|
public static async Task<string> ComputeFileHashAsync(string? filePath, IHashProvider hashProvider, bool ignoreIfBusy = false) {
|
|||
|
|
if (string.IsNullOrEmpty(filePath)) {
|
|||
|
|
LogWrapper.Warn(new ArgumentNullException(nameof(filePath)), "文件路径为空");
|
|||
|
|
return string.Empty;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查文件是否被占用
|
|||
|
|
if (ignoreIfBusy && await CheckFileBusyAsync(filePath).ConfigureAwait(false)) {
|
|||
|
|
return string.Empty;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (var attempt = 0; attempt < 2; attempt++) {
|
|||
|
|
try {
|
|||
|
|
using FileStream fs = new(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
|||
|
|
return (await hashProvider.ComputeHashAsync(fs).ConfigureAwait(false)).ToHexString();
|
|||
|
|
} catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) {
|
|||
|
|
LogWrapper.Warn(ex, $"计算文件哈希失败:{filePath}");
|
|||
|
|
return string.Empty;
|
|||
|
|
} catch (Exception ex) {
|
|||
|
|
if (attempt == 0) {
|
|||
|
|
LogWrapper.Warn(ex, $"计算文件哈希可重试失败:{filePath}");
|
|||
|
|
await Task.Delay(Random.Shared.Next(200, 500)).ConfigureAwait(false);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
LogWrapper.Warn(ex, $"计算文件哈希失败:{filePath}");
|
|||
|
|
return string.Empty;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return string.Empty;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ReSharper disable InconsistentNaming
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步获取文件的 MD5 哈希值。
|
|||
|
|
/// </summary>
|
|||
|
|
public static Task<string> GetFileMD5Async(string? filePath)
|
|||
|
|
=> ComputeFileHashAsync(filePath, MD5Provider.Instance);
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步获取文件的 SHA1 哈希值。
|
|||
|
|
/// </summary>
|
|||
|
|
public static Task<string> GetFileSHA1Async(string? filePath)
|
|||
|
|
=> ComputeFileHashAsync(filePath, SHA1Provider.Instance);
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步获取文件的 SHA256 哈希值。
|
|||
|
|
/// </summary>
|
|||
|
|
public static Task<string> GetFileSHA256Async(string? filePath, bool ignoreIfBusy = false)
|
|||
|
|
=> ComputeFileHashAsync(filePath, SHA256Provider.Instance, ignoreIfBusy);
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步获取文件的 SHA512 哈希值。
|
|||
|
|
/// </summary>
|
|||
|
|
public static Task<string> GetFileSHA512Async(string? filePath, bool ignoreIfBusy = false)
|
|||
|
|
=> ComputeFileHashAsync(filePath, SHA512Provider.Instance, ignoreIfBusy);
|
|||
|
|
|
|||
|
|
// ReSharper restore InconsistentNaming
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 获取文件的完整路径。
|
|||
|
|
/// </summary>
|
|||
|
|
public static string GetFullPath(string filePath) {
|
|||
|
|
ArgumentNullException.ThrowIfNull(filePath);
|
|||
|
|
return Path.IsPathRooted(filePath) ? filePath : Path.Combine(Paths.DefaultDirectory, filePath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 异步检查文件是否被占用。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <returns>若被占用则为 true,否则为 false</returns>
|
|||
|
|
public static async Task<bool> CheckFileBusyAsync(string filePath) {
|
|||
|
|
try {
|
|||
|
|
if (!File.Exists(filePath)) return false;
|
|||
|
|
await using FileStream fs = new(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.Read | FileShare.Delete);
|
|||
|
|
return false;
|
|||
|
|
} catch (IOException) { return true; } catch { return false; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 从剪切板粘贴文件或文件夹
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="dest">目标文件夹</param>
|
|||
|
|
/// <param name="copyFile">是否粘贴文件</param>
|
|||
|
|
/// <param name="copyDir">是否粘贴文件夹</param>
|
|||
|
|
/// <returns>总共粘贴的数量</returns>
|
|||
|
|
public static async Task<int> PasteFromClipboardAsync(string dest, bool copyFile, bool copyDir) {
|
|||
|
|
if (string.IsNullOrEmpty(dest)) {
|
|||
|
|
throw new ArgumentException("Destination folder cannot be null or empty.", nameof(dest));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!Directory.Exists(dest)) {
|
|||
|
|
Directory.CreateDirectory(dest);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var dataObject = Clipboard.GetDataObject();
|
|||
|
|
if (dataObject is null || !dataObject.GetDataPresent(DataFormats.FileDrop)) {
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var data = dataObject.GetData(DataFormats.FileDrop);
|
|||
|
|
if (data is not string[] paths) {
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
if (paths.Length == 0) {
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var count = 0;
|
|||
|
|
foreach (var path in paths) {
|
|||
|
|
if (File.Exists(path) && copyFile) {
|
|||
|
|
var targetPath = Path.Combine(dest, Path.GetFileName(path));
|
|||
|
|
await CopyFileAsync(path, targetPath);
|
|||
|
|
count++;
|
|||
|
|
} else if (Directory.Exists(path) && copyDir) {
|
|||
|
|
var targetDir = Path.Combine(dest, new DirectoryInfo(path).Name);
|
|||
|
|
await CopyDirectoryAsync(path, targetDir);
|
|||
|
|
count++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return count;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 合并两个 JSON 对象,源 JSON 覆盖目标 JSON 的同名键,数组去重合并。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="target">目标 JSON 对象。</param>
|
|||
|
|
/// <param name="source">源 JSON 对象,优先级高于目标对象。</param>
|
|||
|
|
/// <returns>合并后的 JSON 对象。如果输入无效,返回源或目标的深拷贝。</returns>
|
|||
|
|
/// <exception cref="ArgumentNullException">如果 target 和 source 均为 null,则抛出异常。</exception>
|
|||
|
|
public static JsonNode MergeJson(JsonNode target, JsonNode source) {
|
|||
|
|
if (target is null && source is null) {
|
|||
|
|
throw new ArgumentNullException(nameof(target), "目标和源 JSON 不能同时为 null。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (target is null) {
|
|||
|
|
return source.DeepClone();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (target is not JsonObject targetObj || source is not JsonObject sourceObj) {
|
|||
|
|
// 如果源是对象,优先返回源的深拷贝;否则返回目标的深拷贝
|
|||
|
|
return source.DeepClone();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var result = (JsonObject)targetObj.DeepClone(); // 克隆以避免修改原始对象
|
|||
|
|
|
|||
|
|
foreach (var (key, sourceValue) in sourceObj) {
|
|||
|
|
var targetValue = result[key];
|
|||
|
|
|
|||
|
|
if (sourceValue is null) {
|
|||
|
|
// 忽略 null 值,保留目标值
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (sourceValue is JsonObject && targetValue is JsonObject) {
|
|||
|
|
// 递归合并嵌套对象
|
|||
|
|
result[key] = MergeJson(targetValue, sourceValue);
|
|||
|
|
} else if (sourceValue is JsonArray sourceArray && targetValue is JsonArray targetArray) {
|
|||
|
|
// 合并数组并去重
|
|||
|
|
var uniqueValues = new HashSet<string>(StringComparer.Ordinal);
|
|||
|
|
JsonArray mergedArray = [];
|
|||
|
|
|
|||
|
|
// 添加目标数组元素
|
|||
|
|
foreach (var item in targetArray) {
|
|||
|
|
if (item is null) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
var itemStr = item.ToJsonString();
|
|||
|
|
if (uniqueValues.Add(itemStr)) {
|
|||
|
|
mergedArray.Add(item.DeepClone());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 添加源数组元素(源覆盖目标)
|
|||
|
|
foreach (var item in sourceArray) {
|
|||
|
|
if (item is null) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
var itemStr = item.ToJsonString();
|
|||
|
|
if (uniqueValues.Add(itemStr)) {
|
|||
|
|
mergedArray.Add(item.DeepClone());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result[key] = mergedArray;
|
|||
|
|
} else {
|
|||
|
|
// 直接覆盖(包括简单值、数组替换或其他类型)
|
|||
|
|
result[key] = sourceValue.DeepClone();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 检查文件。若成功则返回 null,失败则返回错误的描述文本,描述文本不以句号结尾。不会抛出错误。
|
|||
|
|
/// </summary>
|
|||
|
|
public static async Task<string?> CheckAsync(
|
|||
|
|
string localPath,
|
|||
|
|
long minSize = -1,
|
|||
|
|
long actualSize = -1,
|
|||
|
|
string? hash = null,
|
|||
|
|
bool isJson = false) {
|
|||
|
|
try {
|
|||
|
|
LogWrapper.Debug("Checker", $"开始校验文件 {localPath}");
|
|||
|
|
var info = new FileInfo(localPath);
|
|||
|
|
if (!info.Exists) return $"文件不存在:{localPath}";
|
|||
|
|
|
|||
|
|
var fileSize = info.Length;
|
|||
|
|
var errors = new StringBuilder();
|
|||
|
|
var allowSizeMismatch = false; // 允许相信哈希正确但是大小不正确
|
|||
|
|
|
|||
|
|
if (!string.IsNullOrEmpty(hash)) {
|
|||
|
|
var computedHash = hash.Length switch {
|
|||
|
|
< 35 => await GetFileMD5Async(localPath), // MD5
|
|||
|
|
64 => await GetFileSHA256Async(localPath), // SHA256
|
|||
|
|
_ => await GetFileSHA1Async(localPath) // SHA1 (40)
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (!string.Equals(hash, computedHash, StringComparison.OrdinalIgnoreCase)) {
|
|||
|
|
var hashType = hash.Length switch {
|
|||
|
|
< 35 => "MD5",
|
|||
|
|
64 => "SHA256",
|
|||
|
|
_ => "SHA1"
|
|||
|
|
};
|
|||
|
|
errors.AppendLine($"文件 {hashType} 应为 {hash},实际为 {computedHash}");
|
|||
|
|
} else {
|
|||
|
|
allowSizeMismatch = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查实际大小
|
|||
|
|
if (actualSize >= 0 && actualSize != fileSize && !allowSizeMismatch) {
|
|||
|
|
var contentPreview = fileSize < 2000 ? await ReadAllTextOrEmptyAsync(localPath) : "";
|
|||
|
|
errors.AppendLine($"文件大小应为 {actualSize} B,实际为 {fileSize} B" +
|
|||
|
|
(string.IsNullOrEmpty(contentPreview) ? "" : $",内容为 {contentPreview}"));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查最小大小
|
|||
|
|
if (minSize >= 0 && minSize > fileSize) {
|
|||
|
|
var contentPreview = fileSize < 2000 ? await ReadAllTextOrEmptyAsync(localPath) : "";
|
|||
|
|
errors.AppendLine($"文件大小应大于 {minSize} B,实际为 {fileSize} B" +
|
|||
|
|
(string.IsNullOrEmpty(contentPreview) ? "" : $",内容为 {contentPreview}"));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// JSON 检查
|
|||
|
|
if (isJson) {
|
|||
|
|
var content = await ReadAllTextOrEmptyAsync(localPath);
|
|||
|
|
if (string.IsNullOrEmpty(content)) throw new Exception("读取到的文件为空");
|
|||
|
|
try {
|
|||
|
|
using var document = JsonDocument.Parse(content, JsonCompat.DocumentOptions);
|
|||
|
|
// 简单验证 JSON 有效性
|
|||
|
|
} catch (JsonException ex) {
|
|||
|
|
throw new Exception(Lang.Text("Common.Error.InvalidJson"), ex);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (errors.Length <= 0) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
errors.Insert(0, $"实际校验地址:{localPath}\n");
|
|||
|
|
return errors.ToString().TrimEnd();
|
|||
|
|
} catch (Exception ex) {
|
|||
|
|
LogWrapper.Warn("Checker", $"检查文件出错: {ex}");
|
|||
|
|
return ex.ToString();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 检查指定路径是否在指定文件夹中
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="childPath">预检查路径</param>
|
|||
|
|
/// <param name="baseDirectory">应存在文件夹</param>
|
|||
|
|
/// <returns></returns>
|
|||
|
|
public static bool IsPathWithinDirectory(string childPath, string baseDirectory)
|
|||
|
|
{
|
|||
|
|
var baseDir = Path.GetFullPath(baseDirectory);
|
|||
|
|
var child = Path.GetFullPath(childPath);
|
|||
|
|
|
|||
|
|
return child.StartsWith(
|
|||
|
|
baseDir.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar,
|
|||
|
|
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
|||
|
|
? StringComparison.OrdinalIgnoreCase
|
|||
|
|
: StringComparison.Ordinal
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|