feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

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

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

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
+52
View File
@@ -0,0 +1,52 @@
using System;
using System.Globalization;
using System.IO;
namespace PCL.Core.IO;
public class ByteStream(Stream stream)
{
private static readonly string[] _Units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
public long Length => stream.Length;
public string GetReadableLength()
{
return GetReadableLength(Length);
}
/// <summary>
/// 格式化大小
/// </summary>
/// <param name="length">字节</param>
/// <param name="startUnit">开始单位</param>
/// <param name="provider">格式化区域提供程序,默认使用 <see cref="CultureInfo.InvariantCulture" /></param>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public static string GetReadableLength(long length, int startUnit = 0, IFormatProvider? provider = null)
{
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(startUnit, _Units.Length);
ArgumentOutOfRangeException.ThrowIfLessThan(startUnit, 0);
var isNegative = length < 0;
decimal absBytes = isNegative ? -length : length;
if (absBytes == 0)
return "0 B";
var unitIndex = startUnit;
var value = absBytes;
while (value >= 1024 && unitIndex < _Units.Length - 1)
{
value /= 1024;
unitIndex++;
}
if (unitIndex >= _Units.Length)
throw new ArgumentOutOfRangeException(nameof(length),
"Value too large for predefined units.");
var sign = isNegative ? "-" : "";
return $"{sign}{value.ToString("0.##", provider ?? CultureInfo.InvariantCulture)} {_Units[unitIndex]}";
}
}
+306
View File
@@ -0,0 +1,306 @@
using System.Security.AccessControl;
namespace PCL.Core.IO;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Logging;
public static class Directories {
/// <summary>
/// 异步检查是否拥有对指定文件夹的读写权限。
/// 如果文件夹不存在或没有权限,返回 false,不修改文件系统。
/// </summary>
/// <param name="path">要检查的文件夹路径。</param>
/// <param name="cancellationToken">取消操作的令牌。</param>
/// <returns>如果拥有读写权限且文件夹存在,返回 true;否则返回 false。</returns>
public static async Task<bool> CheckPermissionAsync(string? path, CancellationToken cancellationToken = default) {
try {
if (string.IsNullOrWhiteSpace(path)) {
return false;
}
// 排除特殊系统文件夹
if (IsSystemProtectedFolder(path)) {
return false;
}
// 检查文件夹是否存在
if (!Directory.Exists(path)) {
return false;
}
// 检查目录访问权限
var directoryInfo = new DirectoryInfo(path);
var security = await Task.Run(() => directoryInfo.GetAccessControl(), cancellationToken);
var rules = security.GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount));
// 检查当前用户是否有读写权限,优先考虑拒绝规则
var currentUser = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(currentUser);
var isDenied = false;
var isAllowed = false;
foreach (FileSystemAccessRule rule in rules) {
if (!rule.FileSystemRights.HasFlag(FileSystemRights.Write))
continue;
// 检查规则是否适用于当前用户或其组
if (principal.IsInRole(rule.IdentityReference.Value)) {
if (rule.AccessControlType == AccessControlType.Deny) {
isDenied = true;
break; // 拒绝优先,直接返回
}
if (rule.AccessControlType == AccessControlType.Allow) {
isAllowed = true;
}
}
}
if (isDenied || !isAllowed) {
return false;
}
// 尝试枚举目录内容以确认实际访问能力
await Task.Run(() => Directory.EnumerateFiles(path, "*", SearchOption.TopDirectoryOnly).Any(), cancellationToken);
return true;
} catch (OperationCanceledException) {
LogWrapper.Warn("权限检查被取消");
return false;
} catch (Exception ex) {
LogWrapper.Warn(ex, $"没有对文件夹 {path} 的权限,请尝试以管理员权限运行。");
return false;
}
}
/// <summary>
/// 异步检查文件夹权限,若无权限或文件夹不存在则抛出异常。
/// 不修改文件系统。
/// </summary>
/// <param name="path">要检查的文件夹路径。</param>
/// <param name="cancellationToken">取消操作的令牌。</param>
/// <exception cref="ArgumentNullException">路径为空或只包含空白字符。</exception>
/// <exception cref="DirectoryNotFoundException">文件夹不存在。</exception>
/// <exception cref="UnauthorizedAccessException">无访问权限。</exception>
/// <exception cref="OperationCanceledException">操作被取消。</exception>
public static async Task CheckPermissionWithExceptionAsync(string? path, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(path)) {
throw new ArgumentNullException(nameof(path), "文件夹路径不能为空!");
}
if (IsSystemProtectedFolder(path)) {
throw new UnauthorizedAccessException($"无法访问受保护的系统文件夹:{path}");
}
if (!Directory.Exists(path)) {
throw new DirectoryNotFoundException($"文件夹不存在:{path}");
}
try {
var directoryInfo = new DirectoryInfo(path);
var security = await Task.Run(() => directoryInfo.GetAccessControl(), cancellationToken);
var rules = security.GetAccessRules(true, true, typeof(System.Security.Principal.NTAccount));
var hasAccess = rules.Cast<FileSystemAccessRule>()
.Any(rule => rule.FileSystemRights.HasFlag(FileSystemRights.Write) &&
rule.AccessControlType == AccessControlType.Allow);
if (!hasAccess) {
throw new UnauthorizedAccessException($"没有对文件夹 {path} 的写权限");
}
// 确认实际访问能力
await Task.Run(() => Directory.EnumerateFiles(path, "*", SearchOption.TopDirectoryOnly).Any(), cancellationToken);
} catch (UnauthorizedAccessException) {
throw;
} catch (OperationCanceledException) {
throw;
} catch (Exception ex) {
throw new UnauthorizedAccessException($"无法访问文件夹 {path}{ex.Message}", ex);
}
}
/// <summary>
/// 检查是否为受保护的系统文件夹。
/// </summary>
private static bool IsSystemProtectedFolder(string path) {
return path.EndsWith(":\\System Volume Information", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(":\\$RECYCLE.BIN", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 异步删除文件夹及其内容,返回删除的文件数。支持忽略错误。
/// </summary>
/// <param name="path">要删除的文件夹路径。</param>
/// <param name="ignoreIssue">是否忽略删除过程中的错误。</param>
/// <param name="cancellationToken">取消操作的令牌。</param>
/// <returns>成功删除的文件数。</returns>
/// <exception cref="OperationCanceledException">操作被取消。</exception>
public static async Task<int> DeleteDirectoryAsync(string? path, bool ignoreIssue = false, CancellationToken cancellationToken = default) {
if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) {
return 0;
}
var deletedCount = 0;
try {
// 枚举文件,延迟加载以提高性能
foreach (var filePath in Directory.EnumerateFiles(path)) {
cancellationToken.ThrowIfCancellationRequested();
for (var attempt = 0; attempt < 2; attempt++) {
try {
await FileDeleteAsync(filePath, cancellationToken).ConfigureAwait(false);
deletedCount++;
break;
} catch (Exception ex) when (attempt == 0) {
LogWrapper.Error(ex, $"删除文件失败,将在 0.3s 后重试({filePath}");
await Task.Delay(300, cancellationToken).ConfigureAwait(false);
} catch (Exception ex) {
if (ignoreIssue) {
LogWrapper.Error(ex, "删除单个文件可忽略地失败");
} else {
throw;
}
}
}
}
// 递归删除子目录
foreach (var subDir in Directory.EnumerateDirectories(path)) {
cancellationToken.ThrowIfCancellationRequested();
deletedCount += await DeleteDirectoryAsync(subDir, ignoreIssue, cancellationToken).ConfigureAwait(false);
}
// 删除空目录
for (var attempt = 0; attempt < 2; attempt++) {
try {
Directory.Delete(path, true);
break;
} catch (Exception ex) when (attempt == 0) {
LogWrapper.Error(ex, $"删除文件夹失败,将在 0.3s 后重试({path}");
await Task.Delay(300, cancellationToken).ConfigureAwait(false);
} catch (Exception ex) {
if (ignoreIssue) {
LogWrapper.Error(ex, "删除单个文件夹可忽略地失败");
} else {
throw;
}
}
}
} catch (DirectoryNotFoundException ex) {
// 处理疑似符号链接的情况
LogWrapper.Error(ex, $"疑似为孤立符号链接,尝试直接删除({path})", "Developer");
try {
Directory.Delete(path);
} catch (Exception deleteEx) {
if (!ignoreIssue) {
throw;
}
LogWrapper.Error(deleteEx, $"删除符号链接文件夹失败({path})");
}
}
return deletedCount;
}
/// <summary>
/// 异步复制文件夹及其内容,失败时抛出异常。
/// </summary>
/// <param name="fromPath">源文件夹路径。</param>
/// <param name="toPath">目标文件夹路径。</param>
/// <param name="progressIncrementHandler">进度更新回调,接收 0 到 1 的进度值。</param>
/// <param name="cancellationToken">取消操作的令牌。</param>
/// <exception cref="ArgumentNullException">源或目标文件夹路径为空。</exception>
/// <exception cref="OperationCanceledException">操作被取消。</exception>
public static async Task CopyDirectoryAsync(string? fromPath, string? toPath, Action<double>? progressIncrementHandler = null, CancellationToken cancellationToken = default) {
if (string.IsNullOrEmpty(fromPath)) {
throw new ArgumentNullException(nameof(fromPath), "源文件夹路径为空");
}
if (string.IsNullOrEmpty(toPath)) {
throw new ArgumentNullException(nameof(toPath), "目标文件夹路径为空");
}
// 规范化路径
fromPath = Path.GetFullPath(fromPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
toPath = Path.GetFullPath(toPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var allFiles = (await EnumerateFilesAsync(fromPath, cancellationToken).ConfigureAwait(false)).ToList();
var totalFiles = allFiles.Count;
long copiedFiles = 0;
foreach (var file in allFiles) {
cancellationToken.ThrowIfCancellationRequested();
var relativePath = file.FullName[fromPath.Length..];
var destFilePath = Path.Combine(toPath, relativePath);
// 确保目标目录存在
Directory.CreateDirectory(Path.GetDirectoryName(destFilePath)!);
for (var attempt = 0; attempt < 2; attempt++) {
try {
await FileCopyAsync(file.FullName, destFilePath, overwrite: true, cancellationToken).ConfigureAwait(false);
copiedFiles++;
progressIncrementHandler?.Invoke((double)copiedFiles / totalFiles);
break;
} catch (Exception ex) when (attempt == 0) {
LogWrapper.Error(ex, $"复制文件失败,将在 0.3s 后重试({file.FullName} 到 {destFilePath}");
await Task.Delay(300, cancellationToken).ConfigureAwait(false);
} catch (Exception ex) {
LogWrapper.Error(ex, $"复制文件失败({file.FullName} 到 {destFilePath}");
throw;
}
}
}
}
/// <summary>
/// 异步遍历文件夹中的所有文件。
/// </summary>
/// <param name="directory">要遍历的文件夹路径。</param>
/// <param name="cancellationToken">取消操作的令牌。</param>
/// <returns>文件信息的枚举器。</returns>
public static async Task<IEnumerable<FileInfo>> EnumerateFilesAsync(string? directory, CancellationToken cancellationToken = default) {
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) {
throw new DirectoryNotFoundException($"目录不存在:{directory}");
}
try {
// DirectoryInfo.EnumerateFiles 是同步的,使用 Task.Run 包装
return await Task.Run(() => new DirectoryInfo(directory).EnumerateFiles("*", SearchOption.AllDirectories).ToList(), cancellationToken).ConfigureAwait(false);
} catch (OperationCanceledException) {
LogWrapper.Warn("文件夹遍历被取消");
return [];
} catch (Exception ex) {
LogWrapper.Error(ex, $"遍历文件夹失败({directory}");
return [];
}
}
// 辅助方法:异步打开 FileStream
private static async Task<FileStream> FileStreamOpenAsync(string path, FileMode mode, FileAccess access, FileShare share, CancellationToken cancellationToken) {
var fs = new FileStream(path, mode, access, share, bufferSize: 4096, useAsync: true);
await Task.Yield(); // 确保异步上下文
cancellationToken.ThrowIfCancellationRequested();
return fs;
}
// 辅助方法:异步删除文件
private static async Task FileDeleteAsync(string path, CancellationToken cancellationToken) {
await Task.Run(() => File.Delete(path), cancellationToken).ConfigureAwait(false);
}
// 辅助方法:异步复制文件
private static async Task FileCopyAsync(string sourceFileName, string destFileName, bool overwrite, CancellationToken cancellationToken) {
await using FileStream sourceStream = new(sourceFileName, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
await using FileStream destStream = new(destFileName, overwrite ? FileMode.Create : FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, useAsync: true);
await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,29 @@
using System.Threading.Tasks;
namespace PCL.Core.IO.Download;
/// <summary>
/// 下载连接,负责与服务器进行通信。
/// </summary>
public interface IDlConnection
{
/// <summary>
/// 开始连接,发起与服务器的通信。
/// </summary>
/// <param name="beginOffset">起始偏移,为 <c>0</c> 表示不使用分块</param>
/// <returns>连接信息</returns>
public Task<NDlConnectionInfo> StartAsync(long beginOffset);
/// <summary>
/// 停止连接,同时停止服务器通信并释放资源。
/// </summary>
/// <returns></returns>
public Task StopAsync();
/// <summary>
/// 读取指定长度的数据,若无法继续读取则返回空数组。
/// </summary>
/// <param name="length">读取长度</param>
/// <returns>字节数组形式的数据</returns>
public Task<byte[]> ReadAsync(int length);
}
@@ -0,0 +1,10 @@
namespace PCL.Core.IO.Download;
/// <summary>
/// 资源 ID 映射。
/// </summary>
/// <typeparam name="TMappingValue">映射目标类型</typeparam>
public interface IDlResourceMapping<out TMappingValue>
{
public TMappingValue? Parse(string resId);
}
@@ -0,0 +1,31 @@
using System.IO;
using System.Threading.Tasks;
namespace PCL.Core.IO.Download;
/// <summary>
/// 下载写入器。
/// </summary>
public interface IDlWriter
{
/// <summary>
/// 是否支持并行写入,即是否支持多次调用 <see cref="CreateStreamAsync"/>。
/// </summary>
public bool IsSupportParallel { get; }
/// <summary>
/// 创建写入流。
/// </summary>
/// <returns>写入流</returns>
public Task<Stream> CreateStreamAsync();
/// <summary>
/// 停止写入并释放资源。
/// </summary>
public Task StopAsync();
/// <summary>
/// 完成写入,用于执行某些并行操作的收尾工作 (例如合并文件)。
/// </summary>
public Task FinishAsync();
}
@@ -0,0 +1,15 @@
namespace PCL.Core.IO.Download;
/// <summary>
/// 下载连接信息。
/// </summary>
/// <param name="Length">内容长度,单位为字节</param>
/// <param name="BeginOffset">起始偏移</param>
/// <param name="EndOffset">结束偏移</param>
/// <param name="IsSupportSegment">是否支持分块</param>
public record NDlConnectionInfo(
long Length,
long BeginOffset,
long EndOffset,
bool IsSupportSegment
);
@@ -0,0 +1,65 @@
namespace PCL.Core.IO.Download;
/// <summary>
/// 无泛型的下载器构建工厂。
/// </summary>
public abstract class NDlFactory
{
/// <summary>
/// 新建连接。
/// </summary>
/// <param name="resId">资源 ID</param>
/// <returns>下载连接</returns>
public abstract IDlConnection? CreateConnection(string resId);
/// <summary>
/// 新建写入器。
/// </summary>
/// <param name="resId">资源 ID</param>
/// <returns>下载写入器</returns>
public abstract IDlWriter? CreateWriter(string resId);
}
/// <summary>
/// 下载器构建工厂。
/// </summary>
/// <typeparam name="TSourceArgument">下载源参数类型</typeparam>
/// <typeparam name="TTargetArgument">写入目标参数类型</typeparam>
public abstract class NDlFactory<TSourceArgument, TTargetArgument> : NDlFactory
{
/// <summary>
/// 下载源映射。
/// </summary>
protected abstract IDlResourceMapping<TSourceArgument> SourceMapping { get; }
/// <summary>
/// 写入目标映射。
/// </summary>
protected abstract IDlResourceMapping<TTargetArgument> TargetMapping { get; }
/// <summary>
/// 新建连接。
/// </summary>
/// <param name="source">下载源参数</param>
/// <returns>下载连接</returns>
protected abstract IDlConnection CreateConnection(TSourceArgument source);
public override IDlConnection? CreateConnection(string resId)
{
var source = SourceMapping.Parse(resId);
return (source is null) ? null : CreateConnection(source);
}
/// <summary>
/// 新建写入器。
/// </summary>
/// <param name="target">写入目标</param>
/// <returns>下载写入器</returns>
protected abstract IDlWriter CreateWriter(TTargetArgument target);
public override IDlWriter? CreateWriter(string resId)
{
var target = TargetMapping.Parse(resId);
return (target is null) ? null : CreateWriter(target);
}
}
@@ -0,0 +1,13 @@
namespace PCL.Core.IO.Download;
/// <summary>
/// 下载源质量报告
/// </summary>
/// <param name="MaxSegmentCount">支持最大分块数量</param>
/// <param name="RetryCount">重试计数</param>
/// <param name="AverageSpeed">总体平均速度</param>
public record NDlSourceReport(
int MaxSegmentCount = 1,
int RetryCount = 0,
long AverageSpeed = -1
);
+948
View File
@@ -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
);
}
}
@@ -0,0 +1,103 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Runtime.Caching;
using System.Threading;
using System.Threading.Tasks;
using Ae.Dns.Client;
using Ae.Dns.Protocol;
using Ae.Dns.Protocol.Enums;
using Ae.Dns.Protocol.Records;
using PCL.Core.IO.Net.Http;
using PCL.Core.Logging;
namespace PCL.Core.IO.Net.Dns;
public class DnsQuery : IDisposable
{
private const string ModuleName = "DoH query";
public static DnsQuery Instance { get; } = new();
private readonly DnsCachingClient _resolver;
private readonly HttpClient[] _httpClients;
private DnsQuery()
{
var proxyHandler = new HttpClientHandler()
{
Proxy = HttpProxyManager.Instance
};
// 使用Ae.Dns创建DoH客户端,支持多个DoH服务器
_httpClients =
[
new HttpClient(proxyHandler)
{
BaseAddress = new Uri("https://doh.pub/")
},
new HttpClient(proxyHandler)
{
BaseAddress = new Uri("https://doh.pysio.online/")
},
new HttpClient(proxyHandler)
{
BaseAddress = new Uri("https://cloudflare-dns.com/")
}
];
_resolver = new DnsCachingClient(
new WeightedDnsRacerClient(2, _httpClients.Select(static x => new DnsHttpClient(x)).ToArray<IDnsClient>()),
new MemoryCache("DoH Query Cache"));
}
public async Task<DnsMessage?> QueryAsync(string host, DnsQueryType qType, CancellationToken cts = default)
{
try
{
return await _resolver.Query(DnsQueryFactory.CreateQuery(host, qType), cts);
}
catch (TaskCanceledException)
{
throw;
}
catch (Exception ex)
{
LogWrapper.Warn(ModuleName, $"Failed to resolve DNS for {host}: {ex.Message}, use system default DNS");
}
return null;
}
public async Task<IPAddress[]?> QueryForIpAsync(string host, CancellationToken cts = default)
{
var queryResponse = await Task.WhenAll(
[
QueryAsync(host, DnsQueryType.A, cts),
QueryAsync(host, DnsQueryType.AAAA, cts)
]
);
if (queryResponse.All(static x => x is null))
{
LogWrapper.Warn(ModuleName, $"Failed to query IP for host {host} using DoH, use system default DNS");
return await System.Net.Dns.GetHostAddressesAsync(host, cts);
}
return queryResponse.Where(static x => x is not null)
.SelectMany(static x => x!.Answers)
.Where(static x => x.Type is DnsQueryType.A or DnsQueryType.AAAA)
.Select(static x => x.Resource as DnsIpAddressResource)
.Where(static x => x is not null)
.Select(static x => x!.IPAddress)
.ToArray();
}
public void Dispose()
{
GC.SuppressFinalize(this);
foreach (var client in _httpClients)
{
client.Dispose();
}
_resolver.Dispose(); // 好像这个包的 Dispose 并没有做什么 lol
}
}
@@ -0,0 +1,67 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Ae.Dns.Protocol.Records;
namespace PCL.Core.IO.Net.Dns;
public class DnsSrvResource : IDnsResource
{
public string Target { get; set; } = "";
public int Weight { get; set; }
public int Priority { get; set; }
public int Port { get; set; }
public void WriteBytes(Memory<byte> bytes, ref int offset)
{
// 6 Bytes for priority, weight, port and 2 bytes for length
var buf = bytes.Span[offset..];
BinaryPrimitives.WriteUInt16BigEndian(buf[offset..(offset + 2)], (ushort)Priority);
offset += 2;
BinaryPrimitives.WriteUInt16BigEndian(buf[offset..(offset + 2)], (ushort)Weight);
offset += 2;
BinaryPrimitives.WriteUInt16BigEndian(buf[offset..(offset + 2)], (ushort)Port);
offset += 2;
// target string
foreach (var seg in Target.Split('.'))
{
var segBuf = Encoding.UTF8.GetBytes(seg);
var segLength = (byte)segBuf.Length;
buf[offset] = segLength;
offset++;
segBuf.CopyTo(buf[offset..]);
offset += segBuf.Length;
}
}
public void ReadBytes(ReadOnlyMemory<byte> bytes, ref int offset, int length)
{
var buf = bytes.Slice(offset, length).Span;
offset += length;
var priorityBuf = buf[..2];
Priority = BinaryPrimitives.ReadUInt16BigEndian(priorityBuf);
var weightBuf = buf[2..4];
Weight = BinaryPrimitives.ReadUInt16BigEndian(weightBuf);
var portBuf = buf[4..6];
Port = BinaryPrimitives.ReadUInt16BigEndian(portBuf);
// left target buf. 1 Byte for length, then the string. Until we got end of string.
var segments = new List<string>();
var i = 6;
while (i < length)
{
var segLength = buf[i];
if (segLength == 0) break;
i++;
if (i + segLength > length)
{
throw new ArgumentException("Invalid DNS SRV resource record: segment length exceeds buffer size");
}
segments.Add(Encoding.UTF8.GetString(buf.Slice(i, segLength)));
i += segLength;
}
Target = string.Join('.', segments);
}
}
@@ -0,0 +1,79 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Ae.Dns.Protocol;
using PCL.Core.Utils.Exts;
namespace PCL.Core.IO.Net.Dns;
public sealed class WeightedDnsRacerClient : IDnsClient
{
private readonly (IDnsClient Client, byte Weight)[] _clients;
private readonly int _concurrentCount;
private bool _updateWeigh = true;
private readonly object _lock = new();
public WeightedDnsRacerClient(int concurrentCount, params IDnsClient[] clients)
{
if (clients is null || clients.Length < concurrentCount)
throw new ArgumentException("Not enough clients.");
_concurrentCount = concurrentCount;
_clients = clients.Select(static c => (Client: c, Weight: (byte)0)).ToArray();
}
public async Task<DnsMessage> Query(DnsMessage query, CancellationToken ct = default)
{
(IDnsClient Client, byte Weight)[] candidates;
lock (_lock)
{
candidates = _clients
.OrderByDescending(static x => x.Weight)
.Take(_concurrentCount)
.ToArray();
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var tasks = new Task<DnsMessage>[candidates.Length];
for (var i = 0; i < candidates.Length; i++)
{
var task = candidates[i].Client.Query(query, cts.Token);
task.Forget();
tasks[i] = task;
}
var winnerTask = await tasks.WhenAnySuccessAsync().ConfigureAwait(false);
if (winnerTask is null) throw new InvalidOperationException("All queries failed.");
var winner = candidates[Array.IndexOf(tasks, winnerTask)].Client;
if (_updateWeigh)
{
lock (_lock)
{
for (var i = 0; i < _clients.Length; i++)
{
if (_clients[i].Client == winner)
{
if (_clients[i].Weight < 255)
_clients[i] = (_clients[i].Client, (byte)(_clients[i].Weight + 1));
else
_updateWeigh = false;
break;
}
}
}
}
cts.Cancel();
return await winnerTask.ConfigureAwait(false);
}
public void Dispose()
{
foreach (var (client, _) in _clients)
client?.Dispose();
}
}
@@ -0,0 +1,124 @@
using PCL.Core.IO.Storage.Cache;
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Net.Http.Cache;
/// <summary>
/// HTTP 缓存处理器
/// </summary>
public class HttpCacheHandler : DelegatingHandler
{
private ICacheService _cacheService;
public HttpCacheHandler(HttpMessageHandler invoker, ICacheService cacheService)
{
InnerHandler = invoker;
_cacheService = cacheService;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var uri = request.RequestUri?.ToString();
if (string.IsNullOrEmpty(uri))
{
return await base.SendAsync(request, ct).ConfigureAwait(false);
}
// seek cache
var cacheKey = CacheKeys.ApiResponse("http", uri);
var cached = await _cacheService.GetAsync<byte[]>(cacheKey, ct).ConfigureAwait(false);
if (cached.Found)
{
var cachedResponse = _DeserializeResponse(cached.Value!);
cachedResponse.Headers.Add("X-Cache-Hit", "HIT");
cachedResponse.RequestMessage = request;
return cachedResponse;
}
// seek metadata
var metaKey = CacheKeys.ApiResponseMeta("http", uri);
var metaCached = await _cacheService.GetAsync<HttpCacheMetadata>(metaKey, ct).ConfigureAwait(false);
if (metaCached.Found)
{
if (metaCached.Value!.ETag is not null)
{
request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(metaCached.Value!.ETag));
}
if (metaCached.Value!.LastModified is not null)
{
request.Headers.IfModifiedSince = DateTimeOffset.Parse(metaCached.Value!.LastModified);
}
}
// send request
var response = await base.SendAsync(request, ct).ConfigureAwait(false);
if (response.Headers.CacheControl?.NoStore ?? false)
{
return response;
}
// not modified, return cached response if exists
if (response.StatusCode is HttpStatusCode.NotModified)
{
var reCached = await _cacheService.GetAsync<byte[]>(cacheKey, ct).ConfigureAwait(false);
if (reCached.Found)
{
var cachedResponse = _DeserializeResponse(reCached.Value!);
cachedResponse.Headers.Add("X-Cache-Hit", "HIT");
cachedResponse.RequestMessage = request;
return cachedResponse;
}
}
// cache response
var body = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
var ttl = _ComputeTtl(response);
await _cacheService.SetAsync(cacheKey, body, new CachePolicy
{
AbsoluteExpiration = ttl,
Group = "http",
Tags = "http-response"
}, ct).ConfigureAwait(false);
await _cacheService.SetAsync(metaKey, new HttpCacheMetadata
{
ETag = response.Headers.ETag?.Tag,
LastModified = response.Content.Headers.LastModified?.ToString("O"),
StatusCode = (int)response.StatusCode
}, CachePolicy.NeverExpire, ct).ConfigureAwait(false);
// not matched, return original response
response.Headers.Add("X-Cache-Hit", "MISS");
return response;
}
private static TimeSpan? _ComputeTtl(HttpResponseMessage response)
{
var cc = response.Headers.CacheControl;
if (cc?.MaxAge is not null)
{
return cc.MaxAge;
}
if (cc?.SharedMaxAge is not null)
{
return cc.SharedMaxAge;
}
return TimeSpan.FromMinutes(5);
}
private static HttpResponseMessage _DeserializeResponse(byte[] data)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(data)
};
}
}
@@ -0,0 +1,8 @@
namespace PCL.Core.IO.Net.Http.Cache;
public record HttpCacheMetadata
{
public string? ETag { get; init; }
public string? LastModified { get; init; }
public int StatusCode { get; init; }
}
@@ -0,0 +1,248 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.IO.Net.Dns;
using PCL.Core.Logging;
using PCL.Core.Utils.Exts;
namespace PCL.Core.IO.Net.Http;
public class HostConnectionHandler
{
public static HostConnectionHandler Instance { get; } = new();
private const string ModuleName = "HostConnectionHandler";
private readonly DnsQuery _dnsQuery = DnsQuery.Instance;
private static readonly TimeSpan _CacheDuration = TimeSpan.FromMinutes(10); // 10 分钟缓存(RFC 建议值)
private const int WaitTasks = 2;
// 缓存结构: (host, port) -> (IPAddress -> LastSuccessUtc)
private readonly ConcurrentDictionary<(string host, int port), ConcurrentDictionary<IPAddress, DateTime>>
_connectionCache = new();
public async ValueTask<Stream> GetConnectionAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
{
var host = context.DnsEndPoint.Host;
var port = context.DnsEndPoint.Port;
var now = DateTime.UtcNow;
// 代理地址或者直连 IP 直接返回结果
if (IPAddress.TryParse(host, out var directIp))
{
return await _ConnectToAddressAsync(directIp, port, cancellationToken).ConfigureAwait(false);
}
var addresses = await _dnsQuery.QueryForIpAsync(host, cancellationToken).ConfigureAwait(false);
if (addresses is null || addresses.Length == 0)
{
throw new HttpRequestException($"DNS resolution failed for {host}");
}
// 对待连接地址进行排序 (缓存成功+IPv6优先)
var sortedAddresses = _SortAddresses(host, port, addresses, now);
// Happy Eyeballs 连接逻辑,优先 IPv6 稍后
var connectionTasks = new List<Task<NetworkStream>>(WaitTasks);
var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var connectCancellationToken = cancellationSource.Token;
try
{
for (int i = 0; i < sortedAddresses.Length; i++)
{
var address = sortedAddresses[i];
var delayMs = i * (address.AddressFamily.Equals(AddressFamily.InterNetwork) ? 150 : 80); // 偏向使用 v6
connectionTasks.Add(_DelayedConnectAsync(address, port, delayMs, connectCancellationToken));
}
// 等待首个成功连接
var winner = await connectionTasks
.ToArray()
.WhenAnySuccessAsync()
.ConfigureAwait(false);
var stream = await winner.ConfigureAwait(false);
// 更新缓存 + 记录成功
var remoteIp = ((IPEndPoint)stream.Socket.RemoteEndPoint!).Address;
_UpdateConnectionCache(host, port, remoteIp, now);
LogWrapper.Debug(ModuleName, $"Connected to {host} via {remoteIp}");
// 取消其他连接
// ReSharper disable once MethodHasAsyncOverload
cancellationSource.Cancel();
_ = _CleanupUnusedConnectionsAsync(connectionTasks, winner).ConfigureAwait(false);
return stream;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 清理所有连接
// ReSharper disable once MethodHasAsyncOverload
cancellationSource.Cancel();
_ = _CleanupUnusedConnectionsAsync(connectionTasks, null).ConfigureAwait(false);
LogWrapper.Error(ex, ModuleName, $"All connection attempts failed for {host}");
throw new HttpRequestException($"Connection failed for {host}", ex);
}
finally
{
cancellationSource.Dispose();
}
}
private IPAddress[] _SortAddresses(string host, int port, IPAddress[] addresses, DateTime now)
{
var cacheKey = (host, port);
var addressCache = _connectionCache.GetValueOrDefault(cacheKey);
var cachedAddresses = new List<IPAddress>();
var uncachedAddresses = new List<IPAddress>();
foreach (var ip in addresses)
{
if (addressCache is not null &&
addressCache.TryGetValue(ip, out var successTime) &&
now - successTime <= _CacheDuration)
{
cachedAddresses.Add(ip);
}
else
{
uncachedAddresses.Add(ip);
}
}
// 缓存地址: 按成功时间倒序 (最近成功的优先)
cachedAddresses.Sort((a, b) =>
addressCache![b].CompareTo(addressCache[a]));
// 非缓存地址: IPv6 优先 + 保持原始顺序
var ipv6List = uncachedAddresses
.Where(static ip => ip.AddressFamily == AddressFamily.InterNetworkV6)
.OrderBy(ip => Array.IndexOf(addresses, ip))
.ToList();
var ipv4List = uncachedAddresses
.Where(static ip => ip.AddressFamily == AddressFamily.InterNetwork)
.OrderBy(ip => Array.IndexOf(addresses, ip))
.ToList();
// 交错合并,确保有一个 v6 和一个 v4
var sortedUncached = new List<IPAddress>();
int i = 0, j = 0;
while (i < ipv6List.Count || j < ipv4List.Count)
{
if (i < ipv6List.Count)
sortedUncached.Add(ipv6List[i++]);
if (j < ipv4List.Count)
sortedUncached.Add(ipv4List[j++]);
}
return cachedAddresses
.Concat(sortedUncached)
.Take(WaitTasks)
.ToArray();
}
private void _UpdateConnectionCache(string host, int port, IPAddress ip, DateTime now)
{
var cacheKey = (host, port);
var addressCache = _connectionCache.GetOrAdd(cacheKey, static _ =>
new ConcurrentDictionary<IPAddress, DateTime>());
// 移除过期条目 (懒清理)
foreach (var entry in addressCache)
{
if (now - entry.Value > _CacheDuration)
{
addressCache.TryRemove(entry.Key, out _);
}
}
// 更新当前IP
addressCache[ip] = now;
}
private static async Task<NetworkStream> _DelayedConnectAsync(IPAddress ip, int port, int delay, CancellationToken cancellationToken)
{
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
return await _ConnectToAddressAsync(ip, port, cancellationToken).ConfigureAwait(false);
}
private static async Task<NetworkStream> _ConnectToAddressAsync(IPAddress ip, int port, CancellationToken cancellationToken)
{
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
{
NoDelay = true
};
try
{
await socket.ConnectAsync(ip, port, cancellationToken).ConfigureAwait(false);
return new NetworkStream(socket, ownsSocket: true);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
socket.Dispose();
throw new HttpRequestException($"Connection to {ip}:{port} failed", ex);
}
}
private static async Task _CleanupUnusedConnectionsAsync(
List<Task<NetworkStream>> allTasks,
Task<NetworkStream>? winnerTask)
{
foreach (var task in allTasks.Where(t => t != winnerTask))
{
try
{
if (task.IsCompletedSuccessfully)
{
var stream = await task.ConfigureAwait(false);
await stream.DisposeAsync().ConfigureAwait(false);
}
else if (task.IsCompleted)
{
_ = task.Exception;
}
else
{
_ = task.ContinueWith(static t =>
{
if (t.IsCompletedSuccessfully)
{
t.Result.Dispose();
}
else
{
_ = t.Exception;
}
}, TaskScheduler.Default);
}
}
catch (Exception ex)
{
LogWrapper.Warn(ex, ModuleName, "Dispose connection with an error");
}
}
}
}
@@ -0,0 +1,16 @@
using System;
using System.Net.Http;
namespace PCL.Core.IO.Net.Http;
public static class HttpBasicExtension
{
extension(HttpRequestMessage requestMessage)
{
public HttpRequestMessage WithHttpVersionOption(Version httpVersion)
{
requestMessage.Version = httpVersion;
return requestMessage;
}
}
}
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using PCL.Core.Utils;
namespace PCL.Core.IO.Net.Http;
public static class HttpContentExtension
{
extension (HttpRequestMessage requestMessage)
{
public HttpRequestMessage WithContent(HttpContent content, string? contentType = null)
{
requestMessage.Content = content;
if (contentType is not null) requestMessage.WithHeader("Content-Type", contentType);
return requestMessage;
}
public HttpRequestMessage WithContent(string content, string? contentType = null)
{
requestMessage.Content = contentType is null
? new StringContent(content, Encoding.UTF8)
: new StringContent(content, Encoding.UTF8, contentType);
return requestMessage;
}
public HttpRequestMessage WithBinaryContent(byte[] content)
{
requestMessage.Content = new ByteArrayContent(content);
return requestMessage;
}
public HttpRequestMessage WithJsonContent<T>(T content, string? contentType = null, JsonSerializerOptions? options = null)
{
requestMessage.Content = new StringContent(
JsonSerializer.Serialize(content, options ?? JsonCompat.SerializerOptions),
Encoding.UTF8,
contentType ?? "application/json");
return requestMessage;
}
public HttpRequestMessage WithFormContent(string form)
{
return requestMessage.WithContent(
new ByteArrayContent(Encoding.UTF8.GetBytes(form)),
"application/x-www-form-urlencoded");
}
public HttpRequestMessage WithFormContent(IEnumerable<KeyValuePair<string, string>> pairs)
{
requestMessage.Content = new FormUrlEncodedContent(pairs);
return requestMessage;
}
}
}
@@ -0,0 +1,43 @@
using System;
using System.Linq;
using System.Net.Http;
namespace PCL.Core.IO.Net.Http;
public static class HttpCookieExtension
{
extension (HttpRequestMessage requestMessage)
{
public HttpRequestMessage WithCookie(string name, string value)
{
ArgumentNullException.ThrowIfNull(requestMessage);
ArgumentNullException.ThrowIfNullOrEmpty(value);
ArgumentNullException.ThrowIfNull(value);
var newCookie = $"{name}={_GetSafeCookieValue(value)}";
if (requestMessage.Headers.TryGetValues("Cookie", out var existingValues))
{
var existingCookie = string.Join("; ", existingValues);
requestMessage.Headers.Remove("Cookie");
requestMessage.Headers.Add("Cookie", $"{existingCookie}; {newCookie}");
}
else
{
requestMessage.Headers.Add("Cookie", newCookie);
}
return requestMessage;
}
}
private static string _GetSafeCookieValue(string value)
{
if (string.IsNullOrEmpty(value)) return value;
var needsEncoding = value.Any(c => _ForbiddenCookieValueChar.Contains(c) || char.IsControl(c));
return needsEncoding ? Uri.EscapeDataString(value) : value;
}
private static readonly char[] _ForbiddenCookieValueChar = [';', ',', ' ', '\r', '\n', '\t', '\0', '=', '"', '\'', '\\', '<', '>'];
}
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
namespace PCL.Core.IO.Net.Http;
public static class HttpHeaderHandler
{
extension (HttpRequestMessage requestMessage)
{
public HttpRequestMessage WithHeader(string key, string value)
{
if (key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && requestMessage.Content is not null)
requestMessage.Content.Headers.TryAddWithoutValidation(key, value);
else
requestMessage.Headers.TryAddWithoutValidation(key, value);
return requestMessage;
}
public HttpRequestMessage WithHeaders(IDictionary<string, string> pairs)
{
ArgumentNullException.ThrowIfNull(pairs);
foreach (var item in pairs)
{
requestMessage.WithHeader(item.Key, item.Value);
}
return requestMessage;
}
public HttpRequestMessage WithHeader(KeyValuePair<string, string> pair) =>
requestMessage.WithHeader(pair.Key, pair.Value);
public HttpRequestMessage WithAuthentication(string scheme, string token)
{
ArgumentException.ThrowIfNullOrEmpty(scheme);
ArgumentException.ThrowIfNullOrEmpty(token);
requestMessage.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
return requestMessage;
}
public HttpRequestMessage WithBearerToken(string token) =>
requestMessage.WithAuthentication("Bearer", token);
}
}
@@ -0,0 +1,224 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using Microsoft.Win32;
using PCL.Core.Logging;
using PCL.Core.Utils.Exts;
using PCL.Core.Utils.OS;
namespace PCL.Core.IO.Net.Http;
public class HttpProxyManager : IWebProxy, IDisposable
{
public static readonly HttpProxyManager Instance = new();
public enum ProxyMode
{
NoProxy,
SystemProxy,
CustomProxy
}
private readonly object _lock = new();
private ProxyMode _mode = ProxyMode.SystemProxy;
private readonly WebProxy _customWebProxy = new() { BypassProxyOnLocal = true };
private readonly WebProxy _systemWebProxy = new() { BypassProxyOnLocal = true };
private const string ProxyRegPathFull = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings";
private const string ProxyRegPath = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
private readonly RegistryChangeMonitor _proxyMonitor = new(ProxyRegPath);
private HttpProxyManager()
{
RefreshSystemProxy(); // 初始化系统代理
_proxyMonitor.Changed += _OnSystemProxyChanged;
}
private void _OnSystemProxyChanged(object? sender, EventArgs e)
{
RefreshSystemProxy();
}
private enum ProxyProtocol
{
Http,
Socks
}
private record ProxyItem
{
public ProxyProtocol Protocol;
public required string Address;
}
private static ProxyItem[] _GetProxyFromString(string? proxyString)
{
if (proxyString.IsNullOrWhiteSpace()) return [];
var ret = new List<ProxyItem>();
// 形式:http=192.168.1.100:8080;socks=192.168.1.100:1080
if (proxyString.Contains('='))
{
foreach (var segment in proxyString.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
var eqIndex = segment.IndexOf('=');
if (eqIndex <= 0 || eqIndex >= segment.Length - 1)
continue;
var protocolStr = segment[..eqIndex].Trim();
var address = segment[(eqIndex + 1)..].Trim();
if (string.IsNullOrWhiteSpace(address))
continue;
ret.Add(new ProxyItem { Protocol = _ParseProtocol(protocolStr), Address = address });
}
return ret.Count > 0 ? [.. ret] : [];
}
// 形式:http://127.0.0.1:1145/ 或者单纯 127.0.0.1:1145
if (Uri.TryCreate(proxyString, new UriCreationOptions(), out var proxyAddr))
{
var address = proxyAddr.Port > 0
? $"{proxyAddr.Host}:{proxyAddr.Port}"
: proxyAddr.Host;
ret.Add(new ProxyItem { Protocol = _ParseProtocol(proxyAddr.Scheme), Address = address });
}
else
{
ret.Add(new ProxyItem { Protocol = ProxyProtocol.Http, Address = proxyString.Trim() });
}
return [.. ret];
}
private static ProxyProtocol _ParseProtocol(string scheme)
{
return scheme.ToLowerInvariant() switch
{
"socks" or "socks4" or "socks5" => ProxyProtocol.Socks,
_ => ProxyProtocol.Http
};
}
/// <summary>刷新系统代理设置</summary>
public void RefreshSystemProxy()
{
lock (_lock)
{
try
{
// read from reg
var isSystemProxyEnabled = (int)(Registry.GetValue(ProxyRegPathFull, "ProxyEnable", 0) ?? 0);
var systemProxyString = Registry.GetValue(ProxyRegPathFull, "ProxyServer", string.Empty) as string;
// parse
var proxies = _GetProxyFromString(systemProxyString);
// filter
if (proxies.Length == 0 || !proxies.Any(static x => x.Protocol.Equals(ProxyProtocol.Http))) isSystemProxyEnabled = 0;
var selectedProxy = proxies.FirstOrDefault(static x => x.Protocol.Equals(ProxyProtocol.Http));
// apply
_systemWebProxy.Address = (isSystemProxyEnabled == 0 || selectedProxy!.Address.IsNullOrEmpty())
? null
: new Uri($"http://{selectedProxy.Address}");
LogWrapper.Info("Proxy",
$"已从操作系统更新代理设置,系统代理状态:{isSystemProxyEnabled}|{systemProxyString}");
}
catch (Exception ex)
{
LogWrapper.Error(ex, "Proxy", "获取系统代理时出现异常");
}
}
}
public ProxyMode Mode
{
get { lock (_lock) return _mode; }
set { lock (_lock) _mode = value; }
}
public Uri? CustomProxyAddress
{
get { lock (_lock) return _customWebProxy.Address; }
set { lock (_lock) _customWebProxy.Address = value; }
}
public ICredentials? CustomProxyCredentials
{
get { lock (_lock) return _customWebProxy.Credentials; }
set { lock (_lock) _customWebProxy.Credentials = value; }
}
public bool BypassOnLocal
{
get { lock (_lock) return field; }
set
{
lock (_lock)
{
field = value;
_systemWebProxy.BypassProxyOnLocal = value;
}
}
} = true;
public Uri? GetProxy(Uri destination)
{
lock (_lock)
{
return _mode switch
{
ProxyMode.NoProxy => null, // 返回 null 表明没有代理
ProxyMode.SystemProxy => _systemWebProxy.GetProxy(destination),
ProxyMode.CustomProxy => _customWebProxy.GetProxy(destination),
_ => null
};
}
}
public bool IsBypassed(Uri host)
{
lock (_lock)
{
return _mode switch
{
ProxyMode.NoProxy => true,
ProxyMode.SystemProxy => _systemWebProxy.IsBypassed(host),
ProxyMode.CustomProxy => _customWebProxy.IsBypassed(host),
_ => true
};
}
}
public ICredentials? Credentials
{
get
{
lock (_lock)
{
// 仅 CustomProxy 模式返回凭据
return _mode == ProxyMode.CustomProxy
? _customWebProxy.Credentials
: null;
}
}
set
{
lock (_lock)
{
_customWebProxy.Credentials = value;
}
}
}
public void Dispose()
{
_proxyMonitor.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,49 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
namespace PCL.Core.IO.Net.Http;
public static class HttpRequest
{
public static HttpRequestMessage Create(string url)
{
return new HttpRequestMessage(HttpMethod.Get, new Uri(url));
}
public static HttpRequestMessage CreateHead(string url)
{
return new HttpRequestMessage(HttpMethod.Head, new Uri(url));
}
public static HttpRequestMessage CreatePost(string url)
{
return new HttpRequestMessage(HttpMethod.Post, new Uri(url));
}
public static HttpRequestMessage CreatePut(string url)
{
return new HttpRequestMessage(HttpMethod.Put, new Uri(url));
}
public static HttpRequestMessage CreateDelete(string url)
{
return new HttpRequestMessage(HttpMethod.Delete, new Uri(url));
}
public static async Task<string> GetStringAsync(string url)
{
using var resp = await Create(url).SendAsync().ConfigureAwait(false);
return await resp.AsStringAsync().ConfigureAwait(false);
}
public static async Task<T?> GetJsonAsync<T>(string url)
{
using var resp = await Create(url).SendAsync().ConfigureAwait(false);
return await resp.AsJsonAsync<T>().ConfigureAwait(false);
}
public static async Task<HttpResponseMessage> PostJsonAsync<T>(string url, T data, string? contentType = null)
{
return await CreatePost(url)
.WithJsonContent(data, contentType)
.SendAsync()
.ConfigureAwait(false);
}
}
@@ -0,0 +1,48 @@
using System;
using System.Net.Http;
namespace PCL.Core.IO.Net.Http;
public class HttpResponseException:HttpRequestException,IDisposable
{
public HttpResponseMessage? Response { get; init; }
public HttpResponseException()
{
}
public HttpResponseException(string message) : base(message)
{
}
public HttpResponseException(string message, Exception? inner) : base(message, inner)
{
}
public HttpResponseException(HttpResponseMessage? response) : this(
$"{(int?)response?.StatusCode} {response?.ReasonPhrase ?? (response is null ? "undefined" : Enum.GetName(response.StatusCode))})")
{
Response = response;
}
public void Dispose()
{
Response?.Dispose();
GC.SuppressFinalize(this);
}
~HttpResponseException()
{
try
{
Response?.Dispose();
}
catch
{
// Suppress Exception
}
}
}
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Utils;
namespace PCL.Core.IO.Net.Http;
public static class HttpResponseExtension
{
extension(HttpResponseMessage responseMessage)
{
public bool IsSuccess => responseMessage.IsSuccessStatusCode;
public string AsString() { return responseMessage.AsStringAsync().GetAwaiter().GetResult(); }
public async Task<string> AsStringAsync(CancellationToken ct = default)
{
try
{
return await responseMessage.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
} catch(TaskCanceledException)
{
throw new TimeoutException("The request was canceled due to a timeout.");
} catch(OperationCanceledException)
{
throw new TimeoutException("The operation was canceled.");
}
}
public async Task<Stream> AsStreamAsync(CancellationToken ct = default)
{
try
{
return await responseMessage.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
} catch(TaskCanceledException)
{
throw new TimeoutException("The request was canceled due to a timeout.");
} catch(OperationCanceledException)
{
throw new TimeoutException("The operation was canceled.");
}
}
public async Task<byte[]> AsByteArrayAsync(CancellationToken ct = default)
{
try
{
return await responseMessage.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
} catch(TaskCanceledException)
{
throw new TimeoutException("The request was canceled due to a timeout.");
} catch(OperationCanceledException)
{
throw new TimeoutException("The operation was canceled.");
}
}
public async Task<T?> AsJsonAsync<T>(
JsonSerializerOptions? options = null,
CancellationToken cancellationToken = default)
{
try
{
await using var stream = await responseMessage.AsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync<T>(stream, options ?? JsonCompat.SerializerOptions, cancellationToken)
.ConfigureAwait(false);
} catch(JsonException ex)
{
throw new InvalidDataException("Failed to deserialize JSON response.", ex);
}
}
public Dictionary<string, string[]> GetHeaders()
{
return responseMessage.Headers
.Concat(responseMessage.Content.Headers)
.ToDictionary(k => k.Key, v => v.Value.ToArray());
}
public Dictionary<string, string[]> GetContentHeaders()
{ return responseMessage.Content.Headers.ToDictionary(k => k.Key, v => v.Value.ToArray()); }
public string[] GetHeader(string name)
{
if(responseMessage.Headers.TryGetValues(name, out var values) ||
responseMessage.Content.Headers.TryGetValues(name, out values))
return values.ToArray();
return [];
}
public bool TryGetHeader(string name, out string[] values)
{
if(responseMessage.Headers.TryGetValues(name, out var headerValues) ||
responseMessage.Content.Headers.TryGetValues(name, out headerValues))
{
values = headerValues.ToArray();
return true;
}
values = [];
return false;
}
public string? GetFirstHeaderValue(string name) { return responseMessage.GetHeader(name).FirstOrDefault(); }
public bool TryGetFirstHeaderValue(string name, out string value)
{
var values = responseMessage.GetHeader(name);
if(values.Length == 0)
{
value = string.Empty;
return false;
}
value = values.First();
return true;
}
public void EnsureSuccessStatusCode()
{
if(!responseMessage.IsSuccess)
throw new HttpRequestException(
$"HTTP request failed with status code {responseMessage.StatusCode}: {responseMessage.ReasonPhrase}");
}
public async Task EnsureSuccessStatusCodeWithContentAsync(CancellationToken ct = default)
{
if(!responseMessage.IsSuccess)
{
var content = await responseMessage
.AsStringAsync(ct)
.ConfigureAwait(false);
throw new HttpRequestException(
$"HTTP request failed with status code {responseMessage.StatusCode}: {responseMessage.ReasonPhrase}. Response content: {content}");
}
}
}
}
@@ -0,0 +1,10 @@
using System;
using System.Net.Http;
namespace PCL.Core.IO.Net.Http;
public class HttpRoute(HttpMethod method, string path) : Attribute
{
public HttpMethod Method { get; } = method;
public string Path { get; } = path;
}
@@ -0,0 +1,154 @@
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using PCL.Core.Utils;
namespace PCL.Core.IO.Net.Http;
/// <summary>
/// 用于 <see cref="HttpServer"/> 响应客户端请求的服务端响应结构。
/// </summary>
[Serializable]
public class HttpRouteResponse
{
/// <summary>
/// HTTP 状态码
/// </summary>
public HttpStatusCode? StatusCode = null;
/// <summary>
/// 此响应 <see cref="InputStream"/> 使用的字符编码
/// </summary>
public Encoding? ContentEncoding = null;
/// <summary>
/// [Header] 内容 MIME 类型
/// </summary>
public string? ContentType = null;
/// <summary>
/// [Header] 重定向目标 URL 或路径。
/// </summary>
public string? RedirectLocation = null;
/// <summary>
/// [Header] 是否使用分块传输编码。
/// </summary>
public bool? SendChunked = null;
/// <summary>
/// 随响应添加的 Cookies。
/// </summary>
public CookieCollection? Cookies = null;
/// <summary>
/// 用于传输响应内容的输入流,若非空值,该流将被直接 <c>CopyTo</c> 到实际响应的 <c>OutputStream</c> 中。
/// </summary>
public Stream? InputStream = null;
/// <summary>
/// 向标准 <see cref="HttpListener"/> 的响应对象写入数据。
/// </summary>
/// <param name="target">目标对象</param>
public void Pour(HttpListenerResponse target)
{
target.StatusCode = (int)(StatusCode ?? HttpStatusCode.OK);
if (ContentType is {} contentType) target.ContentType = contentType;
if (ContentEncoding is {} contentEncoding) target.ContentEncoding = contentEncoding;
if (RedirectLocation is {} redirectLocation) target.RedirectLocation = redirectLocation;
if (SendChunked is {} sendChunked) target.SendChunked = sendChunked;
if (Cookies is {} cookies) target.Cookies = cookies;
if (InputStream is {} inputStream) inputStream.CopyTo(target.OutputStream);
}
public Task<HttpRouteResponse> AsTask() => Task.FromResult(this);
/// <summary>
/// 返回指定 HTTP 状态码的空响应
/// </summary>
/// <param name="statusCode">HTTP 状态码</param>
public static HttpRouteResponse Empty(HttpStatusCode statusCode) => new() { StatusCode = statusCode };
/// <summary>
/// 默认的 204 (No Content) 响应。
/// </summary>
public static readonly HttpRouteResponse NoContent = Empty(HttpStatusCode.NoContent);
/// <summary>
/// 默认的 400 (Bad Request) 响应。
/// </summary>
public static readonly HttpRouteResponse BadRequest = Empty(HttpStatusCode.BadRequest);
/// <summary>
/// 默认的 403 (Forbidden) 响应。
/// </summary>
public static readonly HttpRouteResponse Forbidden = Empty(HttpStatusCode.Forbidden);
/// <summary>
/// 默认的 404 (Not Found) 响应。
/// </summary>
public static readonly HttpRouteResponse NotFound = Empty(HttpStatusCode.NotFound);
/// <summary>
/// 默认的 500 (Internal Server Error) 响应。
/// </summary>
public static readonly HttpRouteResponse InternalServerError = Empty(HttpStatusCode.InternalServerError);
/// <summary>
/// 默认的 502 (Bad Gateway) 响应。
/// </summary>
public static readonly HttpRouteResponse BadGateway = Empty(HttpStatusCode.BadGateway);
/// <summary>
/// 响应指定输入流的内容。
/// </summary>
/// <param name="stream">输入流</param>
/// <param name="contentType">内容 MIME 类型</param>
/// <param name="encoding">输入流内容使用的字符编码,默认为 UTF-8</param>
public static HttpRouteResponse Input(Stream stream, string contentType = "application/octet-stream", Encoding? encoding = null) =>
new() { InputStream = stream, ContentType = contentType, ContentEncoding = encoding ?? Encoding.UTF8 };
/// <summary>
/// 响应指定文本内容。
/// </summary>
/// <param name="text">文本内容</param>
/// <param name="contentType">内容 MIME 类型</param>
/// <param name="encoding">响应流使用的字符编码,默认为 UTF-8</param>
public static HttpRouteResponse Text(string text, string contentType = "text/plain", Encoding? encoding = null) =>
Input(new StringStream(text, encoding), contentType, encoding);
/// <summary>
/// 响应重定向。
/// </summary>
/// <param name="location">重定向目标 URL 或路径</param>
/// <param name="statusCode">重定向状态码</param>
public static HttpRouteResponse Redirect(string location, HttpStatusCode statusCode = HttpStatusCode.Found) =>
new() { StatusCode = statusCode, RedirectLocation = location };
/// <summary>
/// 响应指定对象序列化得到的 JSON 内容,固定使用 UTF-8 编码
/// </summary>
/// <param name="obj">用于序列化的对象</param>
/// <param name="options">JSON 序列化选项</param>
public static HttpRouteResponse Json(object obj, JsonSerializerOptions? options)
{
var stream = new MemoryStream();
JsonSerializer.Serialize(stream, obj, options ?? JsonCompat.SerializerOptions);
stream.Position = 0;
return new HttpRouteResponse
{
ContentEncoding = Encoding.UTF8,
ContentType = "application/json, charset=utf-8",
InputStream = stream
};
}
/// <summary>
/// 响应指定对象序列化得到的 JSON 内容,固定使用 UTF-8 编码
/// </summary>
/// <param name="obj">用于序列化的对象</param>
public static HttpRouteResponse Json(object obj) => Json(obj, JsonCompat.SerializerOptions);
}
@@ -0,0 +1,67 @@
using PCL.Core.App;
using PCL.Core.Logging;
using PCL.Core.Utils.Exts;
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Net.Http;
public static class HttpSenderExtension
{
extension(HttpRequestMessage requestMessage)
{
public async Task<HttpResponseMessage> SendAsync(
HttpClient? httpClient = null,
bool addMetedata = true,
bool enableLogging = true,
HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
int retryTimes = 3,
CancellationToken cancellationToken = default)
{
using var request = requestMessage;
httpClient ??= NetworkService.GetClient();
if(addMetedata)
{
request
.WithHeader("User-Agent", $"PCL-Community/PCL2-CE/{Basics.VersionName} (pclc.cc)")
.WithHeader("Referer", $"https://{Basics.VersionCode}.ce.open.pcl2.server/");
}
var requestId = Guid.NewGuid().ToString();
if (enableLogging)
LogWrapper.Info(
"Request",
$"Send request to {request.RequestUri} (method = {request.Method}, id = {requestId})");
var resp = await NetworkService.GetRetryPolicy(retryTimes)
.ExecuteAsync(
async token =>
{
if (enableLogging)
LogWrapper.Debug("Request", $"Try attempt (id = {requestId})");
try
{
using var requestCopy = await request
.CloneAsync()
.ConfigureAwait(false);
return await httpClient
.SendAsync(requestCopy, httpCompletionOption, token)
.ConfigureAwait(false);
}catch(Exception ex)
{
LogWrapper.Debug(ex, "Request", $"Try attempt failed (id = {requestId})");
throw;
}
},cancellationToken
)
.ConfigureAwait(false);
if (enableLogging)
LogWrapper.Info("Request", $"End request, got http status code {resp.StatusCode} (id = {requestId})");
return resp;
}
}
}
@@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Net.Http;
public abstract class HttpServer : IDisposable
{
private readonly HttpListener _server = new();
public readonly ushort Port;
public readonly string[] Host;
private Task? _handleLoop;
private CancellationTokenSource? _cancellationTokenSource;
private readonly Dictionary<(HttpMethod method, string path), Func<HttpListenerRequest, Task<HttpRouteResponse>>> _handlers = new();
private bool _initialized = false;
protected HttpServer(IPAddress[] listenAddr, ushort port = 0)
{
// Check parameters
ArgumentNullException.ThrowIfNull(listenAddr);
// Resolve port
if (port == 0) port = (ushort)NetworkHelper.NewTcpPort();
Port = port;
// Resolve host
if (listenAddr.Length == 0)
listenAddr = [IPAddress.Loopback, IPAddress.IPv6Loopback];
var hosts = new List<string>();
foreach (var address in listenAddr)
{
_server.Prefixes.Add($"http://{address}:{port}/");
hosts.Add(address.ToString());
}
Host = hosts.ToArray();
}
/// <summary>
/// 初始化路由。子类应在此方法中调用 Register 方法注册路由。
/// </summary>
protected abstract void Init();
/// <summary>
/// 注册一个路由处理器。
/// </summary>
/// <param name="method">HTTP 方法</param>
/// <param name="path">路由路径</param>
/// <param name="handler">请求处理函数</param>
protected void Register(HttpMethod method, string path, Func<HttpListenerRequest, Task<HttpRouteResponse>> handler)
{
ArgumentNullException.ThrowIfNull(method);
ArgumentNullException.ThrowIfNull(path);
ArgumentNullException.ThrowIfNull(handler);
_handlers[(method, path)] = handler;
}
/// <summary>
/// 启动 HTTP 服务器。
/// </summary>
public void Start()
{
// 如果没有注册路由,调用 Init 初始化
if (!_initialized && _handlers.Count == 0)
{
Init();
_initialized = true;
}
_cancellationTokenSource = new CancellationTokenSource();
_server.Start();
_handleLoop = _HandleRequestAsync();
}
private async Task _HandleRequestAsync()
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
while (!cancellationToken.IsCancellationRequested)
{
try
{
var context = await _server.GetContextAsync();
_ = Task.Run(async () => await _ProcessRequestAsync(context), cancellationToken);
}
catch (OperationCanceledException) { break; } // Cancellation
catch (ObjectDisposedException) { break; } // Disposed
catch (HttpListenerException) { break; } // Closed
}
}
private async Task _ProcessRequestAsync(HttpListenerContext context)
{
try
{
var request = context.Request;
var response = context.Response;
var path = request.Url?.AbsolutePath ?? string.Empty;
var method = new HttpMethod(request.HttpMethod);
// 首先尝试精确匹配
if (_handlers.TryGetValue((method, path), out var handler))
{
await _ExecuteHandlerAsync(handler, request, response);
return;
}
// 如果没有精确匹配,尝试通配符匹配
if (_handlers.TryGetValue((method, "*"), out var wildcardHandler))
{
await _ExecuteHandlerAsync(wildcardHandler, request, response);
return;
}
// 没有找到匹配的路由
response.StatusCode = (int)HttpStatusCode.NotFound;
}
finally
{
try
{
context.Response.Close();
}
catch
{
// Ignore errors when closing response
}
}
}
private static async Task _ExecuteHandlerAsync(Func<HttpListenerRequest, Task<HttpRouteResponse>> handler, HttpListenerRequest request, HttpListenerResponse response)
{
try
{
var routeResponse = await handler(request);
routeResponse.Pour(response);
}
catch (Exception ex)
{
response.StatusCode = (int)HttpStatusCode.InternalServerError;
response.ContentEncoding = System.Text.Encoding.UTF8;
response.ContentType = "text/plain";
var errorResponse =
HttpRouteResponse.Text($"Internal Server Error:\n{ex}", "text/plain", System.Text.Encoding.UTF8);
errorResponse.Pour(response);
}
}
/// <summary>
/// 停止 HTTP 服务器。
/// </summary>
public void Stop()
{
_cancellationTokenSource?.Cancel();
_server.Stop();
}
public void Dispose()
{
GC.SuppressFinalize(this);
Stop();
_server.Close();
_cancellationTokenSource?.Dispose();
}
}
@@ -0,0 +1,22 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace PCL.Core.IO.Net;
public static class NetworkHelper
{
public static int NewTcpPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
public static bool IsNetworkAvailable()
{
return NetworkInterface.GetIsNetworkAvailable();
}
}
@@ -0,0 +1,97 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace PCL.Core.IO.Net;
public static class NetworkInterfaceUtils
{
public static List<NetworkInterface> GetAvailableInterface()
{
return NetworkInterface.GetAllNetworkInterfaces()
.Where(iface => !_IsVirtualInterface(iface))
.ToList();
}
public enum IPv6Status
{
Unknown,
Public,
RFC4193,
Unavailable,
}
public static IPv6Status GetIPv6Status()
{
foreach (var iface in GetAvailableInterface())
{
var ipv6Addresses = iface.GetIPProperties().UnicastAddresses
.Where(addr => addr.Address.AddressFamily == AddressFamily.InterNetworkV6)
.Select(addr => addr.Address)
.ToArray();
if (ipv6Addresses.Length == 0)
{
return IPv6Status.Unavailable;
}
foreach (var ip in ipv6Addresses)
{
if (_IsPublicIPv6(ip))
{
return IPv6Status.Public;
}
if (_IsUniqueLocalIPv6(ip))
{
return IPv6Status.RFC4193;
}
}
}
return IPv6Status.Unknown;
}
private static bool _IsVirtualInterface(NetworkInterface iface)
{
// 常见的虚拟接口类型和名称关键词
var virtualTypes = new[] {
NetworkInterfaceType.Loopback,
NetworkInterfaceType.Tunnel,
NetworkInterfaceType.Ppp
};
var virtualKeywords = new[] {
"virtual",
"pseudo",
"loopback",
"tunnel",
"vpn",
"ppp",
"veth",
"docker",
"hyper-v",
"vmware",
"virtualbox"
};
return virtualTypes.Contains(iface.NetworkInterfaceType) ||
virtualKeywords.Any(keyword => iface.Description.ToLower().Contains(keyword));
}
private static bool _IsPublicIPv6(IPAddress ip)
{
byte[] addressBytes = ip.GetAddressBytes();
// 公网IPv6地址范围:2000::/3(即首字节在0x20到0x3F之间)
return addressBytes[0] >= 0x20 && addressBytes[0] <= 0x3F;
}
private static bool _IsUniqueLocalIPv6(IPAddress ip)
{
byte[] addressBytes = ip.GetAddressBytes();
// 唯一本地地址范围:FC00::/7(即首字节为0xFC或0xFD
return addressBytes[0] == 0xFC || addressBytes[0] == 0xFD;
}
}
@@ -0,0 +1,194 @@
using Microsoft.Extensions.DependencyInjection;
using PCL.Core.App;
using PCL.Core.App.IoC;
using PCL.Core.IO.Net.Http;
using PCL.Core.IO.Net.Http.Cache;
using PCL.Core.IO.Storage.Cache;
using PCL.Core.Logging;
using Polly;
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
namespace PCL.Core.IO.Net;
[LifecycleService(LifecycleState.Loading)]
[LifecycleScope("network", "网络服务")]
public partial class NetworkService
{
private const int LifeTime = 15;
#region AddressDefinition
private const string MicrosoftEntraIdServer = "https://login.microsoftonline.com/";
private const string MojangPistonMetaServer = "https://piston-meta.mojang.com/";
private const string MojangSessionServer = "https://sessionserver.mojang.com/";
private const string CurseForgeApiServer = "https://api.curseforge.com/v1/";
private const string ModrinthApiServer = "https://api.modrinth.com/v2/";
private const string MinecraftServiceServer = "https://api.minecraftservices.com/";
#endregion
#region HttpClientName
public const string Default = "default";
public const string MicrosoftEntraId = "microsoft_id";
public const string MinecraftService = "minecraft_service";
public const string Cache = "cache";
public const string MojangPistonMeta = "mojang_piston";
public const string MojangSession = "mojang_session";
public const string CurseForgeApi = "curseforge_api";
public const string ModrinthApi = "modrinth_api";
#endregion
private static ServiceProvider? _provider;
private static IHttpClientFactory? _factory;
[LifecycleStart]
private static void _Start()
{
var services = new ServiceCollection();
services.ConfigureHttpClientDefaults(b => b
.ConfigurePrimaryHttpMessageHandler(_GetSocketsHttpHandler)
.ConfigureHttpClient(c => c.DefaultRequestHeaders
.UserAgent.Add(new ProductInfoHeaderValue("PCL-CE", Basics.VersionName)))
.SetHandlerLifetime(TimeSpan.FromMinutes(LifeTime)));
// 默认的 HTTP Client
services.AddHttpClient(Default);
// CurseForge
services.AddHttpClient(CurseForgeApi).ConfigureHttpClient(c =>
{
c.DefaultRequestHeaders.Add("x-api-key", Secrets.CurseForgeAPIKey);
c.BaseAddress = new Uri(CurseForgeApiServer);
});
// Modrinth
services.AddHttpClient(ModrinthApi).ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri(ModrinthApiServer);
});
// Microsoft Entra ID
services.AddHttpClient(MicrosoftEntraId).ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri(MicrosoftEntraIdServer);
});
// Minecraft Service API
services.AddHttpClient(MinecraftService).ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri(MinecraftServiceServer);
});
// Mojang Piston Manifest
services.AddHttpClient(MojangPistonMeta).ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri(MojangPistonMetaServer);
});
// Mojang Session Server
services.AddHttpClient(MojangSession).ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri(MojangSessionServer);
});
// Cache
services.AddHttpClient(Cache)
.ConfigurePrimaryHttpMessageHandler(() => new HttpCacheHandler(
_GetSocketsHttpHandler(),CacheServiceManager.Current
)).SetHandlerLifetime(TimeSpan.FromMinutes(LifeTime));
_provider?.Dispose();
_provider = services.BuildServiceProvider();
_factory = _provider.GetRequiredService<IHttpClientFactory>();
}
[LifecycleStop]
private static void _Stop()
{
_provider?.Dispose();
}
private static SocketsHttpHandler _GetSocketsHttpHandler() => new SocketsHttpHandler
{
UseProxy = true,
AutomaticDecompression = DecompressionMethods.All,
Proxy = HttpProxyManager.Instance,
AllowAutoRedirect = true,
MaxAutomaticRedirections = 20,
UseCookies = false,
ConnectCallback = Config.Network.EnableDoH ? HostConnectionHandler.Instance.GetConnectionAsync : null
};
/// <summary>
/// 获取 HttpClient
/// </summary>
/// <param name="wantClientType">指定要求的 HttpClient 来源</param>
/// <returns>HttpClient 实例</returns>
public static HttpClient GetClient(string wantClientType = "default")
{
return _factory?.CreateClient(wantClientType) ??
throw new InvalidOperationException("在初始化完成前的意外调用");
}
private const int BaseRetryDelayMs = 1000;
private const int MaxRetryDelayMs = 30000;
private static TimeSpan _DefaultSleepDurationProvider(int attempt)
{
var delayMs = Math.Pow(2, attempt - 1) * BaseRetryDelayMs;
delayMs = Math.Min(delayMs, MaxRetryDelayMs);
return TimeSpan.FromMilliseconds(delayMs);
}
/// <summary>
/// 获取重试策略
/// </summary>
/// <param name="retry">最大重试次数</param>
/// <param name="retryPolicy">定义重试器行为</param>
/// <returns>AsyncPolicy</returns>
public static AsyncPolicy GetRetryPolicy(int retry = 3, Func<int, TimeSpan>? retryPolicy = null)
{
retryPolicy ??= _DefaultSleepDurationProvider;
return Policy
.Handle<HttpRequestException>()
.WaitAndRetryAsync(
retry,
attempt => retryPolicy.Invoke(attempt),
onRetry: (exception, timeSpan, retryAttempt, _) =>
{
LogWrapper.Debug(
exception,
"Network",
$"HTTP 请求失败,正在进行第 {retryAttempt} 次重试,等待 {timeSpan.TotalMilliseconds} 毫秒。"
);
});
}
}
@@ -0,0 +1,21 @@
using System.Net.Sockets;
namespace PCL.Core.IO.Net;
public static class SocketExtensions
{
public static void SafeClose(this Socket? socket)
{
if (socket is null) return;
try
{
if (socket.Connected)
{
socket.Shutdown(SocketShutdown.Both);
}
socket.Close();
}
catch { /* 忽略关闭时的任何错误 */ }
}
}
@@ -0,0 +1,222 @@
using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Logging;
namespace PCL.Core.IO.Net;
public sealed class TcpForward(
IPAddress listenAddress,
int listenPort,
IPAddress targetAddress,
int targetPort,
int maxConnections = 10)
: IDisposable
{
private Socket? _listenerSocket;
private CancellationTokenSource? _cts;
private readonly SemaphoreSlim _connectionSemaphore = new(maxConnections, maxConnections);
private readonly ConcurrentDictionary<Guid, ConnectionPair> _activeConnections = new();
private bool _isRunning;
public int LocalPort { get; private set; }
public int ActiveConnections => _activeConnections.Count;
public void Start()
{
if (_isRunning) return;
_cts = new CancellationTokenSource();
_isRunning = true;
try
{
// 创建并启动监听 Socket
_listenerSocket = new Socket(SocketType.Stream, ProtocolType.Tcp)
{
NoDelay = true, // 禁用 Nagle 算法以提高响应速度
ReceiveBufferSize = 8192,
SendBufferSize = 8192
};
_listenerSocket.Bind(new IPEndPoint(listenAddress, listenPort));
_listenerSocket.Listen(100); // 设置挂起连接队列的最大长度
if (_listenerSocket.LocalEndPoint is not IPEndPoint endPoint) throw new InvalidCastException("出现了意外的转换操作");
LocalPort = endPoint.Port;
// 启动 TCP 接受连接任务
_ = Task.Run(() => _AcceptConnectionsAsync(_cts.Token), _cts.Token);
LogWrapper.Info("TcpForward", $"MC 端口转发已启动,监听 {listenAddress}:{LocalPort},目标 {targetAddress}:{targetPort}");
}
catch (Exception ex)
{
_isRunning = false;
LogWrapper.Error(ex, "TcpForward", $"启动 MC 端口转发时发生错误: {ex.Message}");
throw;
}
}
public void Stop()
{
if (!_isRunning) return;
_cts?.Cancel();
_isRunning = false;
// 关闭所有活动连接
foreach (var connection in _activeConnections.Values)
{
connection.ClientSocket.SafeClose();
connection.TargetSocket.SafeClose();
}
_activeConnections.Clear();
_listenerSocket?.SafeClose();
LogWrapper.Info("TcpForward", "MC 端口转发已停止");
}
private async Task _AcceptConnectionsAsync(CancellationToken cancellationToken)
{
cancellationToken.Register(() =>
{
_listenerSocket.SafeClose();
});
while (!cancellationToken.IsCancellationRequested)
{
try
{
if (_listenerSocket is null) break;
var clientSocket = await _listenerSocket.AcceptAsync(cancellationToken);
// 检查是否达到最大连接限制
if (_activeConnections.Count >= maxConnections)
{
clientSocket.SafeClose();
LogWrapper.Warn("TcpForward", $"已达到最大连接数限制({maxConnections}),拒绝新连接");
continue;
}
// 使用信号量控制并发处理
await _connectionSemaphore.WaitAsync(cancellationToken);
// 异步处理连接,不等待完成
_ = Task.Run(() => _HandleConnectionAsync(clientSocket, cancellationToken), cancellationToken)
.ContinueWith(_ => _connectionSemaphore.Release(), TaskScheduler.Default);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
LogWrapper.Error(ex, "TcpForward", $"接受连接时发生错误");
await Task.Delay(1000, cancellationToken); // 出错后等待 1 秒再继续
}
}
}
private async Task _HandleConnectionAsync(Socket clientSocket, CancellationToken cancellationToken)
{
var connectionId = Guid.NewGuid();
try
{
LogWrapper.Info("TcpForward", $"接受来自 {clientSocket.RemoteEndPoint} 的连接");
// 连接到目标服务器
var targetSocket = new Socket(SocketType.Stream, ProtocolType.Tcp)
{
NoDelay = true,
ReceiveBufferSize = 8192,
SendBufferSize = 8192
};
await targetSocket.ConnectAsync(targetAddress, targetPort, cancellationToken);
// 保存连接对
var connectionPair = new ConnectionPair(clientSocket, targetSocket);
_activeConnections[connectionId] = connectionPair;
LogWrapper.Info("TcpForward", $"开始端口转发 {clientSocket.RemoteEndPoint} <-> {targetSocket.RemoteEndPoint}({connectionId})");
// 使用高性能的 SocketAsyncEventArgs 进行双向转发
var forwardTask1 = _ForwardDataAsync(clientSocket, targetSocket, cancellationToken);
var forwardTask2 = _ForwardDataAsync(targetSocket, clientSocket, cancellationToken);
// 等待任意一个方向的数据转发完成
await Task.WhenAny(forwardTask1, forwardTask2);
Console.WriteLine($"端口转发 {connectionId} 已完成");
}
catch (OperationCanceledException)
{
// 取消操作,正常退出
}
catch (Exception ex)
{
Console.WriteLine($"处理连接 {connectionId} 时发生错误: {ex.Message}");
}
finally
{
// 清理资源
clientSocket.SafeClose();
// 从活动连接中移除
_activeConnections.TryRemove(connectionId, out _);
}
}
private static async Task _ForwardDataAsync(Socket source, Socket destination, CancellationToken cancellationToken)
{
using var bufferOwner = MemoryPool<byte>.Shared.Rent(8192);
try
{
var buffer = bufferOwner.Memory;
while (!cancellationToken.IsCancellationRequested)
{
var bytesRead = await source.ReceiveAsync(buffer, SocketFlags.None, cancellationToken);
if (bytesRead == 0) break; // 连接已关闭
await destination.SendAsync(buffer[..bytesRead], SocketFlags.None, cancellationToken);
}
}
catch {/* 忽略错误 */}
}
private bool _disposed;
public void Dispose()
{
_Dispose(true);
GC.SuppressFinalize(this);
}
private void _Dispose(bool disposing)
{
if (!disposing) return;
if (_disposed) return;
Stop();
_cts?.Dispose();
_connectionSemaphore.Dispose();
_disposed = true;
}
~TcpForward()
{
_Dispose(false);
}
private class ConnectionPair(Socket clientSocket, Socket targetSocket)
{
public Socket ClientSocket { get; } = clientSocket;
public Socket TargetSocket { get; } = targetSocket;
}
}
+128
View File
@@ -0,0 +1,128 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Linq;
using System.Text;
using PCL.Core.App;
using PCL.Core.Logging;
using PCL.Core.Utils.OS;
namespace PCL.Core.IO;
public static class PipeComm
{
private static void _PipeLog(string message) => LogWrapper.Trace("Pipe", message);
private static void _PipeLogDebug(string message) => LogWrapper.Debug("Pipe", message);
/// <summary>
/// 用于命名管道通信的统一字符编码
/// </summary>
public static readonly Encoding PipeEncoding = Encoding.UTF8;
/// <summary>
/// 用于命名管道通信的统一终止符
/// </summary>
public const char PipeEndingChar = (char)27; // '\e' (ESC)
/// <summary>
/// 在新的工作线程启动命名管道服务端
/// </summary>
/// <param name="identifier">服务端标识,用于日志标识及工作线程的命名</param>
/// <param name="pipeName">命名管道名称</param>
/// <param name="loopCallback">客户端连接后的回调函数,将会提供用于读取和写入数据的流,以及客户端进程 ID,返回 <c>true</c> 表示继续等待下一个客户端连接,返回 <c>false</c> 则停止服务端运行</param>
/// <param name="stopCallback">服务端停止后的回调函数</param>
/// <param name="stopWhenException">指定当回调函数抛出异常时是否停止服务端运行,使用 <c>true</c> 表示停止</param>
/// <param name="allowedProcessId">允许连接的客户端进程 ID,如为 Nothing 则允许所有</param>
public static NamedPipeServerStream StartPipeServer(string identifier, string pipeName, Func<StreamReader, StreamWriter, Process?, bool> loopCallback, Action? stopCallback = null, bool stopWhenException = false, int[]? allowedProcessId = null)
{
var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.None, 1024, 1024);
var threadName = $"PipeServer/{identifier}";
Basics.RunInNewThread(() =>
{
LogWrapper.Debug("Pipe", $"{identifier}: {pipeName} 服务端已在 '{threadName}' 工作线程启动");
var hasNextLoop = true;
var connected = false;
while (hasNextLoop)
{
try
{
hasNextLoop = false;
pipe.WaitForConnection(); // 等待客户端连接
// 获取客户端进程实例并校验
Process? clientProcess = null;
var clientProcessId = 0;
try
{
var pid = KernelInterop.GetNamedPipeClientProcessId(pipe.SafePipeHandle.DangerousGetHandle());
clientProcessId = (int)pid;
if (allowedProcessId is not null)
{
var denied = allowedProcessId.All(id => id != clientProcessId);
if (denied)
{
hasNextLoop = true;
pipe.Disconnect();
_PipeLog($"[Pipe] {identifier}: 已拒绝 {clientProcessId}");
continue;
}
}
clientProcess = Process.GetProcessById(clientProcessId);
}
catch (Exception)
{
if (allowedProcessId is not null)
{
hasNextLoop = true;
throw;
}
}
connected = true;
LogWrapper.Debug("Pipe", $"{identifier}: {clientProcessId} 已连接");
// 初始化读取/写入流
var reader = new StreamReader(pipe, PipeEncoding, false, 1024, true);
var writer = new StreamWriter(pipe, PipeEncoding, 1024, true);
// 执行回调函数
hasNextLoop = loopCallback(reader, writer, clientProcess);
// 写入终止符
writer.Write(PipeEndingChar);
writer.Flush(); // 刷新写入缓冲
reader.Read(); // 等待客户端
}
catch (Exception ex)
{
if (!pipe.IsConnected && connected && ex is IOException)
{
_PipeLogDebug($"{identifier}: 客户端连接已丢失");
hasNextLoop = true;
}
else
{
LogWrapper.Warn(ex, "Pipe", $"{identifier}: 服务端出错");
if (stopWhenException) hasNextLoop = false;
}
}
try
{
pipe.Disconnect();
}
catch (InvalidOperationException)
{
// 由于没妈的巨硬给的 IsConnected 不一定是准确的,需要运行 Disconnect() 确保管道断开连接
// 如果已经断开会抛出 InvalidOperationException 这里直接忽略掉
}
connected = false;
_PipeLogDebug($"{identifier}: 已断开连接");
}
// 释放资源并执行停止回调
pipe.Dispose();
_PipeLogDebug($"{identifier}: 服务端已停止");
stopCallback?.Invoke();
}, threadName);
return pipe;
}
}
@@ -0,0 +1,110 @@
using PCL.Core.IO.Storage.Cache.Model;
using PCL.Core.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
internal class CacheEvictionService(SqliteCacheStorage db, FileCacheStorage files, CacheOptions options)
{
private CancellationTokenSource? _cts;
private Task? _loop;
private readonly object _startLock = new();
public void Start()
{
lock (_startLock)
{
if (_loop is not null)
{
return;
}
_cts = new CancellationTokenSource();
_loop = Task.Run(() => _EvictionLoopAsync(_cts.Token));
}
}
public void Stop()
{
lock (_startLock)
{
if (_cts is not null)
{
_cts.Cancel();
_cts.Dispose();
_cts = null;
}
_loop = null;
}
}
public void CheckThreshold()
{
// NOTE: this method will not be implemented
// because current eviction strategy is enough to keep the cache size under control without checking threshold after each write operation.
}
private async Task _EvictionLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(options.EvictionInterval, ct).ConfigureAwait(false);
await db.DeleteExpiredAsync(DateTime.UtcNow, ct).ConfigureAwait(false);
var stats = await db.GetStatsAsync(ct).ConfigureAwait(false);
var excess = stats.TotalSizeBytes - (options.MaxCacheSize - options.ReserveBytes);
if (excess > 0)
{
await _EvictAsync(excess, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
LogWrapper.Warn(ex, "CacheEviction", $"An error occurred while evicting cache entries:");
}
}
}
private async Task _EvictAsync(long targetBytes, CancellationToken ct)
{
var freed = 0L;
const int batchSize = 50;
while (freed < targetBytes && !ct.IsCancellationRequested)
{
var candidates = await db.GetEvictionCandidatesAsync(batchSize, ct).ConfigureAwait(false);
if (candidates.Count == 0)
{
return;
}
foreach (var can in candidates)
{
if (ct.IsCancellationRequested)
{
return;
}
await db.DeleteAsync(can.CacheKey!, ct).ConfigureAwait(false);
if (can.EntryType is EntryType.FileRef && can.FileHash is not null)
{
await files.ForceDeleteAsync(can.FileHash).ConfigureAwait(false);
}
freed += can.DataSize;
}
}
}
}
public record CacheEntryEvictedEventArgs(string Key, long DataSize, int Priority, long HitCount);
@@ -0,0 +1,98 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace PCL.Core.IO.Storage.Cache;
public class CacheKeys
{
#region Instance
/// <summary>实例元数据缓存键</summary>
public static string InstanceMeta(string instancePath)
=> _Build("instance", "meta", _HashSegment(instancePath));
/// <summary>实例 version.json 缓存键</summary>
public static string InstanceManifest(string instancePath, string versionId)
=> _Build("instance", "manifest", _HashSegment(instancePath), versionId);
/// <summary>实例指定类型的组件缓存键(mods/rp/shader/saves</summary>
public static string InstanceComponents(string instancePath, string compType)
=> _Build("instance", "components", _HashSegment(instancePath), compType);
/// <summary>实例中单个组件文件缓存键</summary>
public static string InstanceComponentFile(string instancePath, string fileHash)
=> _Build("instance", "component", _HashSegment(instancePath), fileHash);
#endregion
#region Download
/// <summary>URL 下载缓存键(去重)</summary>
public static string Download(string url)
=> _Build("download", _HashSegment(url));
/// <summary>Library 文件缓存键</summary>
public static string Library(string mavenGroup, string artifact, string version)
=> _Build("library", mavenGroup, artifact, version);
/// <summary>Asset 索引 JSON 缓存键</summary>
public static string AssetIndex(string url)
=> _Build("assets", "index", _HashSegment(url));
/// <summary>Asset 对象文件缓存键(由哈希定位)</summary>
public static string AssetObject(string assetHash)
=> _Build("assets", "object", assetHash);
#endregion
#region API/Network
/// <summary>API 响应缓存键</summary>
public static string ApiResponse(string source, string url)
=> _Build("http", source, _HashSegment(url));
public static string ApiResponseMeta(string source, string url)
=> _Build("http", "meta", source, _HashSegment(url));
/// <summary>模组市场搜索缓存键</summary>
public static string CompSearch(string source, string query, int page)
=> _Build("comp", "search", source, _HashSegment(query), page.ToString());
/// <summary>皮肤/头像缓存键</summary>
public static string Skin(string url)
=> _Build("skin", _HashSegment(url));
/// <summary>图片文件缓存键</summary>
public static string Image(string url)
=> _Build("image", _HashSegment(url));
/// <summary>新闻/公告缓存键</summary>
public static string News(string url)
=> _Build("news", _HashSegment(url));
#endregion
#region Account
/// <summary>OAuth Token 缓存键(10min 滑动过期)</summary>
public static string AuthToken(string accountId)
=> _Build("auth", "token", accountId);
/// <summary>账户列表缓存键(短 TTL</summary>
public static string AccountList()
=> "accounts:list"; // 固定键,无需哈希
#endregion
private static string _Build(params string[] segments)
=> string.Join(':', segments);
/// <summary>
/// 对动态内容(路径、URL、长字符串)做 SHA256 哈希,取前 32 字符。
/// 静态已知数据(枚举值、类型名)不哈希,保持可读性。
/// </summary>
private static string _HashSegment(string raw)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(bytes).ToLowerInvariant()[..32];
}
}
@@ -0,0 +1,38 @@
using System;
namespace PCL.Core.IO.Storage.Cache;
public record CacheOptions
{
/// <summary>
/// SQLite database file path.
/// </summary>
public required string DatabasePath { get; init; }
/// <summary>
/// File cache root directory. (Used for file-mapped storage mode)
/// </summary>
public required string FileCacheRoot { get; init; }
/// <summary>
/// Max physical cache size. When the total cache size exceeds this limit, the eviction process will be triggered to free up space.<br/>
/// <b>(Default: 2 GiB)</b>
/// </summary>
public long MaxCacheSize { get; init; } = 2L * 1024 * 1024 * 1024; // 2 GiB
/// <summary>
/// SQLite inline storage size limit. Cache entries smaller than or equal to this size will be stored directly in the SQLite database, while larger entries will be stored as file-mapped.<br/>
/// </summary>
public int MaxInlineSize { get; init; } = 256 * 1024; // 256 KiB
/// <summary>
/// Background eviction interval. The cache will automatically check for expired entries and evict them at this interval.<br/>
/// <b>(Default: 5 minutes)</b>
/// </summary>
public TimeSpan EvictionInterval { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Reserve bytes for the cache. This amount of space will always be reserved for the cache, even when eviction is triggered.<br/>
/// <b>(Default: 256 MiB)</b>
/// </summary>
public long ReserveBytes { get; init; } = 256L * 1024 * 1024; // 256 MiB
/// <summary>
/// Whether to enable compression for cache entries. (Enabled by default)<br/>
/// </summary>
public bool EnableCompression { get; init; } = true;
}
@@ -0,0 +1,58 @@
using System;
namespace PCL.Core.IO.Storage.Cache;
/// <summary>
/// Represents the caching policy for managing cached items.<br/>
/// <code>CachePolicy.Default = AbsoluteExpiration = TimeSpan.FromHours(1), Normal (Priority), Auto (StorageMode)</code>
/// </summary>
public record CachePolicy
{
public static readonly CachePolicy Default = new();
public static readonly CachePolicy NeverExpire = new()
{
SlidingExpiration = null,
AbsoluteExpiration = null,
Priority = CachePriority.NeverEvict
};
/// <summary>
/// Gets the absolute expiration time for the cached item. (Calculated from the time the item was cached)<br/>
/// </summary>
public TimeSpan? AbsoluteExpiration { get; init; } = TimeSpan.FromHours(1);
/// <summary>
/// Gets the sliding expiration time for the cached item. (Resets the expiration timer each time the item is accessed)<br/>
/// </summary>
public TimeSpan? SlidingExpiration { get; init; }
/// <summary>
/// Gets the priority of the cached item. (Determines eviction order when the cache needs to free up space)
/// </summary>
public CachePriority Priority { get; init; } = CachePriority.Normal;
/// <summary>
/// Gets the storage mode for the cached item.<br/>
/// Auto &lt;= 256KiB: Inline, &gt; 256KiB: FileMapped
/// </summary>
public CacheStorageMode StorageMode { get; init; } = CacheStorageMode.Auto;
/// <summary>
/// Group. Used for grouping related cached items together, allowing for bulk operations like eviction or retrieval based on the group key.
/// </summary>
public string? Group { get; init; }
/// <summary>
/// Tag. Spilited by <c>','</c>. Used for multi-demension grouping.
/// </summary>
public string? Tags { get; init; }
/// <summary>
/// Content format version. (Increased when migration is needed.)
/// </summary>
public int ContentVersion { get; init; } = 1;
/// <summary>
/// The minimum time to live for the cached item. <br/>
/// Used when the Internet is not stable.<br/>
/// (Overrides Priority and StorageMode when the net is not stable)
/// </summary>
public TimeSpan? MinTimeToLive { get; init; }
}
public enum CachePriority { Low, Normal, High, NeverEvict }
public enum CacheStorageMode { Auto, Inline, FileMapped }
@@ -0,0 +1,24 @@
using System;
namespace PCL.Core.IO.Storage;
/// <summary>
/// The result of a cache lookup, indicating whether the value was found, the value itself if found, and the time it was cached at.
/// </summary>
public readonly record struct CacheResult<T>
{
public static readonly CacheResult<T> Miss = default;
public readonly bool Found;
public readonly T? Value;
public readonly DateTime CachedAt;
public static CacheResult<T> Hit(T value, DateTime cachedAt) => new(value, cachedAt);
private CacheResult(T value, DateTime cachedAt)
{
Found = true;
Value = value;
CachedAt = cachedAt;
}
}
@@ -0,0 +1,461 @@
using Microsoft.Data.Sqlite;
using PCL.Core.App;
using PCL.Core.IO.Storage.Cache.Model;
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
/// <summary>
/// A cache service that provides asynchronous methods to store and retrieve data with support for expiration, tagging, grouping, and priority.<br/>
/// It uses a combination of SQLite for metadata storage and the file system for large data storage.<br/>
/// The service also includes an eviction mechanism to manage cache size and expired entries.<br/>
/// </summary>
public class CacheService : ICacheService, IAsyncDisposable
{
private readonly CacheOptions _options;
private readonly SchemaManager _schemaManager;
private readonly SqliteCacheStorage _db;
private readonly FileCacheStorage _files;
private readonly CacheEvictionService _eviction;
private long _hits;
private long _misses;
private bool _disposed;
private static readonly JsonSerializerOptions _JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public CacheService()
{
_options = new CacheOptions
{
DatabasePath = Path.Combine(Paths.Temp, "Cache", "pcl_ce_cache.db"),
FileCacheRoot = Path.Combine(Paths.Temp, "Cache", "files")
};
_schemaManager = new SchemaManager($"Data Source={_options.DatabasePath}");
_db = new SqliteCacheStorage(_options.DatabasePath);
_files = new FileCacheStorage(_options.FileCacheRoot, _options.EnableCompression);
_eviction = new CacheEvictionService(_db, _files, _options);
}
/// <summary>
/// Unit Test constructor. This constructor allows injecting custom options for testing purposes.
/// </summary>
[Obsolete("This constructor is for testing purposes only.")]
public CacheService(CacheOptions options)
{
_options = options;
_schemaManager = new SchemaManager($"Data Source={_options.DatabasePath}");
_db = new SqliteCacheStorage(_options.DatabasePath);
_files = new FileCacheStorage(_options.FileCacheRoot, _options.EnableCompression);
_eviction = new CacheEvictionService(_db, _files, _options);
}
internal async Task InitializeAsync()
{
Directory.CreateDirectory(_options.FileCacheRoot);
Directory.CreateDirectory(Path.GetDirectoryName(_options.DatabasePath)!);
await _schemaManager.EnsureCurrentSchemaAsync().ConfigureAwait(false);
await _db.CleanupStartupAsync().ConfigureAwait(false);
_eviction.Start();
}
/// <inheritdoc/>
public async Task SetAsync<T>(string key, T value, CachePolicy? policy = null,
CancellationToken ct = default)
{
_ThrowIfNotReady();
policy ??= CachePolicy.Default;
var (bytes, inline) = _Serialize(value);
var entry = new CacheEntry
{
CacheKey = key,
ContentType = typeof(T).Name,
ContentVersion = policy.ContentVersion,
DataSize = bytes.Length,
Tags = policy.Tags ?? string.Empty,
GroupName = policy.Group ?? string.Empty,
Priority = (int)policy.Priority,
ExpiresAt = _ComputeExpiry(policy),
};
if (inline && bytes.Length <= _options.MaxInlineSize)
{
entry = entry with
{
EntryType = EntryType.Inline,
Data = bytes,
ContentHash = _ComputeSha256(bytes)
};
}
else
{
using var ms = new MemoryStream(bytes);
entry = entry with
{
EntryType = EntryType.FileRef,
FileHash = await _files.StoreAsync(ms).ConfigureAwait(false),
ContentHash = _ComputeSha256(bytes)
};
}
await _db.UpsertAsync(entry, ct).ConfigureAwait(false);
_eviction.CheckThreshold();
}
/// <inheritdoc/>
public async Task<CacheResult<T>> GetAsync<T>(string key, CancellationToken ct = default)
{
_ThrowIfNotReady();
var entry = await _db.LookupAsync(key, ct).ConfigureAwait(false);
if (entry is null) { Interlocked.Increment(ref _misses); return CacheResult<T>.Miss; }
// 检查过期
if (entry.ExpiresAt is not null && (DateTime)entry.ExpiresAt < DateTime.UtcNow)
{
await _db.DeleteAsync(key, ct).ConfigureAwait(false);
if (entry.FileHash is not null)
{
await _files.ReleaseAsync(entry.FileHash).ConfigureAwait(false);
}
Interlocked.Increment(ref _misses);
return CacheResult<T>.Miss;
}
Interlocked.Increment(ref _hits);
byte[] data;
// 读取数据
if (entry is { EntryType: EntryType.FileRef, FileHash: not null })
{
await using var stream = _files.Retrieve(entry.FileHash);
if (stream is null)
{
await _db.DeleteAsync(key, ct).ConfigureAwait(false);
Interlocked.Increment(ref _misses);
return CacheResult<T>.Miss;
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms, ct).ConfigureAwait(false);
data = ms.ToArray();
}
else
{
data = entry.Data ?? [];
}
// 反序列化
var value = _Deserialize<T>(data);
// 更新访问时间
await _db.TouchAsync(key, ct).ConfigureAwait(false);
return CacheResult<T>.Hit(value, entry.CachedAt);
}
/// <inheritdoc/>
public async Task<bool> ExistsAsync(string key)
{
_ThrowIfNotReady();
var entry = await _db.LookupAsync(key, CancellationToken.None).ConfigureAwait(false);
if (entry is null) return false;
return entry.ExpiresAt is null || (DateTime)entry.ExpiresAt >= DateTime.UtcNow;
}
/// <inheritdoc/>
public async Task<bool> DeleteAsync(string key)
{
_ThrowIfNotReady();
var entry = await _db.LookupAsync(key, CancellationToken.None).ConfigureAwait(false);
if (entry is null)
{
return false;
}
if (entry.FileHash is not null)
{
await _files.ReleaseAsync(entry.FileHash).ConfigureAwait(false);
}
return await _db.DeleteAsync(key, CancellationToken.None).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<string?> GetCachedFilePathAsync(string key)
{
var entry = await _db.LookupAsync(key, CancellationToken.None).ConfigureAwait(false);
if (entry?.FileHash is null)
{
return null;
}
return _files.GetFilePath(entry.FileHash);
}
/// <inheritdoc/>
public async Task<string> CacheFileAsync(string key, Stream source,
CachePolicy? policy = null, CancellationToken ct = default)
{
_ThrowIfNotReady();
policy ??= CachePolicy.Default;
var hash = await _files.StoreAsync(source).ConfigureAwait(false);
var entry = new CacheEntry
{
CacheKey = key,
EntryType = EntryType.FileRef,
ContentType = "application/octet-stream",
DataSize = source.Length,
FileHash = hash,
ContentHash = hash,
Tags = policy.Tags ?? "",
GroupName = policy.Group ?? "",
Priority = (int)policy.Priority,
ExpiresAt = _ComputeExpiry(policy),
};
await _db.UpsertAsync(entry, ct).ConfigureAwait(false);
_eviction.CheckThreshold();
return hash;
}
/// <inheritdoc/>
public async Task<int> DeleteByGroupAsync(string groupName)
{
_ThrowIfNotReady();
// 先收集要被删除的文件 hash(用于清理 FileCacheStore
var fileHashes = await _db.GetFileHashesByGroupAsync(groupName, CancellationToken.None).ConfigureAwait(false);
// 逐个释放文件
foreach (var hash in fileHashes)
{
if (hash is not null)
{
await _files.ReleaseAsync(hash).ConfigureAwait(false);
}
}
// 删除数据库中的记录
return await _db.DeleteByGroupAsync(groupName, CancellationToken.None).ConfigureAwait(false);
}
/// <inheritdoc/>
public Task<int> DeleteByTagAsync(string tag)
{
_ThrowIfNotReady();
return _db.DeleteByTagAsync(tag, CancellationToken.None);
}
/// <inheritdoc/>
public async Task<int> DeleteExpiredAsync()
{
_ThrowIfNotReady();
var count = await _db.DeleteExpiredAsync(DateTime.UtcNow, CancellationToken.None).ConfigureAwait(false);
_eviction.CheckThreshold();
return count;
}
/// <inheritdoc/>
public async Task<CacheStats> GetStatsAsync()
{
var row = await _db.GetStatsAsync(CancellationToken.None).ConfigureAwait(false);
return new CacheStats
{
TotalEntries = row.TotalEntries,
TotalSizeBytes = row.TotalSizeBytes,
ExpiredEntries = row.ExpiredEntries,
InlineEntries = row.InlineEntries,
FileEntries = row.FileEntries,
CacheHits = Interlocked.Read(ref _hits),
CacheMisses = Interlocked.Read(ref _misses),
};
}
/// <inheritdoc/>
public async Task ClearAsync()
{
_ThrowIfNotReady();
// 先释放所有文件引用
var fileHashes = await _db.GetAllFileHashesAsync(CancellationToken.None).ConfigureAwait(false);
foreach (var hash in fileHashes)
{
if (hash is not null)
{
await _files.ReleaseAsync(hash).ConfigureAwait(false);
}
}
// 清空所有表
await using var conn = new SqliteConnection($"Data Source={_options.DatabasePath}");
await conn.OpenAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
DELETE FROM cache_entries;
DELETE FROM instance_cache;
DELETE FROM component_cache;
VACUUM;
""";
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public Task CompactAsync()
{
_ThrowIfNotReady();
return _db.CompactAsync(CancellationToken.None);
}
#region Event
public event EventHandler<CacheEntryEvictedEventArgs>? EntryEvicted;
internal void OnEntryEvicted(CacheEntryEvictedEventArgs args)
=> EntryEvicted?.Invoke(this, args);
#endregion
#region Helper
private void _ThrowIfNotReady()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private static DateTime? _ComputeExpiry(CachePolicy policy)
{
if (policy.AbsoluteExpiration is null && policy.SlidingExpiration is null)
return null; // 永不过期
if (policy.AbsoluteExpiration is not null)
return DateTime.UtcNow + policy.AbsoluteExpiration;
// 仅滑动过期——初始也设一个基于滑动过期的过期点
return DateTime.UtcNow + policy.SlidingExpiration;
}
private static (byte[] bytes, bool isSmall) _Serialize<T>(T value)
{
return value switch
{
byte[] raw => (raw, raw.Length <= 256 * 1024),
string s => (Encoding.UTF8.GetBytes(s), s.Length <= 256 * 1024),
_ => (JsonSerializer.SerializeToUtf8Bytes(value, _JsonOpts), true),
};
}
private static T _Deserialize<T>(byte[] data)
{
if (typeof(T) == typeof(byte[]))
return (T)(object)data;
if (typeof(T) == typeof(string))
return (T)(object)Encoding.UTF8.GetString(data);
return JsonSerializer.Deserialize<T>(data, _JsonOpts)!;
}
private static string _ComputeSha256(byte[] data)
{
var hash = SHA256.HashData(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
#endregion
#region Instance/Component
public Task UpsertInstanceAsync(InstanceCacheRow row, CancellationToken ct = default)
=> _db.UpsertInstanceAsync(row, ct);
public Task<InstanceCacheRow?> LookupInstanceAsync(string instancePath, CancellationToken ct = default)
=> _db.LookupInstanceAsync(instancePath, ct);
public Task DeleteInstanceAsync(string instancePath, CancellationToken ct = default)
=> _db.DeleteInstanceAsync(instancePath, ct);
public Task UpsertComponentAsync(ComponentCacheRow row, CancellationToken ct = default)
=> _db.UpsertComponentAsync(row, ct);
public Task<List<ComponentCacheRow>> GetComponentsByInstanceAsync(
string instancePath, string compType, CancellationToken ct = default)
=> _db.GetComponentsByInstanceAsync(instancePath, compType, ct);
public Task<ComponentCacheRow?> GetComponentAsync(
string instancePath, string compType, string fileName, CancellationToken ct = default)
=> _db.GetComponentAsync(instancePath, compType, fileName, ct);
public Task DeleteComponentsByInstanceAsync(string instancePath, CancellationToken ct = default)
=> _db.DeleteComponentsByInstanceAsync(instancePath, ct);
public Task<string?> GetComponentScanHashAsync(
string instancePath, string compType, CancellationToken ct = default)
=> _db.GetComponentScanHashAsync(instancePath, compType, ct);
#endregion
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
_eviction.Stop();
try
{
await using var conn = new SqliteConnection($"Data Source={_options.DatabasePath}");
await conn.OpenAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
catch { /* ignore */ }
await CastAndDispose(_db).ConfigureAwait(false);
await CastAndDispose(_files).ConfigureAwait(false);
return;
static async ValueTask CastAndDispose(IDisposable resource)
{
if (resource is IAsyncDisposable resourceAsyncDisposable)
await resourceAsyncDisposable.DisposeAsync().ConfigureAwait(false);
else
resource.Dispose();
}
}
}
@@ -0,0 +1,42 @@
using PCL.Core.App.IoC;
using System;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
[LifecycleScope("global_cache", "磁盘缓存服务")]
[LifecycleService(LifecycleState.Loading, Priority = 500)]
public partial class CacheServiceManager
{
private static CacheService? _service;
/// <summary>
/// 获取当前缓存服务实例,如果尚未初始化则为 null
/// </summary>
public static CacheService Current => _service ?? throw new InvalidOperationException("Cache service is not initialized yet.");
[LifecycleStart]
private static async Task _StartAsync()
{
Context.Debug("正在初始化缓存服务...");
_service = new CacheService();
await _service.InitializeAsync().ConfigureAwait(false);
Context.Info("缓存服务初始化成功");
}
[LifecycleStop]
private static async Task _StopAsync()
{
Context.Debug("正在停止缓存服务...");
if (_service != null)
{
await _service.DisposeAsync().ConfigureAwait(false);
_service = null;
}
Context.Info("缓存服务已停止");
}
}
@@ -0,0 +1,16 @@
using System;
namespace PCL.Core.IO.Storage.Cache;
public record CacheStats
{
public long TotalEntries { get; init; }
public long TotalSizeBytes { get; init; }
public long ExpiredEntries { get; init; }
public long InlineEntries { get; init; }
public long FileEntries { get; init; }
public long CacheHits { get; init; }
public long CacheMisses { get; init; }
public double HitRate => (CacheHits + CacheMisses) > 0 ? (double)CacheHits / (CacheHits + CacheMisses) : 0;
public DateTime? LastCleanup { get; init; }
}
@@ -0,0 +1,95 @@
using PCL.Core.Utils.Hash;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
public class FileCacheStorage : IDisposable
{
private readonly HashStorage _hashStorage;
private readonly string _basePath;
private readonly ConcurrentDictionary<string, int> _refCounts = [];
public FileCacheStorage(string cacheRoot, bool enableCompression = true)
{
_basePath = cacheRoot;
Directory.CreateDirectory(cacheRoot);
_hashStorage = new HashStorage(
cacheRoot,
SHA256Provider.Instance,
compressObjects: enableCompression,
correctMisplacedFile: false,
prefixLength: 2);
}
public async Task<string> StoreAsync(Stream source, string? knownHash = null)
{
var hash = await _hashStorage.PutAsync(source, knownHash).ConfigureAwait(false);
if (hash is not null)
{
_refCounts.AddOrUpdate(hash, 1, (_, count) => count + 1);
}
return hash!;
}
public Stream? Retrieve(string hash) => _hashStorage.Get(hash);
public string? GetFilePath(string hash)
{
var prefix = hash[..2];
var paht = Path.Combine(_basePath, prefix, hash);
return File.Exists(paht) ? paht : null;
}
public bool Exists(string hash) => _hashStorage.Exists(hash);
public async Task<bool> ReleaseAsync(string hash)
{
var spin = new SpinWait();
while (true)
{
if (!_refCounts.TryGetValue(hash, out var count))
{
return false;
}
switch (TryRelease(hash, count))
{
case ReleaseResult.Removed:
return await _hashStorage.DeleteAsync(hash).ConfigureAwait(false);
case ReleaseResult.Decremented:
return true;
}
if (spin.NextSpinWillYield)
await Task.Yield();
else
spin.SpinOnce();
}
}
private enum ReleaseResult { Retry, Decremented, Removed }
private ReleaseResult TryRelease(string hash, int expected) =>
expected > 1
? (_refCounts.TryUpdate(hash, expected - 1, expected) ? ReleaseResult.Decremented : ReleaseResult.Retry)
: (_refCounts.TryRemove(new KeyValuePair<string, int>(hash, expected)) ? ReleaseResult.Removed : ReleaseResult.Retry);
public Task<bool> ForceDeleteAsync(string hash)
{
_refCounts.TryRemove(hash, out _);
return _hashStorage.DeleteAsync(hash);
}
/// <inheritdoc />
public void Dispose()
{
_refCounts.Clear();
}
}
@@ -0,0 +1,78 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
/// <summary>
/// Provides a contract for a cache service.
/// </summary>
public interface ICacheService
{
/// <summary>
/// Write a value to the cache with the specified key and optional cache policy. If the key already exists, it will be overwritten.<br/>
/// Small data ( &lt; 256KB) is storaged in SQLite inline, while larger data is storaged as file-mapped.
/// </summary>
Task SetAsync<T>(string key, T value, CachePolicy? policy = null, CancellationToken ct = default);
/// <summary>
/// Read the cache.
/// </summary>
/// <returns>The <see cref="CacheResult{T}"/> the tell 'Miss', 'Hit' or 'Expired'</returns>
Task<CacheResult<T>> GetAsync<T>(string key, CancellationToken ct = default);
/// <summary>
/// Check if a cache entry exists for the specified key.
/// </summary>
/// <param name="key"></param>
/// <returns><see langword="true"/> if the entry exists and is not expired, <see langword="false"/> otherwise</returns>
Task<bool> ExistsAsync(string key);
/// <summary>
/// Deletes the cache entry for the specified key.
/// </summary>
/// <param name="key"></param>
/// <returns><see langword="true"/> if the entry was found and deleted, <see langword="false"/> otherwise</returns>
Task<bool> DeleteAsync(string key);
/// <summary>
/// Gets the file path for the cached entry with the specified key.
/// </summary>
/// <returns>The file path if the entry exists, otherwise null</returns>
Task<string?> GetCachedFilePathAsync(string key);
/// <summary>
/// Cache a file stream with the specified key and optional cache policy. If the key already exists, it will be overwritten.<br/>
/// </summary>
/// <returns>The file path</returns>
Task<string> CacheFileAsync(string key, Stream source, CachePolicy? policy = null, CancellationToken ct = default);
/// <summary>
/// Deletes all cache entries belonging to the specified group.
/// </summary>
/// <returns>The number of entries deleted</returns>
Task<int> DeleteByGroupAsync(string groupName);
/// <summary>
/// Deletes all cache entries with the specified tag.
/// </summary>
/// <param name="tag"></param>
/// <returns>The number of entries deleted</returns>
Task<int> DeleteByTagAsync(string tag);
/// <summary>
/// Deletes all expired cache entries.
/// </summary>
/// <returns>The number of entries deleted</returns>
Task<int> DeleteExpiredAsync();
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns></returns>
Task<CacheStats> GetStatsAsync();
/// <summary>
/// Clear all cache entries. Use with caution as this will remove all cached data regardless of expiration or priority.
/// </summary>
Task ClearAsync();
/// <summary>
/// Compacts the cache by removing any unused space.
/// </summary>
Task CompactAsync();
}
@@ -0,0 +1,58 @@
using System;
namespace PCL.Core.IO.Storage.Cache.Model;
public record CacheEntry
{
/// <summary>
/// SHA-256 (Raw key)
/// </summary>
public string CacheKey { get; init; } = string.Empty;
public EntryType EntryType { get; init; }
/// <summary>
/// MIME
/// </summary>
public string ContentType { get; init; } = string.Empty;
/// <summary>
/// Cache data (Used in <see cref="EntryType.Inline"/> mode)
/// </summary>
public byte[]? Data { get; init; }
/// <summary>
/// Original data size
/// </summary>
public long DataSize { get; init; }
/// <summary>
/// File hash (SHA-256) (Used in <see cref="EntryType.FileRef"/> mode)
/// </summary>
public string? FileHash { get; init; }
/// <summary>
/// File path (Relative in cache directory) (Used in <see cref="EntryType.FileRef"/> mode)
/// </summary>
public string? FilePath { get; init; }
public string? ContentHash { get; init; }
/// <summary>
/// Content format version (Used for migration.)
/// </summary>
public int ContentVersion { get; init; }
public DateTime CachedAt { get; init; }
public DateTime LastAccessAt { get; init; }
public DateTime? ExpiresAt { get; init; }
public long HitCount { get; init; }
public string Tags { get; init; } = string.Empty;
public string GroupName { get; init; } = string.Empty;
/// <summary>
/// <c>0 = Low; 1 = Normal; 2 = High; 3 = NeverEvict</c>
/// </summary>
public int Priority { get; init; }
}
public enum EntryType
{
Inline = 0,
FileRef = 1
}
@@ -0,0 +1,29 @@
using System;
namespace PCL.Core.IO.Storage.Cache.Model;
public record ComponentCacheRow
{
public string InstancePath { get; init; } = string.Empty; // PK 1
public string CompType { get; init; } = string.Empty; // PK 2: mods/rp/shader/saves/datapack
public string FileName { get; init; } = string.Empty; // PK 3
public string RelativePath { get; init; } = string.Empty;
public string? FileHash { get; init; } // SHA256
public long FileSize { get; init; }
public DateTime LastModified { get; init; }
public bool Enabled { get; init; }
// Mod 特有(JSON 序列化到 ModMetadata 列)
public string? ModName { get; init; }
public string? ModVersion { get; init; }
public string? ModAuthor { get; init; }
public string? ModDescription { get; init; }
public string? ModLoader { get; init; } // forge / fabric / quilt / liteloader
public string? ModDependencies { get; init; } // JSON array
// Scan metadata
public int CacheVersion { get; init; } = 7;
public DateTime ScannedAt { get; init; }
public string? ScanHash { get; init; } // 目录扫描指纹
}
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace PCL.Core.IO.Storage.Cache.Model;
public record InstanceCacheRow
{
public string InstancePath { get; init; } = string.Empty;
public string InstanceName { get; init; } = string.Empty;
public string InstanceState { get; init; } = "Error";
public int CardType { get; init; }
public bool IsStarred { get; init; }
public string? Logo { get; init; }
public string? Description { get; init; }
public string? ReleaseTime { get; init; }
public string? VanillaName { get; init; }
public string? VanillaVersion { get; init; }
public int DropNumber { get; init; }
public bool Reliable { get; init; }
///<summary>
/// 加载器版本(统一 JSON 存储,替代 n 组 HasXxx + XxxVersion 字段)<br/>
/// 格式:[{"type":"forge","version":"47.2.0"},{"type":"fabric","version":"0.15.0"}]<br/>
/// 空数组 = 无加载器
/// </summary>
public string LoaderJson { get; internal set; } = "[]";
/// <summary>解析加载器列表(反序列化 LoaderJson</summary>
public List<LoaderEntry> GetLoaders() =>
JsonSerializer.Deserialize<List<LoaderEntry>>(LoaderJson) ?? [];
/// <summary>快速检查是否存在指定类型的加载器</summary>
public bool HasLoader(string type) =>
GetLoaders().Any(l => l.Type == type);
/// <summary>获取指定加载器的版本(不存在返回 null</summary>
public string? GetLoaderVersion(string type) =>
GetLoaders().FirstOrDefault(l => l.Type == type)?.Version;
/// <summary>将加载器列表序列化为 JSON 赋给 LoaderJson</summary>
public void SetLoaders(List<LoaderEntry> loaders) =>
LoaderJson = JsonSerializer.Serialize(loaders);
// Manifest summary
public string? MainClass { get; init; }
public string? AssetsIndex { get; init; }
public string? InheritsFrom { get; init; }
public int? JavaVersion { get; init; }
// Cache control
public string SourceJsonHash { get; init; } = string.Empty; // version.json SHA256
public int FormatVersion { get; init; } = 1;
public DateTime CachedAt { get; init; }
public DateTime? LastLoadedAt { get; init; }
}
/// <summary>
/// 加载器版本条目——统一模型替代 N 组 HasXxx/XxxVersion 属性。
/// 新增加载器类型只需在此记录中添加新条目,无需修改表结构或模型类。
/// </summary>
public record LoaderEntry
{
/// <summary>加载器类型标识,如 "forge" / "fabric" / "quilt" / "neoforge" / "liteloader" / "optifine" / "labymod" / "cleanroom" / "legacyfabric"</summary>
public string Type { get; init; } = string.Empty;
/// <summary>加载器版本号(空字符串 = 已检测到加载器但版本未知,null = 不存在)</summary>
public string? Version { get; init; }
}
@@ -0,0 +1,210 @@
using Microsoft.Data.Sqlite;
using System;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
public class SchemaManager(string connectionString)
{
private const int CurrentSchemaVersion = 1;
/// <exception cref="InvalidOperationException">Invalid schema version</exception>
public async Task EnsureCurrentSchemaAsync()
{
await using var conn = new SqliteConnection(connectionString);
await conn.OpenAsync().ConfigureAwait(false);
await _ExecutePragmaAsync(conn, "journal_mode", "WAL").ConfigureAwait(false);
await _ExecutePragmaAsync(conn, "synchronous", "NORMAL").ConfigureAwait(false);
await _ExecutePragmaAsync(conn, "cache_size", "-8000").ConfigureAwait(false); // 8MB page cache
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS cache_meta (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL
)
""";
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
var currentVersion = 0;
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT value FROM cache_meta WHERE key = 'schema_version'";
var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false);
if (result is not null)
{
currentVersion = int.Parse(result.ToString() ?? throw new InvalidOperationException("Invalid schema version"));
}
}
if (currentVersion < CurrentSchemaVersion)
{
// NOTE: this method is ApplyMigrations, not _ApplyMigrationAsync. see the 's' difference
await _ApplyMigrationsAsync(conn, currentVersion).ConfigureAwait(false);
}
await _EnsureAllTablesExistAsync(conn).ConfigureAwait(false);
}
private static async Task<int> _ExecutePragmaAsync(SqliteConnection conn, string pragma, string value)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"PRAGMA {pragma} = {value}";
return await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
#region Version Upgrade
private async Task _ApplyMigrationsAsync(SqliteConnection conn, int fromVersion)
{
var v = fromVersion;
while (v < CurrentSchemaVersion)
{
v++;
await using var tx = conn.BeginTransaction();
try
{
await _ApplyMigrationAsync(conn, v).ConfigureAwait(false);
await using var setVer = conn.CreateCommand();
setVer.CommandText = "INSERT OR REPLACE INTO cache_meta (key, value) VALUES ('schema_version', @v)";
setVer.Parameters.AddWithValue("@v", v);
await setVer.ExecuteNonQueryAsync().ConfigureAwait(false);
await tx.CommitAsync().ConfigureAwait(false);
}
catch
{
await tx.RollbackAsync().ConfigureAwait(false);
throw;
}
}
}
private async Task _ApplyMigrationAsync(SqliteConnection conn, int targetVersion)
{
switch (targetVersion)
{
case 1:
await _ExecuteDdlAsync(conn, DdlV1CreateAllTables).ConfigureAwait(false);
await _ExecuteDdlAsync(conn, DdlV1CreateIndexes).ConfigureAwait(false);
break;
// NOTE: add future migrations here
// e.g.
// case 2:
// await _ExecuteDdlAsync(conn, "some sql").
}
}
#endregion
private async Task<int> _EnsureAllTablesExistAsync(SqliteConnection conn)
{
await _ExecuteDdlAsync(conn, DdlV1CreateAllTables).ConfigureAwait(false);
await _ExecuteDdlAsync(conn, DdlV1CreateIndexes).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT OR IGNORE INTO cache_meta (key, value) VALUES ('schema_version', @v)";
cmd.Parameters.AddWithValue("@v", CurrentSchemaVersion);
return await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
private static async Task<int> _ExecuteDdlAsync(SqliteConnection conn, string ddl)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = ddl;
return await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
#region DDL Text
private const string DdlV1CreateAllTables = """
CREATE TABLE IF NOT EXISTS cache_entries (
cache_key TEXT NOT NULL PRIMARY KEY,
entry_type INTEGER NOT NULL DEFAULT 0,
content_type TEXT NOT NULL DEFAULT '',
data BLOB,
data_size INTEGER NOT NULL DEFAULT 0,
file_hash TEXT,
file_path TEXT,
content_hash TEXT,
content_version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_access_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
hit_count INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '',
group_name TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS instance_cache (
instance_path TEXT NOT NULL PRIMARY KEY,
instance_name TEXT NOT NULL,
instance_state TEXT NOT NULL DEFAULT 'Error',
card_type INTEGER NOT NULL DEFAULT 0,
is_starred INTEGER NOT NULL DEFAULT 0,
logo TEXT,
description TEXT,
release_time TEXT,
vanilla_name TEXT,
vanilla_version TEXT,
drop_number INTEGER NOT NULL DEFAULT 0,
reliable INTEGER NOT NULL DEFAULT 1,
loaders_json TEXT NOT NULL DEFAULT '[]',
main_class TEXT,
assets_index TEXT,
inherits_from TEXT,
java_version INTEGER,
source_json_hash TEXT NOT NULL,
format_version INTEGER NOT NULL DEFAULT 1,
cached_at TEXT NOT NULL DEFAULT (datetime('now')),
last_loaded_at TEXT
);
CREATE TABLE IF NOT EXISTS component_cache (
instance_path TEXT NOT NULL,
comp_type TEXT NOT NULL,
file_name TEXT NOT NULL,
relative_path TEXT NOT NULL,
file_hash TEXT,
file_size INTEGER NOT NULL DEFAULT 0,
last_modified TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
mod_name TEXT,
mod_version TEXT,
mod_author TEXT,
mod_description TEXT,
mod_loader TEXT,
mod_deps TEXT,
cache_version INTEGER NOT NULL DEFAULT 7,
scanned_at TEXT NOT NULL DEFAULT (datetime('now')),
scan_hash TEXT,
PRIMARY KEY (instance_path, comp_type, file_name)
);
CREATE TABLE IF NOT EXISTS cache_stats (
stat_name TEXT NOT NULL PRIMARY KEY,
stat_value INTEGER NOT NULL DEFAULT 0
);
""";
private const string DdlV1CreateIndexes = """
CREATE INDEX IF NOT EXISTS idx_ce_expires ON cache_entries(expires_at);
CREATE INDEX IF NOT EXISTS idx_ce_group ON cache_entries(group_name);
CREATE INDEX IF NOT EXISTS idx_ce_tags ON cache_entries(tags);
CREATE INDEX IF NOT EXISTS idx_ce_access ON cache_entries(last_access_at);
CREATE INDEX IF NOT EXISTS idx_ce_priority ON cache_entries(priority);
CREATE INDEX IF NOT EXISTS idx_ce_hits ON cache_entries(hit_count);
CREATE INDEX IF NOT EXISTS idx_cc_instance ON component_cache(instance_path);
CREATE INDEX IF NOT EXISTS idx_cc_scanhash ON component_cache(instance_path, comp_type, scan_hash);
""";
#endregion
}
@@ -0,0 +1,619 @@
using Microsoft.Data.Sqlite;
using PCL.Core.IO.Storage.Cache.Model;
using System;
using System.Globalization;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
public class SqliteCacheStorage(string dbPath) : IDisposable
{
private readonly string _connectionString = $"Data Source={dbPath};Pooling=True";
private readonly SemaphoreSlim _writeLock = new(1, 1);
private bool _disposed;
private async Task<SqliteConnection> _CreateConnectionAsync()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SqliteCacheStorage));
}
var conn = new SqliteConnection(_connectionString);
// NOTE: WAL mode is managed in SchemaManager, so we don't set it here.
// but new connection may need re-enable some session-level pragmas
// but we don't have any for now, so we just return the connection.
await conn.OpenAsync().ConfigureAwait(false);
return conn;
}
public async Task CleanupStartupAsync()
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < datetime('now')";
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
await using var walCmd = conn.CreateCommand();
walCmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)";
await walCmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
#region cache_entries
public async Task UpsertAsync(CacheEntry entry, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO cache_entries (
cache_key, entry_type, content_type, data, data_size, file_hash, file_path,
content_hash, content_version, created_at, last_access_at, expires_at,
hit_count, tags, group_name, priority
) VALUES (
@cache_key, @entry_type, @content_type, @data, @data_size, @file_hash, @file_path,
@content_hash, @content_version, COALESCE((SELECT created_at FROM cache_entries WHERE cache_key = @cache_key), datetime('now')),
datetime('now'), @expires_at, 0, @tags, @group_name, @priority
)
""";
_BindEntryParams(cmd, entry);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
public async Task<CacheEntry?> LookupAsync(string cacheKey, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM cache_entries WHERE cache_key = @cache_key";
cmd.Parameters.AddWithValue("@cache_key", cacheKey);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
return await reader.ReadAsync(ct).ConfigureAwait(false) ? _ReadEntry(reader) : null;
}
public async Task TouchAsync(string key, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE cache_entries SET last_access_at = datetime('now'), hit_count = hit_count + 1 WHERE cache_key = @cache_key
""";
cmd.Parameters.AddWithValue("@cache_key", key);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
public async Task<bool> DeleteAsync(string cacheKey, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cache_entries WHERE cache_key = @cache_key";
cmd.Parameters.AddWithValue("@cache_key", cacheKey);
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return affected > 0;
}
finally
{
_writeLock.Release();
}
}
public async Task<int> DeleteExpiredAsync(DateTime now, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND datetime(expires_at) < datetime('now')";
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return affected;
}
finally
{
_writeLock.Release();
}
}
public async Task<int> DeleteByGroupAsync(string groupName, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cache_entries WHERE group_name = @group_name";
cmd.Parameters.AddWithValue("@group_name", groupName);
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return affected;
}
finally
{
_writeLock.Release();
}
}
public async Task<List<string?>> GetFileHashesByGroupAsync(string groupName, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT DISTINCT file_hash FROM cache_entries WHERE group_name = @group_name AND file_hash IS NOT NULL";
cmd.Parameters.AddWithValue("@group_name", groupName);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
var hashes = new List<string?>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
hashes.Add(reader.GetString(0));
}
return hashes;
}
public async Task<List<string?>> GetAllFileHashesAsync(CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT DISTINCT file_hash FROM cache_entries WHERE file_hash IS NOT NULL";
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
var hashes = new List<string?>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
hashes.Add(reader.GetString(0));
}
return hashes;
}
public async Task<int> DeleteByTagAsync(string tag, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cache_entries WHERE tags = @t OR tags LIKE @p1 OR tags LIKE @p2 OR tags LIKE @p3";
cmd.Parameters.AddWithValue("@t", tag);
cmd.Parameters.AddWithValue("@p1", $"{tag},%");
cmd.Parameters.AddWithValue("@p2", $"%,{tag},%");
cmd.Parameters.AddWithValue("@p3", $"%,{tag}");
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return affected;
}
finally
{
_writeLock.Release();
}
}
public async Task<CacheStatsRow> GetStatsAsync(CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) AS total, COALESCE(SUM(data_size), 0) AS total_size, COALESCE(SUM(CASE WHEN expires_at < datetime('now') THEN 1 ELSE 0 END), 0) AS expired, COALESCE(SUM(CASE WHEN entry_type = 0 THEN 1 ELSE 0 END), 0) AS inline, COALESCE(SUM(CASE WHEN entry_type = 1 THEN 1 ELSE 0 END), 0) AS file_ref FROM cache_entries";
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
return new CacheStatsRow(
TotalEntries: reader.GetInt64(0),
TotalSizeBytes: reader.GetInt64(1),
ExpiredEntries: reader.GetInt64(2),
InlineEntries: reader.GetInt64(3),
FileEntries: reader.GetInt64(4)
);
}
// none
return new CacheStatsRow(0, 0, 0, 0, 0);
}
public async Task<List<EvictionCandidate>> GetEvictionCandidatesAsync(int limit, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT cache_key, entry_type, file_hash, data_size, priority, hit_count
FROM cache_entries
WHERE priority < 3 -- NeverEvict
ORDER BY priority ASC, hit_count ASC, last_access_at ASC
LIMIT @limit
""";
cmd.Parameters.AddWithValue("@limit", limit);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
var list = new List<EvictionCandidate>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
list.Add(new EvictionCandidate(
CacheKey: reader.GetString(0),
EntryType: (EntryType)reader.GetInt32(1),
FileHash: reader.IsDBNull(2) ? null : reader.GetString(2),
DataSize: reader.GetInt64(3),
Priority: reader.GetInt32(4),
HitCount: reader.GetInt64(5)
));
}
return list;
}
public async Task CompactAsync(CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "VACUUM";
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
#endregion
#region instance_cache
public async Task UpsertInstanceAsync(InstanceCacheRow row, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO instance_cache
(instance_path, instance_name, instance_state, card_type, is_starred,
logo, description, release_time,
vanilla_name, vanilla_version, drop_number, reliable,
loaders_json,
main_class, assets_index, inherits_from, java_version,
source_json_hash, format_version, cached_at, last_loaded_at)
VALUES
(@p, @n, @st, @cty, @is,
@lo, @de, @rt,
@vn, @vv, @dn, @re,
@lj,
@mc, @ai, @if, @jv,
@sh, @fv, datetime('now'), datetime('now'))
""";
_BindInstanceParams(cmd, row);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
public async Task<InstanceCacheRow?> LookupInstanceAsync(string instancePath, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM instance_cache WHERE instance_path = @p";
cmd.Parameters.AddWithValue("@p", instancePath);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
return await reader.ReadAsync(ct).ConfigureAwait(false) ? _ReadInstanceRow(reader) : null;
}
public async Task<int> DeleteInstanceAsync(string instancePath, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM instance_cache WHERE instance_path = @p";
cmd.Parameters.AddWithValue("@p", instancePath);
return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
#endregion
#region component_cache
public async Task UpsertComponentAsync(ComponentCacheRow row, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT OR REPLACE INTO component_cache
(instance_path, comp_type, file_name, relative_path,
file_hash, file_size, last_modified, enabled,
mod_name, mod_version, mod_author, mod_description,
mod_loader, mod_deps,
cache_version, scanned_at, scan_hash)
VALUES
(@ip, @ct, @fn, @rp,
@fh, @fs, @lm, @en,
@mn, @mv, @ma, @md,
@ml, @mdeps,
@cv, datetime('now'), @sh)";
_BindComponentParams(cmd, row);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
public async Task<List<ComponentCacheRow>> GetComponentsByInstanceAsync(
string instancePath, string compType, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM component_cache WHERE instance_path = @ip AND comp_type = @ct";
cmd.Parameters.AddWithValue("@ip", instancePath);
cmd.Parameters.AddWithValue("@ct", compType);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
var list = new List<ComponentCacheRow>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
list.Add(_ReadComponentRow(reader));
}
return list;
}
public async Task<ComponentCacheRow?> GetComponentAsync(
string instancePath, string compType, string fileName, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM component_cache WHERE instance_path = @ip AND comp_type = @ct AND file_name = @fn";
cmd.Parameters.AddWithValue("@ip", instancePath);
cmd.Parameters.AddWithValue("@ct", compType);
cmd.Parameters.AddWithValue("@fn", fileName);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
return await reader.ReadAsync(ct).ConfigureAwait(false) ? _ReadComponentRow(reader) : null;
}
public async Task<int> DeleteComponentsByInstanceAsync(string instancePath, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM component_cache WHERE instance_path = @ip";
cmd.Parameters.AddWithValue("@ip", instancePath);
return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
public async Task<string?> GetComponentScanHashAsync(
string instancePath, string compType, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT DISTINCT scan_hash
FROM component_cache
WHERE instance_path = @ip AND comp_type = @ct AND scan_hash IS NOT NULL
ORDER BY scanned_at DESC
LIMIT 1
""";
cmd.Parameters.AddWithValue("@ip", instancePath);
cmd.Parameters.AddWithValue("@ct", compType);
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result as string;
}
#endregion
#region Arg Binding
private static void _BindEntryParams(SqliteCommand cmd, CacheEntry e)
{
cmd.Parameters.AddWithValue("@cache_key", e.CacheKey);
cmd.Parameters.AddWithValue("@entry_type", e.EntryType);
cmd.Parameters.AddWithValue("@content_type", e.ContentType);
cmd.Parameters.AddWithValue("@data", (object?)e.Data ?? DBNull.Value);
cmd.Parameters.AddWithValue("@data_size", e.DataSize);
cmd.Parameters.AddWithValue("@file_hash", (object?)e.FileHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("@file_path", (object?)e.FilePath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@content_hash", (object?)e.ContentHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("@content_version", e.ContentVersion);
cmd.Parameters.AddWithValue("@expires_at", e.ExpiresAt is not null ? ((DateTime)e.ExpiresAt).ToString("O") : DBNull.Value);
cmd.Parameters.AddWithValue("@tags", e.Tags);
cmd.Parameters.AddWithValue("@group_name", e.GroupName);
cmd.Parameters.AddWithValue("@priority", e.Priority);
}
// 数据库中的时间以 ISO-8601 往返格式("O"UTC 带 Z)写入;读取必须用 RoundtripKind 解析,
// 否则 DateTime.Parse 会把 UTC 转成本地时间并丢失 Kind,使其与 DateTime.UtcNow 的比较偏移一个
// 时区(例如 UTC+8 用户的缓存过期判断会差 8 小时,导致缓存过久失效/供应陈旧数据)。
// 用 TryParse 以容忍损坏/遗留数据:单行时间戳解析失败时回退到 MinValue(对过期判断即视为已过期、
// 对时间排序即视为最旧,均为 fail-safe),避免一行坏数据抛异常中断整个读取路径。
private static DateTime _ParseDateTime(string value) =>
DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result)
? result
: DateTime.MinValue;
private static CacheEntry _ReadEntry(SqliteDataReader r) =>
new()
{
CacheKey = r.GetString(0),
EntryType = (EntryType)r.GetInt32(1),
ContentType = r.GetString(2),
Data = r.IsDBNull(3) ? null : (byte[])r.GetValue(3),
DataSize = r.GetInt64(4),
FileHash = r.IsDBNull(5) ? null : r.GetString(5),
FilePath = r.IsDBNull(6) ? null : r.GetString(6),
ContentHash = r.IsDBNull(7) ? null : r.GetString(7),
ContentVersion = r.GetInt32(8),
CachedAt = _ParseDateTime(r.GetString(9)),
LastAccessAt = _ParseDateTime(r.GetString(10)),
ExpiresAt = r.IsDBNull(11) ? null : _ParseDateTime(r.GetString(11)),
HitCount = r.GetInt64(12),
Tags = r.GetString(13),
GroupName = r.GetString(14),
Priority = r.GetInt32(15),
};
private static void _BindInstanceParams(SqliteCommand cmd, InstanceCacheRow r)
{
cmd.Parameters.AddWithValue("@p", r.InstancePath);
cmd.Parameters.AddWithValue("@n", r.InstanceName);
cmd.Parameters.AddWithValue("@st", r.InstanceState);
cmd.Parameters.AddWithValue("@cty", r.CardType);
cmd.Parameters.AddWithValue("@is", r.IsStarred ? 1 : 0);
cmd.Parameters.AddWithValue("@lo", (object?)r.Logo ?? DBNull.Value);
cmd.Parameters.AddWithValue("@de", (object?)r.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("@rt", (object?)r.ReleaseTime ?? DBNull.Value);
cmd.Parameters.AddWithValue("@vn", (object?)r.VanillaName ?? DBNull.Value);
cmd.Parameters.AddWithValue("@vv", (object?)r.VanillaVersion ?? DBNull.Value);
cmd.Parameters.AddWithValue("@dn", r.DropNumber);
cmd.Parameters.AddWithValue("@re", r.Reliable ? 1 : 0);
cmd.Parameters.AddWithValue("@lj", r.LoaderJson);
cmd.Parameters.AddWithValue("@mc", (object?)r.MainClass ?? DBNull.Value);
cmd.Parameters.AddWithValue("@ai", (object?)r.AssetsIndex ?? DBNull.Value);
cmd.Parameters.AddWithValue("@if", (object?)r.InheritsFrom ?? DBNull.Value);
cmd.Parameters.AddWithValue("@jv", (object?)r.JavaVersion ?? DBNull.Value);
cmd.Parameters.AddWithValue("@sh", r.SourceJsonHash);
cmd.Parameters.AddWithValue("@fv", r.FormatVersion);
}
private static InstanceCacheRow _ReadInstanceRow(SqliteDataReader r)
{
return new InstanceCacheRow
{
InstancePath = r.GetString(0),
InstanceName = r.GetString(1),
InstanceState = r.GetString(2),
CardType = r.GetInt32(3),
IsStarred = r.GetInt32(4) != 0,
Logo = r.IsDBNull(5) ? null : r.GetString(5),
Description = r.IsDBNull(6) ? null : r.GetString(6),
ReleaseTime = r.IsDBNull(7) ? null : r.GetString(7),
VanillaName = r.IsDBNull(8) ? null : r.GetString(8),
VanillaVersion = r.IsDBNull(9) ? null : r.GetString(9),
DropNumber = r.GetInt32(10),
Reliable = r.GetInt32(11) != 0,
LoaderJson = r.GetString(12),
MainClass = r.IsDBNull(13) ? null : r.GetString(13),
AssetsIndex = r.IsDBNull(14) ? null : r.GetString(14),
InheritsFrom = r.IsDBNull(15) ? null : r.GetString(15),
JavaVersion = r.IsDBNull(16) ? null : r.GetInt32(16),
SourceJsonHash = r.GetString(17),
FormatVersion = r.GetInt32(18),
CachedAt = _ParseDateTime(r.GetString(19)),
LastLoadedAt = r.IsDBNull(20) ? null : _ParseDateTime(r.GetString(20)),
};
}
private static void _BindComponentParams(SqliteCommand cmd, ComponentCacheRow r)
{
cmd.Parameters.AddWithValue("@instance_path", r.InstancePath);
cmd.Parameters.AddWithValue("@comp_type", r.CompType);
cmd.Parameters.AddWithValue("@file_name", r.FileName);
cmd.Parameters.AddWithValue("@relative_path", r.RelativePath);
cmd.Parameters.AddWithValue("@file_hash", (object?)r.FileHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("@file_size", r.FileSize);
cmd.Parameters.AddWithValue("@last_modified", r.LastModified.ToString("O"));
cmd.Parameters.AddWithValue("@enabled", r.Enabled ? 1 : 0);
cmd.Parameters.AddWithValue("@mod_name", (object?)r.ModName ?? DBNull.Value);
cmd.Parameters.AddWithValue("@mod_version", (object?)r.ModVersion ?? DBNull.Value);
cmd.Parameters.AddWithValue("@mod_author", (object?)r.ModAuthor ?? DBNull.Value);
cmd.Parameters.AddWithValue("@mod_description", (object?)r.ModDescription ?? DBNull.Value);
cmd.Parameters.AddWithValue("@mod_loader", (object?)r.ModLoader ?? DBNull.Value);
cmd.Parameters.AddWithValue("@mod_dependencies", (object?)r.ModDependencies ?? DBNull.Value);
cmd.Parameters.AddWithValue("@cache_version", r.CacheVersion);
cmd.Parameters.AddWithValue("@scan_hash", (object?)r.ScanHash ?? DBNull.Value);
}
private static ComponentCacheRow _ReadComponentRow(SqliteDataReader r)
{
return new ComponentCacheRow
{
InstancePath = r.GetString(0),
CompType = r.GetString(1),
FileName = r.GetString(2),
RelativePath = r.GetString(3),
FileHash = r.IsDBNull(4) ? null : r.GetString(4),
FileSize = r.GetInt64(5),
LastModified = _ParseDateTime(r.GetString(6)),
Enabled = r.GetInt32(7) != 0,
ModName = r.IsDBNull(8) ? null : r.GetString(8),
ModVersion = r.IsDBNull(9) ? null : r.GetString(9),
ModAuthor = r.IsDBNull(10) ? null : r.GetString(10),
ModDescription = r.IsDBNull(11) ? null : r.GetString(11),
ModLoader = r.IsDBNull(12) ? null : r.GetString(12),
ModDependencies = r.IsDBNull(13) ? null : r.GetString(13),
CacheVersion = r.GetInt32(14),
ScannedAt = _ParseDateTime(r.GetString(15)),
ScanHash = r.IsDBNull(16) ? null : r.GetString(16),
};
}
#endregion
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_writeLock.Dispose();
}
}
public record CacheStatsRow(long TotalEntries, long TotalSizeBytes, long ExpiredEntries, long InlineEntries, long FileEntries);
public record EvictionCandidate(string? CacheKey, EntryType EntryType, string? FileHash, long DataSize, int Priority, long HitCount);
@@ -0,0 +1,143 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Threading.Tasks;
using PCL.Core.Logging;
using PCL.Core.Utils.Exts;
using PCL.Core.Utils.Hash;
namespace PCL.Core.IO.Storage;
public class HashStorage(string folder, IHashProvider hashProvider, bool compressObjects = false, bool correctMisplacedFile = true, int prefixLength = 2)
{
/// <summary>
/// 保存文件到哈希存储库中
/// </summary>
/// <param name="fromPath">欲存储的文件位置</param>
/// <param name="hash">欲存储的文件的哈希,请确保与哈希存储库指定的哈希计算方法所用算法一致</param>
/// <returns>成功返回文件的哈希,失败返回 null</returns>
/// <exception cref="ArgumentNullException">提供的参数不正确</exception>
public async Task<string?> PutAsync(string fromPath, string? hash = null)
{
//参数检查
ArgumentNullException.ThrowIfNull(fromPath);
var filePath = Path.GetFullPath(fromPath);
if (!File.Exists(filePath)) return null;
//必要数据准备
await using var originalFs = File.Open(fromPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await PutAsync(originalFs, hash).ConfigureAwait(false);
}
public async Task<string?> PutAsync(Stream input, string? hash = null)
{
ArgumentNullException.ThrowIfNull(input);
if (hash is not null && hash.Length != hashProvider.Length)
throw new ArgumentException("Provide hash is not correct", nameof(hash));
if (input.CanSeek) input.Position = 0;
var fileHash = hash ?? (await hashProvider.ComputeHashAsync(input).ConfigureAwait(false)).ToHexString();
var destPath = _GetDestPath(fileHash);
//纠正: 由于之前错误设计导致的文件访问效率低下的文件结构
if (correctMisplacedFile && _CorrectMisplacedFile(fileHash)) LogWrapper.Info("HashStorage", "Move misplaced file into correct folder");
//检查是否已存在保存的文件
if (File.Exists(destPath)) return fileHash;
await using var destinationFs = _GetSaveStream(destPath);
await input.CopyToAsync(destinationFs).ConfigureAwait(false);
return fileHash;
}
public Task<bool> DeleteAsync(string hash)
{
ArgumentNullException.ThrowIfNull(hash);
var filePath = _GetDestPath(hash);
if (!File.Exists(filePath) && correctMisplacedFile)
filePath = _GetMisplacedFilePath(hash);
if (!File.Exists(filePath)) return Task.FromResult(false);
try
{
File.Delete(filePath);
}
catch (FileNotFoundException) { /* 忽略此错误 */ }
catch (DirectoryNotFoundException ex)
{
LogWrapper.Error(ex, "HashStorage", $"Unexpected directory not found {filePath}");
return Task.FromResult(false);
}
catch (IOException ex)
{
LogWrapper.Error(ex, "HashStorage", $"Failed to delete file {filePath}");
return Task.FromResult(false);
}
catch (UnauthorizedAccessException ex)
{
LogWrapper.Error(ex, "HashStorage", $"Access denied when deleting file {filePath}");
return Task.FromResult(false);
}
return Task.FromResult(true);
}
public Stream? Get(string hash)
{
ArgumentNullException.ThrowIfNull(hash);
var destPath = _GetDestPath(hash);
if (correctMisplacedFile && _CorrectMisplacedFile(hash))
LogWrapper.Info("HashStorage", $"Move misplaced file into correct folder: {hash}");
return File.Exists(destPath) ? _GetReadStream(destPath) : null;
}
public bool Exists(string hash)
{
ArgumentNullException.ThrowIfNull(hash);
return File.Exists(_GetDestPath(hash)) || (correctMisplacedFile && File.Exists(_GetMisplacedFilePath(hash)));
}
private Stream _GetSaveStream(string destPath)
{
var fs = File.Open(destPath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read);
if (compressObjects) return new DeflateStream(fs, CompressionMode.Compress);
return fs;
}
private Stream _GetReadStream(string destPath)
{
var fs = File.Open(destPath, FileMode.Open, FileAccess.Read, FileShare.Read);
if (compressObjects) return new DeflateStream(fs, CompressionMode.Decompress);
return fs;
}
private string _GetDestPath(string hash)
{
return Path.Combine(folder, _GetPrefixFolder(hash), hash);
}
private bool _CorrectMisplacedFile(string hash)
{
var misplacedPath = _GetMisplacedFilePath(hash);
if (!File.Exists(misplacedPath)) return false;
var correctPath = _GetDestPath(hash);
File.Move(misplacedPath, correctPath);
return true;
}
private string _GetMisplacedFilePath(string hash)
{
return Path.Combine(folder, hash);
}
private string _GetPrefixFolder(string hash)
{
if (hash.Length < prefixLength)
throw new ArgumentException($"Hash length({hash.Length}) is shorter than required prefix length({prefixLength})", nameof(hash));
var folderName = hash[..prefixLength];
var folderPath = Path.Combine(folder, folderName);
Directory.CreateDirectory(folderPath);
return folderName;
}
}