feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -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();
}
}
@@ -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; }
}
@@ -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,
}
};
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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; }
}
@@ -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);
@@ -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);
@@ -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);
@@ -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>15w32a1.9 快照)引入了 DataVersion 字段</summary>
public const int _15w32a = 100;
/// <summary>17w47a1.13 快照)引入了 DataPacks 字段</summary>
public const int _17w47a = 1443;
/// <summary>20w20a1.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>返回值的字符串表示,无值时返回 "&lt;unspecified&gt;"。</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,
};
}
}
@@ -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);
}
@@ -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");
}
}
@@ -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,
};
}
}
@@ -0,0 +1,30 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// 15w32a(1.9) ~ 1.12.2 的存档格式。
/// 特征:DataVersion >= 100 且 &lt; 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 };
}
}
@@ -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 纯 IPIPv4/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) 试图解析为纯 IPIPv4 或 IPv6 无端口)
if (IPAddress.TryParse(input, out _))
return (input, null);
// 3) host:port(仅在末尾存在且为纯数字端口时成立)
var pm = _TrailingPort.Match(input);
if (pm.Success)
{
// 防止误把 IPv6 当作 host:portIPv6 必须用中括号携带端口)
// 此处 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();
}
}