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 }; /// /// 在指定路径创建一个指向目标文件的 .lnk 快捷方式。 /// /// 要创建的快捷方式完整路径,建议以 ".lnk" 结尾 /// 被指向的目标文件或可执行程序路径 /// 启动时的命令行参数 /// 快捷方式的起始目录 /// 快捷方式说明 /// 自定义图标,格式 "图标文件路径,索引" // 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 ExportAsZipArchiveAsync( IEnumerable 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 异步文件操作 /// /// 复制文件,自动创建目标目录并覆盖已有文件。 /// /// 源文件路径(完整或相对) /// 目标文件路径(完整或相对) /// 取消令牌 /// 复制失败时抛出 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); }); } /// /// 读取文件为字节数组,支持读取被占用的文件。 /// /// 文件路径(完整或相对) /// 取消令牌 /// 文件内容的字节数组,失败时返回空数组 public static async Task 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 []; } } /// /// 读取文件为字符串。 /// /// 文件路径(完整或相对) /// 文件编码,默认为 UTF-8 /// 取消令牌 /// 文件内容的字符串,失败时返回空字符串 public static async Task 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 ""; } } /// /// 从流中读取所有文本。 /// /// 要读取的流 /// 文件编码(可选,若为 null 则动态检测) /// 取消令牌 /// 流内容的字符串,失败时返回空字符串 public static async Task 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; } } /// /// 异步读取文件到流。 /// /// 文件路径(完整或相对) /// 取消令牌 /// 包含文件内容的 MemoryStream,失败时返回空的 MemoryStream public static async Task 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 } } /// /// 写入字符串到文件,支持追加或覆盖,自动创建目录。 /// /// 文件路径(完整或相对) /// 要写入的文本 /// 追加到文件(true)或覆盖(false) /// 文件编码(可选),默认智能检测 /// 取消令牌 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); } } /// /// 写入字节数组到文件,自动创建目录。 /// /// 文件路径(完整或相对) /// 要写入的字节数组 /// 追加到文件(true)或覆盖(false),默认为 false /// 取消令牌 /// 一个 Task,表示异步写入操作 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); } /// /// 将流写入文件,自动创建目录。 /// /// 文件路径(完整或相对) /// 要写入的流 /// 取消令牌 /// 写入是否成功 public static async Task 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 文件解压 /// /// 尝试根据文件后缀名判断文件种类并解压,支持 zip、gz、tar、tar.gz 和 bzip2。 /// 会尝试将 jar 文件以 zip 方式解压。不会清空目标目录,但会创建不存在的目录。 /// /// 压缩文件路径 /// 目标解压目录 /// 进度更新回调,接收 0.0 到 1.0 的进度值 /// 取消操作的令牌 /// 异步任务。 /// 为 null 或空时抛出 /// 当文件格式不受支持时抛出 public static async Task ExtractFileAsync(string? compressFilePath, string? destDirectory, Action? 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; } } /// /// 异步解压 GZip 文件(包括 .gz、.tgz 和 .tar.gz)。 /// private static async Task _ExtractGZipAsync( string compressFilePath, string destDirectory, Action? 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); } } /// /// 异步解压 BZip2 文件。 /// private static async Task _ExtractBZip2Async( string compressFilePath, string destDirectory, Action? 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); } /// /// 异步解压 Tar 文件。 /// private static async Task _ExtractTarAsync( string compressFilePath, string destDirectory, Action? 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); } /// /// 异步解压 Tar 流中的内容到指定目录。 /// /// 要解压的 Tar 输入流。 /// 目标解压目录。 /// 进度更新回调,接收 0.0 到 1.0 的进度值(基于条目计数)。 /// 用于取消操作的令牌。 /// 如果操作被取消。 /// 如果路径不合法或解压失败。 /// 如果发生 IO 相关错误。 private static async Task _ExtractTarStreamAsync( TarInputStream tarStream, string destDirectory, Action? progressIncrementHandler, CancellationToken cancellationToken) { var entries = new List(); 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 _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); } /// /// 异步解压 Zip 文件(包括 .zip 和 .jar)。 /// private static async Task _ExtractZipAsync( string compressFilePath, string destDirectory, Action? 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 文件哈希计算 /// /// 异步计算文件的指定哈希值。 /// /// 要计算哈希的文件路径 /// 哈希算法提供者(如 MD5Provider、SHA1Provider 等) /// 是否忽略被占用的文件 /// 哈希值(十六进制字符串),失败时返回空字符串 public static async Task 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 /// /// 异步获取文件的 MD5 哈希值。 /// public static Task GetFileMD5Async(string? filePath) => ComputeFileHashAsync(filePath, MD5Provider.Instance); /// /// 异步获取文件的 SHA1 哈希值。 /// public static Task GetFileSHA1Async(string? filePath) => ComputeFileHashAsync(filePath, SHA1Provider.Instance); /// /// 异步获取文件的 SHA256 哈希值。 /// public static Task GetFileSHA256Async(string? filePath, bool ignoreIfBusy = false) => ComputeFileHashAsync(filePath, SHA256Provider.Instance, ignoreIfBusy); /// /// 异步获取文件的 SHA512 哈希值。 /// public static Task GetFileSHA512Async(string? filePath, bool ignoreIfBusy = false) => ComputeFileHashAsync(filePath, SHA512Provider.Instance, ignoreIfBusy); // ReSharper restore InconsistentNaming /// /// 获取文件的完整路径。 /// public static string GetFullPath(string filePath) { ArgumentNullException.ThrowIfNull(filePath); return Path.IsPathRooted(filePath) ? filePath : Path.Combine(Paths.DefaultDirectory, filePath); } /// /// 异步检查文件是否被占用。 /// /// 若被占用则为 true,否则为 false public static async Task 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 /// /// 从剪切板粘贴文件或文件夹 /// /// 目标文件夹 /// 是否粘贴文件 /// 是否粘贴文件夹 /// 总共粘贴的数量 public static async Task 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; } /// /// 合并两个 JSON 对象,源 JSON 覆盖目标 JSON 的同名键,数组去重合并。 /// /// 目标 JSON 对象。 /// 源 JSON 对象,优先级高于目标对象。 /// 合并后的 JSON 对象。如果输入无效,返回源或目标的深拷贝。 /// 如果 target 和 source 均为 null,则抛出异常。 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(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; } /// /// 检查文件。若成功则返回 null,失败则返回错误的描述文本,描述文本不以句号结尾。不会抛出错误。 /// public static async Task 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(); } } /// /// 检查指定路径是否在指定文件夹中 /// /// 预检查路径 /// 应存在文件夹 /// 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 ); } }