diff --git a/MinecraftLaunch.Base/Interfaces/IVerifiableDependency.cs b/MinecraftLaunch.Base/Interfaces/IVerifiableDependency.cs index d24556f..2588d8c 100644 --- a/MinecraftLaunch.Base/Interfaces/IVerifiableDependency.cs +++ b/MinecraftLaunch.Base/Interfaces/IVerifiableDependency.cs @@ -1,6 +1,8 @@ -namespace MinecraftLaunch.Base.Interfaces; +using MinecraftLaunch.Base.Models.SHA1; + +namespace MinecraftLaunch.Base.Interfaces; public interface IVerifiableDependency { long? Size { get; } - string Sha1 { get; } + Sha1Data? Sha1 { get; } } diff --git a/MinecraftLaunch.Base/MinecraftLaunch.Base.csproj b/MinecraftLaunch.Base/MinecraftLaunch.Base.csproj index 3105013..b98a695 100644 --- a/MinecraftLaunch.Base/MinecraftLaunch.Base.csproj +++ b/MinecraftLaunch.Base/MinecraftLaunch.Base.csproj @@ -18,12 +18,18 @@ disable logo.png False + true - + + + + + + diff --git a/MinecraftLaunch.Base/Models/Game/MinecraftEntry.cs b/MinecraftLaunch.Base/Models/Game/MinecraftEntry.cs index c45a677..7620ab1 100644 --- a/MinecraftLaunch.Base/Models/Game/MinecraftEntry.cs +++ b/MinecraftLaunch.Base/Models/Game/MinecraftEntry.cs @@ -1,10 +1,15 @@ +using System.Buffers; +using System.Diagnostics; using MinecraftLaunch.Base.Interfaces; using MinecraftLaunch.Base.Utilities; using System.Diagnostics.CodeAnalysis; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using MinecraftLaunch.Base.Models.SHA1; +#if DEBUG +using Xunit; +#endif namespace MinecraftLaunch.Base.Models.Game; @@ -73,34 +78,30 @@ private static bool IsLibraryEnabled(IEnumerable rules) { _ => false, }; } - + public IEnumerable GetRequiredAssets() { // Identify file paths - string assetIndexJsonPath = AssetIndexJsonPath; - if (this is ModifiedMinecraftEntry { HasInheritance: true } instance) - assetIndexJsonPath = instance.InheritedMinecraft.AssetIndexJsonPath; - + var assetIndexJsonPath = + this is ModifiedMinecraftEntry { HasInheritance: true } inner + ? inner.InheritedMinecraft.AssetIndexJsonPath + : this.AssetIndexJsonPath; // Parse asset index json - Dictionary assets; - // 这里不用using表达式语法是为了在yield前释放资源,防止被挂起导致池化内存压力增大 - using (var stream = File.OpenRead(assetIndexJsonPath)) - using (var doc = JsonDocument.Parse(stream)) - { - var root = doc.RootElement; - if (!root.TryGetProperty("objects"u8, out var value)) - throw new InvalidDataException("Error in parsing asset index json file"); - assets = value.Deserialize(AssetJsonEntryContext.Default.DictionaryStringAssetJsonEntry) - ?? throw new InvalidDataException("Error in parsing asset index json file"); - } + + using var stream = File.OpenRead(assetIndexJsonPath); + using var doc = JsonDocument.Parse(stream); + var root = doc.RootElement; + if (!root.TryGetProperty("objects"u8, out var value)) + throw new InvalidDataException("Error in parsing asset index json file"); + var assets = value; // Parse GameAsset objects - foreach (var (key, assetJsonNode) in assets) { - int size = assetJsonNode.Size; - string hash = assetJsonNode.Hash ?? throw new InvalidDataException("Invalid asset index"); + foreach (var item in assets.EnumerateObject()) { + var size = item.Value.GetProperty("size"u8).GetInt32(); + var hash = item.Value.GetProperty("hash"u8).Deserialize(Sha1Data.Sha1DataSerializerContext.Default.Sha1Data); yield return new MinecraftAsset { MinecraftFolderPath = MinecraftFolderPath, - Key = key, + Key = item.Name, Sha1 = hash, Size = size }; @@ -110,20 +111,26 @@ public IEnumerable GetRequiredAssets() { public (IEnumerable Libraries, IEnumerable NativeLibraries) GetRequiredLibraries() { List libs = []; List nativeLibs = []; - - using var stream = File.OpenRead(ClientJsonPath); - using var doc = JsonDocument.Parse(stream); - var root = doc.RootElement; - if(!root.TryGetProperty("libraries"u8,out var librariesElement))throw new InvalidDataException("client.json does not contain library information"); - var libNodes = librariesElement.Deserialize(LibraryEntriesContext.Default.IEnumerableLibraryEntry) + IEnumerable libNodes; + using (var stream = File.OpenRead(ClientJsonPath)) + using (var doc = JsonDocument.Parse(stream)) + { + var root = doc.RootElement; + if (!root.TryGetProperty("libraries"u8, out var librariesElement)) + throw new InvalidDataException("client.json does not contain library information"); + libNodes = + librariesElement.Deserialize(LibraryEntriesContext.Default.IEnumerableLibraryEntry) ?? throw new InvalidDataException("client.json does not contain library information"); + } - foreach (var libNode in libNodes) { + foreach (var libNode in libNodes) + { if (libNode is null) continue; // Check if a library is enabled - if (libNode.Rules is { } libRules) { + if (libNode.Rules is { } libRules) + { if (!IsLibraryEnabled(libRules)) continue; } @@ -133,7 +140,8 @@ public IEnumerable GetRequiredAssets() { if (gameLib.IsNativeLibrary) nativeLibs.Add(gameLib); - else { + else + { libs.Add(gameLib); } } @@ -219,7 +227,9 @@ public MinecraftLibrary(string mavenName) { internal string GetLibraryPath() => GetLibraryPath(this.MavenName); - internal static string GetLibraryPath(string mavenName) { +#if DEBUG + + internal static string GetLibraryPathOld(string mavenName) { string path = ""; var extension = mavenName.Contains('@') ? mavenName.Split('@') : []; @@ -240,6 +250,124 @@ internal static string GetLibraryPath(string mavenName) { return Path.Combine(path, filename); } + + [Theory] + [Trait("Category", "Debug")] + // 这些是AI生成的测试样例 + // 基础格式 (group:artifact:version) + [InlineData("com.google.guava:guava:31.1-jre")] + [InlineData("org.springframework:spring-core:5.3.23")] + [InlineData("io.netty:netty-all:4.1.82.Final")] +// 带 Classifier (group:artifact:version:classifier) + [InlineData("org.lwjgl:lwjgl:3.3.1:natives-windows")] + [InlineData("com.android.tools.build:gradle:7.2.0:sources")] + [InlineData("org.jetbrains.kotlin:kotlin-stdlib:1.7.10:javadoc")] +// 带扩展名 (group:artifact:version@extension) + [InlineData("com.google.android:android:4.1.1.4@aar")] + [InlineData("androidx.appcompat:appcompat:1.5.1@aar")] + [InlineData("com.squareup.okhttp3:okhttp:4.10.0@pom")] +// 完整格式 (group:artifact:version:classifier@extension) + [InlineData("org.lwjgl:lwjgl-glfw:3.3.1:natives-linux@jar")] + [InlineData("com.android.support:support-v4:28.0.0:sources@jar")] + [InlineData("io.fabric8:kubernetes-client:6.0.0:tests@jar")] +// 多层级 Group + [InlineData("org.apache.logging.log4j:log4j-core:2.19.0")] + [InlineData("com.fasterxml.jackson.core:jackson-databind:2.13.4")] + [InlineData("org.hibernate.validator:hibernate-validator:7.0.5.Final")] +// 边界情况 + [InlineData("a:b:1.0")] + [InlineData("com.example:my-lib:1.0.0-SNAPSHOT")] + [InlineData("org.test:artifact:1.0-beta.1:classifier@zip")] + [InlineData("io.a.b.c.d.e.f:deep-artifact:1.0.0")] +// 特殊版本号 + [InlineData("com.google.code.gson:gson:2.10.1")] + [InlineData("org.junit.jupiter:junit-jupiter:5.9.0-M1")] + [InlineData("com.squareup.retrofit2:retrofit:2.9.0-RC1")] + public static void T2T(string src) + { + var libraryPathOld = GetLibraryPathOld(src); + var actualMemory = GetLibraryPath(src); + Console.WriteLine(actualMemory); + Console.WriteLine(libraryPathOld); + Assert.Equal(libraryPathOld,actualMemory); + } +#endif + + internal static string GetLibraryPath(string mavenName) + { + scoped Span extensionRanges = stackalloc Range[2]; + var extensionCount = mavenName.AsSpan().Split(extensionRanges, '@'); + var mainSpan = mavenName.AsSpan(extensionRanges[0]); + var extensionSpan = extensionCount > 1 ? mavenName.AsSpan(extensionRanges[1]) : default; + + scoped Span subRanges = stackalloc Range[4]; + var subCount = mainSpan.Split(subRanges, ':'); + Debug.Assert(subCount >= 3, "Maven name must have at least group:artifact:version"); + + // 申请缓冲区 + var bufferSize = mavenName.Length + mainSpan[subRanges[0]].Length + 40; + var buffer = ArrayPool.Shared.Rent(bufferSize); + var offset = 0; + + try + { + // 1. 处理 Group name(替换 . 为 Path.DirectorySeparatorChar) + scoped var groupSpan = mainSpan[subRanges[0]]; + groupSpan.Replace(buffer, '.', Path.DirectorySeparatorChar); + offset += groupSpan.Length; + buffer[offset++] = Path.DirectorySeparatorChar; + + // 2. 处理 Artifact name + scoped var artifactSpan = mainSpan[subRanges[1]]; + artifactSpan.CopyTo(buffer.AsSpan(offset)); + offset += artifactSpan.Length; + buffer[offset++] = Path.DirectorySeparatorChar; + + // 3. 处理 Version + scoped var versionSpan = mainSpan[subRanges[2]]; + versionSpan.CopyTo(buffer.AsSpan(offset)); + offset += versionSpan.Length; + buffer[offset++] = Path.DirectorySeparatorChar; + + // 4.1 Artifact + artifactSpan.CopyTo(buffer.AsSpan(offset)); + offset += artifactSpan.Length; + + // 4.2 -version + buffer[offset++] = '-'; + versionSpan.CopyTo(buffer.AsSpan(offset)); + offset += versionSpan.Length; + + // 4.3 -classifier + if (subCount > 3) + { + buffer[offset++] = '-'; + scoped var classifierSpan = mainSpan[subRanges[3]]; + classifierSpan.CopyTo(buffer.AsSpan(offset)); + offset += classifierSpan.Length; + } + + // 4.4 .extension + buffer[offset++] = '.'; + if (!extensionSpan.IsEmpty) + { + extensionSpan.CopyTo(buffer.AsSpan(offset)); + offset += extensionSpan.Length; + } + else + { + "jar".AsSpan().CopyTo(buffer.AsSpan(offset)); + offset += 3; + } + + // 5. 生成最终字符串 + return new string(buffer.AsSpan(0, offset)); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } public static MinecraftLibrary ParseJsonNode(LibraryEntry libNode, string minecraftFolderPath) { // Check platform-specific library name @@ -251,7 +379,7 @@ public static MinecraftLibrary ParseJsonNode(LibraryEntry libNode, string minecr if (libNode.DownloadInformation != null) { DownloadArtifactEntry artifactNode = GetLibraryArtifactInfo(libNode); - if (artifactNode.Sha1 is null || artifactNode.Size is null || artifactNode.Url is null) + if (artifactNode.Size is null || artifactNode.Url is null) throw new InvalidDataException("Invalid artifact node"); #region Vanilla Pattern @@ -314,7 +442,7 @@ public static MinecraftLibrary ParseJsonNode(LibraryEntry libNode, string minecr || libNode.ServerRequest != null) { string legacyForgeLibraryUrl = (libNode.MavenUrl == "https://maven.minecraftforge.net/" ? "https://maven.minecraftforge.net/" - : "https://libraries.minecraft.net/") + GetLibraryPath(libNode.MavenName).Replace("\\", "/"); + : "https://libraries.minecraft.net/") + GetLibraryPath(libNode.MavenName).Replace('\\','/'); return new LegacyForgeLibrary(libNode.MavenName, legacyForgeLibraryUrl) { MinecraftFolderPath = minecraftFolderPath, @@ -331,8 +459,8 @@ public static MinecraftLibrary ParseJsonNode(LibraryEntry libNode, string minecr return new FabricLibrary(libNode.MavenName) { MinecraftFolderPath = minecraftFolderPath, IsNativeLibrary = false, - Size = libNode?.Size, - Sha1 = libNode?.Sha1 + Size = libNode.Size, + Sha1 = libNode.Sha1!.Value }; } @@ -351,9 +479,9 @@ public static MinecraftLibrary ParseJsonNode(LibraryEntry libNode, string minecr #endregion #region OptiFine Pattern - - if (libNode.MavenName.StartsWith("optifine:optifine", StringComparison.CurrentCultureIgnoreCase) - || libNode.MavenName.StartsWith("optifine:launchwrapper-of", StringComparison.CurrentCultureIgnoreCase)) { + + if (libNode.MavenName.StartsWith("optifine:optifine", StringComparison.Ordinal) + || libNode.MavenName.StartsWith("optifine:launchwrapper-of", StringComparison.Ordinal)) { return new OptiFineLibrary(libNode.MavenName) { IsNativeLibrary = false, MinecraftFolderPath = minecraftFolderPath @@ -395,35 +523,54 @@ public override bool Equals(object obj) { private static partial Regex GenerateMavenParseRegex(); } -public class MinecraftClient : MinecraftDependency, IDownloadDependency, IVerifiableDependency { +public sealed class MinecraftClient : MinecraftDependency, IDownloadDependency, IVerifiableDependency { public override string FilePath => Path.Combine("versions", ClientId, $"{ClientId}.jar"); public required string ClientId { get; init; } public required string Url { get; init; } public required long? Size { get; init; } long? IVerifiableDependency.Size => Size; - public required string Sha1 { get; init; } + public required Sha1Data? Sha1 { get; init; } } public sealed class MinecraftAsset : MinecraftDependency, IDownloadDependency, IVerifiableDependency { public required string Key { get; set; } public required long? Size { get; init; } - public required string Sha1 { get; init; } - public string Url => $"https://resources.download.minecraft.net/{Sha1[0..2]}/{Sha1}"; - public override string FilePath => Path.Combine("assets", "objects", Sha1[0..2], Sha1); + public required Sha1Data? Sha1 { get; init; } + + + public string Url + { + get + { + var buf = (Span)stackalloc char[40]; + Sha1!.Value.FormatTo(buf); + return $"https://resources.download.minecraft.net/{buf[..2]}/{buf}"; + } + } + + public override string FilePath + { + get + { + var buf = (Span)stackalloc char[40]; + Sha1!.Value.FormatTo(buf); + return $"assets{Path.DirectorySeparatorChar}objects{Path.DirectorySeparatorChar}{buf[..2]}{Path.DirectorySeparatorChar}{buf}"; + } + } long? IVerifiableDependency.Size => Size; } -public record DownloadArtifactEntry { +public sealed record DownloadArtifactEntry { [JsonPropertyName("url")] public string Url { get; set; } [JsonPropertyName("size")] public long? Size { get; set; } [JsonPropertyName("path")] public string Path { get; set; } - [JsonPropertyName("sha1")] public string Sha1 { get; set; } + [JsonPropertyName("sha1")] public Sha1Data? Sha1 { get; set; } } -public record LibraryEntry { +public sealed record LibraryEntry { [JsonPropertyName("size")] public long? Size { get; set; } - [JsonPropertyName("sha1")] public string Sha1 { get; set; } + [JsonPropertyName("sha1")] public Sha1Data? Sha1 { get; set; } [JsonPropertyName("url")] public string MavenUrl { get; set; } [JsonPropertyName("clientreq")] public bool? ClientRequest { get; set; } [JsonPropertyName("serverreq")] public bool? ServerRequest { get; set; } @@ -436,17 +583,17 @@ public record LibraryEntry { public string MavenName { get; set; } } -public record DownloadInformationEntry { +public sealed record DownloadInformationEntry { [JsonPropertyName("artifact")] public DownloadArtifactEntry Artifact { get; set; } [JsonPropertyName("classifiers")] public Dictionary Classifiers { get; set; } } -public record RuleEntry { +public sealed record RuleEntry { [JsonPropertyName("os")] public Os System { get; set; } [JsonPropertyName("action")] public string Action { get; set; } } -public record Os { +public sealed record Os { [JsonPropertyName("name")] public string Name { get; set; } [JsonPropertyName("arch")] public string Arch { get; set; } [JsonPropertyName("version")] public string Version { get; set; } @@ -457,15 +604,17 @@ public class ForgeLibrary(string mavenName) : MinecraftLibrary(mavenName), IDown public required long? Size { get; init; } public required string Url { get; init; } - public required string Sha1 { get; init; } + public required Sha1Data? Sha1 { get; init; } } public sealed class VanillaLibrary(string mavenName) : MinecraftLibrary(mavenName), IDownloadDependency, IVerifiableDependency { + private string _url; long? IVerifiableDependency.Size => Size; public required long? Size { get; init; } - public required string Sha1 { get; init; } - public string Url => $"https://libraries.minecraft.net/{GetLibraryPath().Replace("\\", "/")}"; + public required Sha1Data? Sha1 { get; init; } + // 值是不变的,添加缓存 + public string Url => _url ??= $"https://libraries.minecraft.net/{GetLibraryPath().Replace('\\', '/')}"; } public sealed class NeoForgeLibrary(string mavenName) : ForgeLibrary(mavenName); @@ -480,22 +629,25 @@ public sealed class LegacyForgeLibrary(string mavenName, string url) : Minecraft public sealed class OptiFineLibrary(string mavenName) : MinecraftLibrary(mavenName); public sealed class FabricLibrary(string mavenName) : MinecraftLibrary(mavenName), IDownloadDependency, IVerifiableDependency { + private string _url; long? IVerifiableDependency.Size => Size; public long? Size { get; set; } - public string Sha1 { get; set; } - public string Url => $"https://maven.fabricmc.net/{GetLibraryPath().Replace("\\", "/")}"; + public Sha1Data? Sha1 { get; set; } + // 添加缓存 + public string Url => _url ??=$"https://maven.fabricmc.net/{GetLibraryPath().Replace('\\', '/')}"; } -public class QuiltLibrary(string mavenName) : MinecraftLibrary(mavenName), IDownloadDependency, IVerifiableDependency { +public sealed class QuiltLibrary(string mavenName) : MinecraftLibrary(mavenName), IDownloadDependency, IVerifiableDependency { + private string _url; long? IVerifiableDependency.Size => Size; public long? Size { get; set; } - public string Sha1 { get; set; } - public string Url => $"https://maven.quiltmc.org/repository/release/{GetLibraryPath().Replace("\\", "/")}"; + public Sha1Data? Sha1 { get; set; } + public string Url => _url ??= $"https://maven.quiltmc.org/repository/release/{GetLibraryPath().Replace('\\', '/')}"; } -public class DownloadableDependency(string mavenName, string url) : MinecraftLibrary(mavenName), IDownloadDependency { +public sealed class DownloadableDependency(string mavenName, string url) : MinecraftLibrary(mavenName), IDownloadDependency { long? IDownloadDependency.Size => throw new NotSupportedException(); public string Url { get; init; } = url; @@ -503,9 +655,9 @@ public class DownloadableDependency(string mavenName, string url) : MinecraftLib public sealed class UnknownLibrary(string mavenName) : MinecraftLibrary(mavenName); -public record AssetJsonEntry { +public sealed record AssetJsonEntry { [JsonPropertyName("size")] public int Size { get; set; } - [JsonPropertyName("hash")] public string Hash { get; set; } + [JsonPropertyName("hash")] public Sha1Data Hash { get; set; } } [JsonSerializable(typeof(IEnumerable))] diff --git a/MinecraftLaunch.Base/Models/Game/MinecraftJsonEntry.cs b/MinecraftLaunch.Base/Models/Game/MinecraftJsonEntry.cs index 2459194..2277bcf 100644 --- a/MinecraftLaunch.Base/Models/Game/MinecraftJsonEntry.cs +++ b/MinecraftLaunch.Base/Models/Game/MinecraftJsonEntry.cs @@ -1,7 +1,7 @@ using System.Text.Json; using MinecraftLaunch.Base.Interfaces; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using MinecraftLaunch.Base.Models.SHA1; namespace MinecraftLaunch.Base.Models.Game; @@ -10,7 +10,7 @@ public class AssstIndex : MinecraftDependency, IDownloadDependency, IVerifiableD [JsonPropertyName("id")] public string Id { get; set; } [JsonPropertyName("url")] public string Url { get; set; } - [JsonPropertyName("sha1")] public string Sha1 { get; set; } + [JsonPropertyName("sha1")] public Sha1Data? Sha1 { get; set; } [JsonPropertyName("size")] public long? Size { get; set; } [JsonIgnore] public override string FilePath => Path.Combine("assets", "indexes", $"{Id}.json"); @@ -35,7 +35,7 @@ public record AssstIndexJsonEntry { [JsonPropertyName("id")] public string Id { get; set; } [JsonPropertyName("size")] public int Size { get; set; } [JsonPropertyName("url")] public string Url { get; set; } - [JsonPropertyName("sha1")] public string Sha1 { get; set; } + [JsonPropertyName("sha1")] public Sha1Data Sha1 { get; set; } [JsonPropertyName("totalSize")] public int TotalSize { get; set; } } diff --git a/MinecraftLaunch.Base/Models/Network/CurseforgeResource.cs b/MinecraftLaunch.Base/Models/Network/CurseforgeResource.cs index 6224ba2..3f5beef 100644 --- a/MinecraftLaunch.Base/Models/Network/CurseforgeResource.cs +++ b/MinecraftLaunch.Base/Models/Network/CurseforgeResource.cs @@ -1,5 +1,6 @@ using MinecraftLaunch.Base.Enums; using MinecraftLaunch.Base.Interfaces; +using MinecraftLaunch.Base.Models.SHA1; namespace MinecraftLaunch.Base.Models.Network; @@ -32,7 +33,7 @@ public record CurseforgeResourceFile { public required bool IsAvailable { get; init; } public required bool IsServerPack { get; init; } - public required string Sha1 { get; init; } + public required Sha1Data? Sha1 { get; init; } public required string FileName { get; init; } public required string DisplayName { get; init; } public required string DownloadUrl { get; init; } diff --git a/MinecraftLaunch.Base/Models/Network/FileHashes.cs b/MinecraftLaunch.Base/Models/Network/FileHashes.cs index 7acf092..53b3677 100644 --- a/MinecraftLaunch.Base/Models/Network/FileHashes.cs +++ b/MinecraftLaunch.Base/Models/Network/FileHashes.cs @@ -1,8 +1,10 @@  +using MinecraftLaunch.Base.Models.SHA1; + namespace MinecraftLaunch.Base.Models.Network; public record FileHashes { public required string Sha512 { get; init; } - public required string Sha1 { get; init; } + public required Sha1Data Sha1 { get; init; } } diff --git a/MinecraftLaunch.Base/Models/Network/McbbsModpackInstallEntry.cs b/MinecraftLaunch.Base/Models/Network/McbbsModpackInstallEntry.cs index d9be4eb..d815ce3 100644 --- a/MinecraftLaunch.Base/Models/Network/McbbsModpackInstallEntry.cs +++ b/MinecraftLaunch.Base/Models/Network/McbbsModpackInstallEntry.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using MinecraftLaunch.Base.Models.SHA1; namespace MinecraftLaunch.Base.Models.Network; @@ -20,7 +21,7 @@ public record McbbsModpackAddons { public record McbbsModpackFileEntry { [JsonPropertyName("path")] public string Path { get; set; } - [JsonPropertyName("hash")] public string Hash { get; set; } + [JsonPropertyName("hash")] public Sha1Data? Hash { get; set; } [JsonPropertyName("type")] public string Type { get; set; } [JsonPropertyName("force")] public bool IsForce { get; set; } } diff --git a/MinecraftLaunch.Base/Models/Network/ModrinthResource.cs b/MinecraftLaunch.Base/Models/Network/ModrinthResource.cs index 535827b..30dbe73 100644 --- a/MinecraftLaunch.Base/Models/Network/ModrinthResource.cs +++ b/MinecraftLaunch.Base/Models/Network/ModrinthResource.cs @@ -1,6 +1,6 @@ using MinecraftLaunch.Base.Enums; using MinecraftLaunch.Base.Interfaces; -using System; +using MinecraftLaunch.Base.Models.SHA1; namespace MinecraftLaunch.Base.Models.Network; @@ -31,7 +31,7 @@ public record ModrinthResourceFile { public FileReleaseType ReleaseType { get; init; } - public required string Sha1 { get; init; } + public required Sha1Data Sha1 { get; init; } public required string Sha512 { get; init; } public required string FileName { get; init; } public required string DownloadUrl { get; init; } diff --git a/MinecraftLaunch.Base/Models/SHA1/Sha1DataConvert.cs b/MinecraftLaunch.Base/Models/SHA1/Sha1DataConvert.cs new file mode 100644 index 0000000..007b6a6 --- /dev/null +++ b/MinecraftLaunch.Base/Models/SHA1/Sha1DataConvert.cs @@ -0,0 +1,121 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + + +namespace MinecraftLaunch.Base.Models.SHA1; + + +[JsonConverter(typeof(Sha1DataJsonConverter))] +[InlineArray(20)] +public partial struct Sha1Data : IEquatable +{ + public bool Equals(Sha1Data other) => _data == other._data; + + public override bool Equals(object obj) => obj is Sha1Data other && Equals(other); + + public override int GetHashCode() => _data.GetHashCode(); + + private byte _data; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void FormatTo(Span destination) + { + for (var i = 0; i < 20; i++) + { + var b = this[i]; + destination[i * 2] = ToHexCharLower(b >> 4); + destination[i * 2 + 1] = ToHexCharLower(b & 0x0F); + } + return; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static char ToHexCharLower(int b) => b switch + { + < 10 => (char)('0' + b), + _ => (char)('a' + (b - 10)) + }; + } + /// + /// 返回 40 位小写十六进制字符串。 + /// + public override string ToString() + { + // 栈上分配 40 个 char,避免托管内存分配 + Span buffer = stackalloc char[40]; + FormatTo(buffer); + return new string(buffer); + } + + + + public sealed class Sha1DataJsonConverter : JsonConverter +{ + public override Sha1Data Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert != typeof(Sha1Data)) + { + ThrowHelper.ThrowInvalidTargetType(); + } + if (reader.TokenType != JsonTokenType.String) + ThrowHelper.ThrowUnexpectedTokenType(); + + var utf8Hex = reader.ValueSpan; + if (utf8Hex.Length != 40) + ThrowHelper.ThrowInvalidLength(utf8Hex.Length); + Unsafe.SkipInit(out Sha1Data result); + for (var i = 0; i < 20; i++) + { + var idx = i * 2; + result[i] = ParseHex(utf8Hex[idx], utf8Hex[idx + 1]); + } + return result; + } + + + public override void Write(Utf8JsonWriter writer, Sha1Data value, JsonSerializerOptions options) + { + var buffer = (Span)stackalloc byte[40]; + for (var i = 0; i < 20; i++) + { + var b = value[i]; + buffer[i * 2] = ToHexCharLower(b >> 4); + buffer[i * 2 + 1] = ToHexCharLower(b & 0x0F); + } + writer.WriteStringValue(buffer); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static byte ParseHex(byte c1, byte c2) + { + var h = (c1 & 0x0F) + ((c1 & 0x40) != 0 ? 9 : 0); + var l = (c2 & 0x0F) + ((c2 & 0x40) != 0 ? 9 : 0); + return (byte)((h << 4) | l); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static byte ToHexCharLower(int b) => b switch + { + < 10 => (byte)('0' + b), + _ => (byte)('a' + (b - 10)) + }; + + private static class ThrowHelper + { + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowUnexpectedTokenType() => throw new JsonException("Unexpected token type for SHA1 data, expected a string."); + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowInvalidLength(int len) => throw new JsonException($"SHA1 hex string must be 40 characters long, got {len}."); + [MethodImpl(MethodImplOptions.NoInlining)] + public static void ThrowInvalidTargetType() + { + throw new JsonException("Invalid target type."); + } + } +} + + [JsonSerializable(typeof(Sha1Data))] + [JsonSerializable(typeof(Sha1Data[]))] + public sealed partial class Sha1DataSerializerContext : JsonSerializerContext; +} + diff --git a/MinecraftLaunch/Components/Authenticator/MicrosoftAuthenticator.cs b/MinecraftLaunch/Components/Authenticator/MicrosoftAuthenticator.cs index b7da666..f23cb43 100644 --- a/MinecraftLaunch/Components/Authenticator/MicrosoftAuthenticator.cs +++ b/MinecraftLaunch/Components/Authenticator/MicrosoftAuthenticator.cs @@ -31,9 +31,8 @@ public async Task RefreshAsync(MicrosoftAccount account, Cance var result = await request.PostAsync(new FormUrlEncodedContent(payload), cancellationToken: cancellationToken); - var json = await result.GetStringAsync(); - var response = json.Deserialize(OAuth2TokenResponseContext.Default.OAuth2TokenResponse); - + await using var json = await result.GetStreamAsync(); + var response = await JsonSerializer.DeserializeAsync(json,OAuth2TokenResponseContext.Default.OAuth2TokenResponse, cancellationToken); return await AuthenticateAsync(response, cancellationToken); } @@ -73,10 +72,10 @@ public async Task DeviceFlowAuthAsync(Action RefreshAsync(YggdrasilAccount account, Cance YggdrasilRequestPayloadContext.Default.YggdrasilRefreshPayload), cancellationToken: cancellationToken); - var json = await responseMessage.GetStringAsync(); - var entry = json.Deserialize(YggdrasilResponseContext.Default.YggdrasilResponse); + await using var json = await responseMessage.GetStreamAsync(); + var entry = await JsonSerializer.DeserializeAsync(json,YggdrasilResponseContext.Default.YggdrasilResponse, cancellationToken); var profile = entry.SelectedProfile; return new YggdrasilAccount(profile.Name, Guid.Parse(profile.Id), entry.AccessToken, _url, entry.ClientToken); @@ -65,8 +66,8 @@ public async Task> AuthenticateAsync(CancellationT YggdrasilRequestPayloadContext.Default.YggdrasilAuthenticatePayload), cancellationToken: cancellationToken); - var json = await responseMessage.GetStringAsync(); - var entry = json.Deserialize(YggdrasilResponseContext.Default.YggdrasilResponse); + await using var json = await responseMessage.GetStreamAsync(); + var entry = await JsonSerializer.DeserializeAsync(json,YggdrasilResponseContext.Default.YggdrasilResponse, cancellationToken); return entry.AvailableProfiles.Select(profile => new YggdrasilAccount(profile.Name, Guid.Parse(profile.Id), entry.AccessToken, _url, entry.ClientToken)); diff --git a/MinecraftLaunch/Components/Downloader/MinecraftResourceDownloader.cs b/MinecraftLaunch/Components/Downloader/MinecraftResourceDownloader.cs index 6d500d2..a1dc41e 100644 --- a/MinecraftLaunch/Components/Downloader/MinecraftResourceDownloader.cs +++ b/MinecraftLaunch/Components/Downloader/MinecraftResourceDownloader.cs @@ -111,15 +111,10 @@ private static bool VerifyDependency(MinecraftDependency dep, CancellationToken bool VerifySha1() { using var fileStream = File.OpenRead(dep.FullPath); - byte[] sha1Bytes = SHA1.HashData(fileStream); - -#if NET9_0_OR_GREATER - string sha1Str = Convert.ToHexStringLower(sha1Bytes); -#else - string sha1Str = BitConverter.ToString(sha1Bytes).Replace("-", string.Empty).ToLowerInvariant(); -#endif - - return sha1Str == verifiableDependency.Sha1; + var sha1Bytes = (Span)stackalloc byte[20]; + SHA1.HashData(fileStream, sha1Bytes); + var sp = verifiableDependency.Sha1.Value; + return sha1Bytes.SequenceEqual(sp); } bool VerifySize() { diff --git a/MinecraftLaunch/Components/Installer/FabricInstaller.cs b/MinecraftLaunch/Components/Installer/FabricInstaller.cs index e748781..8ae4fc3 100644 --- a/MinecraftLaunch/Components/Installer/FabricInstaller.cs +++ b/MinecraftLaunch/Components/Installer/FabricInstaller.cs @@ -25,10 +25,11 @@ public static FabricInstaller Create(string mcFolder, FabricInstallEntry install } public static async Task> EnumerableFabricAsync(string mcVersion, CancellationToken cancellationToken = default) { - string json = await HttpUtil.FlurlClient.Request($"https://meta.fabricmc.net/v2/versions/loader/{mcVersion}") - .GetStringAsync(cancellationToken: cancellationToken); + await using var json = await HttpUtil.FlurlClient.Request($"https://meta.fabricmc.net/v2/versions/loader/{mcVersion}") + .GetStreamAsync(cancellationToken: cancellationToken); - var entries = json.Deserialize(FabricInstallEntryContext.Default.IEnumerableFabricInstallEntry) + var entries = (await JsonSerializer.DeserializeAsync(json, + FabricInstallEntryContext.Default.IEnumerableFabricInstallEntry, cancellationToken)) .OrderByDescending(x => new Version(x.Loader.Version.Replace(x.Loader.Separator, "."))); return entries; diff --git a/MinecraftLaunch/Components/Installer/ForgeInstaller.cs b/MinecraftLaunch/Components/Installer/ForgeInstaller.cs index 620fbf0..df1181a 100644 --- a/MinecraftLaunch/Components/Installer/ForgeInstaller.cs +++ b/MinecraftLaunch/Components/Installer/ForgeInstaller.cs @@ -67,8 +67,8 @@ await WriteVersionJsonAndSomeDependenciesAsync(isLegacy, doc.RootElement, packag { ReportProgress(InstallStep.Interrupted, 1.0d, TaskStatus.Canceled, 1, 1); ReportCompleted(false, ex); + throw; } - return entry ?? throw new ArgumentNullException(nameof(entry), "Unexpected null reference to variable"); } @@ -77,8 +77,9 @@ public static async Task> EnumerableForgeAsync(st ? $"https://bmclapi2.bangbang93.com/neoforge/list/{mcVersion}" : $"https://bmclapi2.bangbang93.com/forge/minecraft/{mcVersion}"; - string json = await packagesUrl.GetStringAsync(cancellationToken: cancellationToken); - var entries = json.Deserialize(ForgeInstallEntryContext.Default.IEnumerableForgeInstallEntry) + await using var json = await packagesUrl.GetStreamAsync(cancellationToken: cancellationToken); + var entries = (await JsonSerializer.DeserializeAsync(json, + ForgeInstallEntryContext.Default.IEnumerableForgeInstallEntry, cancellationToken)) .OrderByDescending(entry => entry.Build); foreach (var entry in entries) @@ -130,9 +131,9 @@ private async Task DownloadForgePackageAsync(CancellationToken cancell var packageFile = new FileInfo(Path.Combine(MinecraftFolder, fileName)); var downloadRequest = new DownloadRequest(packageUrl, packageFile.FullName); - await new DefaultDownloader() + var downloadResult = await new DefaultDownloader() .DownloadAsync(downloadRequest, cancellationToken); - + if (downloadResult.Type is DownloadResultType.Failed) throw downloadResult.Exception; ReportProgress(InstallStep.DownloadPackage, 0.45d, TaskStatus.Running, 1, 1); return packageFile; diff --git a/MinecraftLaunch/Components/Installer/OptifineInstaller.cs b/MinecraftLaunch/Components/Installer/OptifineInstaller.cs index ece69ff..77c8071 100644 --- a/MinecraftLaunch/Components/Installer/OptifineInstaller.cs +++ b/MinecraftLaunch/Components/Installer/OptifineInstaller.cs @@ -40,8 +40,9 @@ public static OptifineInstaller Create(string mcFolder, OptifineInstallEntry opt public static async Task> EnumerableOptifineAsync(string mcVersion, CancellationToken cancellationToken = default) { string url = $"https://bmclapi2.bangbang93.com/optifine/{mcVersion}"; - string json = await url.GetStringAsync(cancellationToken: cancellationToken); - var entries = json.Deserialize(OptifineInstallEntryContext.Default.IEnumerableOptifineInstallEntry) + await using var json = await url.GetStreamAsync(cancellationToken: cancellationToken); + var entries = (await JsonSerializer.DeserializeAsync(json, + OptifineInstallEntryContext.Default.IEnumerableOptifineInstallEntry, cancellationToken)) .OrderByDescending(entry => entry.Patch); return entries; diff --git a/MinecraftLaunch/Components/Installer/QuiltInstaller.cs b/MinecraftLaunch/Components/Installer/QuiltInstaller.cs index 79770bb..e482f1b 100644 --- a/MinecraftLaunch/Components/Installer/QuiltInstaller.cs +++ b/MinecraftLaunch/Components/Installer/QuiltInstaller.cs @@ -25,10 +25,10 @@ public static QuiltInstaller Create(string mcFolder, QuiltInstallEntry installEn } public static async Task> EnumerableQuiltAsync(string mcVersion, CancellationToken cancellationToken = default) { - string json = await $"https://meta.quiltmc.org/v3/versions/loader/{mcVersion}" - .GetStringAsync(cancellationToken: cancellationToken); + await using var json = await $"https://meta.quiltmc.org/v3/versions/loader/{mcVersion}" + .GetStreamAsync(cancellationToken: cancellationToken); - var entries = json.Deserialize(QuiltInstallEntryContext.Default.IEnumerableQuiltInstallEntry); + var entries = await JsonSerializer.DeserializeAsync(json,QuiltInstallEntryContext.Default.IEnumerableQuiltInstallEntry, cancellationToken); return entries; } diff --git a/MinecraftLaunch/Components/Logging/LogAnalyzer.cs b/MinecraftLaunch/Components/Logging/LogAnalyzer.cs index 6fb8b61..a77aa4d 100644 --- a/MinecraftLaunch/Components/Logging/LogAnalyzer.cs +++ b/MinecraftLaunch/Components/Logging/LogAnalyzer.cs @@ -3,6 +3,7 @@ using MinecraftLaunch.Base.Models.Logging; using MinecraftLaunch.Extensions; using System.Collections.Frozen; +using System.Diagnostics; using System.Text.RegularExpressions; namespace MinecraftLaunch.Components.Logging; @@ -192,12 +193,13 @@ private IEnumerable TryFindSuspiciousModId(IEnumerable logs, str details = details.Replace("Fabric Mods", "¨"); details = details.Split('¨').LastOrDefault(); + Debug.Assert(details is not null); //The FoegeMod is get all has the ".jar" lines and //the fabricmod is get all has the "Mod" lines. var modLines = new List(); foreach (var detail in details.Split(Environment.NewLine)) - if (detail.Contains(".jar", StringComparison.CurrentCultureIgnoreCase) || (isFabricMod && detail.StartsWith("\t" + "\t") && !FabricModIdentifier().IsMatch(detail))) + if (detail.Contains(".jar", StringComparison.OrdinalIgnoreCase) || (isFabricMod && detail.StartsWith("\t\t", StringComparison.Ordinal) && !FabricModIdentifier().IsMatch(detail))) modLines.Add(detail); var hintLines = new List(); diff --git a/MinecraftLaunch/Components/Parser/MinecraftParser.cs b/MinecraftLaunch/Components/Parser/MinecraftParser.cs index 4616c83..ad93670 100644 --- a/MinecraftLaunch/Components/Parser/MinecraftParser.cs +++ b/MinecraftLaunch/Components/Parser/MinecraftParser.cs @@ -50,18 +50,18 @@ public List GetMinecrafts() { if (!versionsDirectory.Exists) return []; - foreach (DirectoryInfo dir in versionsDirectory.EnumerateDirectories()) { - try { - var entry = Parse(dir, list, out bool inheritedInstanceAlreadyFound); - int index = list.FindIndex(i => i.Id == entry.Id); - if (index != -1) { - list.RemoveAt(index); - } - - list.Add(entry); - if (entry is ModifiedMinecraftEntry m && m.HasInheritance && !inheritedInstanceAlreadyFound) - list.Add(m.InheritedMinecraft); - } catch (Exception) { } + foreach (DirectoryInfo dir in versionsDirectory.EnumerateDirectories()) + { + var entry = Parse(dir, list, out bool inheritedInstanceAlreadyFound); + int index = list.FindIndex(i => i.Id == entry.Id); + if (index != -1) + { + list.RemoveAt(index); + } + + list.Add(entry); + if (entry is ModifiedMinecraftEntry m && m.HasInheritance && !inheritedInstanceAlreadyFound) + list.Add(m.InheritedMinecraft); } foreach (var processor in DataProcessors.Values) { @@ -192,8 +192,7 @@ private static ModifiedMinecraftEntry ParseModified(PartialData partialData, Min // Find the inherited instance string inheritedInstanceId = minecraftJsonEntry.InheritsFrom ?? throw new InvalidOperationException("InheritsFrom is not defined in client.json"); - - inheritedEntry = minecraftEntries.FirstOrDefault(i => i is VanillaMinecraftEntry v && v.Version.VersionId == inheritedInstanceId) as VanillaMinecraftEntry; + inheritedEntry = minecraftEntries?.FirstOrDefault(i => i is VanillaMinecraftEntry v && v.Version.VersionId == inheritedInstanceId) as VanillaMinecraftEntry; if (inheritedEntry is not null) { foundInheritedInstanceInParsed = true; diff --git a/MinecraftLaunch/Components/Provider/CurseforgeProvider.cs b/MinecraftLaunch/Components/Provider/CurseforgeProvider.cs index 3d05c2b..8d58775 100644 --- a/MinecraftLaunch/Components/Provider/CurseforgeProvider.cs +++ b/MinecraftLaunch/Components/Provider/CurseforgeProvider.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using Flurl; using Flurl.Http; using MinecraftLaunch.Base.Enums; @@ -7,9 +6,9 @@ using MinecraftLaunch.Utilities; using System.Net.Http.Json; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Web; +using MinecraftLaunch.Base.Models.SHA1; namespace MinecraftLaunch.Components.Provider; @@ -26,7 +25,7 @@ public async Task> GetFeaturedResourcesAsync(Can using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); var dataElement = doc.RootElement.GetProperty("data"u8); - var popular = dataElement.GetPropertyNullable("popular"); - var featured = dataElement.GetPropertyNullable("featured"); + var popular = dataElement.GetPropertyNullable("popular"u8); + var featured = dataElement.GetPropertyNullable("featured"u8); if (popular is null || featured is null) return []; @@ -294,14 +293,16 @@ static Dictionary ProvideDependencies(JsonElement dependenc x => (DependencyType)x.GetProperty("relationType"u8).GetInt32() ); } - static string ProvideSha(JsonElement hashesArrayNode) + static Sha1Data? ProvideSha(JsonElement hashesArrayNode) { foreach (var node in hashesArrayNode.EnumerateArray()) { + if (!node.TryGetProperty("algo"u8,out var algoElement))continue; - if (algoElement.GetInt32() is 1) return node.GetProperty("value"u8).GetString(); + if (algoElement.GetInt32() is 1) return node.GetProperty("value"u8).Deserialize(Sha1Data.Sha1DataSerializerContext.Default.Sha1Data); } - return string.Empty; + + return null; } } diff --git a/MinecraftLaunch/Components/Provider/ModrinthProvider.cs b/MinecraftLaunch/Components/Provider/ModrinthProvider.cs index e5c6cbe..1bc2433 100644 --- a/MinecraftLaunch/Components/Provider/ModrinthProvider.cs +++ b/MinecraftLaunch/Components/Provider/ModrinthProvider.cs @@ -9,6 +9,7 @@ using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; +using MinecraftLaunch.Base.Models.SHA1; namespace MinecraftLaunch.Components.Provider; @@ -235,7 +236,7 @@ private static ModrinthResourceFile ParseFile(JsonElement node) IsPrimary = primaryFileNode.GetProperty("primary"u8).GetBoolean(), FileName = primaryFileNode.GetProperty("filename"u8).GetString(), FileSize = primaryFileNode.GetProperty("size"u8).GetInt64(), - Sha1 = primaryFileNode.GetProperty("hashes"u8).GetProperty("sha1"u8).GetString(), + Sha1 = primaryFileNode.GetProperty("hashes"u8).GetProperty("sha1"u8).Deserialize(Sha1Data.Sha1DataSerializerContext.Default.Sha1Data), Sha512 = primaryFileNode.GetProperty("hashes"u8).GetProperty("sha512"u8).GetString(), ReleaseType = node.GetProperty("version_type"u8).GetString() switch diff --git a/MinecraftLaunch/DownloadManager.cs b/MinecraftLaunch/DownloadManager.cs index a73a337..017a9d9 100644 --- a/MinecraftLaunch/DownloadManager.cs +++ b/MinecraftLaunch/DownloadManager.cs @@ -35,7 +35,7 @@ public string TryFindUrl(string sourceUrl) { return sourceUrl; foreach (var (src, mirror) in _replacementMap) - if (sourceUrl.StartsWith(src)) + if (sourceUrl.StartsWith(src, StringComparison.Ordinal)) return sourceUrl.Replace(src, mirror); return sourceUrl; diff --git a/MinecraftLaunch/Extensions/MinecraftEntryExtension.cs b/MinecraftLaunch/Extensions/MinecraftEntryExtension.cs index 2cc47ea..ae71d8f 100644 --- a/MinecraftLaunch/Extensions/MinecraftEntryExtension.cs +++ b/MinecraftLaunch/Extensions/MinecraftEntryExtension.cs @@ -3,6 +3,7 @@ using MinecraftLaunch.Base.Utilities; using System.IO.Compression; using System.Text.Json; +using MinecraftLaunch.Base.Models.SHA1; namespace MinecraftLaunch.Extensions; @@ -56,7 +57,7 @@ public static MinecraftClient GetJarElement(this MinecraftEntry entry) { long size = clientArtifactNode.GetProperty("size"u8).GetInt64(); string url = clientArtifactNode.GetProperty("url"u8).GetString(); - string sha1 = clientArtifactNode.GetProperty("sha1"u8).GetString(); + var sha1 = clientArtifactNode.GetPropertyNullable("sha1"u8)?.Deserialize(Sha1Data.Sha1DataSerializerContext.Default.Sha1Data); if (sha1 is null || url is null) throw new InvalidDataException("Invalid client info"); @@ -65,7 +66,7 @@ public static MinecraftClient GetJarElement(this MinecraftEntry entry) { MinecraftFolderPath = entry.MinecraftFolderPath, ClientId = Path.GetFileNameWithoutExtension(clientJarPath), Url = url, - Sha1 = sha1, + Sha1 = sha1.Value, Size = size }; } @@ -86,7 +87,7 @@ public static AssstIndex GetAssetIndex(this MinecraftEntry minecraftEntry) { long size = assetIndex.GetProperty("size"u8).GetInt64(); string id = assetIndex.GetProperty("id"u8).GetString() ?? throw new InvalidDataException(); string url = assetIndex.GetProperty("url"u8).GetString() ?? throw new InvalidDataException(); - string sha1 = assetIndex.GetProperty("sha1"u8).GetString() ?? throw new InvalidDataException(); + var sha1 = assetIndex.GetPropertyNullable("sha1"u8)?.Deserialize(Sha1Data.Sha1DataSerializerContext.Default.Sha1Data) ?? throw new InvalidDataException(); return new AssstIndex { Id = id,