Files
MRCC/launcher/PCL-CE/PCL.Core/IO/Files.cs
T
xyou f70b061d1a
CI / Go Backend (push) Canceled after 0s
feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
2026-08-08 14:07:56 +08:00

949 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
);
}
}