diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml b/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml index 555f142e..4f8f0190 100644 --- a/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml +++ b/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml @@ -1,4 +1,5 @@ + @@ -12,6 +13,7 @@ + diff --git a/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/IocHttpClientConfiguration.cs b/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/IocHttpClientConfiguration.cs index 2a20113f..1d091b32 100644 --- a/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/IocHttpClientConfiguration.cs +++ b/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/IocHttpClientConfiguration.cs @@ -46,7 +46,7 @@ internal static partial class IocHttpClientConfiguration client.DefaultRequestHeaders.Accept.ParseAdd(ApplicationJson); client.DefaultRequestHeaders.Add("x-rpc-app_version", SaltConstants.CNVersion); client.DefaultRequestHeaders.Add("x-rpc-client_type", "5"); - client.DefaultRequestHeaders.Add("x-rpc-device_id", HoyolabOptions.DeviceId); + client.DefaultRequestHeaders.Add("x-rpc-device_id", HoyolabOptions.DeviceId36); } /// @@ -62,7 +62,7 @@ internal static partial class IocHttpClientConfiguration client.DefaultRequestHeaders.Add("x-rpc-app_id", "bll8iq97cem8"); client.DefaultRequestHeaders.Add("x-rpc-app_version", SaltConstants.CNVersion); client.DefaultRequestHeaders.Add("x-rpc-client_type", "2"); - client.DefaultRequestHeaders.Add("x-rpc-device_id", HoyolabOptions.DeviceId); + client.DefaultRequestHeaders.Add("x-rpc-device_id", HoyolabOptions.DeviceId36); client.DefaultRequestHeaders.Add("x-rpc-device_name", string.Empty); client.DefaultRequestHeaders.Add("x-rpc-game_biz", "bbs_cn"); client.DefaultRequestHeaders.Add("x-rpc-sdk_version", "2.16.0"); @@ -81,7 +81,7 @@ internal static partial class IocHttpClientConfiguration client.DefaultRequestHeaders.Add("x-rpc-app_version", SaltConstants.OSVersion); client.DefaultRequestHeaders.Add("x-rpc-client_type", "5"); client.DefaultRequestHeaders.Add("x-rpc-language", "zh-cn"); - client.DefaultRequestHeaders.Add("x-rpc-device_id", HoyolabOptions.DeviceId); + client.DefaultRequestHeaders.Add("x-rpc-device_id", HoyolabOptions.DeviceId36); } /// diff --git a/src/Snap.Hutao/Snap.Hutao/Core/Uuid.cs b/src/Snap.Hutao/Snap.Hutao/Core/Uuid.cs new file mode 100644 index 00000000..b0d74b4f --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Core/Uuid.cs @@ -0,0 +1,61 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; + +namespace Snap.Hutao.Core; + +internal static class Uuid +{ + public static Guid NewV5(string name, Guid namespaceId) + { + Span namespaceBuffer = stackalloc byte[16]; + Verify.Operation(namespaceId.TryWriteBytes(namespaceBuffer), "Failed to copy namespace guid bytes"); + Span nameBytes = Encoding.UTF8.GetBytes(name); + + if (BitConverter.IsLittleEndian) + { + ReverseEndianness(namespaceBuffer); + } + + Span data = stackalloc byte[namespaceBuffer.Length + nameBytes.Length]; + namespaceBuffer.CopyTo(data); + nameBytes.CopyTo(data[namespaceBuffer.Length..]); + + Span temp = stackalloc byte[20]; + Verify.Operation(SHA1.TryHashData(data, temp, out _), "Failed to compute SHA1 hash of UUID"); + + Span hash = temp[..16]; + + if (BitConverter.IsLittleEndian) + { + ReverseEndianness(hash); + } + + hash[8] &= 0x3F; + hash[8] |= 0x80; + + int versionIndex = BitConverter.IsLittleEndian ? 7 : 6; + + hash[versionIndex] &= 0x0F; + hash[versionIndex] |= 0x50; + + return new(hash); + } + + private static void ReverseEndianness(in Span guidByte) + { + ExchangeBytes(guidByte, 0, 3); + ExchangeBytes(guidByte, 1, 2); + ExchangeBytes(guidByte, 4, 5); + ExchangeBytes(guidByte, 6, 7); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ExchangeBytes(in Span guid, int left, int right) + { + (guid[right], guid[left]) = (guid[left], guid[right]); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Extension/EnumerableExtension.NameValueCollection.cs b/src/Snap.Hutao/Snap.Hutao/Extension/EnumerableExtension.NameValueCollection.cs index 9d3840c0..2f4bafda 100644 --- a/src/Snap.Hutao/Snap.Hutao/Extension/EnumerableExtension.NameValueCollection.cs +++ b/src/Snap.Hutao/Snap.Hutao/Extension/EnumerableExtension.NameValueCollection.cs @@ -7,7 +7,7 @@ namespace Snap.Hutao.Extension; internal static partial class EnumerableExtension { - public static bool TryGetValue(this NameValueCollection collection, string name, [NotNullWhen(true)] out string? value) + public static bool TryGetSingleValue(this NameValueCollection collection, string name, [NotNullWhen(true)] out string? value) { if (collection.AllKeys.Contains(name)) { diff --git a/src/Snap.Hutao/Snap.Hutao/Factory/QuickResponse/IQRCodeFactory.cs b/src/Snap.Hutao/Snap.Hutao/Factory/QuickResponse/IQRCodeFactory.cs new file mode 100644 index 00000000..8ba3ea82 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Factory/QuickResponse/IQRCodeFactory.cs @@ -0,0 +1,9 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Factory.QrCode; + +internal interface IQRCodeFactory +{ + byte[] Create(string source); +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Factory/QuickResponse/QRCodeFactory.cs b/src/Snap.Hutao/Snap.Hutao/Factory/QuickResponse/QRCodeFactory.cs new file mode 100644 index 00000000..fb28e857 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Factory/QuickResponse/QRCodeFactory.cs @@ -0,0 +1,25 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using QRCoder; + +namespace Snap.Hutao.Factory.QrCode; + +[ConstructorGenerated] +[Injection(InjectAs.Singleton, typeof(IQRCodeFactory))] +internal class QRCodeFactory : IQRCodeFactory +{ + public byte[] Create(string source) + { + using (QRCodeGenerator generator = new()) + { + using (QRCodeData data = generator.CreateQrCode(source, QRCodeGenerator.ECCLevel.Q)) + { + using (BitmapByteQRCode code = new(data)) + { + return code.GetGraphic(10); + } + } + } + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx index 5ee7f8a2..ab31fad0 100644 --- a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx +++ b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx @@ -1265,6 +1265,9 @@ 正在转换客户端 + + 使用米游社扫描二维码 + 该操作是不可逆的,所有用户登录状态会丢失 @@ -2624,6 +2627,9 @@ 网页登录 + + 扫码登录 + 手动输入 diff --git a/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryManualInputProvider.cs b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryManualInputProvider.cs index 9e7b98fc..45be4b6b 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryManualInputProvider.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryManualInputProvider.cs @@ -30,7 +30,7 @@ internal sealed partial class GachaLogQueryManualInputProvider : IGachaLogQueryP { NameValueCollection query = HttpUtility.ParseQueryString(queryString); - if (query.TryGetValue("auth_appid", out string? appId) && appId is "webview_gacha") + if (query.TryGetSingleValue("auth_appid", out string? appId) && appId is "webview_gacha") { string? queryLanguageCode = query["lang"]; if (metadataOptions.LanguageCodeFitsCurrentLocale(queryLanguageCode)) diff --git a/src/Snap.Hutao/Snap.Hutao/Service/User/UserFingerprintService.cs b/src/Snap.Hutao/Snap.Hutao/Service/User/UserFingerprintService.cs index 864346e5..a8a1f59e 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/User/UserFingerprintService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/User/UserFingerprintService.cs @@ -74,7 +74,7 @@ internal sealed partial class UserFingerprintService : IUserFingerprintService SeedTime = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}", ExtFields = JsonSerializer.Serialize(extendProperties), AppName = "bbs_cn", - BbsDeviceId = HoyolabOptions.DeviceId, + BbsDeviceId = HoyolabOptions.DeviceId36, DeviceFp = string.IsNullOrEmpty(user.Fingerprint) ? Core.Random.GetLowerHexString(13) : user.Fingerprint, }; diff --git a/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj b/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj index 96133dfe..29bea2fe 100644 --- a/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj +++ b/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj @@ -174,6 +174,7 @@ + @@ -302,6 +303,7 @@ + all @@ -330,6 +332,11 @@ + + + MSBuild:Compile + + MSBuild:Compile diff --git a/src/Snap.Hutao/Snap.Hutao/View/Dialog/UserQRCodeDialog.xaml b/src/Snap.Hutao/Snap.Hutao/View/Dialog/UserQRCodeDialog.xaml new file mode 100644 index 00000000..62e88581 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/View/Dialog/UserQRCodeDialog.xaml @@ -0,0 +1,21 @@ + + + + + + diff --git a/src/Snap.Hutao/Snap.Hutao/View/Dialog/UserQRCodeDialog.xaml.cs b/src/Snap.Hutao/Snap.Hutao/View/Dialog/UserQRCodeDialog.xaml.cs new file mode 100644 index 00000000..dcbaa0fc --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/View/Dialog/UserQRCodeDialog.xaml.cs @@ -0,0 +1,157 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Snap.Hutao.Factory.QrCode; +using Snap.Hutao.Web.Hoyolab.Hk4e.Sdk.Combo; +using Snap.Hutao.Web.Hoyolab.Passport; +using Snap.Hutao.Web.Response; +using System.Collections.Specialized; +using System.IO; +using System.Web; + +namespace Snap.Hutao.View.Dialog; + +[DependencyProperty("QRCodeSource", typeof(ImageSource))] +internal sealed partial class UserQRCodeDialog : ContentDialog, IDisposable +{ + private readonly ITaskContext taskContext; + private readonly PandaClient pandaClient; + private readonly IQRCodeFactory qrCodeFactory; + + private readonly CancellationTokenSource userManualCancellationTokenSource = new(); + private bool disposed; + + public UserQRCodeDialog(IServiceProvider serviceProvider) + { + InitializeComponent(); + + taskContext = serviceProvider.GetRequiredService(); + pandaClient = serviceProvider.GetRequiredService(); + qrCodeFactory = serviceProvider.GetRequiredService(); + } + + ~UserQRCodeDialog() + { + Dispose(); + } + + public void Dispose() + { + if (!disposed) + { + userManualCancellationTokenSource.Dispose(); + disposed = true; + } + + GC.SuppressFinalize(this); + } + + public async ValueTask> GetUidGameTokenAsync() + { + try + { + return await GetUidGameTokenCoreAsync().ConfigureAwait(false); + } + finally + { + userManualCancellationTokenSource.Dispose(); + } + } + + [Command("CancelCommand")] + private void Cancel() + { + userManualCancellationTokenSource.Cancel(); + } + + private async ValueTask> GetUidGameTokenCoreAsync() + { + await taskContext.SwitchToMainThreadAsync(); + await ShowAsync(); + + while (!userManualCancellationTokenSource.IsCancellationRequested) + { + try + { + CancellationToken token = userManualCancellationTokenSource.Token; + string ticket = await FetchQRCodeAndSetImageAsync(token).ConfigureAwait(false); + UidGameToken? uidGameToken = await WaitQueryQRCodeConfirmAsync(ticket, token).ConfigureAwait(false); + + if (uidGameToken is null) + { + continue; + } + + await taskContext.SwitchToMainThreadAsync(); + Hide(); + return new(true, uidGameToken); + } + catch (OperationCanceledException) + { + break; + } + } + + return new(false, default!); + } + + private async ValueTask FetchQRCodeAndSetImageAsync(CancellationToken token) + { + Response fetchResponse = await pandaClient.QRCodeFetchAsync(token).ConfigureAwait(false); + if (!fetchResponse.IsOk()) + { + return string.Empty; + } + + string url = fetchResponse.Data.Url; + string ticket = GetTicketFromUrl(fetchResponse.Data.Url); + + await taskContext.SwitchToMainThreadAsync(); + + BitmapImage bitmap = new(); + await bitmap.SetSourceAsync(new MemoryStream(qrCodeFactory.Create(url)).AsRandomAccessStream()); + QRCodeSource = bitmap; + + return ticket; + + static string GetTicketFromUrl(in ReadOnlySpan urlSpan) + { + ReadOnlySpan querySpan = urlSpan[urlSpan.IndexOf('?')..]; + NameValueCollection queryCollection = HttpUtility.ParseQueryString(querySpan.ToString()); + if (queryCollection.TryGetSingleValue("ticket", out string? ticket)) + { + return ticket; + } + + return string.Empty; + } + } + + private async ValueTask WaitQueryQRCodeConfirmAsync(string ticket, CancellationToken token) + { + using (PeriodicTimer timer = new(new(0, 0, 3))) + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + Response query = await pandaClient.QRCodeQueryAsync(ticket, token).ConfigureAwait(false); + + if (query is { ReturnCode: 0, Data: { Stat: "Confirmed", Payload.Proto: "Account" } }) + { + UidGameToken? uidGameToken = JsonSerializer.Deserialize(query.Data.Payload.Raw); + ArgumentNullException.ThrowIfNull(uidGameToken); + return uidGameToken; + } + else if (query.ReturnCode == (int)KnownReturnCode.QrCodeExpired) + { + break; + } + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml b/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml index 0fbae96e..8b119393 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml @@ -154,6 +154,13 @@ Command="{Binding LoginMihoyoUserCommand}" Icon="{shcm:FontIcon Glyph={StaticResource FontIconContentWebsite}}" Label="{shcm:ResourceString Name=ViewUserCookieOperationLoginMihoyoUserAction}"/> + ().ConfigureAwait(false); + (bool isOk, UidGameToken? token) = await dialog.GetUidGameTokenAsync().ConfigureAwait(false); + + if (!isOk) + { + return; + } + + Response sTokenResponse = await serviceProvider + .GetRequiredService() + .GetSTokenByGameTokenAsync(token) + .ConfigureAwait(false); + + if (sTokenResponse.IsOk()) + { + Cookie stokenV2 = Cookie.FromLoginResult(sTokenResponse.Data); + + (UserOptionResult optionResult, string uid) = await userService.ProcessInputCookieAsync(stokenV2, false).ConfigureAwait(false); + + await HandleUserOptionResultAsync(optionResult, uid).ConfigureAwait(false); + } + } + [Command("RemoveUserCommand")] private async Task RemoveUserAsync(User? user) { diff --git a/src/Snap.Hutao/Snap.Hutao/Web/ApiEndpoints.cs b/src/Snap.Hutao/Snap.Hutao/Web/ApiEndpoints.cs index 64ffdee8..2774436b 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/ApiEndpoints.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/ApiEndpoints.cs @@ -40,6 +40,7 @@ internal static class ApiEndpoints { return $"{ApiTakumiAuthApi}/getMultiTokenByLoginTicket?login_ticket={loginTicket}&uid={loginUid}&token_types=3"; } + #endregion #region ApiTaKumiBindingApi @@ -282,6 +283,14 @@ internal static class ApiEndpoints public const string AnnContent = $"{Hk4eApiAnnouncementApi}/getAnnContent?{AnnouncementQuery}"; #endregion + #region Hk4eSdk + + public const string QrCodeFetch = $"{Hk4eSdk}/hk4e_cn/combo/panda/qrcode/fetch"; + + public const string QrCodeQuery = $"{Hk4eSdk}/hk4e_cn/combo/panda/qrcode/query"; + + #endregion + #region PassportApi | PassportApiV4 /// @@ -294,6 +303,11 @@ internal static class ApiEndpoints /// public const string AccountGetLTokenBySToken = $"{PassportApiAuthApi}/getLTokenBySToken"; + /// + /// 通过GameToken获取V2SToken + /// + public const string AccountGetSTokenByGameToken = $"{PassportApi}/account/ma-cn-session/app/getTokenByGameToken"; + /// /// 获取V2SToken /// @@ -382,6 +396,8 @@ internal static class ApiEndpoints private const string Hk4eApi = "https://hk4e-api.mihoyo.com"; private const string Hk4eApiAnnouncementApi = $"{Hk4eApi}/common/hk4e_cn/announcement/api"; + private const string Hk4eSdk = "https://hk4e-sdk.mihoyo.com"; + private const string PassportApi = "https://passport-api.mihoyo.com"; private const string PassportApiAuthApi = $"{PassportApi}/account/auth/api"; private const string PassportApiV4 = "https://passport-api-v4.mihoyo.com"; diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Bridge/MiHoYoJSBridge.cs b/src/Snap.Hutao/Snap.Hutao/Web/Bridge/MiHoYoJSBridge.cs index d9992754..3466901d 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Bridge/MiHoYoJSBridge.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Bridge/MiHoYoJSBridge.cs @@ -233,7 +233,7 @@ internal class MiHoYoJSBridge // Skip x-rpc-lifecycle_id { "x-rpc-app_id", "bll8iq97cem8" }, { "x-rpc-client_type", "5" }, - { "x-rpc-device_id", HoyolabOptions.DeviceId }, + { "x-rpc-device_id", HoyolabOptions.DeviceId36 }, { "x-rpc-app_version", userAndUid.IsOversea ? SaltConstants.OSVersion : SaltConstants.CNVersion }, { "x-rpc-sdk_version", "2.16.0" }, }; diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/GameLoginRequest.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/GameLoginRequest.cs new file mode 100644 index 00000000..479d4218 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/GameLoginRequest.cs @@ -0,0 +1,27 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Web.Hoyolab.Hk4e.Sdk.Combo; + +[HighQuality] +internal sealed class GameLoginRequest +{ + [JsonPropertyName("app_id")] + public int AppId { get; set; } + + [JsonPropertyName("device")] + public string Device { get; set; } = default!; + + [JsonPropertyName("ticket")] + public string? Ticket { get; set; } + + public static GameLoginRequest Create(int appId, string device, string? ticket = null) + { + return new() + { + AppId = appId, + Device = device, + Ticket = ticket, + }; + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/GameLoginResult.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/GameLoginResult.cs new file mode 100644 index 00000000..92e6456e --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/GameLoginResult.cs @@ -0,0 +1,17 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Web.Hoyolab.Hk4e.Sdk.Combo; + +/// +/// 扫码登录结果 +/// +[HighQuality] +internal sealed class GameLoginResult +{ + [JsonPropertyName("stat")] + public string Stat { get; set; } = default!; + + [JsonPropertyName("payload")] + public GameLoginResultPayload Payload { get; set; } = default!; +} diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/GameLoginResultPayload.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/GameLoginResultPayload.cs new file mode 100644 index 00000000..80580331 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/GameLoginResultPayload.cs @@ -0,0 +1,17 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Web.Hoyolab.Hk4e.Sdk.Combo; + +[HighQuality] +internal sealed partial class GameLoginResultPayload +{ + [JsonPropertyName("proto")] + public string Proto { get; set; } = default!; + + [JsonPropertyName("raw")] + public string Raw { get; set; } = default!; + + [JsonPropertyName("ext")] + public string Ext { get; set; } = default!; +} diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/PandaClient.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/PandaClient.cs new file mode 100644 index 00000000..90ec1cfc --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/PandaClient.cs @@ -0,0 +1,49 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient; +using Snap.Hutao.Web.Request.Builder; +using Snap.Hutao.Web.Request.Builder.Abstraction; +using Snap.Hutao.Web.Response; +using System.Net.Http; + +namespace Snap.Hutao.Web.Hoyolab.Hk4e.Sdk.Combo; + +[ConstructorGenerated(ResolveHttpClient = true)] +[HttpClient(HttpClientConfiguration.XRpc2)] +internal sealed partial class PandaClient +{ + private readonly IHttpRequestMessageBuilderFactory httpRequestMessageBuilderFactory; + private readonly ILogger logger; + private readonly HttpClient httpClient; + + public async ValueTask> QRCodeFetchAsync(CancellationToken token = default) + { + GameLoginRequest options = GameLoginRequest.Create(4, HoyolabOptions.DeviceId40); + + HttpRequestMessageBuilder builder = httpRequestMessageBuilderFactory.Create() + .SetRequestUri(ApiEndpoints.QrCodeFetch) + .PostJson(options); + + Response? resp = await builder + .TryCatchSendAsync>(httpClient, logger, token) + .ConfigureAwait(false); + + return Response.Response.DefaultIfNull(resp); + } + + public async ValueTask> QRCodeQueryAsync(string ticket, CancellationToken token = default) + { + GameLoginRequest options = GameLoginRequest.Create(4, HoyolabOptions.DeviceId40, ticket); + + HttpRequestMessageBuilder builder = httpRequestMessageBuilderFactory.Create() + .SetRequestUri(ApiEndpoints.QrCodeQuery) + .PostJson(options); + + Response? resp = await builder + .TryCatchSendAsync>(httpClient, logger, token) + .ConfigureAwait(false); + + return Response.Response.DefaultIfNull(resp); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/UrlWrapper.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/UrlWrapper.cs new file mode 100644 index 00000000..9a962e6a --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/Sdk/Combo/UrlWrapper.cs @@ -0,0 +1,10 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Web.Hoyolab.Hk4e.Sdk.Combo; + +internal sealed class UrlWrapper +{ + [JsonPropertyName("url")] + public string Url { get; set; } = default!; +} diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/HoyolabOptions.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/HoyolabOptions.cs index f5e391a6..ad3dff78 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/HoyolabOptions.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/HoyolabOptions.cs @@ -1,16 +1,16 @@ // Copyright (c) DGP Studio. All rights reserved. // Licensed under the MIT license. -using Microsoft.Extensions.Options; using Snap.Hutao.Web.Hoyolab.DataSigning; using System.Collections.Frozen; +using System.Security.Cryptography; namespace Snap.Hutao.Web.Hoyolab; /// /// 米游社选项 /// -internal sealed class HoyolabOptions : IOptions +internal static class HoyolabOptions { /// /// 米游社请求UA @@ -35,7 +35,12 @@ internal sealed class HoyolabOptions : IOptions /// /// 米游社设备Id /// - public static string DeviceId { get; } = Guid.NewGuid().ToString(); + public static string DeviceId36 { get; } = Guid.NewGuid().ToString(); + + /// + /// 扫码登录设备Id + /// + public static string DeviceId40 { get; } = GenerateDeviceId40(); /// /// 盐 @@ -56,6 +61,16 @@ internal sealed class HoyolabOptions : IOptions [SaltType.OSX6] = "okr4obncj8bw5a65hbnn5oo6ixjc3l9w", }.ToFrozenDictionary(); - /// - public HoyolabOptions Value { get => this; } + [SuppressMessage("", "CA1308")] + private static string GenerateDeviceId40() + { + Guid uuid = Core.Uuid.NewV5(DeviceId36, new("9450ea74-be9c-35c0-9568-f97407856768")); + + Span uuidSpan = stackalloc byte[16]; + Span hash = stackalloc byte[20]; + + Verify.Operation(uuid.TryWriteBytes(uuidSpan), "Failed to write UUID bytes"); + Verify.Operation(SHA1.TryHashData(uuidSpan, hash, out _), "Failed to write SHA1 hash"); + return Convert.ToHexString(hash).ToLowerInvariant(); + } } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/AccountIdGameToken.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/AccountIdGameToken.cs new file mode 100644 index 00000000..0c76cc52 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/AccountIdGameToken.cs @@ -0,0 +1,13 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Web.Hoyolab.Passport; + +internal sealed class AccountIdGameToken +{ + [JsonPropertyName("account_id")] + public int AccountId { get; set; } = default!; + + [JsonPropertyName("game_token")] + public string GameToken { get; set; } = default!; +} diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/PassportClient2.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/PassportClient2.cs index f6e3713f..be1ad870 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/PassportClient2.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/PassportClient2.cs @@ -5,9 +5,11 @@ using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient; using Snap.Hutao.Model.Entity; using Snap.Hutao.Web.Hoyolab.Annotation; using Snap.Hutao.Web.Hoyolab.DataSigning; +using Snap.Hutao.Web.Hoyolab.Hk4e.Sdk.Combo; using Snap.Hutao.Web.Request.Builder; using Snap.Hutao.Web.Request.Builder.Abstraction; using Snap.Hutao.Web.Response; +using System.Globalization; using System.Net.Http; namespace Snap.Hutao.Web.Hoyolab.Passport; @@ -68,6 +70,25 @@ internal sealed partial class PassportClient2 return Response.Response.DefaultIfNull(resp); } + public async ValueTask> GetSTokenByGameTokenAsync(UidGameToken account, CancellationToken token = default) + { + AccountIdGameToken data = new() + { + AccountId = int.Parse(account.Uid, CultureInfo.InvariantCulture), + GameToken = account.GameToken, + }; + + HttpRequestMessageBuilder builder = httpRequestMessageBuilderFactory.Create() + .SetRequestUri(ApiEndpoints.AccountGetSTokenByGameToken) + .PostJson(data); + + Response? resp = await builder + .TryCatchSendAsync>(httpClient, logger, token) + .ConfigureAwait(false); + + return Response.Response.DefaultIfNull(resp); + } + private class Timestamp { [JsonPropertyName("t")] diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/UidGameToken.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/UidGameToken.cs new file mode 100644 index 00000000..7f2ffc47 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Passport/UidGameToken.cs @@ -0,0 +1,14 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Web.Hoyolab.Passport; + +[HighQuality] +internal sealed class UidGameToken +{ + [JsonPropertyName("uid")] + public string Uid { get; set; } = default!; + + [JsonPropertyName("token")] + public string GameToken { get; set; } = default!; +} diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs b/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs index b5cc3540..5725680a 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs @@ -84,6 +84,11 @@ internal enum KnownReturnCode /// AppIdError = -109, + /// + /// 二维码已过期 + /// + QrCodeExpired = -106, + /// /// 验证密钥过期 ///