初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
+27
@@ -0,0 +1,27 @@
|
||||
using System.Net.Http;
|
||||
using Downloader;
|
||||
|
||||
namespace PCL.Network;
|
||||
|
||||
internal static class DownloadRequestFactory
|
||||
{
|
||||
internal static RequestConfiguration Create(string url, bool useBrowserUserAgent, string customUserAgent = "")
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
RequestSigning.SecretHeadersSign(url, ref request, useBrowserUserAgent, customUserAgent);
|
||||
try
|
||||
{
|
||||
var configuration = new RequestConfiguration();
|
||||
if (request.Headers.UserAgent.Count > 0)
|
||||
configuration.UserAgent = request.Headers.UserAgent.ToString();
|
||||
foreach (var header in request.Headers)
|
||||
if (!header.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase))
|
||||
configuration.Headers.Add($"{header.Key}: {string.Join(", ", header.Value)}");
|
||||
return configuration;
|
||||
}
|
||||
finally
|
||||
{
|
||||
request.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using Downloader;
|
||||
using PCL.Core.IO.Net;
|
||||
|
||||
|
||||
namespace PCL.Network;
|
||||
|
||||
public static class FileDownloader
|
||||
{
|
||||
public static async Task DownloadAsync(string url, string localPath, bool useBrowserUserAgent = false,
|
||||
string customUserAgent = "", CancellationToken cancellationToken = default,
|
||||
bool enableParallelChunks = true, DownloadFile? trackedFile = null)
|
||||
{
|
||||
await DownloadCoreAsync([url], localPath, useBrowserUserAgent, customUserAgent, cancellationToken,
|
||||
enableParallelChunks, trackedFile).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task DownloadAsync(IEnumerable<string> urls, string localPath, bool useBrowserUserAgent = false,
|
||||
string customUserAgent = "", CancellationToken cancellationToken = default,
|
||||
bool enableParallelChunks = true, DownloadFile? trackedFile = null)
|
||||
{
|
||||
await DownloadCoreAsync(urls, localPath, useBrowserUserAgent, customUserAgent, cancellationToken,
|
||||
enableParallelChunks, trackedFile).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static void DownloadByLoader(string url, string localPath, bool useBrowserUserAgent = false,
|
||||
string customUserAgent = "")
|
||||
{
|
||||
DownloadAsync(url, localPath, useBrowserUserAgent, customUserAgent).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public static void DownloadByLoader(IEnumerable<string> urls, string localPath, bool useBrowserUserAgent = false,
|
||||
string customUserAgent = "")
|
||||
{
|
||||
DownloadAsync(urls, localPath, useBrowserUserAgent, customUserAgent).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private static async Task DownloadCoreAsync(IEnumerable<string> urls, string localPath, bool useBrowserUserAgent,
|
||||
string customUserAgent, CancellationToken cancellationToken, bool enableParallelChunks, DownloadFile? trackedFile)
|
||||
{
|
||||
var urlList = urls.Select(url => RequestSigning.SecretCdnSign(url.Trim())).Where(url => !string.IsNullOrWhiteSpace(url))
|
||||
.Distinct().ToList();
|
||||
if (urlList.Count == 0)
|
||||
throw new ArgumentException("未提供可用的下载地址", nameof(urls));
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(localPath) ?? throw new ArgumentException("下载路径无效", nameof(localPath)));
|
||||
|
||||
Exception? lastException = null;
|
||||
foreach (var url in urlList)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DownloadSingleAsync(url, localPath, useBrowserUserAgent, customUserAgent, cancellationToken,
|
||||
enableParallelChunks, trackedFile).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
CleanupTempFiles(localPath);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
CleanupTempFiles(localPath);
|
||||
ModBase.Log(ex, $"[Download] 下载失败,尝试下一个源:{url}", ModBase.LogLevel.Debug);
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException($"下载失败:{localPath}", lastException);
|
||||
}
|
||||
|
||||
private static async Task DownloadSingleAsync(string url, string localPath, bool useBrowserUserAgent,
|
||||
string customUserAgent, CancellationToken cancellationToken, bool enableParallelChunks, DownloadFile? trackedFile)
|
||||
{
|
||||
ModBase.Log($"[Download] 开始下载:{url} -> {localPath}");
|
||||
CleanupTempFiles(localPath);
|
||||
|
||||
var perFileThreadLimit = enableParallelChunks ? Math.Max(1, ModNet.NetTaskThreadLimit) : 1;
|
||||
// 限制最大分块数,防止大文件下载时内存爆炸
|
||||
var chunkCount = Math.Min(perFileThreadLimit, 4);
|
||||
var configuration = new DownloadConfiguration
|
||||
{
|
||||
ChunkCount = chunkCount,
|
||||
ParallelCount = chunkCount,
|
||||
ParallelDownload = chunkCount > 1,
|
||||
MaximumBytesPerSecond = ModNet.NetTaskSpeedLimitHigh > 0 ? ModNet.NetTaskSpeedLimitHigh : 0,
|
||||
MaxTryAgainOnFailure = 2,
|
||||
BlockTimeout = 60000,
|
||||
DownloadFileExtension = ModNet.netDownloadEnd,
|
||||
EnableAutoResumeDownload = false,
|
||||
CustomHttpClientFactory = () => GetHttpClient(url),
|
||||
MinimumSizeOfChunking = 1024 * 1024L,
|
||||
MaximumMemoryBufferBytes = 256L * 1024 * 1024,
|
||||
};
|
||||
|
||||
using var downloader = new DownloadService(configuration);
|
||||
using var cancelReg = cancellationToken.Register(() =>
|
||||
{
|
||||
try { downloader.CancelAsync(); } catch { } // 忽略
|
||||
});
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
void UpdateDownloadStat(DownloadProgressChangedEventArgs args)
|
||||
{
|
||||
if (trackedFile is null)
|
||||
return;
|
||||
|
||||
trackedFile.State = PCL.Network.NetState.Downloading;
|
||||
trackedFile.TotalSize = Math.Max(trackedFile.TotalSize, args.TotalBytesToReceive);
|
||||
trackedFile.IsUnknownSize = trackedFile.TotalSize <= 0;
|
||||
trackedFile.DownloadedBytes = Math.Max(trackedFile.DownloadedBytes, args.ReceivedBytesSize);
|
||||
trackedFile.Speed = Math.Max(0L, (long)Math.Round(args.BytesPerSecondSpeed));
|
||||
trackedFile.ActiveThreads = Math.Max(0, args.ActiveChunks);
|
||||
}
|
||||
|
||||
downloader.DownloadStarted += (_, args) =>
|
||||
{
|
||||
if (trackedFile is null)
|
||||
return;
|
||||
|
||||
trackedFile.State = PCL.Network.NetState.Reading;
|
||||
trackedFile.TotalSize = Math.Max(trackedFile.TotalSize, args.TotalBytesToReceive);
|
||||
trackedFile.IsUnknownSize = args.TotalBytesToReceive <= 0;
|
||||
trackedFile.DownloadedBytes = 0;
|
||||
trackedFile.Speed = 0;
|
||||
trackedFile.ActiveThreads = 0;
|
||||
};
|
||||
downloader.DownloadProgressChanged += (_, args) => UpdateDownloadStat(args);
|
||||
downloader.ChunkDownloadProgressChanged += (_, args) => UpdateDownloadStat(args);
|
||||
downloader.DownloadFileCompleted += (_, args) =>
|
||||
{
|
||||
if (trackedFile is not null)
|
||||
{
|
||||
trackedFile.Speed = 0;
|
||||
trackedFile.ActiveThreads = 0;
|
||||
trackedFile.DownloadedBytes = Math.Max(trackedFile.DownloadedBytes, trackedFile.TotalSize);
|
||||
}
|
||||
|
||||
if (args.Cancelled)
|
||||
tcs.TrySetCanceled();
|
||||
else if (args.Error != null)
|
||||
tcs.TrySetException(args.Error);
|
||||
else
|
||||
tcs.TrySetResult(true);
|
||||
};
|
||||
try
|
||||
{
|
||||
await downloader.DownloadFileTaskAsync(url, localPath, cancellationToken).ConfigureAwait(false);
|
||||
await tcs.Task.ConfigureAwait(false);
|
||||
var tempPath = localPath + ModNet.netDownloadEnd;
|
||||
if (!File.Exists(localPath) && File.Exists(tempPath))
|
||||
{
|
||||
for (var retry = 0; retry < 5; retry++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Move(tempPath, localPath, true);
|
||||
break;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!File.Exists(localPath))
|
||||
throw new IOException($"下载未产生任何文件:{localPath}");
|
||||
ModBase.Log($"[Download] 下载成功:{localPath}");
|
||||
}
|
||||
catch (TaskCanceledException ex) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
throw new TimeoutException($"下载超时({url})", ex);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new IOException($"下载失败:{url}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupTempFiles(string localPath)
|
||||
{
|
||||
var tempPath = localPath + ModNet.netDownloadEnd;
|
||||
TryDeleteFile(localPath);
|
||||
TryDeleteFile(tempPath);
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
for (var retry = 0; retry < 5; retry++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient GetHttpClient(string url)
|
||||
{
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var parsedUri)
|
||||
&& parsedUri.Host is "edge.forgecdn.net" or "mediafilez.forgecdn.net" or "forgecdn.net" or "api.curseforge.com")
|
||||
{
|
||||
return NetworkService.GetClient(NetworkService.CurseForgeApi);
|
||||
}
|
||||
|
||||
return NetworkService.GetClient();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace PCL.Network;
|
||||
|
||||
public static class ModNet
|
||||
{
|
||||
public const string netDownloadEnd = ".PCLDownloading";
|
||||
public static int NetTaskThreadLimit { get; set; } = 16;
|
||||
public static long NetTaskSpeedLimitLow { get; set; } = 256 * 1024L;
|
||||
public static long NetTaskSpeedLimitHigh { get; set; } = -1;
|
||||
public static long NetTaskSpeedLimitLeft { get; set; } = -1;
|
||||
public static int NetTaskThreadCount { get; set; }
|
||||
public static NetManager NetManager => NetManager.Instance;
|
||||
|
||||
public static object NetGetCodeByRequestRetry(string url, Encoding? encode = null, string accept = "",
|
||||
bool isJson = false, string? backupUrl = null, bool useBrowserUserAgent = false)
|
||||
{
|
||||
var param = new RequestParam
|
||||
{
|
||||
Encoding = encode,
|
||||
Accept = accept,
|
||||
FallbackUrl = backupUrl,
|
||||
UseBrowserUserAgent = useBrowserUserAgent,
|
||||
Timeout = 30000,
|
||||
Retries = 3
|
||||
};
|
||||
var result = Requester.FetchString(url, param);
|
||||
return isJson ? (object)ModBase.GetJson(result) : result;
|
||||
}
|
||||
|
||||
public static object NetGetCodeByRequestOnce(string url, Encoding? encode = null, int timeout = 30000,
|
||||
bool isJson = false, string accept = "", bool useBrowserUserAgent = false)
|
||||
{
|
||||
var param = new RequestParam
|
||||
{
|
||||
Encoding = encode,
|
||||
Accept = accept,
|
||||
UseBrowserUserAgent = useBrowserUserAgent,
|
||||
Timeout = timeout,
|
||||
Retries = 1
|
||||
};
|
||||
var result = Requester.FetchString(url, param);
|
||||
return isJson ? (object)ModBase.GetJson(result) : result;
|
||||
}
|
||||
|
||||
public static string NetGetCodeByLoader(string url, int timeout = 45000, bool isJson = false,
|
||||
bool useBrowserUserAgent = false)
|
||||
{
|
||||
return NetGetCodeByLoader(new[] { url }, timeout, isJson, useBrowserUserAgent);
|
||||
}
|
||||
|
||||
public static string NetGetCodeByLoader(IEnumerable<string> urls, int timeout = 45000, bool isJson = false,
|
||||
bool useBrowserUserAgent = false)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
|
||||
foreach (var url in urls)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = Requester.Fetch(url, new FetchParam
|
||||
{
|
||||
Method = "GET",
|
||||
Timeout = timeout,
|
||||
UseBrowserUserAgent = useBrowserUserAgent
|
||||
});
|
||||
|
||||
return isJson ? ModBase.GetJson(content).ToString() : content;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
ModBase.Log(ex, $"[Fetch] 获取文件内容失败,尝试下一个源:{url}", ModBase.LogLevel.Debug);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception("无法获取文件内容", lastException);
|
||||
}
|
||||
|
||||
public static string NetRequestRetry(string url, string method, string data = "", string? contentType = null,
|
||||
Encoding? encoding = null, string? accept = null, bool useBrowserUserAgent = false)
|
||||
{
|
||||
return Requester.Fetch(url, new FetchParam
|
||||
{
|
||||
Method = method,
|
||||
Content = data,
|
||||
ContentType = contentType,
|
||||
Encoding = encoding,
|
||||
Accept = accept,
|
||||
UseBrowserUserAgent = useBrowserUserAgent,
|
||||
Timeout = 30000
|
||||
});
|
||||
}
|
||||
|
||||
public static string NetRequestOnce(string url, string method, string data = "", string? contentType = null,
|
||||
Encoding? encoding = null, string? accept = null, bool useBrowserUserAgent = false)
|
||||
{
|
||||
return NetRequestRetry(url, method, data, contentType, encoding, accept, useBrowserUserAgent);
|
||||
}
|
||||
|
||||
public static Task NetDownloadByClient(string url, string localFile, bool useBrowserUserAgent = false)
|
||||
{
|
||||
return FileDownloader.DownloadAsync(url, localFile, useBrowserUserAgent);
|
||||
}
|
||||
|
||||
public static void NetDownloadByLoader(string url, string localFile, ModLoader.LoaderBase? loaderToSyncProgress = null,
|
||||
ModBase.FileChecker? check = null, bool useBrowserUserAgent = false)
|
||||
{
|
||||
FileDownloader.DownloadAsync(url, localFile, useBrowserUserAgent).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public static void NetDownloadByLoader(IEnumerable<string> urls, string localFile,
|
||||
ModLoader.LoaderBase? loaderToSyncProgress = null, ModBase.FileChecker? check = null,
|
||||
bool useBrowserUserAgent = false)
|
||||
{
|
||||
FileDownloader.DownloadAsync(urls, localFile, useBrowserUserAgent).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public static bool HasDownloadingTask(bool ignoreCustomDownload = false)
|
||||
{
|
||||
foreach (var task in ModLoader.loaderTaskbar.ToList())
|
||||
{
|
||||
if (task.show && task.State == ModBase.LoadState.Loading &&
|
||||
(!ignoreCustomDownload || !task.name.Contains("自定义下载")))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using PCL.Core.App;
|
||||
|
||||
namespace PCL.Network;
|
||||
|
||||
public static class RequestSigning
|
||||
{
|
||||
internal static string SecretCdnSign(string urlWithMark)
|
||||
{
|
||||
if (!urlWithMark.EndsWithF("{CDN}"))
|
||||
return urlWithMark;
|
||||
return urlWithMark.Replace("{CDN}", "").Replace(" ", "%20");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置 Headers 的 UA、Referer。
|
||||
/// </summary>
|
||||
internal static void SecretHeadersSign(string url, ref HttpRequestMessage client, bool useBrowserUserAgent = false,
|
||||
string customUserAgent = "")
|
||||
{
|
||||
client.Version = HttpVersion.Version20;
|
||||
client.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var parsedUri)
|
||||
&& (parsedUri.Host == "api.curseforge.com"
|
||||
|| parsedUri.Host == "edge.forgecdn.net"
|
||||
|| parsedUri.Host == "mediafilez.forgecdn.net"))
|
||||
{
|
||||
client.Headers.Add("x-api-key", Secrets.CurseForgeAPIKey);
|
||||
}
|
||||
var userAgent = !string.IsNullOrEmpty(customUserAgent)
|
||||
? customUserAgent
|
||||
: useBrowserUserAgent
|
||||
? $"PCL2/{ModBase.upstreamVersion}.{ModBase.versionBranchCode} PCLCE/{ModBase.versionStandardCode} Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0"
|
||||
: $"PCL2/{ModBase.upstreamVersion}.{ModBase.versionBranchCode} PCLCE/{ModBase.versionStandardCode}";
|
||||
client.Headers.Add("User-Agent", userAgent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Text;
|
||||
using Downloader;
|
||||
using System.Text.Json.Nodes;
|
||||
using PCL.Core.IO.Net;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
|
||||
namespace PCL.Network;
|
||||
|
||||
public static class Requester
|
||||
{
|
||||
public static void EnsureSuccess(HttpResponseMessage? response)
|
||||
{
|
||||
if (!response?.IsSuccessStatusCode ?? true)
|
||||
throw new HttpResponseException(response);
|
||||
}
|
||||
|
||||
public static async Task<string> FetchStringAsync(string url, RequestParam param = default)
|
||||
{
|
||||
return await FetchAsync(url, new FetchParam
|
||||
{
|
||||
Method = "GET",
|
||||
Accept = param.Accept,
|
||||
FallbackUrl = param.FallbackUrl,
|
||||
UseBrowserUserAgent = param.UseBrowserUserAgent,
|
||||
Timeout = param.Timeout,
|
||||
Encoding = param.Encoding,
|
||||
MakeLog = true
|
||||
}, Math.Max(1, param.Retries == 0 ? 1 : param.Retries)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static string FetchString(string url, RequestParam param = default)
|
||||
{
|
||||
return FetchStringAsync(url, param).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public static async Task<JsonNode> FetchJsonAsync(string url, RequestParam param = default)
|
||||
{
|
||||
return ModBase.GetJson(await FetchStringAsync(url, param).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
public static async Task<T> FetchJsonAsync<T>(string url, RequestParam param = default) where T : JsonNode
|
||||
{
|
||||
return (T)await FetchJsonAsync(url, param).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static JsonNode FetchJson(string url, RequestParam param = default)
|
||||
{
|
||||
return FetchJsonAsync(url, param).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public static T FetchJson<T>(string url, RequestParam param = default) where T : JsonNode
|
||||
{
|
||||
return FetchJsonAsync<T>(url, param).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public static async Task<string> FetchAsync(string url, FetchParam param)
|
||||
{
|
||||
return await FetchAsync(url, param, 3).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static string Fetch(string url, FetchParam param)
|
||||
{
|
||||
return FetchAsync(url, param).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public static string Fetch(string url)
|
||||
{
|
||||
return Fetch(url, new FetchParam { Method = "GET", Timeout = 30000, MakeLog = true });
|
||||
}
|
||||
|
||||
private static async Task<string> FetchAsync(string url, FetchParam param, int retries)
|
||||
{
|
||||
var urls = new[] { url, param.FallbackUrl }.Where(u => !string.IsNullOrWhiteSpace(u)).Cast<string>().ToList();
|
||||
Exception? lastException = null;
|
||||
foreach (var currentUrl in urls)
|
||||
{
|
||||
for (var attempt = 0; attempt < Math.Max(1, retries); attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await FetchOnceAsync(currentUrl, param).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastException ?? new HttpRequestException("请求失败");
|
||||
}
|
||||
|
||||
private static async Task<string> FetchOnceAsync(string url, FetchParam param)
|
||||
{
|
||||
HttpResponseMessage? response = null;
|
||||
var request = new HttpRequestMessage(ParseMethod(param.Method), RequestSigning.SecretCdnSign(url));
|
||||
RequestSigning.SecretHeadersSign(url, ref request, param.UseBrowserUserAgent);
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(param.Accept))
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(param.Accept));
|
||||
if (param.Headers is not null)
|
||||
foreach (var header in param.Headers)
|
||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
if (SupportBody(request.Method) && param.Content is not null)
|
||||
{
|
||||
if (param.Content is HttpContent httpContent)
|
||||
{
|
||||
request.Content = httpContent;
|
||||
}
|
||||
else
|
||||
{
|
||||
var content = param.Content is string text ? text : param.Content.ToString() ?? "";
|
||||
request.Content = new StringContent(content, param.Encoding ?? Encoding.UTF8,
|
||||
param.ContentType ?? "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.CancelAfter(param.Timeout <= 0 ? 30000 : param.Timeout);
|
||||
response = await NetworkService.GetClient().SendAsync(request, cts.Token).ConfigureAwait(false);
|
||||
EnsureSuccess(response);
|
||||
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(!param.RequireContent) response?.Dispose();
|
||||
request.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task DownloadFileAsync(string url, string filePath)
|
||||
{
|
||||
await FileDownloader.DownloadAsync(url, filePath).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task DownloadFileOnceAsync(string url, string filePath)
|
||||
{
|
||||
await FileDownloader.DownloadAsync(url, filePath).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static DownloadService CreateDownloadService(string url, bool useBrowserUserAgent = false)
|
||||
{
|
||||
var chunkCount = Math.Min(Math.Max(1, ModNet.NetTaskThreadLimit), 4);
|
||||
return new DownloadService(new DownloadConfiguration
|
||||
{
|
||||
ChunkCount = chunkCount,
|
||||
ParallelCount = chunkCount,
|
||||
ParallelDownload = chunkCount > 1,
|
||||
MaximumBytesPerSecond = ModNet.NetTaskSpeedLimitHigh > 0 ? ModNet.NetTaskSpeedLimitHigh : 0,
|
||||
DownloadFileExtension = ModNet.netDownloadEnd,
|
||||
EnableAutoResumeDownload = false,
|
||||
MaximumMemoryBufferBytes = 256L * 1024 * 1024,
|
||||
RequestConfiguration = DownloadRequestFactory.Create(url, useBrowserUserAgent)
|
||||
});
|
||||
}
|
||||
|
||||
public static HttpMethod ParseMethod(string? method)
|
||||
{
|
||||
return (method ?? "GET").ToUpperInvariant() switch
|
||||
{
|
||||
"POST" => HttpMethod.Post,
|
||||
"PUT" => HttpMethod.Put,
|
||||
"DELETE" => HttpMethod.Delete,
|
||||
"PATCH" => HttpMethod.Patch,
|
||||
"HEAD" => HttpMethod.Head,
|
||||
_ => HttpMethod.Get
|
||||
};
|
||||
}
|
||||
|
||||
public static bool SupportBody(HttpMethod method)
|
||||
{
|
||||
return method == HttpMethod.Post || method == HttpMethod.Put || method == HttpMethod.Patch ||
|
||||
method == HttpMethod.Delete;
|
||||
}
|
||||
|
||||
public static int Ping(string ip)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = new Ping().Send(ip);
|
||||
return result.Status == IPStatus.Success ? (int)result.RoundtripTime : -1;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Network.Loaders;
|
||||
|
||||
public class LoaderDownload : ModLoader.LoaderBase
|
||||
{
|
||||
public ModBase.SafeList<PCL.Network.DownloadFile> files;
|
||||
private int _fileRemain;
|
||||
private readonly object _fileRemainLock = new();
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
public int FailCount { get; set; }
|
||||
|
||||
public override double Progress
|
||||
{
|
||||
get => State >= ModBase.LoadState.Finished ? 1 : (files.Any() ? files.Average(file => file.Progress) : 0);
|
||||
set => throw new Exception("文件下载不允许指定进度");
|
||||
}
|
||||
|
||||
public LoaderDownload(string name, List<PCL.Network.DownloadFile> fileTasks)
|
||||
{
|
||||
base.name = name;
|
||||
files = new ModBase.SafeList<PCL.Network.DownloadFile>(fileTasks ?? new List<PCL.Network.DownloadFile>());
|
||||
}
|
||||
|
||||
public void RefreshStat() { }
|
||||
|
||||
public override void Start(object input = null, bool isForceRestart = false)
|
||||
{
|
||||
if (input is List<PCL.Network.DownloadFile> inputFiles)
|
||||
files = new ModBase.SafeList<PCL.Network.DownloadFile>(inputFiles);
|
||||
|
||||
lock (lockState)
|
||||
{
|
||||
if (State == ModBase.LoadState.Loading)
|
||||
return;
|
||||
State = ModBase.LoadState.Loading;
|
||||
}
|
||||
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
lock (_fileRemainLock)
|
||||
{
|
||||
_fileRemain = files.Count;
|
||||
}
|
||||
|
||||
ModNet.NetManager.Start(this);
|
||||
|
||||
ModBase.RunInNewThread(() => Run(_cancellationTokenSource.Token), $"DL/{Uuid}");
|
||||
}
|
||||
|
||||
private void Run(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!files.Any())
|
||||
{
|
||||
OnFinish();
|
||||
return;
|
||||
}
|
||||
|
||||
var exceptions = new ConcurrentQueue<Exception>();
|
||||
using var semaphore = new SemaphoreSlim(GetMaxParallelFiles());
|
||||
var tasks = files.Select(async file =>
|
||||
{
|
||||
var entered = false;
|
||||
try
|
||||
{
|
||||
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
entered = true;
|
||||
await ProcessFileAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
file.AddError(ex);
|
||||
file.State = PCL.Network.NetState.Interrupted;
|
||||
exceptions.Enqueue(ex);
|
||||
_cancellationTokenSource?.Cancel();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (entered)
|
||||
semaphore.Release();
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
Task.WhenAll(tasks).GetAwaiter().GetResult();
|
||||
if (!exceptions.IsEmpty)
|
||||
OnFail(exceptions.ToList());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Abort();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnFail(new List<Exception> { ex });
|
||||
}
|
||||
}
|
||||
|
||||
private int GetMaxParallelFiles()
|
||||
{
|
||||
return Math.Max(1, Math.Min(files.Count, Math.Clamp(ModNet.NetTaskThreadLimit, 1, 64)));
|
||||
}
|
||||
|
||||
private async Task ProcessFileAsync(PCL.Network.DownloadFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
file.RegisterLoader(this);
|
||||
|
||||
if (State >= ModBase.LoadState.Finished)
|
||||
return;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(file.LocalPath) ?? throw new IOException("下载路径无效"));
|
||||
if (file.Check?.canUseExistsFile == true && file.Check.Check(file.LocalPath) is null)
|
||||
{
|
||||
file.IsCopy = true;
|
||||
file.State = PCL.Network.NetState.Finished;
|
||||
try { file.TotalSize = new FileInfo(file.LocalPath).Length; }
|
||||
catch (IOException) { file.TotalSize = -1; }
|
||||
file.DownloadedBytes = file.TotalSize;
|
||||
file.Speed = 0;
|
||||
file.ActiveThreads = 0;
|
||||
OnFileFinish(file);
|
||||
return;
|
||||
}
|
||||
|
||||
file.State = PCL.Network.NetState.Connecting;
|
||||
var enableParallelChunks = files.Count <= 1;
|
||||
for (var retry = 0; retry < 4; retry++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
await FileDownloader.DownloadAsync(file.Urls, file.LocalPath, file.UseBrowserUserAgent, file.CustomUserAgent,
|
||||
cancellationToken, enableParallelChunks, file).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (retry < 3)
|
||||
{
|
||||
ModBase.Log(ex, $"[Download] 重试 {retry + 1}/3:{file.LocalPath}", ModBase.LogLevel.Debug);
|
||||
Thread.Sleep(RandomUtils.NextInt(300, 500 + retry * 300));
|
||||
}
|
||||
}
|
||||
try { file.TotalSize = new FileInfo(file.LocalPath).Length; }
|
||||
catch (IOException) { file.TotalSize = -1; }
|
||||
file.IsUnknownSize = file.TotalSize < 0;
|
||||
file.DownloadedBytes = Math.Max(0, file.TotalSize);
|
||||
file.Speed = 0;
|
||||
file.ActiveThreads = 0;
|
||||
file.State = PCL.Network.NetState.Finished;
|
||||
OnFileFinish(file);
|
||||
}
|
||||
|
||||
public void OnFileFinish(PCL.Network.DownloadFile file)
|
||||
{
|
||||
lock (_fileRemainLock)
|
||||
{
|
||||
_fileRemain -= 1;
|
||||
if (_fileRemain > 0)
|
||||
return;
|
||||
}
|
||||
|
||||
OnFinish();
|
||||
}
|
||||
|
||||
public void OnFinish()
|
||||
{
|
||||
RaisePreviewFinish();
|
||||
lock (lockState)
|
||||
{
|
||||
if (State > ModBase.LoadState.Loading)
|
||||
return;
|
||||
State = ModBase.LoadState.Finished;
|
||||
}
|
||||
|
||||
ModNet.NetManager.Finish(this);
|
||||
}
|
||||
|
||||
public void OnFileFail(PCL.Network.DownloadFile file)
|
||||
{
|
||||
var errors = file.Errors;
|
||||
OnFail(errors.Count > 0
|
||||
? errors.ToList()
|
||||
: new List<Exception> { new Exception($"文件下载失败:{file.LocalPath}") });
|
||||
}
|
||||
|
||||
public void OnFail(List<Exception> exList)
|
||||
{
|
||||
lock (lockState)
|
||||
{
|
||||
if (State > ModBase.LoadState.Loading)
|
||||
return;
|
||||
Error = exList.FirstOrDefault() ?? new Exception("未知下载错误");
|
||||
State = ModBase.LoadState.Failed;
|
||||
}
|
||||
|
||||
FailCount += exList.Count;
|
||||
foreach (var file in files.Where(file => file.State < PCL.Network.NetState.Finished))
|
||||
{
|
||||
file.State = PCL.Network.NetState.Interrupted;
|
||||
file.Speed = 0;
|
||||
file.ActiveThreads = 0;
|
||||
file.AddErrors(exList);
|
||||
}
|
||||
|
||||
ModNet.NetManager.Finish(this);
|
||||
}
|
||||
|
||||
public override void Abort()
|
||||
{
|
||||
lock (lockState)
|
||||
{
|
||||
if (State >= ModBase.LoadState.Finished)
|
||||
return;
|
||||
State = ModBase.LoadState.Aborted;
|
||||
}
|
||||
|
||||
_cancellationTokenSource?.Cancel();
|
||||
foreach (var file in files.Where(file => file.State < PCL.Network.NetState.Finished))
|
||||
{
|
||||
file.State = PCL.Network.NetState.Interrupted;
|
||||
file.Speed = 0;
|
||||
file.ActiveThreads = 0;
|
||||
}
|
||||
|
||||
ModNet.NetManager.Finish(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace PCL.Network.Loaders;
|
||||
|
||||
public class LoaderDownloadUnc : ModLoader.LoaderBase
|
||||
{
|
||||
public string unc;
|
||||
public string savePath;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
|
||||
public LoaderDownloadUnc(string name, Tuple<string, string> file)
|
||||
{
|
||||
base.name = name;
|
||||
unc = file.Item1;
|
||||
savePath = file.Item2;
|
||||
}
|
||||
|
||||
public override void Start(object input = null, bool isForceRestart = false)
|
||||
{
|
||||
if (input is Tuple<string, string> tuple)
|
||||
{
|
||||
unc = tuple.Item1;
|
||||
savePath = tuple.Item2;
|
||||
}
|
||||
|
||||
lock (lockState)
|
||||
{
|
||||
if (State == ModBase.LoadState.Loading)
|
||||
return;
|
||||
State = ModBase.LoadState.Loading;
|
||||
}
|
||||
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
ModBase.RunInNewThread(() => Run(_cancellationTokenSource.Token), $"UNC/{Uuid}");
|
||||
}
|
||||
|
||||
private void Run(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new IOException("下载路径无效"));
|
||||
ModBase.CopyFile(unc, savePath);
|
||||
State = ModBase.LoadState.Finished;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Abort();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Error = ex;
|
||||
State = ModBase.LoadState.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Abort()
|
||||
{
|
||||
if (State >= ModBase.LoadState.Finished)
|
||||
return;
|
||||
State = ModBase.LoadState.Aborted;
|
||||
_cancellationTokenSource?.Cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace PCL.Network;
|
||||
|
||||
public sealed class NetManager
|
||||
{
|
||||
private static readonly Lazy<NetManager> _instance = new(() => new NetManager());
|
||||
public static NetManager Instance => _instance.Value;
|
||||
|
||||
public Dictionary<string, DownloadFile> Files { get; } = new();
|
||||
public object LockFiles { get; } = new();
|
||||
public ModBase.SafeList<PCL.Network.Loaders.LoaderDownload> Tasks { get; } = new();
|
||||
public object LockRemain { get; } = new();
|
||||
public int FileRemain
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (LockFiles)
|
||||
return Files.Values.Count(file => file.State != NetState.Finished);
|
||||
}
|
||||
}
|
||||
public object LockDone { get; } = new();
|
||||
public long DownloadDone
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (LockDone)
|
||||
return field;
|
||||
}
|
||||
set
|
||||
{
|
||||
lock (LockDone)
|
||||
field = value;
|
||||
}
|
||||
}
|
||||
|
||||
public long Speed
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (LockFiles)
|
||||
return Files.Values.Sum(file => file.Speed);
|
||||
}
|
||||
}
|
||||
|
||||
public int ThreadCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (LockFiles)
|
||||
return Files.Values.Sum(file => file.ActiveThreads);
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(PCL.Network.Loaders.LoaderDownload task)
|
||||
{
|
||||
lock (LockFiles)
|
||||
{
|
||||
Tasks.Remove(task);
|
||||
Tasks.Add(task);
|
||||
foreach (var file in task.files)
|
||||
Files[file.LocalPath] = file;
|
||||
}
|
||||
}
|
||||
|
||||
public void Finish(PCL.Network.Loaders.LoaderDownload task)
|
||||
{
|
||||
lock (LockFiles)
|
||||
{
|
||||
Tasks.Remove(task);
|
||||
foreach (var file in task.files)
|
||||
Files.Remove(file.LocalPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using PCL.Core.Utils;
|
||||
using PCL.Network.Loaders;
|
||||
|
||||
namespace PCL.Network;
|
||||
|
||||
public class DownloadFile
|
||||
{
|
||||
public int Id { get; } = ModBase.GetUuid();
|
||||
public string LocalPath { get; set; }
|
||||
public string LocalName { get; }
|
||||
public List<string> Urls { get; }
|
||||
public ModBase.FileChecker? Check { get; }
|
||||
public bool UseBrowserUserAgent { get; }
|
||||
public string CustomUserAgent { get; }
|
||||
public NetState State { get; set; } = NetState.WaitingToCheck;
|
||||
public long TotalSize { get; set; } = -1;
|
||||
public bool IsUnknownSize { get; set; } = true;
|
||||
public long DownloadedBytes { get; set; }
|
||||
public bool IsCopy { get; set; }
|
||||
// Errors / Loaders 可能被多个并发下载加载器共享访问(同一 DownloadFile 可登记到多个加载器),
|
||||
// 故所有访问都经 _sync 加锁,避免普通 List 被并发读写而抛 InvalidOperationException。
|
||||
private readonly object _sync = new();
|
||||
private readonly List<Exception> _errors = new();
|
||||
private readonly List<LoaderDownload> _loaders = new();
|
||||
|
||||
/// <summary>该文件下载过程中记录的错误(返回快照,线程安全)。</summary>
|
||||
public IReadOnlyList<Exception> Errors
|
||||
{
|
||||
get { lock (_sync) return _errors.ToArray(); }
|
||||
}
|
||||
|
||||
/// <summary>已登记的下载加载器(返回快照,线程安全)。</summary>
|
||||
public IReadOnlyList<LoaderDownload> Loaders
|
||||
{
|
||||
get { lock (_sync) return _loaders.ToArray(); }
|
||||
}
|
||||
|
||||
public void AddError(Exception error)
|
||||
{
|
||||
lock (_sync) _errors.Add(error);
|
||||
}
|
||||
|
||||
public void AddErrors(IEnumerable<Exception> errors)
|
||||
{
|
||||
lock (_sync) _errors.AddRange(errors);
|
||||
}
|
||||
|
||||
/// <summary>登记一个下载加载器;同一加载器只登记一次。返回是否为首次登记。</summary>
|
||||
public bool RegisterLoader(LoaderDownload loader)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_loaders.Contains(loader)) return false;
|
||||
_loaders.Add(loader);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
public long Speed { get; set; }
|
||||
public int ActiveThreads { get; set; }
|
||||
public double Progress
|
||||
{
|
||||
get
|
||||
{
|
||||
return State switch
|
||||
{
|
||||
NetState.WaitingToCheck => 0,
|
||||
NetState.WaitingToDownload => 0.01,
|
||||
NetState.Connecting => 0.02,
|
||||
NetState.Reading => 0.04,
|
||||
NetState.Downloading when TotalSize > 0 => Math.Clamp((double)DownloadedBytes / TotalSize, 0.05, 1),
|
||||
NetState.Downloading => 0.5,
|
||||
NetState.Merging => 0.99,
|
||||
NetState.Finished or NetState.Interrupted => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadFile(IEnumerable<string> urls, string localPath, ModBase.FileChecker? checker = null,
|
||||
bool useBrowserUserAgent = false, string customUserAgent = "")
|
||||
{
|
||||
Urls = urls.Where(url => !string.IsNullOrWhiteSpace(url)).Distinct().ToList();
|
||||
LocalPath = localPath;
|
||||
LocalName = ModBase.GetFileNameFromPath(localPath);
|
||||
Check = checker;
|
||||
UseBrowserUserAgent = useBrowserUserAgent;
|
||||
CustomUserAgent = customUserAgent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
|
||||
namespace PCL.Network;
|
||||
|
||||
public struct FetchParam
|
||||
{
|
||||
public string Method { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 请求体内容。支持 <see cref="string"/>(自动包装为 StringContent)和 <see cref="HttpContent"/>(直接使用)。
|
||||
/// </summary>
|
||||
public object? Content { get; set; }
|
||||
public string? ContentType { get; set; }
|
||||
public Dictionary<string, string>? Headers { get; set; }
|
||||
public Encoding? Encoding { get; set; }
|
||||
public string? Accept { get; set; }
|
||||
public string? FallbackUrl { get; set; }
|
||||
public bool UseBrowserUserAgent { get; set; }
|
||||
public bool MakeLog { get; set; }
|
||||
public bool DontRetryOnRefused { get; set; }
|
||||
public int Timeout { get; set; }
|
||||
public bool RequireContent { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace PCL.Network;
|
||||
|
||||
public enum NetState
|
||||
{
|
||||
WaitingToCheck = -1,
|
||||
WaitingToDownload = 0,
|
||||
Connecting = 1,
|
||||
Reading = 2,
|
||||
Downloading = 3,
|
||||
Merging = 4,
|
||||
Finished = 5,
|
||||
Interrupted = 6
|
||||
}
|
||||
|
||||
public enum NetPreDownloadBehaviour
|
||||
{
|
||||
HintWhileExists,
|
||||
ExitWhileExistsOrDownloading,
|
||||
IgnoreCheck
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Text;
|
||||
|
||||
namespace PCL.Network;
|
||||
|
||||
public struct RequestParam
|
||||
{
|
||||
public Encoding? Encoding { get; set; }
|
||||
public string? Accept { get; set; }
|
||||
public string? FallbackUrl { get; set; }
|
||||
public bool UseBrowserUserAgent { get; set; }
|
||||
public int Timeout { get; set; }
|
||||
public int Retries { get; set; }
|
||||
|
||||
public static RequestParam WithRetry => new()
|
||||
{
|
||||
Timeout = 30000,
|
||||
Retries = 3
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user