初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -0,0 +1,948 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user