MinecraftInstaller.cs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. using System.IO.Compression;
  2. using System.Text.Json.Nodes;
  3. namespace PCL.Communist.Core;
  4. public sealed record MinecraftVersion(string Id, string Type, string Url, string Sha1)
  5. {
  6. public override string ToString() => Id;
  7. }
  8. public sealed class MinecraftInstaller(DownloadClient downloads, LauncherPaths paths)
  9. {
  10. public const string ManifestUrl = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
  11. public async Task<IReadOnlyList<MinecraftVersion>> GetVersionsAsync(CancellationToken cancellationToken = default)
  12. {
  13. var manifest = await downloads.GetJsonAsync(ManifestUrl, cancellationToken);
  14. return manifest["versions"]!.AsArray().Select(node => new MinecraftVersion(
  15. node!["id"]!.GetValue<string>(), node["type"]!.GetValue<string>(),
  16. node["url"]!.GetValue<string>(), node["sha1"]!.GetValue<string>())).ToArray();
  17. }
  18. public async Task<JsonObject> InstallAsync(MinecraftVersion version, PlatformInfo platform,
  19. IProgress<InstallProgress>? progress = null, CancellationToken cancellationToken = default)
  20. {
  21. progress?.Report(new("读取版本信息…", 0, 1));
  22. await downloads.FetchAsync(new(version.Url, paths.VersionJson(version.Id), version.Sha1), cancellationToken);
  23. var metadata = JsonNode.Parse(await File.ReadAllTextAsync(paths.VersionJson(version.Id), cancellationToken))!.AsObject();
  24. if (metadata["id"]?.GetValue<string>() != version.Id) throw new InvalidDataException("版本元数据的 ID 不一致。");
  25. return await InstallResourcesAsync(metadata, platform, progress, cancellationToken);
  26. }
  27. public async Task<JsonObject> RepairAsync(string versionId, PlatformInfo platform,
  28. IProgress<InstallProgress>? progress = null, CancellationToken cancellationToken = default)
  29. {
  30. var metadata = JsonNode.Parse(await File.ReadAllTextAsync(paths.VersionJson(versionId), cancellationToken))!.AsObject();
  31. if (metadata["id"]?.GetValue<string>() != versionId) throw new InvalidDataException("本地版本元数据的 ID 不一致。");
  32. return await InstallResourcesAsync(metadata, platform, progress, cancellationToken);
  33. }
  34. private async Task<JsonObject> InstallResourcesAsync(JsonObject metadata, PlatformInfo platform,
  35. IProgress<InstallProgress>? progress, CancellationToken cancellationToken)
  36. {
  37. var id = metadata["id"]!.GetValue<string>();
  38. if (metadata["inheritsFrom"] is not null) throw new NotSupportedException("此移植版目前安装原版游戏;加载器继承版本尚待移植。");
  39. MinecraftRules.ValidateNativeArchitecture(metadata, platform);
  40. var files = new List<DownloadFile>();
  41. var nativeArchives = new List<(string File, string[] Exclude)>();
  42. files.Add(Artifact(metadata["downloads"]!["client"]!, Path.Combine(paths.VersionDirectory(id), id + ".jar")));
  43. // A mobile installation downloads portable resources only. Desktop natives are never substituted for a mobile runtime.
  44. if (!platform.IsMobile)
  45. {
  46. foreach (var library in metadata["libraries"]!.AsArray())
  47. {
  48. if (library is null || !MinecraftRules.Allows(library["rules"], platform)) continue;
  49. if (library["downloads"]?["artifact"] is JsonNode artifact)
  50. files.Add(Artifact(artifact, LauncherPaths.SafeChild(paths.Libraries, artifact["path"]!.GetValue<string>())));
  51. var classifier = MinecraftRules.NativeClassifier(library, platform);
  52. if (classifier is null) continue;
  53. var native = library["downloads"]?["classifiers"]?[classifier]
  54. ?? throw new PlatformNotSupportedException($"该版本未提供 {platform.DisplayName} 原生库:{library["name"]}");
  55. var path = LauncherPaths.SafeChild(paths.Libraries, native["path"]!.GetValue<string>());
  56. files.Add(Artifact(native, path));
  57. nativeArchives.Add((path, library["extract"]?["exclude"]?.AsArray().Select(e => e!.GetValue<string>()).ToArray() ?? ["META-INF/"]));
  58. }
  59. }
  60. if (metadata["assetIndex"] is JsonNode indexInfo)
  61. {
  62. var indexPath = LauncherPaths.SafeChild(Path.Combine(paths.Assets, "indexes"), indexInfo["id"]!.GetValue<string>() + ".json");
  63. await downloads.FetchAsync(Artifact(indexInfo, indexPath), cancellationToken);
  64. var index = JsonNode.Parse(await File.ReadAllTextAsync(indexPath, cancellationToken))!;
  65. foreach (var asset in index["objects"]!.AsObject())
  66. {
  67. var hash = asset.Value!["hash"]!.GetValue<string>();
  68. if (hash.Length != 40 || hash.Any(c => !Uri.IsHexDigit(c))) throw new InvalidDataException("资源哈希格式不正确。");
  69. files.Add(new($"https://resources.download.minecraft.net/{hash[..2]}/{hash}",
  70. LauncherPaths.SafeChild(Path.Combine(paths.Assets, "objects"), $"{hash[..2]}/{hash}"), hash, asset.Value["size"]!.GetValue<long>()));
  71. }
  72. }
  73. if (!platform.IsMobile && metadata["logging"]?["client"]?["file"] is JsonNode logging)
  74. files.Add(Artifact(logging, LauncherPaths.SafeChild(Path.Combine(paths.Assets, "log_configs"), logging["id"]!.GetValue<string>())));
  75. var uniqueFiles = files.DistinctBy(f => f.Path).ToArray();
  76. var completed = 0;
  77. await Parallel.ForEachAsync(uniqueFiles, new ParallelOptions { MaxDegreeOfParallelism = 6, CancellationToken = cancellationToken },
  78. async (file, token) =>
  79. {
  80. await downloads.FetchAsync(file, token);
  81. progress?.Report(new($"下载资源 · {Path.GetFileName(file.Path)}", Interlocked.Increment(ref completed), uniqueFiles.Length));
  82. });
  83. var nativeDirectory = Path.Combine(paths.VersionDirectory(id), "natives", platform.Kind + "-" + platform.Architecture);
  84. foreach (var archive in nativeArchives) ExtractNatives(archive.File, nativeDirectory, archive.Exclude);
  85. await CopyLegacyAssetsAsync(metadata, cancellationToken);
  86. progress?.Report(new(platform.IsMobile ? "游戏资源已下载。移动端原生运行时尚未接入。" : "游戏安装完成。", uniqueFiles.Length, uniqueFiles.Length));
  87. return metadata;
  88. }
  89. private async Task CopyLegacyAssetsAsync(JsonObject metadata, CancellationToken cancellationToken)
  90. {
  91. var id = metadata["assetIndex"]?["id"]?.GetValue<string>();
  92. if (id is null) return;
  93. var index = JsonNode.Parse(await File.ReadAllTextAsync(LauncherPaths.SafeChild(Path.Combine(paths.Assets, "indexes"), id + ".json"), cancellationToken))!;
  94. var destinations = new List<string>();
  95. if (index["virtual"]?.GetValue<bool>() == true) destinations.Add(LauncherPaths.SafeChild(Path.Combine(paths.Assets, "virtual"), id));
  96. if (index["map_to_resources"]?.GetValue<bool>() == true) destinations.Add(Path.Combine(paths.Root, "resources"));
  97. foreach (var asset in index["objects"]!.AsObject())
  98. {
  99. if (destinations.Count == 0) break;
  100. var hash = asset.Value!["hash"]!.GetValue<string>();
  101. foreach (var destination in destinations)
  102. {
  103. cancellationToken.ThrowIfCancellationRequested();
  104. var target = LauncherPaths.SafeChild(destination, asset.Key);
  105. Directory.CreateDirectory(Path.GetDirectoryName(target)!);
  106. File.Copy(LauncherPaths.SafeChild(Path.Combine(paths.Assets, "objects"), $"{hash[..2]}/{hash}"), target, true);
  107. }
  108. }
  109. }
  110. public static DownloadFile Artifact(JsonNode node, string path) => new(node["url"]!.GetValue<string>(), path,
  111. node["sha1"]?.GetValue<string>(), node["size"]?.GetValue<long>());
  112. public static void ExtractNatives(string archivePath, string destination, string[] exclude)
  113. {
  114. using var archive = ZipFile.OpenRead(archivePath);
  115. foreach (var entry in archive.Entries)
  116. {
  117. if (entry.FullName.EndsWith('/') || exclude.Any(p => entry.FullName.StartsWith(p, StringComparison.Ordinal))) continue;
  118. var path = LauncherPaths.SafeChild(destination, entry.FullName);
  119. Directory.CreateDirectory(Path.GetDirectoryName(path)!);
  120. entry.ExtractToFile(path, true);
  121. }
  122. }
  123. }