DownloadClient.cs 4.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. using System.Net.Http.Json;
  2. using System.Security.Cryptography;
  3. using System.Text.Json.Nodes;
  4. namespace PCL.Communist.Core;
  5. public sealed record DownloadFile(string Url, string Path, string? Sha1 = null, long? Size = null);
  6. public sealed record InstallProgress(string Message, int Completed, int Total)
  7. {
  8. public double Percent => Total == 0 ? 0 : Completed * 100d / Total;
  9. }
  10. public sealed class DownloadClient(HttpClient http, bool preferMirror = true)
  11. {
  12. public async Task<JsonObject> GetJsonAsync(string url, CancellationToken cancellationToken = default)
  13. {
  14. Exception? failure = null;
  15. foreach (var source in Sources(url))
  16. {
  17. try
  18. {
  19. using var response = await http.GetAsync(source, cancellationToken);
  20. response.EnsureSuccessStatusCode();
  21. using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
  22. return await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken) as JsonObject
  23. ?? throw new InvalidDataException("下载的元数据不是 JSON 对象。");
  24. }
  25. catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidDataException or System.Text.Json.JsonException)
  26. { failure = ex; }
  27. catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
  28. { failure = ex; }
  29. }
  30. throw new IOException("下载元数据失败,请检查网络后重试。", failure);
  31. }
  32. public async Task FetchAsync(DownloadFile file, CancellationToken cancellationToken = default)
  33. {
  34. if (await IsValidAsync(file, cancellationToken)) return;
  35. Directory.CreateDirectory(System.IO.Path.GetDirectoryName(file.Path)!);
  36. Exception? failure = null;
  37. foreach (var source in Sources(file.Url))
  38. {
  39. var temporary = file.Path + "." + Guid.NewGuid().ToString("N") + ".part";
  40. try
  41. {
  42. using var response = await http.GetAsync(source, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
  43. response.EnsureSuccessStatusCode();
  44. await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, true))
  45. await response.Content.CopyToAsync(output, cancellationToken);
  46. if (!await IsValidAsync(file with { Path = temporary }, cancellationToken))
  47. throw new InvalidDataException("下载文件的长度或 SHA1 不匹配。");
  48. File.Move(temporary, file.Path, true);
  49. return;
  50. }
  51. catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidDataException)
  52. { failure = ex; }
  53. catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
  54. { failure = ex; }
  55. finally { if (File.Exists(temporary)) File.Delete(temporary); }
  56. }
  57. throw new IOException($"下载失败:{System.IO.Path.GetFileName(file.Path)}", failure);
  58. }
  59. public static async Task<bool> IsValidAsync(DownloadFile file, CancellationToken cancellationToken = default)
  60. {
  61. if (!File.Exists(file.Path)) return false;
  62. if (file.Size is long size && new FileInfo(file.Path).Length != size) return false;
  63. if (string.IsNullOrEmpty(file.Sha1)) return true;
  64. await using var stream = File.OpenRead(file.Path);
  65. var actual = Convert.ToHexString(await SHA1.HashDataAsync(stream, cancellationToken));
  66. return actual.Equals(file.Sha1, StringComparison.OrdinalIgnoreCase);
  67. }
  68. public IEnumerable<Uri> Sources(string url)
  69. {
  70. var original = new Uri(url);
  71. if (original.Scheme != Uri.UriSchemeHttps) throw new InvalidDataException("只允许 HTTPS 下载源。");
  72. if (preferMirror)
  73. {
  74. var mirrorPath = original.Host switch
  75. {
  76. "piston-meta.mojang.com" or "launchermeta.mojang.com" or "piston-data.mojang.com" => original.PathAndQuery,
  77. "libraries.minecraft.net" => "/maven" + original.PathAndQuery,
  78. "resources.download.minecraft.net" => "/assets" + original.PathAndQuery,
  79. _ => null
  80. };
  81. if (mirrorPath is not null) yield return new Uri("https://bmclapi2.bangbang93.com" + mirrorPath);
  82. }
  83. yield return original;
  84. }
  85. }