初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace PCL.Core.Minecraft;
|
||||
|
||||
public class GameCore
|
||||
{
|
||||
private readonly string _corePath;
|
||||
public GameCore(string corePath)
|
||||
{
|
||||
if (!File.Exists(corePath)) throw new FileNotFoundException($"未找到指定文件:{corePath}");
|
||||
this._corePath = corePath;
|
||||
}
|
||||
/// <summary>
|
||||
/// 将指定的 Jar 文件添加到到游戏核心
|
||||
/// </summary>
|
||||
/// <param name="jarPath">要添加到 Jar 的文件</param>
|
||||
/// <exception cref="FileNotFoundException">提供的文件路径不存在</exception>
|
||||
public void AddToCore(string jarPath)
|
||||
{
|
||||
if (!File.Exists(jarPath)) throw new FileNotFoundException($"未找到指定文件:{jarPath}");
|
||||
using var coreStream = new FileStream(_corePath,FileMode.Open,FileAccess.ReadWrite,FileShare.Read,16384,true);
|
||||
using var jarStream = new FileStream(jarPath, FileMode.Open, FileAccess.Read, FileShare.Read, 16384, true);
|
||||
using var coreArchive = new ZipArchive(coreStream,ZipArchiveMode.Update);
|
||||
using var jarArchive = new ZipArchive(jarStream);
|
||||
// Better Than Wolves 的 Mod File 是 .zip 结尾的
|
||||
var filter = jarPath.EndsWith(".jar") ? "" : "MINECRAFT-JAR";
|
||||
foreach (var entry in jarArchive.Entries)
|
||||
{
|
||||
if (!entry.FullName.Contains(filter)) continue;
|
||||
using var coreArchiveStream = coreArchive.CreateEntry(entry.FullName).Open();
|
||||
using var jarArchiveStream = jarArchive.GetEntry(entry.FullName)?.Open();
|
||||
jarArchiveStream?.CopyTo(coreArchiveStream);
|
||||
}
|
||||
// 删除包含签名文件的目录,避免 Oracle JDK 加载时验证签名失败导致无法启动
|
||||
coreArchive.GetEntry("META-INF")?.Delete();
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken;
|
||||
|
||||
public record JsonWebKeys
|
||||
{
|
||||
[JsonPropertyName("keys")] public required JsonWebKey[] Keys { get; init; }
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security;
|
||||
using System.Text.Json;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken;
|
||||
|
||||
/// <summary>
|
||||
/// Json Web Token 类
|
||||
/// </summary>
|
||||
/// <param name="token">JWT 令牌字符串</param>
|
||||
/// <param name="meta">OpenID 元数据</param>
|
||||
public class JsonWebToken(string token, OpenIdMetadata meta)
|
||||
{
|
||||
public delegate SecurityToken? TokenValidateCallback(OpenIdMetadata metadata, string token, JsonWebKey? key, string? clientId);
|
||||
|
||||
/// <summary>
|
||||
/// 安全令牌验证回调函数,默认验证签名、发行者、nbf 和 exp
|
||||
/// </summary>
|
||||
public TokenValidateCallback SecurityTokenValidateCallback { get; set; } = static (meta, token, key, clientId) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
var parameter = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = meta.Issuer,
|
||||
ValidateAudience = !string.IsNullOrEmpty(clientId),
|
||||
ValidAudience = clientId,
|
||||
ValidateIssuerSigningKey = key is not null,
|
||||
IssuerSigningKey = key is not null ? new JsonWebKeySet { Keys = { key } }.Keys[0] : null,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
|
||||
handler.ValidateToken(token, parameter, out var secToken);
|
||||
return secToken;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SecurityException($"令牌验证失败:{ex.Message}", ex);
|
||||
}
|
||||
};
|
||||
|
||||
private bool _verified;
|
||||
private JwtSecurityToken? _parsedToken;
|
||||
private readonly JwtSecurityTokenHandler _tokenHandler = new();
|
||||
|
||||
/// <summary>
|
||||
/// 解析令牌(不验证签名)
|
||||
/// </summary>
|
||||
/// <returns>解析后的 JWT 令牌对象</returns>
|
||||
/// <exception cref="SecurityException">令牌格式无效</exception>
|
||||
private JwtSecurityToken _ParseToken()
|
||||
{
|
||||
if (_parsedToken is not null)
|
||||
return _parsedToken;
|
||||
|
||||
try
|
||||
{
|
||||
if (!_tokenHandler.CanReadToken(token))
|
||||
throw new SecurityException("无法读取令牌:格式无效");
|
||||
|
||||
_parsedToken = _tokenHandler.ReadJwtToken(token);
|
||||
return _parsedToken;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SecurityException($"令牌解析失败:{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试读取 Token 中的字段
|
||||
/// </summary>
|
||||
/// <param name="allowUnverifyToken">是否允许在未验证的情况下读取字段,若为 false,当 Token 未验证时将抛出异常</param>
|
||||
/// <typeparam name="T">声明值的目标类型</typeparam>
|
||||
/// <returns>解析后的声明对象</returns>
|
||||
/// <exception cref="SecurityException">未调用 VerifySignature() 且 allowUnverifyToken 为 false</exception>
|
||||
/// <exception cref="InvalidOperationException">令牌中不存在 payload 数据</exception>
|
||||
public T? ReadTokenPayload<T>(bool allowUnverifyToken = false)
|
||||
{
|
||||
if (!allowUnverifyToken && !_verified)
|
||||
throw new SecurityException("不安全的令牌");
|
||||
|
||||
try
|
||||
{
|
||||
var jwtToken = _ParseToken();
|
||||
|
||||
if (jwtToken.Payload is null || jwtToken.Payload.Count == 0)
|
||||
throw new InvalidOperationException("令牌 Payload 无效");
|
||||
|
||||
if (typeof(T).IsAssignableFrom(typeof(Dictionary<string, object>)))
|
||||
return (T)(object)jwtToken.Payload;
|
||||
|
||||
if (typeof(T) == typeof(JwtPayload))
|
||||
return (T)(object)jwtToken.Payload;
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(jwtToken.Payload, JsonCompat.SerializerOptions);
|
||||
var result = JsonSerializer.Deserialize<T>(payloadJson, JsonCompat.SerializerOptions);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SecurityException($"读取令牌 payload 失败:{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取 Token 头
|
||||
/// </summary>
|
||||
/// <typeparam name="T">声明值的目标类型</typeparam>
|
||||
/// <returns>解析后的头对象</returns>
|
||||
/// <exception cref="InvalidOperationException">令牌中不存在 header 数据</exception>
|
||||
public T? ReadTokenHeader<T>()
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwtToken = _ParseToken();
|
||||
|
||||
if (jwtToken.Header is null || jwtToken.Header.Count == 0)
|
||||
throw new InvalidOperationException("令牌中不存在 header 数据");
|
||||
|
||||
if (typeof(T).IsAssignableFrom(typeof(Dictionary<string, object>)))
|
||||
return (T)(object)jwtToken.Header;
|
||||
|
||||
if (typeof(T) == typeof(JwtHeader))
|
||||
return (T)(object)jwtToken.Header;
|
||||
|
||||
var headerJson = JsonSerializer.Serialize(jwtToken.Header, JsonCompat.SerializerOptions);
|
||||
var result = JsonSerializer.Deserialize<T>(headerJson, JsonCompat.SerializerOptions);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SecurityException($"读取令牌 header 失败:{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对 Token 进行签名验证 <br/>
|
||||
/// 默认情况下仅对签名、iss、nbf、exp 进行验证,如果需要更细粒度验证,请设置 <see cref="SecurityTokenValidateCallback"/>
|
||||
/// </summary>
|
||||
/// <param name="key">用于验证签名的 JSON Web Key</param>
|
||||
/// <param name="clientId">预期的受众(audience),可选</param>
|
||||
/// <returns>验证成功返回 SecurityToken 对象,否则返回 null</returns>
|
||||
public SecurityToken? VerifySignature(JsonWebKey key, string? clientId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = SecurityTokenValidateCallback.Invoke(meta, token, key, clientId);
|
||||
if (result is not null)
|
||||
_verified = true;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SecurityException($"令牌签名验证失败:{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重载方法,用于无参调用验证(仅验证基本声明)
|
||||
/// </summary>
|
||||
/// <returns>验证成功返回 SecurityToken 对象,否则返回 null</returns>
|
||||
public SecurityToken? VerifySignature()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = SecurityTokenValidateCallback.Invoke(meta, token, null, null);
|
||||
if (result is not null)
|
||||
_verified = true;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SecurityException($"令牌验证失败:{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取令牌的过期时间
|
||||
/// </summary>
|
||||
/// <returns>过期时间,若不存在则返回 null</returns>
|
||||
public DateTime? GetExpirationTime()
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwtToken = _ParseToken();
|
||||
return jwtToken.ValidTo != DateTime.MinValue ? jwtToken.ValidTo : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SecurityException($"获取令牌过期时间失败:{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取令牌的签发时间
|
||||
/// </summary>
|
||||
/// <returns>签发时间,若不存在则返回 null</returns>
|
||||
public DateTime? GetIssuedAtTime()
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwtToken = _ParseToken();
|
||||
return jwtToken.ValidFrom != DateTime.MinValue ? jwtToken.ValidFrom : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SecurityException($"获取令牌签发时间失败:{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查令牌是否已过期
|
||||
/// </summary>
|
||||
/// <returns>若已过期返回 true,否则返回 false</returns>
|
||||
public bool IsExpired()
|
||||
{
|
||||
try
|
||||
{
|
||||
var expTime = GetExpirationTime();
|
||||
return expTime.HasValue && DateTime.UtcNow > expTime.Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true; // 如果无法解析,视为已过期
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取特定声明的值
|
||||
/// </summary>
|
||||
/// <param name="claimType">声明类型</param>
|
||||
/// <param name="allowUnverifyToken">是否允许在未验证的情况下读取</param>
|
||||
/// <returns>声明值,若不存在则返回 null</returns>
|
||||
public string? GetClaimValue(string claimType, bool allowUnverifyToken = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = ReadTokenPayload<Dictionary<string, object>>(allowUnverifyToken);
|
||||
return payload?.TryGetValue(claimType, out var value) ?? false ? value.ToString() : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SecurityException($"获取声明值失败({claimType}):{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取原始令牌字符串
|
||||
/// </summary>
|
||||
/// <returns>JWT 令牌字符串</returns>
|
||||
public string GetTokenString() => token;
|
||||
|
||||
/// <summary>
|
||||
/// 检查令牌验证状态
|
||||
/// </summary>
|
||||
public bool IsVerified => _verified;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Minecraft.IdentityModel;
|
||||
using PCL.Core.Minecraft.IdentityModel.Extensions.Pkce;
|
||||
using PCL.Core.Minecraft.IdentityModel.OAuth;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
|
||||
|
||||
public class OpenIdClient(OpenIdOptions options):IOAuthClient
|
||||
{
|
||||
private IOAuthClient? _client;
|
||||
/// <summary>
|
||||
/// 初始化并从网络加载 OpenId 配置
|
||||
/// </summary>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="checkAddress"></param>
|
||||
/// <exception cref="IdentityModelConfigurationException">当要求检查地址并不存在任何授权端点时,将触发此错误</exception>
|
||||
public async Task InitializeAsync(CancellationToken token,bool checkAddress = false)
|
||||
{
|
||||
await options.InitializeAsync(token);
|
||||
var opt = options.BuildOAuthOptions();
|
||||
if (checkAddress && opt.Meta.AuthorizeEndpoint.IsNullOrEmpty() && opt.Meta.DeviceEndpoint.IsNullOrEmpty())
|
||||
throw new IdentityModelConfigurationException("OpenID 元数据缺少授权代码流端点和设备代码流端点");
|
||||
|
||||
_client = options.EnablePkceSupport ? new PkceClient(opt) : new SimpleOAuthClient(opt);
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取授权代码流地址
|
||||
/// </summary>
|
||||
/// <param name="scopes">权限列表</param>
|
||||
/// <param name="state"></param>
|
||||
/// <param name="extData">扩展数据</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
|
||||
public string GetAuthorizeUrl(string[] scopes, string state,Dictionary<string,string>? extData = null)
|
||||
{
|
||||
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
|
||||
return _client.GetAuthorizeUrl(scopes, state, extData);
|
||||
}
|
||||
/// <summary>
|
||||
/// 使用授权代码兑换 Token
|
||||
/// </summary>
|
||||
/// <param name="code"></param>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
|
||||
public async Task<AuthorizeResult?> AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
|
||||
return await _client.AuthorizeWithCodeAsync(code, token, extData);
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取设备代码流代码对
|
||||
/// </summary>
|
||||
/// <param name="scopes"></param>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
|
||||
public async Task<DeviceCodeData?> GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
|
||||
return await _client.GetCodePairAsync(scopes, token, extData);
|
||||
}
|
||||
/// <summary>
|
||||
/// 发起一次验证,以检查认证是否成功
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
|
||||
public async Task<AuthorizeResult?> AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
|
||||
return await _client.AuthorizeWithDeviceAsync(data, token, extData);
|
||||
}
|
||||
/// <summary>
|
||||
/// 进行一次刷新调用
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
|
||||
public async Task<AuthorizeResult?> AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
|
||||
return await _client.AuthorizeWithSilentAsync(data, token, extData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
|
||||
|
||||
|
||||
|
||||
public record OpenIdMetadata
|
||||
{
|
||||
[JsonPropertyName("issuer")]
|
||||
public required string Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("authorization_endpoint")]
|
||||
public string? AuthorizationEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("device_authorization_endpoint")]
|
||||
public string? DeviceAuthorizationEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("token_endpoint")]
|
||||
public required string TokenEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("userinfo_endpoint")]
|
||||
public required string UserInfoEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("registration_endpoint")]
|
||||
public string? RegistrationEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("jwks_uri")]
|
||||
public required string JwksUri { get; init; }
|
||||
|
||||
[JsonPropertyName("scopes_supported")]
|
||||
public required IReadOnlyList<string> ScopesSupported { get; init; }
|
||||
|
||||
[JsonPropertyName("subject_types_supported")]
|
||||
public required IReadOnlyList<string> SubjectTypesSupported { get; init; }
|
||||
|
||||
[JsonPropertyName("id_token_signing_alg_values_supported")]
|
||||
public required IReadOnlyList<string> IdTokenSigningAlgValuesSupported { get; init; }
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken;
|
||||
using PCL.Core.Minecraft.IdentityModel.OAuth;
|
||||
using PCL.Core.Minecraft.IdentityModel;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
|
||||
|
||||
public record OpenIdOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// OpenId Discovery 地址
|
||||
/// </summary>
|
||||
public required string OpenIdDiscoveryAddress { get; set; }
|
||||
/// <summary>
|
||||
/// 客户端 ID(必须设置)
|
||||
/// </summary>
|
||||
public required string ClientId
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
// 为了让 YggdrasilConnect Client 复用代码做的逻辑
|
||||
|
||||
/// <summary>
|
||||
/// 是否只使用设备代码流授权
|
||||
/// </summary>
|
||||
public bool OnlyDeviceAuthorize { get; set; }
|
||||
/// <summary>
|
||||
/// 回调 Uri
|
||||
/// </summary>
|
||||
public string? RedirectUri { get; set; }
|
||||
/// <summary>
|
||||
/// 发送 HTTP 请求时设置的请求头,仅适用于请求头(丢到 HttpRequestMessage 不会报错的那种)
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? Headers { get; set; }
|
||||
/// <summary>
|
||||
/// 是否启用 PKCE 支持,默认启用
|
||||
/// </summary>
|
||||
public bool EnablePkceSupport { get; set; } = true;
|
||||
/// <summary>
|
||||
/// 获取 HttpClient,生命周期由调用方管理
|
||||
/// </summary>
|
||||
public required Func<HttpClient> GetClient { get; set; }
|
||||
/// <summary>
|
||||
/// OpenId 元数据,请勿自行设置此属性,而是应该调用 <see cref="InitializeAsync"/>
|
||||
/// </summary>
|
||||
public OpenIdMetadata? Meta { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// 从互联网拉取 OpenID 配置信息
|
||||
/// </summary>
|
||||
/// <param name="token"></param>
|
||||
public virtual async Task InitializeAsync(CancellationToken token)
|
||||
{
|
||||
using var response = await HttpRequest
|
||||
.Create(OpenIdDiscoveryAddress)
|
||||
.WithHeaders(Headers ?? [])
|
||||
.SendAsync(GetClient.Invoke(), cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Meta = await response
|
||||
.AsJsonAsync<OpenIdMetadata>(cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取 Json Web Key
|
||||
/// </summary>
|
||||
/// <param name="kid">密钥 ID</param>
|
||||
/// <param name="token"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/>,或找不到匹配的 Jwk</exception>
|
||||
public async Task<JsonWebKey> GetSignatureKeyAsync(string kid,CancellationToken token)
|
||||
{
|
||||
if (Meta?.JwksUri is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 加载 OpenID 元数据");
|
||||
using var response = await HttpRequest.Create(Meta.JwksUri)
|
||||
.WithHeaders(Headers ?? [])
|
||||
.SendAsync(GetClient.Invoke(), cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var result = await response
|
||||
.AsJsonAsync<JsonWebKeys>(cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
return result?.Keys.SingleOrDefault(k => k.Kid == kid)
|
||||
?? throw new IdentityModelConfigurationException($"找不到匹配的 Jwk:{kid}");
|
||||
}
|
||||
/// <summary>
|
||||
/// 构建 OAuth 客户端配置
|
||||
/// </summary>
|
||||
/// <returns><see cref="OAuthClientOptions">OAuth 客户端选项</returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/> 或缺少授权所需配置</exception>
|
||||
public virtual OAuthClientOptions BuildOAuthOptions()
|
||||
{
|
||||
if (Meta is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 加载 OpenID 元数据");
|
||||
if (string.IsNullOrEmpty(Meta.TokenEndpoint))
|
||||
throw new IdentityModelConfigurationException("OpenID 元数据缺少 TokenEndpoint");
|
||||
if (!OnlyDeviceAuthorize && string.IsNullOrEmpty(RedirectUri))
|
||||
throw new IdentityModelConfigurationException("授权代码流需要设置 RedirectUri");
|
||||
return new OAuthClientOptions
|
||||
{
|
||||
GetClient = GetClient,
|
||||
ClientId = ClientId,
|
||||
RedirectUri = OnlyDeviceAuthorize ? string.Empty:RedirectUri!,
|
||||
Meta = new EndpointMeta
|
||||
{
|
||||
AuthorizeEndpoint = Meta?.AuthorizationEndpoint??string.Empty,
|
||||
DeviceEndpoint = Meta?.DeviceAuthorizationEndpoint??string.Empty,
|
||||
TokenEndpoint = Meta!.TokenEndpoint,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Extensions.Pkce;
|
||||
|
||||
public enum PkceChallengeOptions
|
||||
{
|
||||
Sha256,
|
||||
PlainText
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Minecraft.IdentityModel.OAuth;
|
||||
using PCL.Core.Utils.Hash;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Extensions.Pkce;
|
||||
|
||||
/// <summary>
|
||||
/// 带 PKCE 支持的客户端 <br/>
|
||||
/// 此客户端并非线程安全,请勿在多个线程间共享示例
|
||||
/// </summary>
|
||||
/// <param name="options"></param>
|
||||
public class PkceClient(OAuthClientOptions options):IOAuthClient
|
||||
{
|
||||
private byte[] _ChallengeCode { get; set; } = new byte[32];
|
||||
private bool _isCallGetAuthorizeUrl;
|
||||
/// <summary>
|
||||
/// 设置验证方法,支持 PlainText 和 SHA256
|
||||
/// </summary>
|
||||
public PkceChallengeOptions ChallengeMethod { get; private set; } = PkceChallengeOptions.Sha256;
|
||||
private readonly SimpleOAuthClient _client = new(options);
|
||||
/// <summary>
|
||||
/// 获取授权地址
|
||||
/// </summary>
|
||||
/// <param name="scopes"></param>
|
||||
/// <param name="state"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
public string GetAuthorizeUrl(string[] scopes, string state, Dictionary<string, string>? extData)
|
||||
{
|
||||
RandomNumberGenerator.Fill(_ChallengeCode);
|
||||
extData ??= [];
|
||||
extData["code_challenge"] = ChallengeMethod == PkceChallengeOptions.Sha256
|
||||
? SHA256Provider.Instance.ComputeHash(_ChallengeCode).ToHexString()
|
||||
: _ChallengeCode.FromBytesToB64UrlSafe();
|
||||
extData["code_challenge_method"] = ChallengeMethod == PkceChallengeOptions.Sha256 ? "S256":"plain";
|
||||
_isCallGetAuthorizeUrl = true;
|
||||
return _client.GetAuthorizeUrl(scopes, state, extData);
|
||||
}
|
||||
/// <summary>
|
||||
/// 使用授权代码兑换令牌
|
||||
/// </summary>
|
||||
/// <param name="code"></param>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
public async Task<AuthorizeResult?> AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
if (!_isCallGetAuthorizeUrl) throw new InvalidOperationException("Challenge code is invalid");
|
||||
var pkce = _ChallengeCode.FromBytesToB64UrlSafe();
|
||||
extData ??= [];
|
||||
extData["code_verifier"] = pkce;
|
||||
_isCallGetAuthorizeUrl = false;
|
||||
return await _client.AuthorizeWithCodeAsync(code, token, extData);
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取代码对
|
||||
/// </summary>
|
||||
/// <param name="scopes"></param>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<DeviceCodeData?> GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
return await _client.GetCodePairAsync(scopes, token, extData);
|
||||
}
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<AuthorizeResult?> AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
return await _client.AuthorizeWithDeviceAsync(data, token, extData);
|
||||
}
|
||||
|
||||
public async Task<AuthorizeResult?> AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
return await _client.AuthorizeWithSilentAsync(data, token, extData);
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Minecraft.IdentityModel;
|
||||
using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
|
||||
using PCL.Core.Minecraft.IdentityModel.OAuth;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect;
|
||||
|
||||
// Steven Qiu 说这东西完全就是 OpenId + 魔改了一部分,所以可以直接复用 OpenId 的逻辑
|
||||
|
||||
/// <summary>
|
||||
/// Yggdrasil Connect Client 客户端
|
||||
/// </summary>
|
||||
public class YggdrasilClient:IOAuthClient
|
||||
{
|
||||
|
||||
private OpenIdClient? _client;
|
||||
|
||||
private YggdrasilOptions _options;
|
||||
|
||||
public YggdrasilClient(YggdrasilOptions options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化并拉取网络配置
|
||||
/// </summary>
|
||||
/// <exception cref="IdentityModelConfigurationException">无法获取 ClientId 或 OpenID 元数据无效</exception>
|
||||
/// <param name="token"></param>
|
||||
public async Task InitializeAsync(CancellationToken token)
|
||||
{
|
||||
_client = new OpenIdClient(_options);
|
||||
await _client.InitializeAsync(token, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取授权端点地址
|
||||
/// </summary>
|
||||
/// <param name="scopes">Yggdrasil Connect 规范所规定的权限</param>
|
||||
/// <param name="state">用于关联会话</param>
|
||||
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
|
||||
/// <returns>OAuth 授权地址</returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
|
||||
public string GetAuthorizeUrl(string[] scopes, string state, Dictionary<string, string>? extData)
|
||||
{
|
||||
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
|
||||
return _client.GetAuthorizeUrl(scopes, state, extData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用授权代码兑换令牌
|
||||
/// </summary>
|
||||
/// <param name="code">授权代码</param>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
|
||||
/// <returns><see cref="AuthorizeResult"> 授权结果</returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
|
||||
public async Task<AuthorizeResult?> AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
|
||||
return await _client.AuthorizeWithCodeAsync(code, token, extData);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取代码对
|
||||
/// </summary>
|
||||
/// <param name="scopes">Yggdrasil Connect 规范所规定的权限</param>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
|
||||
/// <returns><see cref="DeviceCodeData"> 设备流数据</returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
|
||||
public async Task<DeviceCodeData?> GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
|
||||
return await _client.GetCodePairAsync(scopes, token, extData);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发起一次请求验证用户授权状态
|
||||
/// </summary>
|
||||
/// <param name="data"><see cref="DeviceCodeData"> 设备流数据</param>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
|
||||
/// <returns><see cref="AuthorizeResult"> 授权结果</returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
|
||||
public async Task<AuthorizeResult?> AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
|
||||
return await _client.AuthorizeWithDeviceAsync(data, token, extData);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新登录
|
||||
/// </summary>
|
||||
/// <param name="data"><see cref="AuthorizeResult"> 先前的授权结果</param>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
|
||||
/// <returns><see cref="AuthorizeResult"> 授权结果</returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
|
||||
public async Task<AuthorizeResult?> AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
|
||||
return await _client.AuthorizeWithSilentAsync(data, token, extData);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect;
|
||||
|
||||
public record YggdrasilConnectMetaData: OpenIdMetadata
|
||||
{
|
||||
[JsonPropertyName("shared_client_id")]
|
||||
public string? SharedClientId { get; init; }
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Minecraft.IdentityModel;
|
||||
using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
|
||||
using PCL.Core.Minecraft.IdentityModel.OAuth;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect;
|
||||
|
||||
public record YggdrasilOptions:OpenIdOptions
|
||||
{
|
||||
private string[] _scopesRequired = ["openid", "Yggdrasil.PlayerProfiles.Select", "Yggdrasil.Server.Join"];
|
||||
|
||||
// 重写这个鬼方法是因为 Yggdrasil Connect 有要求(
|
||||
|
||||
/// <summary>
|
||||
/// 拉取 Yggdrasil 配置
|
||||
/// </summary>
|
||||
/// <param name="token"></param>
|
||||
/// <exception cref="IdentityModelConfigurationException">无法加载元数据或缺少必要 scope</exception>
|
||||
public override async Task InitializeAsync(CancellationToken token)
|
||||
{
|
||||
using var response = await HttpRequest
|
||||
.Create(OpenIdDiscoveryAddress)
|
||||
.WithHeaders(Headers ?? [])
|
||||
.SendAsync(GetClient.Invoke(), cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Meta = (await response.AsJsonAsync<YggdrasilConnectMetaData>(cancellationToken: token).ConfigureAwait(false))
|
||||
?? throw new IdentityModelConfigurationException("无法加载 Yggdrasil Connect 元数据");
|
||||
|
||||
var missingScopes = _scopesRequired.Except(Meta.ScopesSupported).ToArray();
|
||||
if (missingScopes.Length > 0)
|
||||
throw new IdentityModelConfigurationException($"Yggdrasil Connect 元数据缺少必要 scope:{string.Join(", ", missingScopes)}");
|
||||
}
|
||||
/// <summary>
|
||||
/// 构建 OAuth 客户端选项
|
||||
/// </summary>
|
||||
/// <returns><see cerf="OAuthClientOptions"> OAuth 客户端选项</returns>
|
||||
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/> 或缺少必要的客户端配置</exception>
|
||||
public override OAuthClientOptions BuildOAuthOptions()
|
||||
{
|
||||
if (Meta is YggdrasilConnectMetaData meta)
|
||||
{
|
||||
var options = base.BuildOAuthOptions();
|
||||
if (!options.ClientId.IsNullOrEmpty()) return options;
|
||||
if (!meta.SharedClientId.IsNullOrEmpty())
|
||||
{
|
||||
options.ClientId = meta.SharedClientId;
|
||||
return options;
|
||||
}
|
||||
|
||||
throw new IdentityModelConfigurationException("Yggdrasil Connect 需要设置 ClientId,或由元数据提供 sharedClientId");
|
||||
}
|
||||
|
||||
throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 加载 Yggdrasil Connect 元数据");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel;
|
||||
|
||||
/// <summary>
|
||||
/// IdentityModel 模块异常基类。
|
||||
/// </summary>
|
||||
public class IdentityModelException(string message, Exception? innerException = null) : Exception(message, innerException);
|
||||
|
||||
/// <summary>
|
||||
/// IdentityModel 配置或元数据不满足当前认证流程要求时抛出的异常。
|
||||
/// </summary>
|
||||
public class IdentityModelConfigurationException(string message, Exception? innerException = null)
|
||||
: IdentityModelException(message, innerException);
|
||||
|
||||
/// <summary>
|
||||
/// 认证服务器返回 OAuth/Yggdrasil 协议错误时抛出的异常。
|
||||
/// </summary>
|
||||
/// <param name="error">协议错误代码。</param>
|
||||
/// <param name="errorDescription">协议错误描述。</param>
|
||||
/// <param name="innerException">内部异常。</param>
|
||||
public class IdentityModelAuthenticationException(
|
||||
string? error,
|
||||
string? errorDescription,
|
||||
Exception? innerException = null)
|
||||
: IdentityModelException(_BuildMessage(error, errorDescription), innerException)
|
||||
{
|
||||
/// <summary>
|
||||
/// 协议错误代码。
|
||||
/// </summary>
|
||||
public string? Error { get; } = error;
|
||||
|
||||
/// <summary>
|
||||
/// 协议错误描述。
|
||||
/// </summary>
|
||||
public string? ErrorDescription { get; } = errorDescription;
|
||||
|
||||
private static string _BuildMessage(string? error, string? errorDescription)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(error) && !string.IsNullOrWhiteSpace(errorDescription))
|
||||
return $"认证失败:{error} - {errorDescription}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorDescription)) return $"认证失败:{errorDescription}";
|
||||
if (!string.IsNullOrWhiteSpace(error)) return $"认证失败:{error}";
|
||||
return "认证失败";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
|
||||
|
||||
public record AuthorizeResult
|
||||
{
|
||||
public bool IsError => !Error.IsNullOrEmpty();
|
||||
/// <summary>
|
||||
/// 错误类型 (e.g. invalid_request)
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")] public string? Error { get; init; }
|
||||
/// <summary>
|
||||
/// 描述此错误的文本
|
||||
/// </summary>
|
||||
[JsonPropertyName("error_description")] public string? ErrorDescription { get; init; }
|
||||
|
||||
// 不用 SecureString,因为这东西依赖 DPAPI,不是最佳实践
|
||||
|
||||
/// <summary>
|
||||
/// 访问令牌
|
||||
/// </summary>
|
||||
[JsonPropertyName("access_token")] public string? AccessToken { get; init; }
|
||||
/// <summary>
|
||||
/// 刷新令牌
|
||||
/// </summary>
|
||||
[JsonPropertyName("refresh_token")] public string? RefreshToken { get; init; }
|
||||
/// <summary>
|
||||
/// ID Token
|
||||
/// </summary>
|
||||
[JsonPropertyName("id_token")] public string? IdToken { get; init; }
|
||||
/// <summary>
|
||||
/// 令牌类型
|
||||
/// </summary>
|
||||
[JsonPropertyName("token_type")] public string? TokenType { get; init; }
|
||||
/// <summary>
|
||||
/// 过期时间
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_in")] public int? ExpiresIn { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
using PCL.Core.Minecraft.IdentityModel;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
|
||||
|
||||
/// <summary>
|
||||
/// OAuth 客户端实现,配合 Polly 食用效果更佳
|
||||
/// </summary>
|
||||
/// <param name="options">OAuth 参数</param>
|
||||
public sealed class SimpleOAuthClient(OAuthClientOptions options):IOAuthClient
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取授权 Url
|
||||
/// </summary>
|
||||
/// <param name="scopes">访问权限列表</param>
|
||||
/// <param name="state"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
public string GetAuthorizeUrl(string[] scopes,string state,Dictionary<string,string>? extData = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(options.Meta.AuthorizeEndpoint);
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(options.Meta.AuthorizeEndpoint);
|
||||
sb.Append($"?response_type=code&scope={Uri.EscapeDataString(string.Join(" ", scopes))}");
|
||||
sb.Append($"&redirect_uri={Uri.EscapeDataString(options.RedirectUri)}");
|
||||
sb.Append($"&client_id={options.ClientId}&state={state}");
|
||||
if (extData is null) return sb.ToString();
|
||||
foreach (var kvp in extData)
|
||||
sb.Append($"&{kvp.Key}={Uri.EscapeDataString(kvp.Value)}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用授权代码获取令牌
|
||||
/// </summary>
|
||||
/// <param name="code">授权代码</param>
|
||||
/// <param name="extData">附加属性,不应该包含必须参数和预定义字段 (e.g. client_id、grant_type)</param>
|
||||
/// <param name="token"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<AuthorizeResult?> AuthorizeWithCodeAsync(
|
||||
string code,CancellationToken token,Dictionary<string,string>? extData = null
|
||||
)
|
||||
{
|
||||
extData ??= new Dictionary<string, string>();
|
||||
extData["client_id"] = options.ClientId;
|
||||
extData["grant_type"] = "authorization_code";
|
||||
extData["code"] = code;
|
||||
var client = options.GetClient.Invoke();
|
||||
using var content = new FormUrlEncodedContent(extData);
|
||||
using var response = await HttpRequest
|
||||
.CreatePost(options.Meta.TokenEndpoint)
|
||||
.WithContent(content)
|
||||
.WithHeaders(options.Headers ?? [])
|
||||
.SendAsync(client, cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
return await response
|
||||
.AsJsonAsync<AuthorizeResult>(cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
/// <summary>
|
||||
/// 获取设备代码对
|
||||
/// </summary>
|
||||
/// <param name="scopes"></param>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<DeviceCodeData?> GetCodePairAsync
|
||||
(string[] scopes,CancellationToken token, Dictionary<string, string>? extData = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(options.Meta.DeviceEndpoint);
|
||||
var client = options.GetClient.Invoke();
|
||||
extData ??= new Dictionary<string, string>();
|
||||
extData["scope"] = string.Join(" ", scopes);
|
||||
extData["client_id"] = options.ClientId;
|
||||
var content = new FormUrlEncodedContent(extData);
|
||||
|
||||
using var response = await HttpRequest
|
||||
.CreatePost(options.Meta.DeviceEndpoint)
|
||||
.WithContent(content)
|
||||
.WithHeaders(options.Headers ?? [])
|
||||
.SendAsync(client, cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await response
|
||||
.AsJsonAsync<DeviceCodeData>(cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
/// <summary>
|
||||
/// 验证用户授权状态 <br/>
|
||||
/// 注:此方法不会检查是否过去了 Interval 秒,请自行处理
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="IdentityModelAuthenticationException">认证服务器返回设备授权错误</exception>
|
||||
public async Task<AuthorizeResult?> AuthorizeWithDeviceAsync
|
||||
(DeviceCodeData data,CancellationToken token,Dictionary<string,string>? extData = null)
|
||||
{
|
||||
if (data.IsError) throw new IdentityModelAuthenticationException(data.Error, data.ErrorDescription);
|
||||
var client = options.GetClient.Invoke();
|
||||
extData ??= new Dictionary<string, string>();
|
||||
extData["client_id"] = options.ClientId;
|
||||
extData["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
extData["device_code"] = data.DeviceCode!;
|
||||
|
||||
using var content = new FormUrlEncodedContent(extData);
|
||||
using var response = await HttpRequest
|
||||
.CreatePost(options.Meta.TokenEndpoint)
|
||||
.WithContent(content)
|
||||
.WithHeaders(options.Headers ?? [])
|
||||
.SendAsync(client, cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await response
|
||||
.AsJsonAsync<AuthorizeResult>(cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
/// <summary>
|
||||
/// 刷新登录
|
||||
/// </summary>
|
||||
/// <param name="data"></param>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="extData"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="IdentityModelAuthenticationException">认证服务器返回刷新授权错误</exception>
|
||||
public async Task<AuthorizeResult?> AuthorizeWithSilentAsync
|
||||
(AuthorizeResult data,CancellationToken token,Dictionary<string,string>? extData = null)
|
||||
{
|
||||
var client = options.GetClient.Invoke();
|
||||
if (data.IsError) throw new IdentityModelAuthenticationException(data.Error, data.ErrorDescription);
|
||||
extData ??= [];
|
||||
extData["refresh_token"] = data.RefreshToken!;
|
||||
extData["grant_type"] = "refresh_token";
|
||||
extData["client_id"] = options.ClientId;
|
||||
using var content = new FormUrlEncodedContent(extData);
|
||||
using var response = await HttpRequest
|
||||
.CreatePost(options.Meta.TokenEndpoint)
|
||||
.WithHeaders(options.Headers ?? [])
|
||||
.WithContent(content)
|
||||
.SendAsync(client, cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await response
|
||||
.AsJsonAsync<AuthorizeResult>(cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
|
||||
|
||||
public record OAuthClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 请求头
|
||||
/// </summary>
|
||||
public Dictionary<string,string>? Headers { get; set; }
|
||||
/// <summary>
|
||||
/// 端点数据
|
||||
/// </summary>
|
||||
public required EndpointMeta Meta { get; set; }
|
||||
public required Func<HttpClient> GetClient { get; set; }
|
||||
/// <summary>
|
||||
/// 重定向 Uri
|
||||
/// </summary>
|
||||
public required string RedirectUri { get; set; }
|
||||
/// <summary>
|
||||
/// 客户端 ID
|
||||
/// </summary>
|
||||
public required string ClientId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
|
||||
|
||||
public record DeviceCodeData
|
||||
{
|
||||
public bool IsError => !Error.IsNullOrEmpty();
|
||||
/// <summary>
|
||||
/// 错误类型
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
/// <summary>
|
||||
/// 错误描述
|
||||
/// </summary>
|
||||
[JsonPropertyName("error_description")]
|
||||
public string? ErrorDescription { get; init; }
|
||||
/// <summary>
|
||||
/// 用户授权码
|
||||
/// </summary>
|
||||
[JsonPropertyName("user_code")]
|
||||
public string? UserCode { get; init; }
|
||||
/// <summary>
|
||||
/// 设备授权码
|
||||
/// </summary>
|
||||
[JsonPropertyName("device_code")]
|
||||
public string? DeviceCode { get; init; }
|
||||
/// <summary>
|
||||
/// 验证 Uri
|
||||
/// </summary>
|
||||
[JsonPropertyName("verification_uri")]
|
||||
public string? VerificationUri { get; init; }
|
||||
/// <summary>
|
||||
/// 验证 Uri (自动填充代码)
|
||||
/// </summary>
|
||||
[JsonPropertyName("verification_uri_complete")]
|
||||
public string? VerificationUriComplete { get; init; }
|
||||
/// <summary>
|
||||
/// 轮询间隔
|
||||
/// </summary>
|
||||
[JsonPropertyName("interval")]
|
||||
public int? Interval { get; init; }
|
||||
/// <summary>
|
||||
/// 过期时间
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int? ExpiresIn { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
|
||||
|
||||
public record EndpointMeta
|
||||
{
|
||||
/// <summary>
|
||||
/// 设备授权端点
|
||||
/// </summary>
|
||||
public string? DeviceEndpoint { get; set; }
|
||||
/// <summary>
|
||||
/// 授权端点
|
||||
/// </summary>
|
||||
public required string AuthorizeEndpoint { get; set; }
|
||||
/// <summary>
|
||||
/// 令牌端点
|
||||
/// </summary>
|
||||
public required string TokenEndpoint { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
|
||||
|
||||
public interface IOAuthClient
|
||||
{
|
||||
public string GetAuthorizeUrl(string[] scopes,string state,Dictionary<string,string>? extData);
|
||||
public Task<AuthorizeResult?> AuthorizeWithCodeAsync(string code,CancellationToken token,Dictionary<string,string>? extData = null);
|
||||
public Task<DeviceCodeData?> GetCodePairAsync(string[] scopes,CancellationToken token, Dictionary<string, string>? extData = null);
|
||||
public Task<AuthorizeResult?> AuthorizeWithDeviceAsync(DeviceCodeData data,CancellationToken token,Dictionary<string,string>? extData = null);
|
||||
public Task<AuthorizeResult?> AuthorizeWithSilentAsync(AuthorizeResult data,CancellationToken token,Dictionary<string,string>? extData = null);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil;
|
||||
|
||||
/// <summary>
|
||||
/// Yggdrasil Agent
|
||||
/// </summary>
|
||||
public record Agent
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; init; } = "minecraft";
|
||||
[JsonPropertyName("version")] public int Version { get; init; } = 1;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil;
|
||||
|
||||
/// <summary>
|
||||
/// 提供 Yggdrasil 传统认证支持
|
||||
/// </summary>
|
||||
/// <param name="options"><see cref="YggdrasilLegacyAuthenticateOptions" /> 认证参数</param>
|
||||
public sealed class YggdrasilLegacyClient(YggdrasilLegacyAuthenticateOptions options)
|
||||
{
|
||||
/// <summary>
|
||||
/// 异步向服务器发送一次登录请求
|
||||
/// </summary>
|
||||
/// <param name="token"></param>
|
||||
/// <returns><see cref="YggdrasilAuthenticateResult" /> 认证结果</returns>
|
||||
/// <exception cref="ArgumentException">用户名或密码无效</exception>
|
||||
public async Task<YggdrasilAuthenticateResult?> AuthenticateAsync(CancellationToken token)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(options.Username);
|
||||
ArgumentException.ThrowIfNullOrEmpty(options.Password);
|
||||
|
||||
var credential = new YggdrasilCredential
|
||||
{
|
||||
User = options.Username,
|
||||
Password = options.Password
|
||||
};
|
||||
var address = $"{options.YggdrasilApiLocation}/authserver/authenticate";
|
||||
|
||||
using var response = await HttpRequest
|
||||
.CreatePost(address)
|
||||
.WithHeaders(options.Headers ?? [])
|
||||
.WithJsonContent(credential)
|
||||
.SendAsync(options.GetClient.Invoke(), cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await response
|
||||
.AsJsonAsync<YggdrasilAuthenticateResult>(cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步向服务器发送一次刷新请求
|
||||
/// </summary>
|
||||
/// <param name="token"></param>
|
||||
/// <param name="seleectedProfile">如果需要选择角色,请填写此参数</param>
|
||||
public async Task<YggdrasilAuthenticateResult?> RefreshAsync(CancellationToken token, Profile? seleectedProfile)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(options.AccessToken);
|
||||
|
||||
var refreshData = new YggdrasilRefresh
|
||||
{
|
||||
AccessToken = options.AccessToken
|
||||
};
|
||||
if (seleectedProfile is not null) refreshData.SelectedProfile = seleectedProfile;
|
||||
|
||||
var address = $"{options.YggdrasilApiLocation}/authserver/refresh";
|
||||
|
||||
using var response = await HttpRequest
|
||||
.CreatePost(address)
|
||||
.WithJsonContent(refreshData)
|
||||
.WithHeaders(options.Headers ?? [])
|
||||
.SendAsync(options.GetClient.Invoke(), cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await response
|
||||
.AsJsonAsync<YggdrasilAuthenticateResult>(cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步向服务器发送一次验证请求
|
||||
/// </summary>
|
||||
/// <param name="token"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> ValidateAsync(CancellationToken token)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(options.AccessToken);
|
||||
|
||||
var validateData = new YggdrasilRefresh
|
||||
{
|
||||
AccessToken = options.AccessToken
|
||||
};
|
||||
var address = $"{options.YggdrasilApiLocation}/authserver/invalidate";
|
||||
|
||||
using var response = await HttpRequest
|
||||
.CreatePost(address)
|
||||
.WithHeaders(options.Headers ?? [])
|
||||
.WithJsonContent(validateData)
|
||||
.SendAsync(options.GetClient.Invoke(), cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return response.StatusCode == HttpStatusCode.NoContent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步向服务器发送一次注销请求
|
||||
/// </summary>
|
||||
/// <param name="token"></param>
|
||||
public async Task InvalidateAsync(CancellationToken token)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(options.AccessToken);
|
||||
|
||||
var validateData = new YggdrasilRefresh
|
||||
{
|
||||
AccessToken = options.AccessToken
|
||||
};
|
||||
var address = $"{options.YggdrasilApiLocation}/authserver/invalidate";
|
||||
|
||||
using var _ = await HttpRequest
|
||||
.CreatePost(address)
|
||||
.WithHeaders(options.Headers ?? [])
|
||||
.WithJsonContent(validateData)
|
||||
.SendAsync(options.GetClient.Invoke(), cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步向服务器发送登出请求 <br />
|
||||
/// 这会立刻注销所有会话,无论当前会话是否属于调用方
|
||||
/// </summary>
|
||||
/// <param name="token"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<(bool IsSuccess, string ErrorDescription)> SignOutAsync(CancellationToken token)
|
||||
{
|
||||
// 不想写 Model 了,就这样吧(趴
|
||||
var signoutData = new JsonObject
|
||||
{
|
||||
["username"] = options.Username,
|
||||
["password"] = options.Password
|
||||
};
|
||||
var address = $"{options.YggdrasilApiLocation}/authserver/signout";
|
||||
|
||||
using var response = await HttpRequest
|
||||
.CreatePost(address)
|
||||
.WithHeaders(options.Headers ?? [])
|
||||
.WithJsonContent(signoutData)
|
||||
.SendAsync(options.GetClient.Invoke(), cancellationToken: token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
return (true, string.Empty);
|
||||
|
||||
var content = await response.AsStringAsync(token).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return (false, string.Empty);
|
||||
|
||||
JsonNode? data;
|
||||
try
|
||||
{
|
||||
data = JsonCompat.ParseNode(content);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 认证服务器偶尔会返回纯文本或 HTML 错误页,保留原始响应便于上层展示和诊断。
|
||||
return (false, content);
|
||||
}
|
||||
|
||||
var error = data?["errorMessage"]?.ToString() ?? string.Empty;
|
||||
return (false, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil;
|
||||
|
||||
public record YggdrasilLegacyAuthenticateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// API 基地址 (e.g. https://api.example.com/api/yggdrasil)
|
||||
/// </summary>
|
||||
public required string YggdrasilApiLocation { get; set; }
|
||||
/// <summary>
|
||||
/// 用户名
|
||||
/// </summary>
|
||||
public string? Username { get; set; }
|
||||
/// <summary>
|
||||
/// 密码
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
/// <summary>
|
||||
/// 访问令牌
|
||||
/// </summary>
|
||||
public string? AccessToken { get; set; }
|
||||
public required Func<HttpClient> GetClient { get; set; }
|
||||
/// <summary>
|
||||
/// 请求头
|
||||
/// </summary>
|
||||
public Dictionary<string,string>? Headers { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil;
|
||||
|
||||
|
||||
public record Profile
|
||||
{
|
||||
/// <summary>
|
||||
/// UUID
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
/// <summary>
|
||||
/// 档案名称
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")] public string? Name { get; init; }
|
||||
/// <summary>
|
||||
/// 属性信息
|
||||
/// </summary>
|
||||
[JsonPropertyName("properties")] public PlayerProperty[]? Properties { get; init; }
|
||||
}
|
||||
|
||||
public record PlayerProperty
|
||||
{
|
||||
/// <summary>
|
||||
/// 属性名称
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
/// <summary>
|
||||
/// 属性值
|
||||
/// </summary>
|
||||
[JsonPropertyName("value")] public required string Value { get; init; }
|
||||
/// <summary>
|
||||
/// 数字签名
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature")] public string? Signature { get; init; }
|
||||
}
|
||||
|
||||
public record PlayerTextureProperty
|
||||
{
|
||||
/// <summary>
|
||||
/// Unix 时间戳
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")] public required long Timestamp { get; init; }
|
||||
/// <summary>
|
||||
/// 所有者的 UUID
|
||||
/// </summary>
|
||||
[JsonPropertyName("profileId")] public required string ProfileId { get; init; }
|
||||
/// <summary>
|
||||
/// 所有者名称
|
||||
/// </summary>
|
||||
[JsonPropertyName("profileName")] public required string ProfileName { get; init; }
|
||||
/// <summary>
|
||||
/// 材质信息
|
||||
/// </summary>
|
||||
[JsonPropertyName("textures")] public required PlayerTextures Textures { get; init; }
|
||||
}
|
||||
|
||||
public record PlayerTextures
|
||||
{
|
||||
/// <summary>
|
||||
/// 皮肤
|
||||
/// </summary>
|
||||
[JsonPropertyName("skin")] public required PlayerTexture Skin { get; init; }
|
||||
/// <summary>
|
||||
/// 披风
|
||||
/// </summary>
|
||||
[JsonPropertyName("cape")] public required PlayerTexture Cape { get; init; }
|
||||
}
|
||||
|
||||
public record PlayerTexture
|
||||
{
|
||||
/// <summary>
|
||||
/// 材质地址
|
||||
/// </summary>
|
||||
[JsonPropertyName("Url")] public required string Url { get; init; }
|
||||
/// <summary>
|
||||
/// 元数据
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")] public required PlayerTextureMetadata Metadata { get; init; }
|
||||
}
|
||||
|
||||
public record PlayerTextureMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// 模型信息 (e.g. Steven -> default, Alex -> Slim)
|
||||
/// </summary>
|
||||
[JsonPropertyName("model")] public required string Model { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil;
|
||||
|
||||
public record YggdrasilCredential
|
||||
{
|
||||
[JsonPropertyName("username")] public required string User { get; init; }
|
||||
[JsonPropertyName("password")] public required string Password { get; init; }
|
||||
[JsonPropertyName("agent")] public Agent Agent { get; init; } = new();
|
||||
[JsonPropertyName("requestUser")] public bool RequestUser { get; set; }
|
||||
}
|
||||
|
||||
public record YggdrasilAuthenticateResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 错误类型
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")] public string? Error { get; init; }
|
||||
/// <summary>
|
||||
/// 错误消息
|
||||
/// </summary>
|
||||
[JsonPropertyName("errorMessage")] public string? ErrorMessage { get; init; }
|
||||
/// <summary>
|
||||
/// 访问令牌
|
||||
/// </summary>
|
||||
[JsonPropertyName("accessToken")] public string? AccessToken { get; init; }
|
||||
/// <summary>
|
||||
/// 客户端令牌,基本没用
|
||||
/// </summary>
|
||||
[JsonPropertyName("clientToken")] public string? ClientToken { get; init; }
|
||||
/// <summary>
|
||||
/// 选择的档案
|
||||
/// </summary>
|
||||
[JsonPropertyName("selectedProfile")] public Profile? SelectedProfile { get; init; }
|
||||
/// <summary>
|
||||
/// 可用档案
|
||||
/// </summary>
|
||||
[JsonPropertyName("availableProfiles")] public Profile[]? AvailableProfiles { get; init; }
|
||||
/// <summary>
|
||||
/// 用户信息
|
||||
/// </summary>
|
||||
[JsonPropertyName("user")] public Profile? User { get; init; }
|
||||
}
|
||||
|
||||
public record YggdrasilRefresh
|
||||
{
|
||||
[JsonPropertyName("accessToken")] public required string AccessToken { get; set; }
|
||||
[JsonPropertyName("selectedProfile")] public Profile? SelectedProfile { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace PCL.Core.Minecraft.Java;
|
||||
|
||||
public enum JavaBrandType
|
||||
{
|
||||
EclipseTemurin,
|
||||
Liberica,
|
||||
Zulu,
|
||||
Corretto,
|
||||
Microsoft,
|
||||
IBMSemeru,
|
||||
Oracle,
|
||||
Dragonwell,
|
||||
TencentKona,
|
||||
OpenJDK,
|
||||
GraalVmCommunity,
|
||||
JetBrains,
|
||||
Unknown
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace PCL.Core.Minecraft.Java;
|
||||
public class JavaConsts
|
||||
{
|
||||
public static readonly string[] ExcludeFolderNames = ["javapath", "java8path", "common files", "netease"];
|
||||
|
||||
public static readonly string[] MostPossibleKeywords =
|
||||
[
|
||||
"java", "jdk", "jre",
|
||||
"dragonwell", "azul", "zulu", "oracle", "open", "amazon", "corretto",
|
||||
"eclipse", "temurin", "hotspot", "semeru", "kona", "bellsoft"
|
||||
];
|
||||
|
||||
public static readonly string[] PossibleKeywords =
|
||||
[
|
||||
"environment", "env", "runtime", "x86_64", "amd64", "arm64", "x64",
|
||||
"pcl", "hmcl", "baka", "minecraft"
|
||||
];
|
||||
|
||||
public static readonly string[] AllKeywords = [.. PossibleKeywords, .. MostPossibleKeywords];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using PCL.Core.Minecraft.Java;
|
||||
|
||||
namespace PCL.Core.Minecraft;
|
||||
|
||||
public sealed class JavaEntry
|
||||
{
|
||||
public required JavaInstallation Installation { get; init; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public JavaSource Source { get; set; } = JavaSource.AutoScanned;
|
||||
|
||||
public override string ToString() =>
|
||||
$"{(IsEnabled ? "[✓]" : "[ ]")} {Installation}";
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using PCL.Core.Utils;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace PCL.Core.Minecraft.Java;
|
||||
|
||||
public sealed record JavaInstallation(
|
||||
string JavaFolder,
|
||||
Version Version,
|
||||
JavaBrandType Brand,
|
||||
MachineType Architecture,
|
||||
bool Is64Bit,
|
||||
bool IsJre)
|
||||
{
|
||||
public string JavaExePath => Path.Combine(JavaFolder, "java.exe");
|
||||
public string? JavawExePath
|
||||
{
|
||||
get
|
||||
{
|
||||
var javaw = Path.Combine(JavaFolder, "javaw.exe");
|
||||
return File.Exists(javaw) ? javaw : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Java 主版本号(处理 1.8 → 8 的映射)
|
||||
/// </summary>
|
||||
public int MajorVersion => Version.Major == 1 ? Version.Minor : Version.Major;
|
||||
|
||||
/// <summary>
|
||||
/// 检查物理文件是否存在(合理查询,非状态存储)
|
||||
/// </summary>
|
||||
public bool IsStillAvailable => File.Exists(JavaExePath);
|
||||
|
||||
public override string ToString() =>
|
||||
$"{(IsJre ? "JRE" : "JDK")} {MajorVersion} {Brand} {(Is64Bit ? "64 Bit" : "32 Bit")} | {JavaFolder}";
|
||||
|
||||
public string ToDetailedString() =>
|
||||
$"{(IsJre ? "JRE" : "JDK")} {Version} {Brand} {(Is64Bit ? "64 Bit" : "32 Bit")} | {JavaFolder}";
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Minecraft.Java;
|
||||
using PCL.Core.Minecraft.Java.Parser;
|
||||
using PCL.Core.Minecraft.Java.Scanner;
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.Json;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.Minecraft;
|
||||
|
||||
public class JavaManager
|
||||
{
|
||||
private const string ModuleName = "JavaManager";
|
||||
private readonly Dictionary<string, JavaEntry> _javaEntrys = new();
|
||||
|
||||
private readonly IJavaParser _parser;
|
||||
private readonly IJavaScanner[] _scanners;
|
||||
|
||||
private readonly SemaphoreSlim _scanLock = new(1, 1);
|
||||
private DateTime _lastScanTime = DateTime.MinValue;
|
||||
private static readonly TimeSpan _MinScanInterval = TimeSpan.FromSeconds(13);
|
||||
|
||||
public JavaManager(
|
||||
IJavaParser parser,
|
||||
params IJavaScanner[] scanners)
|
||||
{
|
||||
_parser = parser;
|
||||
_scanners = scanners;
|
||||
}
|
||||
|
||||
public void SaveConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
var items = _javaEntrys
|
||||
.Select(x => new JavaStorageItem()
|
||||
{
|
||||
Path = x.Value.Installation.JavaExePath,
|
||||
IsEnable = x.Value.IsEnabled,
|
||||
Source = x.Value.Source
|
||||
})
|
||||
.ToArray();
|
||||
States.Game.JavaList = JsonSerializer.Serialize(items, JsonCompat.SerializerOptions);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, ModuleName, "保存 Java 配置项失败");
|
||||
}
|
||||
}
|
||||
|
||||
public void ReadConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
var items = JsonSerializer.Deserialize<JavaStorageItem[]>(States.Game.JavaList, JsonCompat.SerializerOptions);
|
||||
if (items is null) return;
|
||||
|
||||
var itemsAdded = new List<JavaEntry>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var parserResult = _parser.Parse(item.Path);
|
||||
if (parserResult is null)
|
||||
{
|
||||
LogWrapper.Trace(ModuleName, $"Can not find Java {item.Path}, skip");
|
||||
continue;
|
||||
}
|
||||
|
||||
itemsAdded.Add(new JavaEntry() {
|
||||
Installation = parserResult,
|
||||
IsEnabled = item.IsEnable,
|
||||
Source = item.Source ?? JavaSource.AutoScanned
|
||||
});
|
||||
}
|
||||
|
||||
lock (_javaEntrys)
|
||||
{
|
||||
foreach(var item in itemsAdded)
|
||||
{
|
||||
if (_javaEntrys.TryGetValue(item.Installation.JavaExePath, out var existingRecord))
|
||||
{
|
||||
existingRecord.IsEnabled = item.IsEnabled;
|
||||
existingRecord.Source = item.Source;
|
||||
}
|
||||
else
|
||||
{
|
||||
_javaEntrys.Add(item.Installation.JavaExePath, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, ModuleName, "无法读取 Java 配置项");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫描 Java 安装
|
||||
/// </summary>
|
||||
public async Task ScanJavaAsync(bool force = false)
|
||||
{
|
||||
if (ShouldSkip()) return;
|
||||
|
||||
if (!await _scanLock.WaitAsync(TimeSpan.FromSeconds(7))) return;
|
||||
try
|
||||
{
|
||||
if (ShouldSkip()) return;
|
||||
|
||||
await Task.Run(_ScanInternal);
|
||||
_lastScanTime = DateTime.Now;
|
||||
SaveConfig();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_scanLock.Release();
|
||||
}
|
||||
|
||||
bool ShouldSkip()
|
||||
{
|
||||
return !force && (DateTime.Now - _lastScanTime) < _MinScanInterval;
|
||||
}
|
||||
}
|
||||
|
||||
private void _ScanInternal()
|
||||
{
|
||||
var pathSet = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
Parallel.ForEach(_scanners, scanner =>
|
||||
{
|
||||
var temp = new List<string>();
|
||||
scanner.Scan(temp);
|
||||
foreach (var path in temp)
|
||||
{
|
||||
var normalized = _NormalizePath(path);
|
||||
if (!_ShouldExcludePath(normalized))
|
||||
pathSet.TryAdd(normalized, true);
|
||||
}
|
||||
});
|
||||
|
||||
var scannedEntries = pathSet.Keys
|
||||
.Select(_parser.Parse)
|
||||
.Where(inst => inst is not null)
|
||||
.Select(inst => new JavaEntry
|
||||
{
|
||||
Installation = inst!,
|
||||
IsEnabled = _javaEntrys.TryGetValue(_NormalizePath(inst!.JavaExePath), out var existingJava)
|
||||
? existingJava.IsEnabled
|
||||
: _ShouldEnableByDefault(inst!),
|
||||
Source = JavaSource.AutoScanned
|
||||
})
|
||||
.ToList();
|
||||
|
||||
lock (_javaEntrys)
|
||||
{
|
||||
foreach(var entry in scannedEntries)
|
||||
{
|
||||
_javaEntrys[entry.Installation.JavaExePath] = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool _ShouldEnableByDefault(JavaInstallation inst)
|
||||
{
|
||||
var libDir = Path.Combine(Directory.GetParent(inst.JavaFolder)!.FullName, "lib");
|
||||
var isUsable = (!inst.IsJre && File.Exists(Path.Combine(libDir, "jvm.lib"))) ||
|
||||
(inst.IsJre && File.Exists(Path.Combine(libDir, "rt.jar")));
|
||||
|
||||
return !((inst.IsJre && inst.MajorVersion > 8) ||
|
||||
(inst.Is64Bit ^ Environment.Is64BitOperatingSystem) ||
|
||||
!isUsable);
|
||||
}
|
||||
|
||||
public List<JavaEntry> GetSortedJavaList()
|
||||
{
|
||||
var ret = _javaEntrys.Values.ToList();
|
||||
ret.Sort((a, b) =>
|
||||
{
|
||||
var versionCmp = a.Installation.Version.CompareTo(b.Installation.Version);
|
||||
if (versionCmp != 0) return versionCmp;
|
||||
return a.Installation.Brand - b.Installation.Brand;
|
||||
});
|
||||
ret.Reverse();
|
||||
return ret;
|
||||
}
|
||||
|
||||
public bool Existing64BitJava()
|
||||
{
|
||||
lock (_javaEntrys)
|
||||
{
|
||||
return _javaEntrys.Any(x => x.Value.Installation.Is64Bit);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ExistAnyJava()
|
||||
{
|
||||
return _javaEntrys.Count != 0;
|
||||
}
|
||||
|
||||
public bool Exist(string javaExePath)
|
||||
{
|
||||
return _javaEntrys.ContainsKey(javaExePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取,如果没有就加入记录
|
||||
/// </summary>
|
||||
/// <param name="javaExePath"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="ArgumentException"></exception>
|
||||
public JavaEntry? AddOrGet(string javaExePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (javaExePath.IsNullOrWhiteSpace() || !File.Exists(javaExePath)) return null;
|
||||
|
||||
var installation = _parser.Parse(javaExePath);
|
||||
if (installation is null) return null;
|
||||
|
||||
var exePath = _NormalizePath(installation.JavaExePath);
|
||||
lock (_javaEntrys)
|
||||
{
|
||||
if (_javaEntrys.TryGetValue(exePath, out var ret))
|
||||
return ret;
|
||||
|
||||
var entry = new JavaEntry
|
||||
{
|
||||
Installation = installation,
|
||||
IsEnabled = _ShouldEnableByDefault(installation),
|
||||
Source = JavaSource.ManualAdded
|
||||
};
|
||||
|
||||
_javaEntrys.Add(exePath, entry);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, ModuleName, $"Failed to add or get {javaExePath}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 仅获取,如果没有不增加记录
|
||||
/// </summary>
|
||||
public JavaEntry? Get(string javaExePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (javaExePath.IsNullOrWhiteSpace() || !File.Exists(javaExePath)) return null;
|
||||
|
||||
var installation = _parser.Parse(javaExePath);
|
||||
if (installation is null) return null;
|
||||
|
||||
var exePath = _NormalizePath(installation.JavaExePath);
|
||||
lock (_javaEntrys)
|
||||
{
|
||||
if (_javaEntrys.TryGetValue(exePath, out var ret))
|
||||
return ret;
|
||||
|
||||
var entry = new JavaEntry
|
||||
{
|
||||
Installation = installation,
|
||||
IsEnabled = _ShouldEnableByDefault(installation),
|
||||
Source = JavaSource.ManualAdded
|
||||
};
|
||||
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, ModuleName, $"Failed to get {javaExePath}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JavaEntry[]> SelectSuitableJavaAsync(Version minVersion, Version maxVersion)
|
||||
{
|
||||
if (_javaEntrys.Count == 0)
|
||||
await ScanJavaAsync();
|
||||
|
||||
lock (_javaEntrys)
|
||||
{
|
||||
return _javaEntrys
|
||||
.Values.ToList()
|
||||
.Where(j => j.Installation.IsStillAvailable && j.IsEnabled &&
|
||||
IsVersionSuitable(j.Installation.Version, minVersion, maxVersion))
|
||||
.OrderBy(static j => j.Installation.MajorVersion) // 确保首要选择的大版本正确
|
||||
.ThenBy(static j => j.Installation.IsJre) // JDK 优先
|
||||
.ThenBy(static j => j.Installation.Brand) // Java 发行版优选
|
||||
.ThenByDescending(static j => j.Installation.Version) // 优选后小版本号较高的版本
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public void CheckAllAvailability()
|
||||
{
|
||||
lock (_javaEntrys)
|
||||
{
|
||||
var keys4Remove = _javaEntrys
|
||||
.Where(kv => !kv.Value.Installation.IsStillAvailable)
|
||||
.Select(kv => kv.Key)
|
||||
.ToArray();
|
||||
foreach (var key in keys4Remove)
|
||||
_javaEntrys.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 路径工具 =====
|
||||
private static string _NormalizePath(string path) =>
|
||||
Path.GetFullPath(path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
private static bool _ShouldExcludePath(string path) =>
|
||||
path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
.Any(part => JavaConsts.ExcludeFolderNames.Contains(part, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 将 Java 版本规范化为统一比较格式(1.8.0 → 8.0.0)
|
||||
/// </summary>
|
||||
public static Version NormalizeVersion(Version version) =>
|
||||
version.Major == 1 && version.Minor >= 0
|
||||
? new Version(version.Minor, Math.Max(version.Build, 0), Math.Max(version.Revision, 0))
|
||||
: version;
|
||||
|
||||
// ===== 版本处理工具 =====
|
||||
|
||||
/// <summary>
|
||||
/// 检查版本是否在指定范围内(闭区间)
|
||||
/// </summary>
|
||||
public static bool IsVersionSuitable(Version javaVersion, Version minVersion, Version maxVersion)
|
||||
{
|
||||
var normalizedJava = NormalizeVersion(javaVersion);
|
||||
var normalizedMin = NormalizeVersion(minVersion);
|
||||
var normalizedMax = NormalizeVersion(maxVersion);
|
||||
|
||||
return normalizedJava >= normalizedMin && normalizedJava <= normalizedMax;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using PCL.Core.Minecraft.Java.Parser;
|
||||
using PCL.Core.Minecraft.Java.Scanner;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.App.IoC;
|
||||
|
||||
namespace PCL.Core.Minecraft;
|
||||
|
||||
[LifecycleService(LifecycleState.Loaded)]
|
||||
[LifecycleScope("java", "Java 管理")]
|
||||
public sealed partial class JavaService
|
||||
{
|
||||
|
||||
private static JavaManager? _javaManager;
|
||||
public static JavaManager JavaManager => _javaManager!;
|
||||
|
||||
[LifecycleStart]
|
||||
private static async Task _StartAsync()
|
||||
{
|
||||
if (_javaManager is not null) return;
|
||||
|
||||
Context.Info("Initializing Java Manager...");
|
||||
|
||||
_javaManager = new JavaManager(
|
||||
new PeHeaderParser(),
|
||||
[
|
||||
new RegistryJavaScanner(),
|
||||
new DefaultPathsScanner(),
|
||||
new PathEnvironmentScanner(),
|
||||
new MicrosoftStoreJavaScanner(),
|
||||
new WhereCommandScanner()
|
||||
]);
|
||||
_javaManager.ReadConfig();
|
||||
|
||||
Context.Info("Lookup for local Java...");
|
||||
await _javaManager.ScanJavaAsync();
|
||||
|
||||
var logInfo = string.Join("\n\t", _javaManager.GetSortedJavaList());
|
||||
Context.Info($"Finished to scan java: \n\t{logInfo}");
|
||||
}
|
||||
|
||||
[LifecycleStop]
|
||||
private static void _Stop()
|
||||
{
|
||||
if (_javaManager is null) return;
|
||||
|
||||
_javaManager.SaveConfig();
|
||||
_javaManager = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace PCL.Core.Minecraft.Java;
|
||||
public enum JavaSource
|
||||
{
|
||||
AutoScanned,
|
||||
AutoInstalled,
|
||||
ManualAdded,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PCL.Core.Minecraft.Java;
|
||||
|
||||
public class JavaStorageItem
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public bool IsEnable { get; init; }
|
||||
public JavaSource? Source { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace PCL.Core.Minecraft.Java.Parser;
|
||||
public interface IJavaParser
|
||||
{
|
||||
JavaInstallation? Parse(string javaExePath);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace PCL.Core.Minecraft.Java.Parser;
|
||||
public class PeHeaderParser : IJavaParser
|
||||
{
|
||||
private static readonly Dictionary<string, JavaBrandType> _BrandMap = new()
|
||||
{
|
||||
["Eclipse"] = JavaBrandType.EclipseTemurin,
|
||||
["Temurin"] = JavaBrandType.EclipseTemurin,
|
||||
["Bellsoft"] = JavaBrandType.Liberica,
|
||||
["Microsoft"] = JavaBrandType.Microsoft,
|
||||
["Amazon"] = JavaBrandType.Corretto,
|
||||
["Azul"] = JavaBrandType.Zulu,
|
||||
["IBM"] = JavaBrandType.IBMSemeru,
|
||||
["Oracle"] = JavaBrandType.Oracle,
|
||||
["Tencent"] = JavaBrandType.TencentKona,
|
||||
["OpenJDK"] = JavaBrandType.OpenJDK,
|
||||
["Alibaba"] = JavaBrandType.Dragonwell,
|
||||
["GraalVM"] = JavaBrandType.GraalVmCommunity,
|
||||
["JetBrains"] = JavaBrandType.JetBrains
|
||||
};
|
||||
|
||||
public JavaInstallation? Parse(string javaExePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(javaExePath))
|
||||
return null;
|
||||
|
||||
LogWrapper.Info("Java", $"解析 {javaExePath} 的 Java 程序信息");
|
||||
|
||||
var versionInfo = FileVersionInfo.GetVersionInfo(javaExePath);
|
||||
var fileVersion = Version.Parse(versionInfo.FileVersion ?? "0.0.0.0");
|
||||
var companyName = _NormalizeCompanyName(versionInfo);
|
||||
var brand = _DetermineBrand(companyName);
|
||||
|
||||
var javaFolder = Path.GetDirectoryName(javaExePath)!;
|
||||
var isJre = !File.Exists(Path.Combine(javaFolder, "javac.exe"));
|
||||
|
||||
var peData = PEHeaderReader.ReadPEHeader(javaExePath);
|
||||
var arch = peData.Machine;
|
||||
var is64Bit = PEHeaderReader.IsMachine64Bit(arch);
|
||||
|
||||
// 可用性检查(不影响模型创建,由调用方决定是否启用)
|
||||
var libDir = Path.Combine(Directory.GetParent(javaFolder)!.FullName, "lib");
|
||||
var isUsable = (!isJre && File.Exists(Path.Combine(libDir, "jvm.lib"))) ||
|
||||
(isJre && File.Exists(Path.Combine(libDir, "rt.jar")));
|
||||
|
||||
return new JavaInstallation(
|
||||
javaFolder,
|
||||
fileVersion,
|
||||
brand,
|
||||
arch,
|
||||
is64Bit,
|
||||
isJre
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, $"[Java] 解析 {javaExePath} 时出错");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string _NormalizeCompanyName(FileVersionInfo info)
|
||||
{
|
||||
var name = info.CompanyName ?? info.FileDescription ?? info.ProductName ?? string.Empty;
|
||||
|
||||
// 修复 Oracle/OpenJDK 混淆问题
|
||||
if (name.Contains("Oracle", StringComparison.OrdinalIgnoreCase) || name == "N/A")
|
||||
{
|
||||
if ((info.FileDescription?.Contains("Java(TM)", StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(info.ProductName?.Contains("Java(TM)", StringComparison.OrdinalIgnoreCase) ?? false))
|
||||
return "Oracle";
|
||||
return "OpenJDK";
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private static JavaBrandType _DetermineBrand(string output)
|
||||
{
|
||||
var match = _BrandMap.Keys
|
||||
.FirstOrDefault(k => output.Contains(k, StringComparison.OrdinalIgnoreCase));
|
||||
return match is not null ? _BrandMap[match] : JavaBrandType.Unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace PCL.Core.Minecraft.Java.Scanner;
|
||||
|
||||
public class DefaultPathsScanner : IJavaScanner
|
||||
{
|
||||
private const int MaxSearchDepth = 6;
|
||||
|
||||
public void Scan(ICollection<string> results)
|
||||
{
|
||||
try
|
||||
{
|
||||
var searchRoots = _GetSearchRoots();
|
||||
LogWrapper.Info($"[Java] 对下列目录进行广度关键词搜索:{Environment.NewLine}{string.Join(Environment.NewLine, searchRoots)}");
|
||||
|
||||
foreach (var root in searchRoots)
|
||||
{
|
||||
_BfsSearch(root, results);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Java", "默认路径扫描失败");
|
||||
}
|
||||
}
|
||||
|
||||
private static HashSet<string> _GetSearchRoots()
|
||||
{
|
||||
var roots = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ".minecraft", "runtime"),
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
Path.Combine(Basics.ExecutableDirectory, "PCL")
|
||||
};
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
var keyFolders = new[] { "Program Files", "Program Files (x86)" };
|
||||
var drives = DriveInfo.GetDrives()
|
||||
.Where(d => d.DriveType.Equals(DriveType.Fixed) && d.IsReady)
|
||||
.Select(d => d.Name);
|
||||
|
||||
foreach (var drive in drives)
|
||||
{
|
||||
foreach (var folder in keyFolders)
|
||||
{
|
||||
roots.Add(Path.Combine(drive, folder));
|
||||
}
|
||||
|
||||
// 根目录关键词搜索
|
||||
try
|
||||
{
|
||||
var rootDirs = Directory.EnumerateDirectories(drive)
|
||||
.Where(dir => JavaConsts.MostPossibleKeywords.Any(k =>
|
||||
Path.GetFileName(dir).Contains(k, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
foreach (var dir in rootDirs)
|
||||
roots.Add(dir);
|
||||
}
|
||||
catch (UnauthorizedAccessException) { /* 忽略无权限目录 */ }
|
||||
catch (IOException) { /* 忽略IO错误 */ }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
|
||||
|
||||
if (!string.IsNullOrEmpty(programFiles) && Directory.Exists(programFiles))
|
||||
roots.Add(programFiles);
|
||||
if (!string.IsNullOrEmpty(programFilesX86) && Directory.Exists(programFilesX86))
|
||||
roots.Add(programFilesX86);
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
private static void _BfsSearch(string rootPath, ICollection<string> results)
|
||||
{
|
||||
if (!Directory.Exists(rootPath)) return;
|
||||
|
||||
var queue = new Queue<(string Path, int Depth)>();
|
||||
queue.Enqueue((rootPath, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (current, depth) = queue.Dequeue();
|
||||
if (depth > MaxSearchDepth || !Directory.Exists(current)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var subDir in Directory.EnumerateDirectories(current))
|
||||
{
|
||||
var javaExe = Path.Combine(subDir, "java.exe");
|
||||
if (File.Exists(javaExe))
|
||||
{
|
||||
results.Add(javaExe);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_ShouldExploreDeeper(subDir))
|
||||
queue.Enqueue((subDir, depth + 1));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or DirectoryNotFoundException)
|
||||
{
|
||||
LogWrapper.Debug($"跳过目录 {current}: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Java", $"搜索目录 {current} 时出错");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool _ShouldExploreDeeper(string path)
|
||||
{
|
||||
var name = Path.GetFileName(path).AsSpan();
|
||||
|
||||
foreach (var ex in JavaConsts.ExcludeFolderNames)
|
||||
if (name.Contains(ex, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
foreach (var kw in JavaConsts.AllKeywords)
|
||||
if (name.Contains(kw, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return _IsVersionLikeDirectory(name);
|
||||
}
|
||||
|
||||
private static bool _IsVersionLikeDirectory(ReadOnlySpan<char> name)
|
||||
{
|
||||
if (name.IsEmpty || name.Length > 20)
|
||||
return false;
|
||||
|
||||
var hasDigit = false;
|
||||
foreach (var c in name)
|
||||
{
|
||||
if (char.IsDigit(c))
|
||||
{
|
||||
hasDigit = true;
|
||||
}
|
||||
else if (c != '.' && c != '_' && c != '-')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return hasDigit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.Minecraft.Java.Scanner;
|
||||
public interface IJavaScanner
|
||||
{
|
||||
void Scan(ICollection<string> results);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using PCL.Core.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace PCL.Core.Minecraft.Java.Scanner;
|
||||
public class MicrosoftStoreJavaScanner : IJavaScanner
|
||||
{
|
||||
private const string StorePackagePath =
|
||||
@"Packages\Microsoft.4297127D64EC6_8wekyb3d8bbwe\LocalCache\Local\runtime";
|
||||
|
||||
public void Scan(ICollection<string> results)
|
||||
{
|
||||
try
|
||||
{
|
||||
var basePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
StorePackagePath);
|
||||
|
||||
if (!Directory.Exists(basePath)) return;
|
||||
|
||||
// 第一级:java-runtime* 目录
|
||||
foreach (var runtimeDir in Directory.EnumerateDirectories(basePath))
|
||||
{
|
||||
if (!Path.GetFileName(runtimeDir).StartsWith("java-runtime", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// 第二级:架构目录 (windows-x64等)
|
||||
foreach (var archDir in Directory.EnumerateDirectories(runtimeDir))
|
||||
{
|
||||
// 第三级:版本目录
|
||||
foreach (var versionDir in Directory.EnumerateDirectories(archDir))
|
||||
{
|
||||
var javaExe = Path.Combine(versionDir, "bin", "java.exe");
|
||||
if (File.Exists(javaExe))
|
||||
{
|
||||
LogWrapper.Info($"[Java] 检测到 Microsoft Store Java: {javaExe}");
|
||||
results.Add(javaExe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Java", "Microsoft Store Java 扫描失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using PCL.Core.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace PCL.Core.Minecraft.Java.Scanner;
|
||||
|
||||
public class PathEnvironmentScanner : IJavaScanner
|
||||
{
|
||||
public void Scan(ICollection<string> results)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pathVar = Environment.GetEnvironmentVariable("PATH");
|
||||
if (string.IsNullOrEmpty(pathVar)) return;
|
||||
|
||||
foreach (var dir in pathVar.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
|
||||
var javaExe = Path.Combine(dir, "java.exe");
|
||||
if (File.Exists(javaExe)) results.Add(javaExe);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Java", "PATH环境变量扫描失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.Win32;
|
||||
using PCL.Core.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace PCL.Core.Minecraft.Java.Scanner;
|
||||
|
||||
public class RegistryJavaScanner : IJavaScanner
|
||||
{
|
||||
private static readonly string[] _RegistryPaths =
|
||||
[
|
||||
@"SOFTWARE\JavaSoft\Java Development Kit",
|
||||
@"SOFTWARE\JavaSoft\Java Runtime Environment",
|
||||
@"SOFTWARE\WOW6432Node\JavaSoft\Java Development Kit",
|
||||
@"SOFTWARE\WOW6432Node\JavaSoft\Java Runtime Environment"
|
||||
];
|
||||
|
||||
private static readonly string[] _BrandRegistryPaths =
|
||||
[
|
||||
@"SOFTWARE\Azul Systems\Zulu",
|
||||
@"SOFTWARE\BellSoft\Liberica"
|
||||
];
|
||||
|
||||
public void Scan(ICollection<string> results)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ScanJavaSoftRegistry(results);
|
||||
_ScanBrandRegistry(results);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Java", "注册表扫描失败");
|
||||
}
|
||||
}
|
||||
|
||||
private static void _ScanJavaSoftRegistry(ICollection<string> results)
|
||||
{
|
||||
foreach (var regPath in _RegistryPaths)
|
||||
{
|
||||
using var regKey = Registry.LocalMachine.OpenSubKey(regPath);
|
||||
if (regKey is null) continue;
|
||||
|
||||
foreach (var subKeyName in regKey.GetSubKeyNames())
|
||||
{
|
||||
using var subKey = regKey.OpenSubKey(subKeyName);
|
||||
var javaHome = subKey?.GetValue("JavaHome") as string;
|
||||
if (string.IsNullOrEmpty(javaHome) ||
|
||||
Path.GetInvalidPathChars().Any(c => javaHome.Contains(c))) continue;
|
||||
|
||||
var javaExePath = Path.Combine(javaHome, "bin", "java.exe");
|
||||
if (File.Exists(javaExePath)) results.Add(javaExePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void _ScanBrandRegistry(ICollection<string> results)
|
||||
{
|
||||
foreach (var keyPath in _BrandRegistryPaths)
|
||||
{
|
||||
using var brandKey = Registry.LocalMachine.OpenSubKey(keyPath);
|
||||
if (brandKey is null) continue;
|
||||
|
||||
foreach (var subKeyName in brandKey.GetSubKeyNames())
|
||||
{
|
||||
using var subKey = brandKey.OpenSubKey(subKeyName);
|
||||
var installPath = subKey?.GetValue("InstallationPath") as string;
|
||||
if (string.IsNullOrEmpty(installPath) ||
|
||||
Path.GetInvalidPathChars().Any(c => installPath.Contains(c))) continue;
|
||||
|
||||
var javaExePath = Path.Combine(installPath, "bin", "java.exe");
|
||||
if (File.Exists(javaExePath)) results.Add(javaExePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using PCL.Core.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace PCL.Core.Minecraft.Java.Scanner;
|
||||
|
||||
public class WhereCommandScanner : IJavaScanner
|
||||
{
|
||||
public void Scan(ICollection<string> results)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "where",
|
||||
Arguments = "java",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null) return;
|
||||
|
||||
var output = proc.StandardOutput.ReadToEnd();
|
||||
proc.WaitForExit();
|
||||
|
||||
if (proc.ExitCode != 0) return;
|
||||
|
||||
var paths = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(p => p.Trim())
|
||||
.Where(p => File.Exists(p));
|
||||
|
||||
foreach (var path in paths)
|
||||
results.Add(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Java", "where 命令扫描失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace PCL.Core.Minecraft.Java.UserPreference;
|
||||
|
||||
public record AutoSelect : JavaPreference;
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace PCL.Core.Minecraft.Java.UserPreference;
|
||||
|
||||
public record ExistingJava(string JavaExePath) : JavaPreference;
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PCL.Core.Minecraft.Java.UserPreference;
|
||||
|
||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
|
||||
[JsonDerivedType(typeof(ExistingJava), "exist")]
|
||||
[JsonDerivedType(typeof(UseGlobalPreference), "global")]
|
||||
[JsonDerivedType(typeof(UseRelativePath), "relative")]
|
||||
[JsonDerivedType(typeof(AutoSelect), "auto")]
|
||||
public abstract record JavaPreference;
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace PCL.Core.Minecraft.Java.UserPreference;
|
||||
|
||||
public record UseGlobalPreference : JavaPreference;
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace PCL.Core.Minecraft.Java.UserPreference;
|
||||
|
||||
public record UseRelativePath(string RelativePath) : JavaPreference;
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.Logging;
|
||||
|
||||
namespace PCL.Core.Minecraft.Launch.Utils;
|
||||
|
||||
public static class LaunchEnvUtils {
|
||||
private const string DebugLegacyLog4J2ConfigResource = "Resources/log4j2-legacy-debug.xml";
|
||||
private const string DebugLog4J2ConfigResource = "Resources/log4j2-debug.xml";
|
||||
|
||||
private static readonly object _ExtractLegacyDebugLog4J2ConfigLock = new();
|
||||
private static readonly object _ExtractDebugLog4J2ConfigLock = new();
|
||||
|
||||
public static string ExtractLegacyDebugLog4j2Config() => _ExtractFile(DebugLegacyLog4J2ConfigResource, "log4j2-legacy-debug.xml", _ExtractLegacyDebugLog4J2ConfigLock);
|
||||
public static string ExtractDebugLog4j2Config() => _ExtractFile(DebugLog4J2ConfigResource, "log4j2-debug.xml", _ExtractDebugLog4J2ConfigLock);
|
||||
|
||||
private static string _ExtractFile(string resourceName, string fileName, object lockObj) {
|
||||
var filePath = Path.Combine(Paths.Temp, fileName);
|
||||
LogWrapper.Info(resourceName, $"选定路径:{filePath}");
|
||||
|
||||
lock (lockObj) {
|
||||
try {
|
||||
_WriteResourceToFile(resourceName, filePath);
|
||||
} catch (Exception ex) {
|
||||
if (File.Exists(filePath)) {
|
||||
LogWrapper.Warn(ex, $"{resourceName} 文件释放失败,尝试删除后重试");
|
||||
File.Delete(filePath);
|
||||
try {
|
||||
_WriteResourceToFile(resourceName, filePath);
|
||||
} catch (Exception ex2) {
|
||||
var fallbackPath = Path.Combine(Paths.Temp, $"{Path.GetFileNameWithoutExtension(fileName)}2{Path.GetExtension(fileName)}");
|
||||
LogWrapper.Warn(ex2, $"{resourceName} 重试失败,尝试新路径:{fallbackPath}");
|
||||
_WriteResourceToFile(resourceName, fallbackPath);
|
||||
filePath = fallbackPath;
|
||||
}
|
||||
} else {
|
||||
throw new FileNotFoundException($"释放 {resourceName} 失败", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private static void _WriteResourceToFile(string resourceName, string path) {
|
||||
using var sourceStream = Basics.GetResourceStream(resourceName);
|
||||
if (sourceStream is null) {
|
||||
throw new FileNotFoundException($"资源 {resourceName} 未找到。");
|
||||
}
|
||||
|
||||
using var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 4096);
|
||||
sourceStream.CopyTo(fileStream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft;
|
||||
|
||||
/// <summary>
|
||||
/// 用于解析 Maven 包 ID 为 Uri 或 Path
|
||||
/// </summary>
|
||||
/// <param name="mavenId"></param>
|
||||
public class MavenArtifact(string mavenId)
|
||||
{
|
||||
/// <summary>
|
||||
/// 将 Maven 包 ID 转换为 Uri 或 Path
|
||||
/// </summary>
|
||||
/// <param name="uriOrPath"></param>
|
||||
/// <returns></returns>
|
||||
public string Resolve(string uriOrPath)
|
||||
{
|
||||
return $"{uriOrPath.TrimEnd('/')}{_GetMavenPath(mavenId)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析 Maven 包 ID
|
||||
/// </summary>
|
||||
/// <param name="packageId">包 ID</param>
|
||||
/// <returns>Maven 包相对路径 (以 / 开头)</returns>
|
||||
/// <exception cref="FormatException">给定的 Maven 包 ID 长度过长或过短</exception>
|
||||
private static string _GetMavenPath(string packageId)
|
||||
{
|
||||
var packageIds = packageId.Split(":");
|
||||
switch (packageIds.Length)
|
||||
{
|
||||
case 3:
|
||||
return $"/{packageIds[0].Replace(".","/")}/{packageIds[1]}/{packageIds[1]}-{packageIds[2]}.jar";
|
||||
case 4:
|
||||
if (_IsCommonPackaging(packageIds[2]))
|
||||
{
|
||||
return $"/{packageIds[0].Replace(".","/")}/{packageIds[1]}/{packageIds[1]}-{packageIds[3]}.{packageIds[2]}";
|
||||
}
|
||||
return $"/{packageIds[0].Replace(".","/")}/{packageId[1]}/{packageIds[1]}-{packageIds[2]}-{packageIds[3]}.jar";
|
||||
case 5:
|
||||
return $"{packageIds[0].Replace(".","/")}/{packageIds[1]}/{packageIds[1]}-{packageIds[3]}-{packageId[4]}.{packageId[2]}";
|
||||
default:
|
||||
throw new FormatException($"Invalid maven package id: Length is {packageIds.Length}");
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 用于检查是否是 Packaging
|
||||
/// </summary>
|
||||
private static bool _IsCommonPackaging(string name)
|
||||
{
|
||||
return name == "jar" || name == "zip" || name == "pom";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using PCL.Core.App.Localization;
|
||||
|
||||
namespace PCL.Core.Minecraft;
|
||||
|
||||
public static class McFormatter
|
||||
{
|
||||
private static readonly Dictionary<string, string> _WikiPrefixMap = new(StringComparer.Ordinal)
|
||||
{
|
||||
["de"] = "de",
|
||||
["es"] = "es",
|
||||
["fr"] = "fr",
|
||||
["it"] = "it",
|
||||
["ja"] = "ja",
|
||||
["ko"] = "ko",
|
||||
["lzh"] = "lzh",
|
||||
["nl"] = "nl",
|
||||
["pt"] = "pt",
|
||||
["ru"] = "ru",
|
||||
["th"] = "th",
|
||||
["uk"] = "uk",
|
||||
["zh"] = "zh"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 根据当前 UI 语言返回 Minecraft Wiki 的基础 URL。
|
||||
/// </summary>
|
||||
public static string GetWikiBaseUrl()
|
||||
{
|
||||
var langCode = LocalizationService.CurrentLanguage.Code;
|
||||
|
||||
var prefix = (
|
||||
from kvp in _WikiPrefixMap
|
||||
where langCode.StartsWith(kvp.Key, StringComparison.Ordinal)
|
||||
select kvp.Value)
|
||||
.FirstOrDefault();
|
||||
|
||||
return prefix is null
|
||||
? "https://minecraft.wiki"
|
||||
: $"https://{prefix}.minecraft.wiki";
|
||||
}
|
||||
|
||||
public static string GetWikiUrlSuffix(string gameVersion)
|
||||
{
|
||||
var formattedVersion = FormatVersion(gameVersion);
|
||||
var langCode = LocalizationService.CurrentLanguage.Code;
|
||||
|
||||
// 非 zh 语言使用搜索 URL
|
||||
if (!langCode.StartsWith("zh", StringComparison.Ordinal))
|
||||
return $"/w/Special:Search?search={Uri.EscapeDataString($"Java Edition {formattedVersion}")}";
|
||||
|
||||
if (gameVersion.Contains('w')) return formattedVersion;
|
||||
|
||||
return "Java版" + formattedVersion;
|
||||
}
|
||||
|
||||
public static string FormatVersion(string gameVersion)
|
||||
{
|
||||
var id = gameVersion.ToLowerInvariant();
|
||||
|
||||
switch (id)
|
||||
{
|
||||
case "0.30-1":
|
||||
case "0.30-2":
|
||||
case "c0.30_01c":
|
||||
return "Classic_0.30";
|
||||
case "in-20100206-2103":
|
||||
return "Indev_20100206";
|
||||
case "inf-20100630-1":
|
||||
return "Infdev_20100630";
|
||||
case "inf-20100630-2":
|
||||
return "Alpha_v1.0.0";
|
||||
case "1.19_deep_dark_experimental_snapshot-1":
|
||||
return "1.19-exp1";
|
||||
case "in-20100130":
|
||||
return "Indev_0.31_20100130";
|
||||
case "b1.6-tb3":
|
||||
return "Beta_1.6_Test_Build_3";
|
||||
case "1_14_combat-212796":
|
||||
return "1.14.3_-_Combat_Test";
|
||||
case "1_14_combat-0":
|
||||
return "Combat_Test_2";
|
||||
case "1_14_combat-3":
|
||||
return "Combat_Test_3";
|
||||
case "1_15_combat-1":
|
||||
return "Combat_Test_4";
|
||||
case "1_15_combat-6":
|
||||
return "Combat_Test_5";
|
||||
case "1_16_combat-0":
|
||||
return "Combat_Test_6";
|
||||
case "1_16_combat-1":
|
||||
return "Combat_Test_7";
|
||||
case "1_16_combat-2":
|
||||
return "Combat_Test_7b";
|
||||
case "1_16_combat-3":
|
||||
return "Combat_Test_7c";
|
||||
case "1_16_combat-4":
|
||||
return "Combat_Test_8";
|
||||
case "1_16_combat-5":
|
||||
return "Combat_Test_8b";
|
||||
case "1_16_combat-6":
|
||||
return "Combat_Test_8c";
|
||||
}
|
||||
|
||||
if (id.StartsWith("1.0.0-rc2")) return "RC2";
|
||||
if (id.StartsWith("2.0") || id.StartsWith("2point0")) return "2.0";
|
||||
if (id.StartsWith("b1.8-pre1")) return "Beta_1.8-pre1";
|
||||
if (id.StartsWith("b1.1-")) return "Beta_1.1";
|
||||
if (id.StartsWith("a1.1.0")) return "Alpha_v1.1.0";
|
||||
if (id.StartsWith("a1.0.14")) return "Alpha_v1.0.14";
|
||||
if (id.StartsWith("a1.0.13_01")) return "Alpha_v1.0.13_01";
|
||||
if (id.StartsWith("in-20100214")) return "Indev_20100214";
|
||||
|
||||
if (id.Contains("experimental-snapshot")) return id.Replace("_experimental-snapshot-", "-exp");
|
||||
|
||||
if (id.StartsWith("inf-")) return "Infdev_" + id[4..];
|
||||
if (id.StartsWith("in-")) return "Indev_" + id[3..];
|
||||
if (id.StartsWith("rd-")) return "pre-Classic_" + id;
|
||||
if (id.StartsWith('b')) return "Beta_" + id[1..];
|
||||
if (id.StartsWith('a')) return "Alpha_v" + id[1..];
|
||||
|
||||
return id.StartsWith('c') ? ("Classic_" + id[1..]).Replace("st", "SURVIVAL_TEST") : id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Text.Json.Nodes;
|
||||
using PCL.Core.App.Localization;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL;
|
||||
|
||||
public enum McVersionCategory
|
||||
{
|
||||
Release,
|
||||
Snapshot,
|
||||
BeforeRelease,
|
||||
AprilFools
|
||||
}
|
||||
|
||||
public static class McVersionClassifier
|
||||
{
|
||||
public static string GetCategoryDisplayName(McVersionCategory cat)
|
||||
{
|
||||
return cat switch
|
||||
{
|
||||
McVersionCategory.Release => Lang.Text("Download.Version.Type.Release"),
|
||||
McVersionCategory.Snapshot => Lang.Text("Download.Version.Type.Development"),
|
||||
McVersionCategory.BeforeRelease => Lang.Text("Download.Version.Type.BeforeRelease"),
|
||||
McVersionCategory.AprilFools => Lang.Text("Download.Version.Type.AprilFools"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(cat))
|
||||
};
|
||||
}
|
||||
|
||||
public static McVersionCategory ClassifyVersion(JsonObject version)
|
||||
{
|
||||
var type = _GetString(version, "type");
|
||||
var idLower = _GetString(version, "id").ToLowerInvariant();
|
||||
|
||||
return type switch
|
||||
{
|
||||
"release" => McVersionCategory.Release,
|
||||
"special" => _RefreshAprilFools(version, idLower),
|
||||
"snapshot" or "pending" => _ClassifySnapshotOrPending(version, idLower),
|
||||
_ => McVersionCategory.BeforeRelease
|
||||
};
|
||||
}
|
||||
|
||||
private static McVersionCategory _RefreshAprilFools(JsonObject version, string idLower)
|
||||
{
|
||||
_TryMarkAprilFoolsVersion(version, idLower);
|
||||
return McVersionCategory.AprilFools;
|
||||
}
|
||||
|
||||
public static DateTime GetReleaseTime(JsonObject version)
|
||||
{
|
||||
return _GetDateTime(version, "releaseTime");
|
||||
}
|
||||
|
||||
private static McVersionCategory _ClassifySnapshotOrPending(JsonObject version, string idLower)
|
||||
{
|
||||
var category = McVersionCategory.Snapshot;
|
||||
|
||||
if (
|
||||
idLower.StartsWith("1.") &&
|
||||
!idLower.Contains("combat") &&
|
||||
!idLower.Contains("rc") &&
|
||||
!idLower.Contains("experimental") &&
|
||||
idLower != "1.2" &&
|
||||
!idLower.Contains("pre")
|
||||
)
|
||||
{
|
||||
category = McVersionCategory.Release;
|
||||
version["type"] = "release";
|
||||
}
|
||||
|
||||
return _TryMarkAprilFoolsVersion(version, idLower)
|
||||
? McVersionCategory.AprilFools
|
||||
: category;
|
||||
}
|
||||
|
||||
private static bool _TryMarkAprilFoolsVersion(JsonObject version, string idLower)
|
||||
{
|
||||
switch (idLower)
|
||||
{
|
||||
case "2point0_blue":
|
||||
case "2point0_red":
|
||||
case "2point0_purple":
|
||||
case "2.0_blue":
|
||||
case "2.0_red":
|
||||
case "2.0_purple":
|
||||
case "2.0":
|
||||
version["id"] = _GetString(version, "id").Replace("point", ".");
|
||||
_MarkAsAprilFools(version, true);
|
||||
return true;
|
||||
|
||||
case "20w14infinite":
|
||||
case "20w14∞":
|
||||
version["id"] = "20w14∞";
|
||||
_MarkAsAprilFools(version, true);
|
||||
return true;
|
||||
|
||||
case "3d shareware v1.34":
|
||||
case "1.rv-pre1":
|
||||
case "15w14a":
|
||||
case "22w13oneblockatatime":
|
||||
case "23w13a_or_b":
|
||||
case "24w14potato":
|
||||
case "25w14craftmine":
|
||||
case "26w14a":
|
||||
_MarkAsAprilFools(version, true);
|
||||
return true;
|
||||
|
||||
default:
|
||||
var releaseDate = GetReleaseTime(version).ToUniversalTime().AddHours(2d);
|
||||
if (releaseDate is not { Month: 4, Day: 1 }) return false;
|
||||
_MarkAsAprilFools(version, false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void _MarkAsAprilFools(JsonObject version, bool addLore)
|
||||
{
|
||||
version["type"] = "special";
|
||||
|
||||
if (addLore)
|
||||
version["lore"] = GetMcFoolName(_GetString(version, "id"));
|
||||
}
|
||||
|
||||
public static string GetMcFoolName(string name)
|
||||
{
|
||||
name = name.ToLowerInvariant();
|
||||
|
||||
return name switch
|
||||
{
|
||||
_ when name.StartsWith("2.0") || name.StartsWith("2point0")
|
||||
=> Lang.Text("Minecraft.Fool.Description.2013") + name switch
|
||||
{
|
||||
_ when name.EndsWith("red")
|
||||
=> Lang.Text("Minecraft.Fool.Tag.Red"),
|
||||
|
||||
_ when name.EndsWith("blue")
|
||||
=> Lang.Text("Minecraft.Fool.Tag.Blue"),
|
||||
|
||||
_ when name.EndsWith("purple")
|
||||
=> Lang.Text("Minecraft.Fool.Tag.Purple"),
|
||||
|
||||
_ => ""
|
||||
},
|
||||
|
||||
"15w14a" => Lang.Text("Minecraft.Fool.Description.2015"),
|
||||
|
||||
"1.rv-pre1" => Lang.Text("Minecraft.Fool.Description.2016"),
|
||||
|
||||
"3d shareware v1.34" => Lang.Text("Minecraft.Fool.Description.2019"),
|
||||
|
||||
_ when name.StartsWith("20w14inf") || name == "20w14∞"
|
||||
=> Lang.Text("Minecraft.Fool.Description.2020"),
|
||||
|
||||
"22w13oneblockatatime" => Lang.Text("Minecraft.Fool.Description.2022"),
|
||||
|
||||
"23w13a_or_b" => Lang.Text("Minecraft.Fool.Description.2023"),
|
||||
|
||||
"24w14potato" => Lang.Text("Minecraft.Fool.Description.2024"),
|
||||
|
||||
"25w14craftmine" => Lang.Text("Minecraft.Fool.Description.2025"),
|
||||
|
||||
"26w14a" => Lang.Text("Minecraft.Fool.Description.2026"),
|
||||
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime _GetDateTime(JsonObject obj, string key)
|
||||
{
|
||||
return JsonCompat.TryGetDateTime(obj[key], out var dateTime)
|
||||
? dateTime
|
||||
: DateTime.MinValue;
|
||||
}
|
||||
|
||||
private static string _GetString(JsonObject obj, string key)
|
||||
{
|
||||
var node = obj[key];
|
||||
if (node is null) return "";
|
||||
|
||||
return node is JsonValue value && value.TryGetValue<string>(out var result)
|
||||
? result
|
||||
: node.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Logging;
|
||||
using fNbt;
|
||||
|
||||
namespace PCL.Core.Minecraft;
|
||||
|
||||
/// <summary>
|
||||
/// 提供 NBT 文件的异步读写操作。
|
||||
/// </summary>
|
||||
public static class NbtFileHandler {
|
||||
/// <summary>
|
||||
/// 异步读取 NBT 文件中指定 Tag 内容。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">要读取的 NbtTag 类型,必须继承自 NbtTag。</typeparam>
|
||||
/// <param name="filePath">目标文件路径(完整或相对)。</param>
|
||||
/// <param name="tagName">要读取的 NbtTag 的标签名称。</param>
|
||||
/// <param name="cancelToken">取消操作的令牌。</param>
|
||||
/// <returns>一个指定类型的 NbtTag 对象,如果文件或标签不存在则返回 null。</returns>
|
||||
public static async Task<T?> ReadTagInNbtFileAsync<T>(string filePath, string tagName, CancellationToken cancelToken = default) where T: NbtTag {
|
||||
try {
|
||||
var fullPath = Path.GetFullPath(filePath);
|
||||
if (!File.Exists(fullPath)) {
|
||||
LogWrapper.Warn($"NBT 文件不存在:{fullPath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
const int bufferSize = 4096;
|
||||
var nbtFile = new NbtFile();
|
||||
await Task.Run(async () => {
|
||||
await using var fs = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, FileOptions.Asynchronous);
|
||||
nbtFile.LoadFromStream(fs, NbtCompression.AutoDetect);
|
||||
}, cancelToken);
|
||||
|
||||
var result = nbtFile.RootTag.Get<T>(tagName);
|
||||
if (result is null) {
|
||||
LogWrapper.Warn($"未找到指定的 NBT 标签:{tagName}");
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (OperationCanceledException) {
|
||||
LogWrapper.Info($"读取 NBT 文件操作被取消:{filePath}");
|
||||
return null;
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Warn(ex, $"读取 NBT 文件出错:{filePath}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步将指定标签写入 NBT 文件的 RootTag 中。(文件可以不存在)
|
||||
/// </summary>
|
||||
/// <param name="nbtTag">要写入文件的 NbtTag 对象。</param>
|
||||
/// <param name="filePath">目标文件路径(完整或相对)。</param>
|
||||
/// <param name="compression">NBT 文件的压缩类型,默认为 NbtCompression.None。</param>
|
||||
/// <param name="cancelToken">取消操作的令牌。</param>
|
||||
/// <returns>返回操作是否成功。</returns>
|
||||
public static async Task<bool> WriteTagInNbtFileAsync(NbtTag nbtTag, string filePath, NbtCompression compression = NbtCompression.None, CancellationToken cancelToken = default) {
|
||||
try {
|
||||
var fullPath = Path.GetFullPath(filePath);
|
||||
var directoryName = Path.GetDirectoryName(fullPath);
|
||||
if (string.IsNullOrEmpty(directoryName)) {
|
||||
LogWrapper.Warn($"无法获取目标目录:{fullPath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(directoryName);
|
||||
|
||||
var rootTag = new NbtCompound { Name = "" };
|
||||
rootTag.Add(nbtTag);
|
||||
var nbtFile = new NbtFile(rootTag);
|
||||
|
||||
const int bufferSize = 4096;
|
||||
await Task.Run(async () => {
|
||||
await using var fs = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize, FileOptions.Asynchronous);
|
||||
nbtFile.SaveToStream(fs, compression);
|
||||
}, cancelToken);
|
||||
|
||||
LogWrapper.Info($"NBT 文件成功保存于:{fullPath}");
|
||||
return true;
|
||||
} catch (OperationCanceledException) {
|
||||
LogWrapper.Info($"写入 NBT 文件操作被取消:{filePath}");
|
||||
return false;
|
||||
} catch (NbtFormatException ex) {
|
||||
LogWrapper.Warn(ex, $"NBT 格式错误:{filePath}");
|
||||
return false;
|
||||
} catch (IOException ex) {
|
||||
LogWrapper.Warn(ex, $"文件操作错误:{filePath}");
|
||||
return false;
|
||||
} catch (Exception ex) {
|
||||
LogWrapper.Warn(ex, $"写入 NBT 文件出错:{filePath}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Curseforge;
|
||||
|
||||
[Serializable]
|
||||
public record CurseforgeAuthors(
|
||||
int id,
|
||||
string name,
|
||||
string url);
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Curseforge;
|
||||
|
||||
[Serializable]
|
||||
public record CurseforgeCategories(
|
||||
int id,
|
||||
int gameId,
|
||||
string name,
|
||||
string slug,
|
||||
string url,
|
||||
string iconUrl,
|
||||
string dateModified,
|
||||
bool isClass,
|
||||
int classId,
|
||||
int parentCategoryId,
|
||||
int displayIndex);
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Curseforge;
|
||||
|
||||
[Serializable]
|
||||
public record CurseforgeFile(
|
||||
int id,
|
||||
int gameId,
|
||||
int modId,
|
||||
bool isAvailable,
|
||||
string displayName,
|
||||
string fileName,
|
||||
int releaseType,
|
||||
int fileStatus,
|
||||
CurseforgeHashes hashes);
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Curseforge;
|
||||
|
||||
[Serializable]
|
||||
public record CurseforgeHashes(
|
||||
string value,
|
||||
int algo);
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Curseforge;
|
||||
|
||||
[Serializable]
|
||||
public record CurseforgeLinks(
|
||||
string websiteUrl,
|
||||
string wikiUrl,
|
||||
string issuesUrl,
|
||||
string sourceUrl);
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Curseforge;
|
||||
|
||||
[Serializable]
|
||||
public record CurseforgePictures(
|
||||
int id,
|
||||
int modId,
|
||||
string title,
|
||||
string description,
|
||||
string thumbnailUrl,
|
||||
string url);
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Curseforge;
|
||||
|
||||
[Serializable]
|
||||
public record class CurseforgeProject(
|
||||
int id,
|
||||
int gameId,
|
||||
string name,
|
||||
string slug,
|
||||
CurseforgeLinks links,
|
||||
string summary,
|
||||
int status,
|
||||
int downloadCount,
|
||||
bool isFeatured,
|
||||
int primaryCategoryId,
|
||||
List<CurseforgeCategories> categories,
|
||||
int classId,
|
||||
List<CurseforgeAuthors> authors,
|
||||
CurseforgePictures logo,
|
||||
List<CurseforgePictures> screenshots,
|
||||
int mainFileId,
|
||||
object latestFiles);
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Curseforge;
|
||||
|
||||
[Serializable]
|
||||
public record class CurseforgeProjectResponse(CurseforgeProject data);
|
||||
[Serializable]
|
||||
public record class CurseforgeProjectsResponse(List<CurseforgeProject> data);
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Model.ResourceProject.Curseforge;
|
||||
|
||||
[Serializable]
|
||||
public record CurseforgeScreenshots(
|
||||
int id,
|
||||
int modId,
|
||||
string title,
|
||||
string description,
|
||||
string thumbnailUrl);
|
||||
@@ -0,0 +1,316 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject;
|
||||
|
||||
public sealed record class ModDependencyReference
|
||||
{
|
||||
public string ProjectId { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public bool IsRequired { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed record class ModDependencyRequest
|
||||
{
|
||||
public string TargetMinecraftVersion { get; init; } = string.Empty;
|
||||
public List<string> TargetLoaders { get; init; } = [];
|
||||
public List<ModDependencyReference> RequiredDependencies { get; init; } = [];
|
||||
public List<InstalledModIdentity> InstalledMods { get; init; } = [];
|
||||
public Func<string, string, ModDependencyProject?> ProjectResolver { get; init; } = (_, _) => null;
|
||||
}
|
||||
|
||||
public sealed record class ModDependencyProject
|
||||
{
|
||||
public string ProjectId { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string? ProjectName { get; init; }
|
||||
public List<ModDependencyFile> Files { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record class ModDependencyFile
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
public string? Version { get; init; }
|
||||
public List<string> GameVersions { get; init; } = [];
|
||||
public List<string> Loaders { get; init; } = [];
|
||||
public int ReleaseType { get; init; }
|
||||
public DateTime ReleaseDate { get; init; }
|
||||
public List<ModDependencyReference> RequiredDependencies { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record class InstalledModIdentity
|
||||
{
|
||||
public string? SourceProjectId { get; init; }
|
||||
public string? Source { get; init; }
|
||||
public string? ModId { get; init; }
|
||||
public List<string> GameVersions { get; init; } = [];
|
||||
public List<string> Loaders { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record class ModDependencyResolutionResult
|
||||
{
|
||||
public List<ResolvedDependencyInstall> ToInstall { get; } = [];
|
||||
public List<UnresolvedDependency> Unresolved { get; } = [];
|
||||
public List<IgnoredDependency> Satisfied { get; } = [];
|
||||
}
|
||||
|
||||
public sealed record class ResolvedDependencyInstall
|
||||
{
|
||||
public string ProjectId { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string? ProjectName { get; init; }
|
||||
public ModDependencyFile File { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record class UnresolvedDependency
|
||||
{
|
||||
public string ProjectId { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record class IgnoredDependency
|
||||
{
|
||||
public string ProjectId { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ModDependencyResolver
|
||||
{
|
||||
private const int MaxDepth = 32;
|
||||
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public ModDependencyResolutionResult Resolve(ModDependencyRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.ProjectResolver);
|
||||
|
||||
var context = new ResolutionContext(request);
|
||||
foreach (var dependency in request.RequiredDependencies)
|
||||
{
|
||||
ResolveDependency(context, dependency, 0);
|
||||
}
|
||||
|
||||
return context.Result;
|
||||
}
|
||||
|
||||
private static void ResolveDependency(ResolutionContext context, ModDependencyReference dependency, int depth)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dependency.ProjectId) || string.IsNullOrWhiteSpace(dependency.Source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dependency.IsRequired)
|
||||
{
|
||||
context.AddSatisfied(dependency.ProjectId, dependency.Source, "Optional dependency ignored.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (depth > MaxDepth)
|
||||
{
|
||||
context.AddUnresolved(dependency.ProjectId, dependency.Source, "Maximum dependency depth exceeded.");
|
||||
return;
|
||||
}
|
||||
|
||||
var visitedKey = context.GetVisitedKey(dependency.ProjectId, dependency.Source);
|
||||
if (!context.Visited.Add(visitedKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.IsInstalledCompatible(dependency.ProjectId, dependency.Source))
|
||||
{
|
||||
context.AddSatisfied(dependency.ProjectId, dependency.Source, "Already installed and compatible.");
|
||||
return;
|
||||
}
|
||||
|
||||
var project = context.Request.ProjectResolver(dependency.Source, dependency.ProjectId);
|
||||
if (project is null)
|
||||
{
|
||||
context.AddUnresolved(dependency.ProjectId, dependency.Source, "Dependency project was not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedFile = SelectBestFile(project.Files, context.TargetMinecraftVersion, context.TargetLoaders);
|
||||
if (selectedFile is null)
|
||||
{
|
||||
context.AddUnresolved(project.ProjectId, project.Source, "No compatible file was found.");
|
||||
return;
|
||||
}
|
||||
|
||||
context.AddInstall(project, selectedFile);
|
||||
|
||||
foreach (var nestedDependency in selectedFile.RequiredDependencies)
|
||||
{
|
||||
ResolveDependency(context, nestedDependency, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static ModDependencyFile? SelectBestFile(
|
||||
IEnumerable<ModDependencyFile> files,
|
||||
string targetMinecraftVersion,
|
||||
HashSet<string> targetLoaders)
|
||||
{
|
||||
return files
|
||||
.Where(file => IsCompatibleFile(file, targetMinecraftVersion, targetLoaders))
|
||||
.OrderByDescending(file => HasExactGameVersionMatch(file, targetMinecraftVersion))
|
||||
.ThenByDescending(file => HasLoaderMatch(file, targetLoaders))
|
||||
.ThenBy(file => NormalizeReleaseType(file.ReleaseType))
|
||||
.ThenByDescending(file => file.ReleaseDate)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static bool IsCompatibleFile(ModDependencyFile file, string targetMinecraftVersion, HashSet<string> targetLoaders)
|
||||
{
|
||||
if (!HasExactGameVersionMatch(file, targetMinecraftVersion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (targetLoaders.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (file.Loaders.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return file.Loaders.Any(loader => targetLoaders.Contains(loader));
|
||||
}
|
||||
|
||||
private static bool HasExactGameVersionMatch(ModDependencyFile file, string targetMinecraftVersion)
|
||||
{
|
||||
return file.GameVersions.Any(version => Comparer.Equals(version, targetMinecraftVersion));
|
||||
}
|
||||
|
||||
private static bool HasLoaderMatch(ModDependencyFile file, HashSet<string> targetLoaders)
|
||||
{
|
||||
if (targetLoaders.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return file.Loaders.Any(loader => targetLoaders.Contains(loader));
|
||||
}
|
||||
|
||||
private static int NormalizeReleaseType(int releaseType)
|
||||
{
|
||||
return releaseType switch
|
||||
{
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
3 => 3,
|
||||
_ => int.MaxValue,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ResolutionContext
|
||||
{
|
||||
private readonly HashSet<string> _installDedupe = new(Comparer);
|
||||
private readonly HashSet<string> _unresolvedDedupe = new(Comparer);
|
||||
private readonly HashSet<string> _satisfiedDedupe = new(Comparer);
|
||||
|
||||
public ResolutionContext(ModDependencyRequest request)
|
||||
{
|
||||
Request = request;
|
||||
Result = new ModDependencyResolutionResult();
|
||||
Visited = new HashSet<string>(Comparer);
|
||||
TargetMinecraftVersion = request.TargetMinecraftVersion ?? string.Empty;
|
||||
TargetLoaders = new HashSet<string>(
|
||||
request.TargetLoaders.Where(static loader => !string.IsNullOrWhiteSpace(loader)),
|
||||
Comparer);
|
||||
LoaderSetKey = string.Join(",", TargetLoaders.OrderBy(static loader => loader, Comparer));
|
||||
}
|
||||
|
||||
public ModDependencyRequest Request { get; }
|
||||
public ModDependencyResolutionResult Result { get; }
|
||||
public HashSet<string> Visited { get; }
|
||||
public string TargetMinecraftVersion { get; }
|
||||
public HashSet<string> TargetLoaders { get; }
|
||||
private string LoaderSetKey { get; }
|
||||
|
||||
public string GetVisitedKey(string projectId, string source)
|
||||
{
|
||||
return $"{source}:{projectId}:{TargetMinecraftVersion}:{LoaderSetKey}";
|
||||
}
|
||||
|
||||
public bool IsInstalledCompatible(string projectId, string source)
|
||||
{
|
||||
return Request.InstalledMods.Any(installed =>
|
||||
Comparer.Equals(installed.SourceProjectId, projectId)
|
||||
&& Comparer.Equals(installed.Source, source)
|
||||
&& installed.GameVersions.Any(version => Comparer.Equals(version, TargetMinecraftVersion))
|
||||
&& LoadersCompatible(installed.Loaders));
|
||||
}
|
||||
|
||||
public void AddInstall(ModDependencyProject project, ModDependencyFile file)
|
||||
{
|
||||
var dedupeKey = GetProjectKey(project.ProjectId, project.Source);
|
||||
if (!_installDedupe.Add(dedupeKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Result.ToInstall.Add(new ResolvedDependencyInstall
|
||||
{
|
||||
ProjectId = project.ProjectId,
|
||||
Source = project.Source,
|
||||
ProjectName = project.ProjectName,
|
||||
File = file,
|
||||
});
|
||||
}
|
||||
|
||||
public void AddUnresolved(string projectId, string source, string reason)
|
||||
{
|
||||
var dedupeKey = GetProjectKey(projectId, source);
|
||||
if (!_unresolvedDedupe.Add(dedupeKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Result.Unresolved.Add(new UnresolvedDependency
|
||||
{
|
||||
ProjectId = projectId,
|
||||
Source = source,
|
||||
Reason = reason,
|
||||
});
|
||||
}
|
||||
|
||||
public void AddSatisfied(string projectId, string source, string reason)
|
||||
{
|
||||
var dedupeKey = GetProjectKey(projectId, source);
|
||||
if (!_satisfiedDedupe.Add(dedupeKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Result.Satisfied.Add(new IgnoredDependency
|
||||
{
|
||||
ProjectId = projectId,
|
||||
Source = source,
|
||||
Reason = reason,
|
||||
});
|
||||
}
|
||||
|
||||
private bool LoadersCompatible(List<string> installedLoaders)
|
||||
{
|
||||
if (TargetLoaders.Count == 0 || installedLoaders.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return installedLoaders.Any(loader => TargetLoaders.Contains(loader));
|
||||
}
|
||||
|
||||
private static string GetProjectKey(string projectId, string source)
|
||||
{
|
||||
return $"{source}:{projectId}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Modrinth;
|
||||
|
||||
[Serializable]
|
||||
public record ModrinthDonationUrl(
|
||||
string id,
|
||||
string platform,
|
||||
string url);
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Modrinth;
|
||||
|
||||
[Serializable]
|
||||
public record ModrinthGallery(
|
||||
string url,
|
||||
bool featured,
|
||||
string? title,
|
||||
string? description,
|
||||
string created,
|
||||
int ordering);
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Modrinth;
|
||||
|
||||
[Serializable]
|
||||
public record ModrinthLicense(
|
||||
string id,
|
||||
string name,
|
||||
string? url);
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Modrinth;
|
||||
|
||||
[Serializable]
|
||||
public record ModrinthModeratorMessage(
|
||||
string message,
|
||||
string? body);
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PCL.Core.Minecraft.ResourceProject.Modrinth;
|
||||
|
||||
[Serializable]
|
||||
public record class ModrinthProject(
|
||||
string slug,
|
||||
string title,
|
||||
string description,
|
||||
List<string> categories,
|
||||
string client_side,
|
||||
string server_side,
|
||||
string body,
|
||||
string status,
|
||||
string? requested_status,
|
||||
List<string> additional_categories,
|
||||
string? issues_url,
|
||||
string? source_url,
|
||||
string? wiki_url,
|
||||
string? discord_url,
|
||||
List<ModrinthDonationUrl> donation_urls,
|
||||
string project_type,
|
||||
int downloads,
|
||||
string icon_url,
|
||||
int color,
|
||||
string thread_id,
|
||||
string monetization_status,
|
||||
string id,
|
||||
string team,
|
||||
string body_url,
|
||||
ModrinthModeratorMessage moderator_message,
|
||||
string published,
|
||||
string updated,
|
||||
string? approved,
|
||||
string? queued,
|
||||
int followers,
|
||||
ModrinthLicense license,
|
||||
List<string> versions,
|
||||
List<string> game_versions,
|
||||
List<string> loaders,
|
||||
List<object> gallery);
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace PCL.Core.Minecraft;
|
||||
|
||||
public static class SaveImportHelper
|
||||
{
|
||||
public static string? GetSaveRootDirectory(string extractedDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(extractedDirectory) || !Directory.Exists(extractedDirectory))
|
||||
return null;
|
||||
|
||||
var rootDirectory = Path.GetFullPath(extractedDirectory);
|
||||
if (File.Exists(Path.Combine(rootDirectory, "level.dat")))
|
||||
return rootDirectory;
|
||||
|
||||
var rootDirectories = Directory.GetDirectories(rootDirectory);
|
||||
if (rootDirectories.Length != 1)
|
||||
return null;
|
||||
|
||||
var nestedDirectory = Path.GetFullPath(rootDirectories.Single());
|
||||
return File.Exists(Path.Combine(nestedDirectory, "level.dat")) ? nestedDirectory : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace PCL.Core.Minecraft.Saves;
|
||||
|
||||
/// <summary>
|
||||
/// DataVersion 关键分界线常量。
|
||||
/// 各值取自对应快照的 <c>Data.DataVersion</c>。
|
||||
/// </summary>
|
||||
public static class DataVersionBoundaries
|
||||
{
|
||||
/// <summary>15w32a(1.9 快照)引入了 DataVersion 字段</summary>
|
||||
public const int _15w32a = 100;
|
||||
|
||||
/// <summary>17w47a(1.13 快照)引入了 DataPacks 字段</summary>
|
||||
public const int _17w47a = 1443;
|
||||
|
||||
/// <summary>20w20a(1.16 快照)引入了 WorldGenSettings.seed 替代 RandomSeed</summary>
|
||||
public const int _20w20a = 2536;
|
||||
|
||||
/// <summary>26.1-snapshot-6 引入了 difficulty_settings 复合标签、spawn.pos 数组、外部种子文件</summary>
|
||||
public const int _261snapshot6 = 4774;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Editing;
|
||||
|
||||
/// <summary>
|
||||
/// 可编辑值的标记联合 —— 表示"不修改"或"修改为某值"。
|
||||
/// 值类型实现,避免在 <see cref="Editing.SaveChanges"/> 中产生堆分配。
|
||||
/// </summary>
|
||||
/// <typeparam name="T">值的类型(必须为值类型)。</typeparam>
|
||||
public readonly record struct Editable<T> where T : struct
|
||||
{
|
||||
private readonly T _value;
|
||||
private readonly bool _hasValue;
|
||||
|
||||
/// <summary>创建一个"修改为指定值"的实例。</summary>
|
||||
public Editable(T value)
|
||||
{
|
||||
_value = value;
|
||||
_hasValue = true;
|
||||
}
|
||||
|
||||
/// <summary>是否显式设置了新值。</summary>
|
||||
public bool HasValue => _hasValue;
|
||||
|
||||
/// <summary>新值。如果 <see cref="HasValue"/> 为 false,则抛出异常。</summary>
|
||||
public T Value => _hasValue ? _value : throw new InvalidOperationException("Editable 没有值。");
|
||||
|
||||
/// <summary>有值则返回新值,否则返回 <paramref name="defaultValue"/>。</summary>
|
||||
public T GetValueOrDefault(T defaultValue) => _hasValue ? _value : defaultValue;
|
||||
|
||||
/// <summary>尝试获取新值。</summary>
|
||||
public bool TryGetValue(out T value)
|
||||
{
|
||||
value = _value;
|
||||
return _hasValue;
|
||||
}
|
||||
|
||||
/// <summary>返回值的字符串表示,无值时返回 "<unspecified>"。</summary>
|
||||
public override string ToString() => _hasValue ? _value!.ToString() ?? "" : "<unspecified>";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using fNbt;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Editing;
|
||||
|
||||
/// <summary>
|
||||
/// 存档编辑器接口 —— 负责将修改写入 level.dat 的 Data 复合标签(内存操作)。
|
||||
/// 实现类需声明自己支持的 DataVersion 范围。
|
||||
/// </summary>
|
||||
public interface ISaveEditor
|
||||
{
|
||||
/// <summary>返回此编辑器能否处理指定 DataVersion 的存档。</summary>
|
||||
bool CanHandle(int? dataVersion);
|
||||
|
||||
/// <summary>
|
||||
/// 将 <paramref name="changes"/> 中的修改写入 <paramref name="data"/> 复合标签。
|
||||
/// 返回 true 表示至少有一项修改被成功写入。
|
||||
/// </summary>
|
||||
bool ApplyChanges(NbtCompound data, SaveChanges changes);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using fNbt;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Editing.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// 26.1 之前的存档编辑器(含整个 1.x 版本体系)。
|
||||
/// 仅操作内存中的 NbtCompound,文件 IO 由 <see cref="SaveManager"/> 统一处理。
|
||||
/// </summary>
|
||||
internal sealed class Pre261SaveEditor : ISaveEditor
|
||||
{
|
||||
public bool CanHandle(int? dataVersion)
|
||||
=> dataVersion is null || dataVersion < DataVersionBoundaries._261snapshot6;
|
||||
|
||||
public bool ApplyChanges(NbtCompound data, SaveChanges changes)
|
||||
{
|
||||
if (changes.IsEmpty)
|
||||
return false;
|
||||
|
||||
var changed = false;
|
||||
changed |= WriteAllowCommands(data, changes);
|
||||
changed |= WriteDifficulty(data, changes);
|
||||
changed |= WriteDifficultyLocked(data, changes);
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>写入 Data.allowCommands(字节型:0/1)。仅当该字段原本存在时才写入,避免向 pre-1.3.1 存档添加新字段。</summary>
|
||||
internal static bool WriteAllowCommands(NbtCompound data, SaveChanges changes)
|
||||
{
|
||||
if (!changes.AllowCommands.HasValue || !data.Contains("allowCommands"))
|
||||
return false;
|
||||
data["allowCommands"] = new NbtByte("allowCommands", (byte)(changes.AllowCommands.Value ? 1 : 0));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>写入 Data.Difficulty(字节型:0=和平, 1=简单, 2=普通, 3=困难)。仅当该字段原本存在时才写入。</summary>
|
||||
internal static bool WriteDifficulty(NbtCompound data, SaveChanges changes)
|
||||
{
|
||||
if (!changes.Difficulty.HasValue || !data.Contains("Difficulty"))
|
||||
return false;
|
||||
data["Difficulty"] = new NbtByte("Difficulty", (byte)changes.Difficulty.Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>写入 Data.DifficultyLocked(字节型:0/1)。仅当该字段原本存在时才写入。</summary>
|
||||
internal static bool WriteDifficultyLocked(NbtCompound data, SaveChanges changes)
|
||||
{
|
||||
if (!changes.LockDifficulty.HasValue || !data.Contains("DifficultyLocked"))
|
||||
return false;
|
||||
data["DifficultyLocked"] = new NbtByte("DifficultyLocked", (byte)(changes.LockDifficulty.Value ? 1 : 0));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using fNbt;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Editing.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// 26.1-snapshot-6 及之后的存档编辑器(2026 新版本号体系)。
|
||||
/// 仅操作内存中的 NbtCompound,文件 IO 由 <see cref="SaveManager"/> 统一处理。
|
||||
/// </summary>
|
||||
internal sealed class Version261PlusSaveEditor : ISaveEditor
|
||||
{
|
||||
public bool CanHandle(int? dataVersion)
|
||||
=> dataVersion >= DataVersionBoundaries._261snapshot6;
|
||||
|
||||
public bool ApplyChanges(NbtCompound data, SaveChanges changes)
|
||||
{
|
||||
if (changes.IsEmpty)
|
||||
return false;
|
||||
|
||||
// 确保 difficulty_settings 复合标签存在
|
||||
if (!data.TryGet<NbtCompound>("difficulty_settings", out var ds) || ds is null)
|
||||
{
|
||||
ds = new NbtCompound("difficulty_settings");
|
||||
data.Add(ds);
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
changed |= Pre261SaveEditor.WriteAllowCommands(data, changes);
|
||||
changed |= WriteDifficulty(ds!, changes);
|
||||
changed |= WriteLocked(ds!, changes);
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>写入 difficulty_settings.difficulty(字符串型)。</summary>
|
||||
internal static bool WriteDifficulty(NbtCompound difficultySettings, SaveChanges changes)
|
||||
{
|
||||
if (!changes.Difficulty.HasValue)
|
||||
return false;
|
||||
var val = changes.Difficulty.Value switch
|
||||
{
|
||||
Difficulty.Peaceful => "peaceful",
|
||||
Difficulty.Easy => "easy",
|
||||
Difficulty.Normal => "normal",
|
||||
Difficulty.Hard => "hard",
|
||||
_ => "normal",
|
||||
};
|
||||
difficultySettings["difficulty"] = new NbtString("difficulty", val);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>写入 difficulty_settings.locked(字节型:0/1)。</summary>
|
||||
internal static bool WriteLocked(NbtCompound difficultySettings, SaveChanges changes)
|
||||
{
|
||||
if (!changes.LockDifficulty.HasValue)
|
||||
return false;
|
||||
difficultySettings["locked"] = new NbtByte("locked", (byte)(changes.LockDifficulty.Value ? 1 : 0));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace PCL.Core.Minecraft.Saves.Editing;
|
||||
|
||||
/// <summary>
|
||||
/// 用户希望对存档应用的修改。使用 <c>default</c> 表示"无任何修改"。
|
||||
/// 仅当 <see cref="Editable{T}.HasValue"/> 为 true 的字段才会被写入。
|
||||
/// </summary>
|
||||
public record struct SaveChanges
|
||||
{
|
||||
/// <summary>是否允许作弊命令的修改。</summary>
|
||||
public Editable<bool> AllowCommands { get; set; }
|
||||
|
||||
/// <summary>游戏难度的修改。</summary>
|
||||
public Editable<Difficulty> Difficulty { get; set; }
|
||||
|
||||
/// <summary>是否锁定难度的修改。</summary>
|
||||
public Editable<bool> LockDifficulty { get; set; }
|
||||
|
||||
/// <summary>此结构体是否不包含任何待写入的修改。</summary>
|
||||
public bool IsEmpty => !AllowCommands.HasValue && !Difficulty.HasValue && !LockDifficulty.HasValue;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// 存档损坏异常 —— level.dat 存在但无法解析或无法写入时抛出。
|
||||
/// </summary>
|
||||
public class SaveCorruptedException : Exception
|
||||
{
|
||||
/// <summary>存档文件夹的绝对路径。</summary>
|
||||
public string FolderPath { get; }
|
||||
|
||||
public SaveCorruptedException(string folderPath)
|
||||
: base($"存档损坏:无法解析 '{folderPath}' 中的 level.dat")
|
||||
{
|
||||
FolderPath = folderPath;
|
||||
}
|
||||
|
||||
public SaveCorruptedException(string folderPath, string message)
|
||||
: base(message)
|
||||
{
|
||||
FolderPath = folderPath;
|
||||
}
|
||||
|
||||
public SaveCorruptedException(string folderPath, string message, Exception inner)
|
||||
: base(message, inner)
|
||||
{
|
||||
FolderPath = folderPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// 存档未找到异常 —— level.dat 缺失或指定文件夹非有效存档时抛出。
|
||||
/// </summary>
|
||||
public class SaveNotFoundException : Exception
|
||||
{
|
||||
/// <summary>存档文件夹的绝对路径。</summary>
|
||||
public string FolderPath { get; }
|
||||
|
||||
public SaveNotFoundException(string folderPath)
|
||||
: base($"未找到存档:'{folderPath}' 中缺少 level.dat")
|
||||
{
|
||||
FolderPath = folderPath;
|
||||
}
|
||||
|
||||
public SaveNotFoundException(string folderPath, string message)
|
||||
: base(message)
|
||||
{
|
||||
FolderPath = folderPath;
|
||||
}
|
||||
|
||||
public SaveNotFoundException(string folderPath, string message, Exception inner)
|
||||
: base(message, inner)
|
||||
{
|
||||
FolderPath = folderPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace PCL.Core.Minecraft.Saves;
|
||||
|
||||
/// <summary>
|
||||
/// 游戏难度。
|
||||
/// </summary>
|
||||
public enum Difficulty
|
||||
{
|
||||
/// <summary>和平</summary>
|
||||
Peaceful = 0,
|
||||
/// <summary>简单</summary>
|
||||
Easy = 1,
|
||||
/// <summary>普通</summary>
|
||||
Normal = 2,
|
||||
/// <summary>困难</summary>
|
||||
Hard = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 游戏模式。
|
||||
/// </summary>
|
||||
public enum GameMode
|
||||
{
|
||||
/// <summary>生存</summary>
|
||||
Survival = 0,
|
||||
/// <summary>创造</summary>
|
||||
Creative = 1,
|
||||
/// <summary>冒险</summary>
|
||||
Adventure = 2,
|
||||
/// <summary>旁观</summary>
|
||||
Spectator = 3,
|
||||
/// <summary>极限模式 —— 在 NBT 中并非独立的 GameType,而是 Survival + hardcore=1。</summary>
|
||||
Hardcore = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存档格式版本,按 Minecraft 大版本的历史演进排列,直接对应解析器类型。
|
||||
/// 各解析器的匹配优先级等于版本号从高到低的顺序。
|
||||
/// </summary>
|
||||
public enum SaveFormatVersion
|
||||
{
|
||||
/// <summary>Alpha ~ 正式 1.2.5</summary>
|
||||
Pre113,
|
||||
|
||||
/// <summary>1.3.1 ~ 1.8.9</summary>
|
||||
Version131To189,
|
||||
|
||||
/// <summary>15w32a(1.9) ~ 1.12.2</summary>
|
||||
Version19To1122,
|
||||
|
||||
/// <summary>17w47a(1.13) ~ 1.15.2</summary>
|
||||
Version113To1152,
|
||||
|
||||
/// <summary>20w20a(1.16) ~ 1.21.11</summary>
|
||||
Version116To1211,
|
||||
|
||||
/// <summary>26.1-snapshot-6 及之后(2026 新版本号体系)</summary>
|
||||
Version261Plus,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using fNbt;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// 存档解析器接口 —— 负责将 level.dat 中的 NBT 数据转换为 <see cref="SaveInfo"/>。
|
||||
/// 每种格式版本对应一个实现类。
|
||||
/// </summary>
|
||||
public interface ISaveParser
|
||||
{
|
||||
/// <summary>此解析器对应的存档格式版本。</summary>
|
||||
SaveFormatVersion FormatVersion { get; }
|
||||
|
||||
/// <summary>返回此解析器能否处理给定的 NBT 数据。</summary>
|
||||
/// <param name="data">level.dat 中的 Data 复合标签。</param>
|
||||
/// <param name="dataVersion">Data 中的 DataVersion 字段值,如果不存在则为 null。</param>
|
||||
bool CanHandle(NbtCompound data, int? dataVersion);
|
||||
|
||||
/// <summary>
|
||||
/// 解析 NBT 数据并返回 <see cref="SaveInfo"/>。
|
||||
/// 文件系统元数据(创建时间、修改时间)由调用方传入。
|
||||
/// </summary>
|
||||
/// <param name="folderPath">存档文件夹的绝对路径。</param>
|
||||
/// <param name="data">level.dat 中的 Data 复合标签。</param>
|
||||
/// <param name="createdAt">文件夹创建时间(UTC)。</param>
|
||||
/// <param name="modifiedAt">level.dat 最后修改时间(UTC)。</param>
|
||||
SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using fNbt;
|
||||
using System.Numerics;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// NBT 读取工具方法,多个版本解析器共用。
|
||||
/// </summary>
|
||||
internal static class NbtReadHelper
|
||||
{
|
||||
/// <summary>尝试从 NBT 复合标签中读取 long 值。</summary>
|
||||
public static long? TryGetLong(NbtCompound data, string key) =>
|
||||
data.TryGet<NbtLong>(key, out var tag) ? tag!.Value : null;
|
||||
|
||||
/// <summary>读取最后游玩时间并转为 UTC DateTime。</summary>
|
||||
public static DateTime ReadLastPlayed(NbtCompound data) =>
|
||||
EpochMsToUtc(TryGetLong(data, "LastPlayed") ?? 0);
|
||||
|
||||
/// <summary>将 Unix 毫秒时间戳转为 UTC DateTime。</summary>
|
||||
public static DateTime EpochMsToUtc(long ms) =>
|
||||
DateTime.UnixEpoch.AddMilliseconds(ms);
|
||||
|
||||
/// <summary>读取累计游戏时间。Minecraft 以 tick 为单位(20 tick = 1 秒)。</summary>
|
||||
public static TimeSpan ReadPlayTime(NbtCompound data)
|
||||
{
|
||||
var ticks = TryGetLong(data, "Time");
|
||||
return TimeSpan.FromSeconds((ticks ?? 0) / 20.0d);
|
||||
}
|
||||
|
||||
/// <summary>读取游戏模式。hardcore 不是独立的 GameType,而是 Survival + hardcore=1。</summary>
|
||||
public static GameMode ReadGameMode(NbtCompound data, out bool isHardcore)
|
||||
{
|
||||
isHardcore = data.TryGet<NbtByte>("hardcore", out var hc) && hc!.Value == 1;
|
||||
if (isHardcore) return GameMode.Hardcore;
|
||||
var gt = data.TryGet<NbtInt>("GameType", out var gameType) ? gameType!.Value : 0;
|
||||
return gt switch
|
||||
{
|
||||
1 => GameMode.Creative,
|
||||
2 => GameMode.Adventure,
|
||||
3 => GameMode.Spectator,
|
||||
_ => GameMode.Survival,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>读取出生点坐标 —— 旧版格式(SpawnX/Y/Z 三个独立 int 字段)。</summary>
|
||||
public static Vector3? TryReadSpawnFromFields(NbtCompound data)
|
||||
{
|
||||
if (data.TryGet<NbtInt>("SpawnX", out var sx) &&
|
||||
data.TryGet<NbtInt>("SpawnY", out var sy) &&
|
||||
data.TryGet<NbtInt>("SpawnZ", out var sz))
|
||||
return new Vector3(sx!.Value, sy!.Value, sz!.Value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>读取出生点坐标 —— 新版格式(spawn.pos int[] 数组)。</summary>
|
||||
public static Vector3? TryReadSpawnFromPos(NbtCompound data)
|
||||
{
|
||||
if (data.TryGet<NbtCompound>("spawn", out var spawn) &&
|
||||
spawn!.TryGet<NbtIntArray>("pos", out var pos) && pos!.Value.Length == 3)
|
||||
return new Vector3(pos[0], pos[1], pos[2]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>读取旧版字节型难度(0=和平, 1=简单, 2=普通, 3=困难)。</summary>
|
||||
public static Difficulty? ReadDifficultyByte(NbtCompound data)
|
||||
{
|
||||
if (data.TryGet<NbtByte>("Difficulty", out var diff))
|
||||
return (Difficulty)diff!.Value;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>读取 Data.Version 复合标签中的版本信息。</summary>
|
||||
public static (string? name, int? id) ReadVersion(NbtCompound data)
|
||||
{
|
||||
if (data.TryGet<NbtCompound>("Version", out var version))
|
||||
{
|
||||
var name = version!.TryGet<NbtString>("Name", out var n) ? n!.Value : null;
|
||||
var id = version.TryGet<NbtInt>("Id", out var i) ? i!.Value : (int?)null;
|
||||
return (name, id);
|
||||
}
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using fNbt;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Alpha ~ 1.2.5 的存档格式。
|
||||
/// 特征:没有 DataVersion、没有 allowCommands、没有 Difficulty。
|
||||
/// </summary>
|
||||
internal sealed class Pre113SaveParser : ISaveParser
|
||||
{
|
||||
public SaveFormatVersion FormatVersion => SaveFormatVersion.Pre113;
|
||||
|
||||
public bool CanHandle(NbtCompound data, int? dataVersion)
|
||||
=> dataVersion is null && !data.Contains("allowCommands");
|
||||
|
||||
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
|
||||
{
|
||||
return new SaveInfo
|
||||
{
|
||||
LevelName = data.TryGet<NbtString>("LevelName", out var ln) ? ln!.Value : "unknown",
|
||||
VersionName = null,
|
||||
VersionId = null,
|
||||
Seed = NbtReadHelper.TryGetLong(data, "RandomSeed"),
|
||||
LastPlayedUtc = NbtReadHelper.ReadLastPlayed(data),
|
||||
Spawn = NbtReadHelper.TryReadSpawnFromFields(data),
|
||||
GameMode = NbtReadHelper.ReadGameMode(data, out var isHardcore),
|
||||
Difficulty = null,
|
||||
IsDifficultyLocked = false,
|
||||
IsHardcore = isHardcore,
|
||||
AllowCommands = false,
|
||||
PlayTime = NbtReadHelper.ReadPlayTime(data),
|
||||
FolderPath = folderPath,
|
||||
CreatedAt = createdAt,
|
||||
ModifiedAt = modifiedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using fNbt;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// 17w47a(1.13) ~ 1.15.2 的存档格式。
|
||||
/// 特征:DataVersion 在 [1443, 2536) 之间,新增 DataPacks 字段。
|
||||
/// </summary>
|
||||
internal sealed class Version113To1152SaveParser : ISaveParser
|
||||
{
|
||||
private readonly ISaveParser _baseParser;
|
||||
|
||||
public Version113To1152SaveParser() : this(new Version19To1122SaveParser()) { }
|
||||
public Version113To1152SaveParser(ISaveParser baseParser) => _baseParser = baseParser;
|
||||
|
||||
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version113To1152;
|
||||
|
||||
public bool CanHandle(NbtCompound data, int? dataVersion)
|
||||
=> dataVersion.HasValue
|
||||
&& dataVersion.Value >= DataVersionBoundaries._17w47a
|
||||
&& dataVersion.Value < DataVersionBoundaries._20w20a;
|
||||
|
||||
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
|
||||
=> _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using fNbt;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// 20w20a(1.16) ~ 1.21.11 的存档格式。
|
||||
/// 特征:DataVersion 在 [2536, 4774) 之间。
|
||||
/// 变更:种子从 Data.RandomSeed 迁移到 Data.WorldGenSettings.seed。
|
||||
/// </summary>
|
||||
internal sealed class Version116To1211SaveParser : ISaveParser
|
||||
{
|
||||
private readonly ISaveParser _baseParser;
|
||||
|
||||
public Version116To1211SaveParser() : this(new Version19To1122SaveParser()) { }
|
||||
public Version116To1211SaveParser(ISaveParser baseParser) => _baseParser = baseParser;
|
||||
|
||||
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version116To1211;
|
||||
|
||||
public bool CanHandle(NbtCompound data, int? dataVersion)
|
||||
=> dataVersion.HasValue
|
||||
&& dataVersion.Value >= DataVersionBoundaries._20w20a
|
||||
&& dataVersion.Value < DataVersionBoundaries._261snapshot6;
|
||||
|
||||
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
|
||||
{
|
||||
var baseInfo = _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
|
||||
return baseInfo with
|
||||
{
|
||||
Seed = ReadWorldGenSeed(data),
|
||||
Spawn = NbtReadHelper.TryReadSpawnFromPos(data)
|
||||
?? NbtReadHelper.TryReadSpawnFromFields(data),
|
||||
};
|
||||
}
|
||||
|
||||
internal static long? ReadWorldGenSeed(NbtCompound data)
|
||||
{
|
||||
if (data.TryGet<NbtCompound>("WorldGenSettings", out var wgs) &&
|
||||
wgs!.TryGet<NbtLong>("seed", out var seed))
|
||||
return seed!.Value;
|
||||
return NbtReadHelper.TryGetLong(data, "RandomSeed");
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using fNbt;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// 1.3.1 ~ 1.8.9 的存档格式。
|
||||
/// 特征:没有 DataVersion,有 allowCommands。
|
||||
/// </summary>
|
||||
internal sealed class Version131To189SaveParser : ISaveParser
|
||||
{
|
||||
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version131To189;
|
||||
|
||||
public bool CanHandle(NbtCompound data, int? dataVersion)
|
||||
=> dataVersion is null && data.Contains("allowCommands");
|
||||
|
||||
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
|
||||
{
|
||||
return new SaveInfo
|
||||
{
|
||||
LevelName = data.TryGet<NbtString>("LevelName", out var ln) ? ln!.Value : "unknown",
|
||||
VersionName = null,
|
||||
VersionId = null,
|
||||
Seed = NbtReadHelper.TryGetLong(data, "RandomSeed"),
|
||||
LastPlayedUtc = NbtReadHelper.ReadLastPlayed(data),
|
||||
Spawn = NbtReadHelper.TryReadSpawnFromFields(data),
|
||||
GameMode = NbtReadHelper.ReadGameMode(data, out _),
|
||||
Difficulty = NbtReadHelper.ReadDifficultyByte(data),
|
||||
IsDifficultyLocked = data.TryGet<NbtByte>("DifficultyLocked", out var dl) && dl!.Value == 1,
|
||||
IsHardcore = data.TryGet<NbtByte>("hardcore", out var hc) && hc!.Value == 1,
|
||||
AllowCommands = data.TryGet<NbtByte>("allowCommands", out var ac) && ac!.Value == 1,
|
||||
PlayTime = NbtReadHelper.ReadPlayTime(data),
|
||||
FolderPath = folderPath,
|
||||
CreatedAt = createdAt,
|
||||
ModifiedAt = modifiedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using fNbt;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// 15w32a(1.9) ~ 1.12.2 的存档格式。
|
||||
/// 特征:DataVersion >= 100 且 < 1443,新增 DataVersion 和 Version 复合标签。
|
||||
/// </summary>
|
||||
internal sealed class Version19To1122SaveParser : ISaveParser
|
||||
{
|
||||
private readonly ISaveParser _baseParser;
|
||||
|
||||
public Version19To1122SaveParser() : this(new Version131To189SaveParser()) { }
|
||||
public Version19To1122SaveParser(ISaveParser baseParser) => _baseParser = baseParser;
|
||||
|
||||
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version19To1122;
|
||||
|
||||
public bool CanHandle(NbtCompound data, int? dataVersion)
|
||||
=> dataVersion.HasValue
|
||||
&& dataVersion.Value >= DataVersionBoundaries._15w32a
|
||||
&& dataVersion.Value < DataVersionBoundaries._17w47a;
|
||||
|
||||
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
|
||||
{
|
||||
var baseInfo = _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
|
||||
(var versionName, var versionId) = NbtReadHelper.ReadVersion(data);
|
||||
return baseInfo with { VersionName = versionName, VersionId = versionId };
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using fNbt;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// 26.1-snapshot-6 及之后的存档格式(2026 新版本号体系)。
|
||||
/// 特征:DataVersion >= 4774 或存在 difficulty_settings 复合标签。
|
||||
/// 变更:
|
||||
/// - 出生点迁移到 spawn.pos int[3]
|
||||
/// - 难度迁移到 difficulty_settings 复合标签(字符串型)
|
||||
/// - 种子可能在外部文件 data/minecraft/world_gen_settings.dat 中
|
||||
/// </summary>
|
||||
internal sealed class Version261PlusSaveParser : ISaveParser
|
||||
{
|
||||
private readonly ISaveParser _baseParser;
|
||||
|
||||
public Version261PlusSaveParser() : this(new Version19To1122SaveParser()) { }
|
||||
public Version261PlusSaveParser(ISaveParser baseParser) => _baseParser = baseParser;
|
||||
|
||||
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version261Plus;
|
||||
|
||||
public bool CanHandle(NbtCompound data, int? dataVersion)
|
||||
=> dataVersion >= DataVersionBoundaries._261snapshot6
|
||||
|| data.Contains("difficulty_settings");
|
||||
|
||||
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
|
||||
{
|
||||
var baseInfo = _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
|
||||
|
||||
var seed = Version116To1211SaveParser.ReadWorldGenSeed(data)
|
||||
?? ReadSeedFromExternalFile(folderPath);
|
||||
|
||||
var spawn = NbtReadHelper.TryReadSpawnFromPos(data)
|
||||
?? NbtReadHelper.TryReadSpawnFromFields(data);
|
||||
|
||||
var difficulty = ReadDifficultySettings(data);
|
||||
var isHardcore = ReadHardcore(data);
|
||||
var isLocked = ReadLocked(data);
|
||||
|
||||
return baseInfo with
|
||||
{
|
||||
Seed = seed,
|
||||
Spawn = spawn,
|
||||
Difficulty = difficulty,
|
||||
IsHardcore = isHardcore,
|
||||
IsDifficultyLocked = isLocked,
|
||||
GameMode = isHardcore ? GameMode.Hardcore : baseInfo.GameMode,
|
||||
};
|
||||
}
|
||||
|
||||
// ── difficulty_settings 复合标签解析 ──
|
||||
|
||||
internal static Difficulty? ReadDifficultySettings(NbtCompound data)
|
||||
{
|
||||
if (data.TryGet<NbtCompound>("difficulty_settings", out var ds) &&
|
||||
ds!.TryGet<NbtString>("difficulty", out var diffStr))
|
||||
{
|
||||
return diffStr!.Value switch
|
||||
{
|
||||
"peaceful" => Difficulty.Peaceful,
|
||||
"easy" => Difficulty.Easy,
|
||||
"normal" => Difficulty.Normal,
|
||||
"hard" => Difficulty.Hard,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
return NbtReadHelper.ReadDifficultyByte(data);
|
||||
}
|
||||
|
||||
internal static bool ReadHardcore(NbtCompound data)
|
||||
{
|
||||
if (data.TryGet<NbtCompound>("difficulty_settings", out var ds) &&
|
||||
ds!.TryGet<NbtByte>("hardcore", out var hc))
|
||||
return hc!.Value == 1;
|
||||
return data.TryGet<NbtByte>("hardcore", out var legacyHc) && legacyHc!.Value == 1;
|
||||
}
|
||||
|
||||
internal static bool ReadLocked(NbtCompound data)
|
||||
{
|
||||
if (data.TryGet<NbtCompound>("difficulty_settings", out var ds) &&
|
||||
ds!.TryGet<NbtByte>("locked", out var locked))
|
||||
return locked!.Value == 1;
|
||||
return data.TryGet<NbtByte>("DifficultyLocked", out var dl) && dl!.Value == 1;
|
||||
}
|
||||
|
||||
internal static long? ReadSeedFromExternalFile(string folderPath)
|
||||
{
|
||||
var externalPath = Path.Combine(folderPath, "data", "minecraft", "world_gen_settings.dat");
|
||||
if (!File.Exists(externalPath))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
var nbtFile = new NbtFile(externalPath);
|
||||
var rootData = nbtFile.RootTag.Get<NbtCompound>("data");
|
||||
return rootData?.TryGet<NbtLong>("seed", out var seed) == true ? seed!.Value : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using fNbt;
|
||||
using PCL.Core.Minecraft.Saves.Parsing.Internal;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// 解析器工厂 —— 按优先级遍历已注册的解析器,返回第一个能处理给定数据的解析器。
|
||||
/// 默认注册顺序从高版本到低版本,确保最特化的解析器优先匹配。
|
||||
/// 可通过构造函数注入自定义解析器列表。
|
||||
/// </summary>
|
||||
public sealed class SaveParserFactory
|
||||
{
|
||||
private readonly IReadOnlyList<ISaveParser> _parsers;
|
||||
|
||||
/// <summary>使用内置的默认解析器列表初始化(从高版本到低版本)。</summary>
|
||||
public SaveParserFactory()
|
||||
{
|
||||
_parsers =
|
||||
[
|
||||
new Version261PlusSaveParser(), // >= 26.1-snapshot-6
|
||||
new Version116To1211SaveParser(), // 1.16 ~ 1.21.11
|
||||
new Version113To1152SaveParser(), // 1.13 ~ 1.15.2
|
||||
new Version19To1122SaveParser(), // 1.9 ~ 1.12.2
|
||||
new Version131To189SaveParser(), // 1.3.1 ~ 1.8.9
|
||||
new Pre113SaveParser(), // Alpha ~ 1.2.5
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>使用自定义解析器列表初始化(支持 DI 注入)。解析器按传入顺序求值。</summary>
|
||||
public SaveParserFactory(IEnumerable<ISaveParser> customParsers)
|
||||
{
|
||||
_parsers = customParsers?.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找第一个能处理给定 NBT 数据的解析器。
|
||||
/// </summary>
|
||||
/// <param name="data">level.dat 中的 Data 复合标签。</param>
|
||||
/// <param name="dataVersion">DataVersion 字段值,如果不存在则为 null。</param>
|
||||
/// <returns>匹配的解析器,未找到时返回 null。</returns>
|
||||
public ISaveParser? Resolve(NbtCompound data, int? dataVersion)
|
||||
{
|
||||
foreach (var parser in _parsers)
|
||||
{
|
||||
if (parser.CanHandle(data, dataVersion))
|
||||
return parser;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves;
|
||||
|
||||
/// <summary>
|
||||
/// 存档核心数据模型 —— 不可变记录,由解析器从 level.dat 中提取。
|
||||
/// 调用方通过 <see cref="SaveManager"/> 获取此对象。
|
||||
/// </summary>
|
||||
public sealed record SaveInfo
|
||||
{
|
||||
/// <summary>世界名称。</summary>
|
||||
public required string LevelName { get; init; }
|
||||
|
||||
/// <summary>最后保存此存档的游戏版本名(如 "1.20.4")。</summary>
|
||||
public string? VersionName { get; init; }
|
||||
|
||||
/// <summary>最后保存此存档的游戏数据版本号(对应 <c>Data.Version.Id</c>)。</summary>
|
||||
public int? VersionId { get; init; }
|
||||
|
||||
/// <summary>世界种子。</summary>
|
||||
public long? Seed { get; init; }
|
||||
|
||||
/// <summary>最后游玩时间(UTC)。</summary>
|
||||
public DateTime LastPlayedUtc { get; init; }
|
||||
|
||||
/// <summary>出生点坐标 (X, Y, Z)。</summary>
|
||||
public Vector3? Spawn { get; init; }
|
||||
|
||||
/// <summary>游戏模式。Hardcore 通过 IsHardcore 字段表示。</summary>
|
||||
public GameMode GameMode { get; init; }
|
||||
|
||||
/// <summary>游戏难度。1.3.1 之前的存档中可能为 null。</summary>
|
||||
public Difficulty? Difficulty { get; init; }
|
||||
|
||||
/// <summary>难度是否已锁定。</summary>
|
||||
public bool IsDifficultyLocked { get; init; }
|
||||
|
||||
/// <summary>是否为极限模式。</summary>
|
||||
public bool IsHardcore { get; init; }
|
||||
|
||||
/// <summary>是否允许作弊命令。</summary>
|
||||
public bool AllowCommands { get; init; }
|
||||
|
||||
/// <summary>累计游戏时间。</summary>
|
||||
public TimeSpan PlayTime { get; init; }
|
||||
|
||||
/// <summary>存档文件夹的绝对路径。</summary>
|
||||
public required string FolderPath { get; init; }
|
||||
|
||||
/// <summary>存档文件夹的创建时间(UTC)。</summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>level.dat 的最后修改时间(UTC)。</summary>
|
||||
public DateTime ModifiedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using fNbt;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Minecraft.Saves.Editing;
|
||||
using PCL.Core.Minecraft.Saves.Editing.Internal;
|
||||
using PCL.Core.Minecraft.Saves.Exceptions;
|
||||
using PCL.Core.Minecraft.Saves.Parsing;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves;
|
||||
|
||||
/// <summary>
|
||||
/// 存档管理器 —— 存档系统的统一入口。
|
||||
/// 提供扫描、读取、批量读取和修改存档的功能。
|
||||
/// 可通过构造函数注入自定义的解析器工厂和编辑器列表。
|
||||
/// </summary>
|
||||
public class SaveManager
|
||||
{
|
||||
private readonly SaveParserFactory _parserFactory;
|
||||
private readonly IReadOnlyList<ISaveEditor> _editors;
|
||||
|
||||
/// <summary>
|
||||
/// 创建新的存档管理器。
|
||||
/// </summary>
|
||||
/// <param name="parserFactory">自定义解析器工厂,为 null 时使用默认工厂。</param>
|
||||
/// <param name="customEditors">自定义编辑器列表,为 null 时使用默认编辑器。</param>
|
||||
public SaveManager(
|
||||
SaveParserFactory? parserFactory = null,
|
||||
IEnumerable<ISaveEditor>? customEditors = null)
|
||||
{
|
||||
_parserFactory = parserFactory ?? new SaveParserFactory();
|
||||
_editors = customEditors?.ToArray() ?? [new Pre261SaveEditor(), new Version261PlusSaveEditor()];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫描指定目录下的所有有效存档文件夹,返回按最后游玩时间降序排列的列表。
|
||||
/// 不含 level.dat 的文件夹会被静默跳过。
|
||||
/// </summary>
|
||||
/// <param name="savesPath">存档根目录(通常为 .minecraft/saves)。</param>
|
||||
/// <param name="ct">取消令牌。</param>
|
||||
public async Task<IReadOnlyList<SaveInfo>> ScanSaveFoldersAsync(
|
||||
string savesPath, CancellationToken ct = default)
|
||||
{
|
||||
if (!Directory.Exists(savesPath))
|
||||
return [];
|
||||
|
||||
var folderPaths = Directory.GetDirectories(savesPath);
|
||||
var results = new List<SaveInfo>(folderPaths.Length);
|
||||
|
||||
foreach (var folder in folderPaths)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var info = await LoadSaveAsync(folder, ct).ConfigureAwait(false);
|
||||
if (info is not null)
|
||||
results.Add(info);
|
||||
}
|
||||
catch (SaveNotFoundException)
|
||||
{
|
||||
// 非存档文件夹,静默跳过
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, "Saves", $"扫描存档文件夹失败:{folder}");
|
||||
}
|
||||
}
|
||||
|
||||
return results.OrderByDescending(s => s.LastPlayedUtc).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载指定文件夹中的单个存档。
|
||||
/// </summary>
|
||||
/// <param name="folderPath">存档文件夹的绝对路径。</param>
|
||||
/// <param name="ct">取消令牌。</param>
|
||||
/// <exception cref="SaveNotFoundException">level.dat 缺失。</exception>
|
||||
/// <exception cref="SaveCorruptedException">level.dat 存在但无法解析。</exception>
|
||||
public Task<SaveInfo> LoadSaveAsync(string folderPath, CancellationToken ct = default)
|
||||
{
|
||||
var levelDatPath = ResolveLevelDatPath(folderPath);
|
||||
if (levelDatPath is null)
|
||||
throw new SaveNotFoundException(folderPath);
|
||||
|
||||
return LoadFromPathAsync(folderPath, levelDatPath, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量异步加载存档目录下的所有存档,每解析完一个即通过 IAsyncEnumerable 向外产出。
|
||||
/// 无法加载的存档会被记录日志并跳过,不会中断整个枚举。
|
||||
/// </summary>
|
||||
/// <param name="savesPath">存档根目录。</param>
|
||||
/// <param name="ct">取消令牌。</param>
|
||||
public async IAsyncEnumerable<SaveInfo> LoadSavesAsync(
|
||||
string savesPath,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
if (!Directory.Exists(savesPath))
|
||||
yield break;
|
||||
|
||||
var folderPaths = Directory.GetDirectories(savesPath);
|
||||
foreach (var folder in folderPaths)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
SaveInfo? info = null;
|
||||
try
|
||||
{
|
||||
info = await LoadSaveAsync(folder, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (SaveNotFoundException)
|
||||
{
|
||||
// 非存档文件夹,跳过
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, "Saves", $"加载存档失败:{folder}");
|
||||
}
|
||||
|
||||
if (info is not null)
|
||||
yield return info;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将指定的修改应用到某个存档。
|
||||
/// </summary>
|
||||
/// <param name="folderPath">存档文件夹的绝对路径。</param>
|
||||
/// <param name="changes">要应用的修改集合。</param>
|
||||
/// <param name="ct">取消令牌。</param>
|
||||
/// <returns>至少有一项修改成功写入时返回 true。</returns>
|
||||
/// <exception cref="SaveNotFoundException">level.dat 缺失。</exception>
|
||||
/// <exception cref="SaveCorruptedException">level.dat 解析或写入失败。</exception>
|
||||
public async Task<bool> ApplyChangesAsync(
|
||||
string folderPath, SaveChanges changes, CancellationToken ct = default)
|
||||
{
|
||||
// 无修改时直接返回,避免不必要的文件 IO
|
||||
if (changes.IsEmpty)
|
||||
return false;
|
||||
|
||||
var levelDatPath = ResolveLevelDatPath(folderPath)
|
||||
?? throw new SaveNotFoundException(folderPath);
|
||||
|
||||
// 一次解析 level.dat,提取 Data 复合标签和 DataVersion
|
||||
NbtFile nbtFile;
|
||||
NbtCompound data;
|
||||
try
|
||||
{
|
||||
nbtFile = new NbtFile();
|
||||
await Task.Run(() =>
|
||||
{
|
||||
using var fs = new FileStream(levelDatPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
||||
nbtFile.LoadFromStream(fs, NbtCompression.AutoDetect);
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
data = nbtFile.RootTag.Get<NbtCompound>("Data")
|
||||
?? throw new InvalidDataException("level.dat 中缺少 Data 复合标签");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
throw new SaveCorruptedException(folderPath, $"解析 level.dat 失败:'{levelDatPath}'", ex);
|
||||
}
|
||||
|
||||
var dataVersion = ReadDataVersionFromCompound(data);
|
||||
|
||||
// 匹配编辑器,执行内存修改
|
||||
foreach (var editor in _editors)
|
||||
{
|
||||
if (editor.CanHandle(dataVersion))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!editor.ApplyChanges(data, changes))
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
throw new SaveCorruptedException(folderPath,
|
||||
$"应用修改失败:'{folderPath}'", ex);
|
||||
}
|
||||
|
||||
// 原子写入:temp → 备份 → 重命名
|
||||
await WriteLevelDatAtomicallyAsync(levelDatPath, nbtFile, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 找不到匹配编辑器时抛出异常,与"无修改"(返回 false)区分
|
||||
throw new SaveCorruptedException(folderPath,
|
||||
$"找不到匹配的存档编辑器(DataVersion: {dataVersion})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 原子写入 level.dat:先将 NBT 写入临时文件,再通过重命名完成原子替换。
|
||||
/// 始终写入 level.dat(即使从 level.dat_old 回退读取)。
|
||||
/// </summary>
|
||||
private static async Task WriteLevelDatAtomicallyAsync(
|
||||
string sourcePath, NbtFile nbtFile, CancellationToken ct)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(sourcePath)!;
|
||||
var tempPath = Path.Combine(dir, $"level{Guid.NewGuid():N}.dat");
|
||||
var backupPath = Path.Combine(dir, "level.dat_old");
|
||||
var targetPath = Path.Combine(dir, "level.dat");
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 写入临时文件
|
||||
await Task.Run(() =>
|
||||
{
|
||||
using var fs = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write,
|
||||
FileShare.None, 4096, true);
|
||||
nbtFile.SaveToStream(fs, NbtCompression.GZip);
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
// 2. 仅当从 level.dat 读取时,才备份当前 level.dat → level.dat_old;
|
||||
// 若从 level.dat_old 回退读取,说明 level.dat 已损坏/不存在,跳过备份。
|
||||
if (sourcePath == targetPath && File.Exists(targetPath))
|
||||
{
|
||||
File.Move(targetPath, backupPath, overwrite: true);
|
||||
}
|
||||
|
||||
// 3. 重命名临时文件 → level.dat
|
||||
File.Move(tempPath, targetPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(tempPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/// <summary>核心加载逻辑:读取 level.dat → 解析 DataVersion → 匹配解析器 → 构建 SaveInfo。</summary>
|
||||
private async Task<SaveInfo> LoadFromPathAsync(
|
||||
string folderPath, string levelDatPath, CancellationToken ct)
|
||||
{
|
||||
NbtFile nbtFile;
|
||||
try
|
||||
{
|
||||
nbtFile = await LoadNbtFileAsync(levelDatPath, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SaveCorruptedException(folderPath,
|
||||
$"解析 level.dat 失败:'{levelDatPath}'", ex);
|
||||
}
|
||||
|
||||
// level.dat 根标签下必须有 Data 复合标签
|
||||
var data = nbtFile.RootTag.Get<NbtCompound>("Data")
|
||||
?? throw new SaveCorruptedException(folderPath,
|
||||
$"level.dat 中缺少 Data 复合标签:{levelDatPath}");
|
||||
|
||||
var dataVersion = ReadDataVersionFromCompound(data);
|
||||
var createdAt = Directory.GetCreationTimeUtc(folderPath);
|
||||
var modifiedAt = File.GetLastWriteTimeUtc(levelDatPath);
|
||||
|
||||
var parser = _parserFactory.Resolve(data, dataVersion)
|
||||
?? throw new SaveCorruptedException(folderPath,
|
||||
$"找不到与存档 '{folderPath}' 匹配的解析器(DataVersion: {dataVersion})");
|
||||
|
||||
return parser.Parse(folderPath, data, createdAt, modifiedAt);
|
||||
}
|
||||
|
||||
/// <summary>以异步方式加载 NBT 文件,自动检测压缩格式。</summary>
|
||||
private static async Task<NbtFile> LoadNbtFileAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var nbtFile = new NbtFile();
|
||||
await Task.Run(() =>
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
||||
nbtFile.LoadFromStream(fs, NbtCompression.AutoDetect);
|
||||
}, ct).ConfigureAwait(false);
|
||||
return nbtFile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确定 level.dat 的路径。
|
||||
/// 优先查找 level.dat,如果不存在则查找 level.dat_old 作为备份。
|
||||
/// </summary>
|
||||
private static string? ResolveLevelDatPath(string folderPath)
|
||||
{
|
||||
var primary = Path.Combine(folderPath, "level.dat");
|
||||
if (File.Exists(primary))
|
||||
return primary;
|
||||
var backup = Path.Combine(folderPath, "level.dat_old");
|
||||
return File.Exists(backup) ? backup : null;
|
||||
}
|
||||
|
||||
/// <summary>从 Data 复合标签中读取 DataVersion 字段。</summary>
|
||||
private static int? ReadDataVersionFromCompound(NbtCompound data)
|
||||
{
|
||||
if (data.TryGet<NbtInt>("DataVersion", out var dv))
|
||||
return dv!.Value;
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Ae.Dns.Protocol.Enums;
|
||||
using Ae.Dns.Protocol.Records;
|
||||
using PCL.Core.IO.Net.Dns;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.Minecraft;
|
||||
|
||||
public static class ServerAddressResolver
|
||||
{
|
||||
public readonly record struct ResolvedServerAddress(string Host, string? Ip, int Port);
|
||||
|
||||
// Minecraft Java 默认端口
|
||||
private const int DefaultPort = 25565;
|
||||
// Happy Eyeballs 族间启动间隔(降低首包时延)
|
||||
private static readonly TimeSpan _HappyEyeballsStagger = TimeSpan.FromMilliseconds(250);
|
||||
// 单次 TCP 连接超时
|
||||
private static readonly TimeSpan _ConnectTimeout = TimeSpan.FromSeconds(2.5);
|
||||
|
||||
// [IPv6]:port 或 [IPv6]
|
||||
private static readonly Regex _BracketedIpv6 =
|
||||
new(@"^\[(?<ip>.+?)\](?::(?<port>\d{1,5}))?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
// 纯端口(用于 host:port 末尾匹配)
|
||||
private static readonly Regex _TrailingPort =
|
||||
new(@":(?<port>\d{1,5})$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public static async Task<ResolvedServerAddress> GetResolvedServerAddressAsync(string address, CancellationToken cancelToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
throw new ArgumentException("服务器地址不能为空", nameof(address));
|
||||
|
||||
// 规范化:去除 scheme、空白、尾随斜杠
|
||||
address = _NormalizeInput(address);
|
||||
|
||||
// 1) 解析出 host/ip 与端口(若端口未提供则为 null)
|
||||
var (hostOrIp, portOpt) = _ParseHostAndPort(address);
|
||||
|
||||
// 2) 显式端口 => 禁止 SRV,直接解析 IP 并尝试连接
|
||||
if (portOpt is { } explicitPort)
|
||||
{
|
||||
_ValidatePort(explicitPort);
|
||||
LogWrapper.Info($"使用显式端口,跳过 SRV:{hostOrIp}:{explicitPort}");
|
||||
var target = await _ResolveReachableAsync(hostOrIp, explicitPort, cancelToken).ConfigureAwait(false);
|
||||
if (target is not null)
|
||||
return new ResolvedServerAddress(hostOrIp, target.Value.Ip, target.Value.Port);
|
||||
|
||||
// 回退策略:无法连接则仍返回解析到的首个 IP
|
||||
var fallbackIp = await _ResolveFirstIpAsync(hostOrIp, cancelToken).ConfigureAwait(false);
|
||||
return new ResolvedServerAddress(hostOrIp, fallbackIp, explicitPort);
|
||||
}
|
||||
|
||||
// 3) 未指定端口
|
||||
// 3.1 纯 IP(IPv4/IPv6)=> 直接使用默认端口
|
||||
if (IPAddress.TryParse(hostOrIp, out _))
|
||||
{
|
||||
var target = await _ResolveReachableAsync(hostOrIp, DefaultPort, cancelToken).ConfigureAwait(false);
|
||||
if (target is not null)
|
||||
return new ResolvedServerAddress(hostOrIp, target.Value.Ip, target.Value.Port);
|
||||
|
||||
return new ResolvedServerAddress(hostOrIp, hostOrIp, DefaultPort);
|
||||
}
|
||||
|
||||
// 3.2 域名 => 先尝试 SRV(_minecraft._tcp.),成功则按 SRV 顺序与加权尝试
|
||||
var idnHost = _ToAsciiIdn(hostOrIp);
|
||||
var srvOrdered = await _QuerySrvOrderedAsync(idnHost, cancelToken).ConfigureAwait(false);
|
||||
|
||||
if (srvOrdered.Count > 0)
|
||||
{
|
||||
LogWrapper.Info($"SRV 记录可用({srvOrdered.Count}): _minecraft._tcp.{idnHost}");
|
||||
foreach (var srv in srvOrdered)
|
||||
{
|
||||
var targetHost = _TrimTrailingDot(srv.Target);
|
||||
var port = srv.Port;
|
||||
|
||||
var reachable = await _ResolveReachableAsync(targetHost, port, cancelToken).ConfigureAwait(false);
|
||||
if (reachable is not null)
|
||||
{
|
||||
LogWrapper.Info($"SRV 命中:{targetHost}:{port} -> {reachable.Value.Ip}:{port}");
|
||||
return new ResolvedServerAddress(idnHost, reachable.Value.Ip, reachable.Value.Port);
|
||||
}
|
||||
}
|
||||
|
||||
// SRV 全部不可达则回退到 SRV 第一条的解析 IP 或域名默认端口
|
||||
var first = srvOrdered[0];
|
||||
var firstIp = await _ResolveFirstIpAsync(_TrimTrailingDot(first.Target), cancelToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(firstIp)) return new ResolvedServerAddress(idnHost, firstIp, first.Port);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogWrapper.Info($"无 SRV 记录或查询失败,回退默认端口:{idnHost}:{DefaultPort}");
|
||||
}
|
||||
|
||||
// 3.3 最终回退:域名 + 默认端口
|
||||
var ip = await _ResolveFirstIpAsync(idnHost, cancelToken).ConfigureAwait(false);
|
||||
return new ResolvedServerAddress(idnHost, ip, DefaultPort);
|
||||
}
|
||||
|
||||
// 规范化地址输入:去掉 scheme、空白、尾随 '/'
|
||||
private static string _NormalizeInput(string input)
|
||||
{
|
||||
var s = input.Trim();
|
||||
|
||||
// 去掉任意 scheme:// 前缀(例如 http://、https://、minecraft://)
|
||||
var schemeIdx = s.IndexOf("://", StringComparison.Ordinal);
|
||||
if (schemeIdx > 0)
|
||||
s = s[(schemeIdx + 3)..];
|
||||
|
||||
// 去掉尾随的 '/'
|
||||
while (s.EndsWith("/", StringComparison.Ordinal))
|
||||
s = s[..^1];
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
private static void _ValidatePort(int port)
|
||||
{
|
||||
if (port is < 1 or > 65535)
|
||||
throw new FormatException($"无效的端口:{port}");
|
||||
}
|
||||
|
||||
private static string _ToAsciiIdn(string host)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 处理国际化域名
|
||||
var idn = new IdnMapping();
|
||||
// 允许末尾点号(FQDN)
|
||||
var h = _TrimTrailingDot(host);
|
||||
return idn.GetAscii(h) + (host.EndsWith(".", StringComparison.Ordinal) ? "." : "");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 无法转换时返回原值,交给后续 DNS 解析处理
|
||||
return host;
|
||||
}
|
||||
}
|
||||
|
||||
private static string _TrimTrailingDot(string host)
|
||||
=> host.EndsWith(".", StringComparison.Ordinal) ? host[..^1] : host;
|
||||
|
||||
private static (string HostOrIp, int? Port) _ParseHostAndPort(string input)
|
||||
{
|
||||
// 1) [IPv6] 或 [IPv6]:port
|
||||
var m = _BracketedIpv6.Match(input);
|
||||
if (m.Success)
|
||||
{
|
||||
var ip = m.Groups["ip"].Value;
|
||||
if (!IPAddress.TryParse(ip, out _))
|
||||
throw new FormatException("无效的 IPv6 地址格式");
|
||||
var portGroup = m.Groups["port"];
|
||||
if (portGroup.Success)
|
||||
{
|
||||
var port = int.Parse(portGroup.Value, CultureInfo.InvariantCulture);
|
||||
_ValidatePort(port);
|
||||
return (ip, port);
|
||||
}
|
||||
return (ip, null);
|
||||
}
|
||||
|
||||
// 2) 试图解析为纯 IP(IPv4 或 IPv6 无端口)
|
||||
if (IPAddress.TryParse(input, out _))
|
||||
return (input, null);
|
||||
|
||||
// 3) host:port(仅在末尾存在且为纯数字端口时成立)
|
||||
var pm = _TrailingPort.Match(input);
|
||||
if (pm.Success)
|
||||
{
|
||||
// 防止误把 IPv6 当作 host:port(IPv6 必须用中括号携带端口)
|
||||
// 此处 input 中若包含多个 ':' 则极可能是 IPv6 而非 host:port
|
||||
var colonCount = input.Count(c => c == ':');
|
||||
if (colonCount == 1)
|
||||
{
|
||||
var port = int.Parse(pm.Groups["port"].Value, CultureInfo.InvariantCulture);
|
||||
_ValidatePort(port);
|
||||
var host = input[..^pm.Value.Length];
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
throw new FormatException("无效的主机名");
|
||||
return (host, port);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 其余情况按“域名(无端口)”处理
|
||||
return (input, null);
|
||||
}
|
||||
|
||||
// ===== SRV 查询与排序(RFC 2782) =====
|
||||
|
||||
private sealed record SrvRecord(int Priority, int Weight, int Port, string Target);
|
||||
|
||||
private static async Task<List<SrvRecord>> _QuerySrvOrderedAsync(string domain, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = $"_minecraft._tcp.{_TrimTrailingDot(domain)}";
|
||||
LogWrapper.Info($"尝试 SRV 查询:{name}");
|
||||
|
||||
// NDnsQuery.GetSrvRecords 返回 string 列表,为兼容不同实现,这里进行鲁棒解析
|
||||
var raw = await DnsQuery.Instance.QueryAsync(name, DnsQueryType.SRV, ct);
|
||||
if (raw is null || raw.Answers.Count == 0) return [];
|
||||
List<SrvRecord> parsed = [];
|
||||
foreach (var answer in raw.Answers)
|
||||
{
|
||||
if (answer.Resource is not DnsUnknownResource dnsRaw) return [];
|
||||
var srcRecord = new DnsSrvResource();
|
||||
var offset = 0;
|
||||
srcRecord.ReadBytes(dnsRaw.Raw, ref offset, dnsRaw.Raw.Length);
|
||||
parsed.Add(new SrvRecord(srcRecord.Priority, srcRecord.Weight, srcRecord.Port, srcRecord.Target));
|
||||
}
|
||||
|
||||
// 过滤 target 为 "."(表示服务不可用)
|
||||
parsed.RemoveAll(p => p.Target == ".");
|
||||
|
||||
if (parsed.Count == 0)
|
||||
return [];
|
||||
|
||||
// RFC 2782:按 priority 升序;相同 priority 内按权重加权随机选择顺序
|
||||
var ordered = new List<SrvRecord>(parsed.Count);
|
||||
foreach (var group in parsed.GroupBy(p => p.Priority).OrderBy(g => g.Key))
|
||||
{
|
||||
var pool = group.ToList();
|
||||
while (pool.Count > 0)
|
||||
{
|
||||
var next = _PopByWeight(pool);
|
||||
ordered.Add(next);
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, "SRV 查询失败(网络错误)");
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, "SRV 查询异常");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static SrvRecord _PopByWeight(List<SrvRecord> pool)
|
||||
{
|
||||
// RFC 2782 加权随机:在组内以 weight 为权重抽取
|
||||
var total = pool.Sum(p => p.Weight);
|
||||
if (total <= 0)
|
||||
{
|
||||
// 无权重时等概率
|
||||
var i = RandomUtils.NextInt(0, pool.Count - 1);
|
||||
var chosen = pool[i];
|
||||
pool.RemoveAt(i);
|
||||
return chosen;
|
||||
}
|
||||
|
||||
var r = RandomUtils.NextInt(1, total); // (1..total)
|
||||
var sum = 0;
|
||||
for (var i = 0; i < pool.Count; i++)
|
||||
{
|
||||
sum += pool[i].Weight;
|
||||
if (sum >= r)
|
||||
{
|
||||
var chosen = pool[i];
|
||||
pool.RemoveAt(i);
|
||||
return chosen;
|
||||
}
|
||||
}
|
||||
|
||||
// 理论不可达,兜底返回末尾
|
||||
var last = pool[^1];
|
||||
pool.RemoveAt(pool.Count - 1);
|
||||
return last;
|
||||
}
|
||||
|
||||
// ===== DNS 与连接可达性 =====
|
||||
|
||||
private static async Task<(string Ip, int Port)?> _ResolveReachableAsync(string hostOrIp, int port, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 已是字面量 IP
|
||||
if (IPAddress.TryParse(hostOrIp, out var ipLiteral))
|
||||
{
|
||||
var result = await _ConnectOneAsync(ipLiteral, port, ct).ConfigureAwait(false);
|
||||
if (result.ok) return (ipLiteral.ToString(), port);
|
||||
return null;
|
||||
}
|
||||
|
||||
var addresses = await Dns.GetHostAddressesAsync(_TrimTrailingDot(hostOrIp), ct).ConfigureAwait(false);
|
||||
if (addresses.Length == 0)
|
||||
return null;
|
||||
|
||||
// Happy Eyeballs:分组(IPv6、IPv4),按组分阶段并行连接,取首个成功
|
||||
var v6 = addresses.Where(a => a.AddressFamily == AddressFamily.InterNetworkV6).ToArray();
|
||||
var v4 = addresses.Where(a => a.AddressFamily == AddressFamily.InterNetwork).ToArray();
|
||||
|
||||
// 第一阶段:IPv6
|
||||
var winner = await _ConnectAnyAsync(v6, port, TimeSpan.Zero, ct).ConfigureAwait(false);
|
||||
if (winner is not null)
|
||||
return (winner, port);
|
||||
|
||||
// 第二阶段:IPv4(稍作延迟以避免同时轰炸)
|
||||
winner = await _ConnectAnyAsync(v4, port, _HappyEyeballsStagger, ct).ConfigureAwait(false);
|
||||
if (winner is not null)
|
||||
return (winner, port);
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
LogWrapper.Warn(ex, $"解析或连接失败:{hostOrIp}:{port}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> _ResolveFirstIpAsync(string hostOrIp, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IPAddress.TryParse(hostOrIp, out var ip))
|
||||
return ip.ToString();
|
||||
|
||||
var addresses = await Dns.GetHostAddressesAsync(_TrimTrailingDot(hostOrIp), ct).ConfigureAwait(false);
|
||||
var chosen = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
?? addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork);
|
||||
return chosen?.ToString();
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
LogWrapper.Warn(ex, $"DNS 解析失败:{hostOrIp}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> _ConnectAnyAsync(IReadOnlyList<IPAddress> addrs, int port, TimeSpan delay, CancellationToken ct)
|
||||
{
|
||||
if (addrs.Count == 0) return null;
|
||||
if (delay > TimeSpan.Zero)
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
var tasks = new List<Task<(bool ok, string ip)>>(addrs.Count);
|
||||
tasks.AddRange(addrs.Select(ip => _ConnectOneAsync(ip, port, cts.Token)));
|
||||
|
||||
while (tasks.Count > 0)
|
||||
{
|
||||
var done = await Task.WhenAny(tasks).ConfigureAwait(false);
|
||||
tasks.Remove(done);
|
||||
var (ok, ip) = await done.ConfigureAwait(false);
|
||||
if (!ok) continue;
|
||||
// 取消其余连接尝试
|
||||
try { await cts.CancelAsync().ConfigureAwait(false); } catch { /* ignore */ }
|
||||
return ip;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<(bool ok, string ip)> _ConnectOneAsync(IPAddress ip, int port, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var sock = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||
sock.NoDelay = true;
|
||||
|
||||
using var timeoutCts = new CancellationTokenSource(_ConnectTimeout);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
|
||||
#if NET8_0_OR_GREATER
|
||||
await sock.ConnectAsync(new IPEndPoint(ip, port), linked.Token).ConfigureAwait(false);
|
||||
#else
|
||||
await sock.ConnectAsync(new IPEndPoint(ip, port)).WaitAsync(ConnectTimeout, linked.Token).ConfigureAwait(false);
|
||||
#endif
|
||||
return (true, ip.ToString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (false, ip.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.IO.Net.Http;
|
||||
|
||||
namespace PCL.Core.Minecraft.Yggdrasil;
|
||||
|
||||
public static class ApiLocation
|
||||
{
|
||||
public static async Task<string> TryRequestAsync(string address)
|
||||
{
|
||||
var originAddr = address.StartsWith("http") ? address : $"https://{address}";
|
||||
var originUri = new Uri(originAddr);
|
||||
using var response = await HttpRequest
|
||||
.CreateHead(originAddr)
|
||||
.SendAsync();
|
||||
|
||||
|
||||
if (!response.TryGetHeader("X-Authlib-Injector-Api-Location", out var location)
|
||||
|| location.Length == 0)
|
||||
return originAddr;
|
||||
|
||||
// TODO: use schema instead
|
||||
var resultAddr = location[0];
|
||||
|
||||
if (string.IsNullOrEmpty(resultAddr)) return originAddr;
|
||||
if (resultAddr.StartsWith(originUri.Scheme)) return resultAddr;
|
||||
// 不允许 HTTPS 降 HTTP
|
||||
if (resultAddr.StartsWith("http:") && originUri.Scheme == "https")
|
||||
return resultAddr.Replace("http","https");
|
||||
|
||||
return new Uri(originUri, resultAddr).ToString();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user