diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml b/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml
index 555f142e..d658fc29 100644
--- a/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml
+++ b/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml
@@ -17,4 +17,5 @@
+
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Core/Random.cs b/src/Snap.Hutao/Snap.Hutao/Core/Random.cs
index 74afc366..f1427404 100644
--- a/src/Snap.Hutao/Snap.Hutao/Core/Random.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Core/Random.cs
@@ -19,4 +19,9 @@ internal static class Random
{
return new(System.Random.Shared.GetItems("0123456789abcdefghijklmnopqrstuvwxyz".AsSpan(), length));
}
+
+ public static string GetLetterAndNumberString(int length)
+ {
+ return new(System.Random.Shared.GetItems("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".AsSpan(), length));
+ }
}
\ No newline at end of file
diff --git a/src/Snap.Hutao/Snap.Hutao/Factory/QrCode/IQrCodeFactory.cs b/src/Snap.Hutao/Snap.Hutao/Factory/QrCode/IQrCodeFactory.cs
new file mode 100644
index 00000000..3381dc01
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Factory/QrCode/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[] CreateByteArr(string source);
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Factory/QrCode/QrCodeFactory.cs b/src/Snap.Hutao/Snap.Hutao/Factory/QrCode/QrCodeFactory.cs
new file mode 100644
index 00000000..b16d0d29
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Factory/QrCode/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[] CreateByteArr(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 2ba84fb3..42907257 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 @@
正在转换客户端
+
+ 使用米游社扫描二维码
+
该操作是不可逆的,所有用户登录状态会丢失
@@ -2618,6 +2621,9 @@
网页登录
+
+ 扫码登录
+
手动输入
diff --git a/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj b/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj
index a1ed520d..7f248f3e 100644
--- a/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj
+++ b/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj
@@ -173,6 +173,7 @@
+
@@ -301,6 +302,7 @@
+
all
@@ -329,6 +331,11 @@
+
+
+ MSBuild:Compile
+
+
MSBuild:Compile
diff --git a/src/Snap.Hutao/Snap.Hutao/View/Dialog/QrCodeDialog.xaml b/src/Snap.Hutao/Snap.Hutao/View/Dialog/QrCodeDialog.xaml
new file mode 100644
index 00000000..bc8e44cb
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/View/Dialog/QrCodeDialog.xaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
diff --git a/src/Snap.Hutao/Snap.Hutao/View/Dialog/QrCodeDialog.xaml.cs b/src/Snap.Hutao/Snap.Hutao/View/Dialog/QrCodeDialog.xaml.cs
new file mode 100644
index 00000000..e14bf962
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/View/Dialog/QrCodeDialog.xaml.cs
@@ -0,0 +1,107 @@
+// 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.QrCode;
+using Snap.Hutao.Web.Response;
+using System.IO;
+using System.Text.RegularExpressions;
+
+namespace Snap.Hutao.View.Dialog;
+
+///
+/// 扫描二维码对话框
+///
+[HighQuality]
+internal sealed partial class QrCodeDialog : ContentDialog
+{
+ private readonly ITaskContext taskContext;
+ private readonly QrCodeClient qrCodeClient;
+ private readonly IQrCodeFactory qrCodeFactory;
+
+ private QrCodeAccount? account;
+
+ ///
+ /// 构造一个新的扫描二维码对话框
+ ///
+ /// 服务提供器
+ public QrCodeDialog(IServiceProvider serviceProvider)
+ {
+ InitializeComponent();
+
+ taskContext = serviceProvider.GetRequiredService();
+ qrCodeClient = serviceProvider.GetRequiredService();
+ qrCodeFactory = serviceProvider.GetRequiredService();
+
+ Initialize().SafeForget();
+ }
+
+ ///
+ /// 获取登录的用户
+ ///
+ /// QrCodeAccount
+ public async ValueTask> GetAccountAsync()
+ {
+ await taskContext.SwitchToMainThreadAsync();
+ ContentDialogResult result = await ShowAsync();
+ return new(account is not null, account!);
+ }
+
+ private async ValueTask Initialize()
+ {
+ Response fetch = await qrCodeClient.PostQrCodeFetchAsync().ConfigureAwait(false);
+ if (fetch.IsOk())
+ {
+ string url = Regex.Unescape(fetch.Data.Url);
+ string ticket = url.ToUri().Query.Split('&').Last().Split('=').Last();
+
+ await taskContext.SwitchToMainThreadAsync();
+ BitmapImage bitmap = new();
+ await bitmap.SetSourceAsync(new MemoryStream(qrCodeFactory.CreateByteArr(url)).AsRandomAccessStream());
+
+ ImageView.Source = bitmap;
+ if (bitmap is BitmapSource { PixelHeight: > 0, PixelWidth: > 0 })
+ {
+ VisualStateManager.GoToState(this, "Loaded", true);
+ }
+
+ await taskContext.SwitchToBackgroundAsync();
+
+ using (PeriodicTimer timer = new(TimeSpan.FromSeconds(3)))
+ {
+ while (await timer.WaitForNextTickAsync().ConfigureAwait(false))
+ {
+ Response query = await qrCodeClient.PostQrCodeQueryAsync(ticket).ConfigureAwait(false);
+ if (query.IsOk(false))
+ {
+ switch (query.Data.Stat)
+ {
+ case QrCodeQueryStatus.INIT:
+ case QrCodeQueryStatus.SCANNED:
+ break; // @switch
+ case QrCodeQueryStatus.CONFIRMED:
+ if (query.Data.Payload.Proto == QrCodeQueryPayload.ACCOUNT)
+ {
+ account = JsonSerializer.Deserialize(query.Data.Payload.Raw);
+ await taskContext.SwitchToMainThreadAsync();
+ Hide();
+ return;
+ }
+
+ break; // @switch
+ }
+ }
+ else if (query.ReturnCode == (int)KnownReturnCode.QrCodeExpired)
+ {
+ Initialize().SafeForget();
+ break; // @while
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml b/src/Snap.Hutao/Snap.Hutao/View/UserView.xaml
index 0fbae96e..fe59a7b2 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}"/>
+
? users;
@@ -173,6 +178,29 @@ internal sealed partial class UserViewModel : ObservableObject
}
}
+ [Command("LoginQrCodeCommand")]
+ private async Task LoginQrCode()
+ {
+ // ContentDialog must be created by main thread.
+ await taskContext.SwitchToMainThreadAsync();
+
+ QrCodeDialog dialog = await contentDialogFactory.CreateInstanceAsync().ConfigureAwait(false);
+ ValueResult result = await dialog.GetAccountAsync().ConfigureAwait(false);
+
+ if (result.TryGetValue(out QrCodeAccount account))
+ {
+ Response gameTokenResp = await sessionAppClient.PostSTokenByGameTokenAsync(account).ConfigureAwait(false);
+
+ if (gameTokenResp.IsOk())
+ {
+ Cookie stokenV2 = Cookie.FromLoginResult(gameTokenResp.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..e19db0fc 100644
--- a/src/Snap.Hutao/Snap.Hutao/Web/ApiEndpoints.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Web/ApiEndpoints.cs
@@ -16,6 +16,15 @@ namespace Snap.Hutao.Web;
[SuppressMessage("", "SA1124")]
internal static class ApiEndpoints
{
+ #region ApiTakumiAccountSessionApp
+
+ ///
+ /// 通过 GameToken 获取 SToken (V2)
+ ///
+ public const string STokenByGameToken = $"{ApiTakumiAccountSessionApp}/getTokenByGameToken";
+
+ #endregion
+
#region ApiTakumiAuthApi
///
@@ -40,6 +49,7 @@ internal static class ApiEndpoints
{
return $"{ApiTakumiAuthApi}/getMultiTokenByLoginTicket?login_ticket={loginTicket}&uid={loginUid}&token_types=3";
}
+
#endregion
#region ApiTaKumiBindingApi
@@ -357,9 +367,18 @@ internal static class ApiEndpoints
// https://sdk-static.mihoyo.com/hk4e_cn/mdk/launcher/api/content?key=eYd89JmJ&language=zh-cn&launcher_id=18
#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 Hosts | Queries
private const string ApiTakumi = "https://api-takumi.mihoyo.com";
private const string ApiTakumiAuthApi = $"{ApiTakumi}/auth/api";
+ private const string ApiTakumiAccountSessionApp = $"{ApiTakumi}/account/ma-cn-session/app";
private const string ApiTaKumiBindingApi = $"{ApiTakumi}/binding/api";
private const string ApiTakumiCardApi = $"{ApiTakumiRecord}/game_record/app/card/api";
@@ -382,6 +401,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";
@@ -402,4 +423,4 @@ internal static class ApiEndpoints
private const string AnnouncementQuery = "game=hk4e&game_biz=hk4e_cn&lang=zh-cn&bundle_id=hk4e_cn&platform=pc®ion=cn_gf01&level=55&uid=100000000";
#endregion
-}
\ No newline at end of file
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeAccount.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeAccount.cs
new file mode 100644
index 00000000..9c48cd45
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeAccount.cs
@@ -0,0 +1,14 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.QrCode;
+
+[HighQuality]
+internal sealed class QrCodeAccount
+{
+ [JsonPropertyName("uid")]
+ public string Stuid { get; set; } = default!;
+
+ [JsonPropertyName("token")]
+ public string GameToken { get; set; } = default!;
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeClient.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeClient.cs
new file mode 100644
index 00000000..7c5d5020
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeClient.cs
@@ -0,0 +1,63 @@
+// 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.QrCode;
+
+[HighQuality]
+[ConstructorGenerated(ResolveHttpClient = true)]
+[HttpClient(HttpClientConfiguration.Default)]
+internal sealed partial class QrCodeClient
+{
+ private readonly IHttpRequestMessageBuilderFactory httpRequestMessageBuilderFactory;
+ private readonly ILogger logger;
+ private readonly HttpClient httpClient;
+
+ private readonly string device = Core.Random.GetLetterAndNumberString(64);
+
+ ///
+ /// 异步获取扫码链接
+ ///
+ /// 取消令牌
+ /// login url
+ public async ValueTask> PostQrCodeFetchAsync(CancellationToken token = default)
+ {
+ QrCodeFetchOptions options = new(4, device);
+
+ HttpRequestMessageBuilder builder = httpRequestMessageBuilderFactory.Create()
+ .SetRequestUri(ApiEndpoints.QrCodeFetch)
+ .PostJson(options);
+
+ Response? resp = await builder
+ .TryCatchSendAsync>(httpClient, logger, token)
+ .ConfigureAwait(false);
+
+ return Response.Response.DefaultIfNull(resp);
+ }
+
+ ///
+ /// 异步获取扫码状态
+ ///
+ /// 扫码链接中的ticket
+ /// 取消令牌/param>
+ /// 扫码状态
+ public async ValueTask> PostQrCodeQueryAsync(string ticket, CancellationToken token = default)
+ {
+ QrCodeQueryOptions options = new(4, device, 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);
+ }
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeFetch.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeFetch.cs
new file mode 100644
index 00000000..bac70add
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeFetch.cs
@@ -0,0 +1,11 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.QrCode;
+
+[HighQuality]
+internal sealed class QrCodeFetch
+{
+ [JsonPropertyName("url")]
+ public string Url { get; set; } = default!;
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeFetchOptions.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeFetchOptions.cs
new file mode 100644
index 00000000..df689141
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeFetchOptions.cs
@@ -0,0 +1,20 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.QrCode;
+
+[HighQuality]
+internal sealed class QrCodeFetchOptions
+{
+ public QrCodeFetchOptions(int appId, string device)
+ {
+ AppId = appId;
+ Device = device;
+ }
+
+ [JsonPropertyName("app_id")]
+ public int AppId { get; set; }
+
+ [JsonPropertyName("device")]
+ public string Device { get; set; } = default!;
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQuery.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQuery.cs
new file mode 100644
index 00000000..3091ec4e
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQuery.cs
@@ -0,0 +1,14 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.QrCode;
+
+[HighQuality]
+internal sealed class QrCodeQuery
+{
+ [JsonPropertyName("stat")]
+ public string Stat { get; set; } = default!;
+
+ [JsonPropertyName("payload")]
+ public QrCodeQueryPayload Payload { get; set; } = default!;
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQueryOptions.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQueryOptions.cs
new file mode 100644
index 00000000..9f2806d8
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQueryOptions.cs
@@ -0,0 +1,24 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.QrCode;
+
+[HighQuality]
+internal sealed class QrCodeQueryOptions
+{
+ public QrCodeQueryOptions(int appId, string device, string ticket)
+ {
+ AppId = appId;
+ Device = device;
+ Ticket = ticket;
+ }
+
+ [JsonPropertyName("app_id")]
+ public int AppId { get; set; }
+
+ [JsonPropertyName("device")]
+ public string Device { get; set; } = default!;
+
+ [JsonPropertyName("ticket")]
+ public string Ticket { get; set; } = default!;
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQueryPayload.Constant.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQueryPayload.Constant.cs
new file mode 100644
index 00000000..d71edfd4
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQueryPayload.Constant.cs
@@ -0,0 +1,13 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.QrCode;
+
+///
+/// Proto 常量
+///
+internal sealed partial class QrCodeQueryPayload
+{
+ public const string ACCOUNT = "Account";
+ public const string RAW = "Raw";
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQueryPayload.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQueryPayload.cs
new file mode 100644
index 00000000..e66b64ba
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQueryPayload.cs
@@ -0,0 +1,17 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.QrCode;
+
+[HighQuality]
+internal sealed partial class QrCodeQueryPayload
+{
+ [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/QrCode/QrCodeQueryStatus.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQueryStatus.cs
new file mode 100644
index 00000000..ce0e9813
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Hk4e/QrCode/QrCodeQueryStatus.cs
@@ -0,0 +1,14 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Hoyolab.Hk4e.QrCode;
+
+///
+/// 扫码状态
+///
+internal sealed partial class QrCodeQueryStatus
+{
+ public const string INIT = "Init";
+ public const string SCANNED = "Scanned";
+ public const string CONFIRMED = "Confirmed";
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Account/GameTokenWrapper.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Account/GameTokenWrapper.cs
new file mode 100644
index 00000000..1b62d7cb
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Account/GameTokenWrapper.cs
@@ -0,0 +1,13 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+namespace Snap.Hutao.Web.Hoyolab.Takumi.Account;
+
+internal sealed class GameTokenWrapper
+{
+ [JsonPropertyName("account_id")]
+ public int Stuid { get; set; } = default!;
+
+ [JsonPropertyName("game_token")]
+ public string GameToken { get; set; } = default!;
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Account/SessionAppClient.cs b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Account/SessionAppClient.cs
new file mode 100644
index 00000000..6cd5b0bf
--- /dev/null
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/Takumi/Account/SessionAppClient.cs
@@ -0,0 +1,48 @@
+// Copyright (c) DGP Studio. All rights reserved.
+// Licensed under the MIT license.
+
+using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
+using Snap.Hutao.Web.Hoyolab.Hk4e.QrCode;
+using Snap.Hutao.Web.Hoyolab.Passport;
+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.Takumi.Account;
+
+[HighQuality]
+[ConstructorGenerated(ResolveHttpClient = true)]
+[HttpClient(HttpClientConfiguration.XRpc2)]
+internal sealed partial class SessionAppClient
+{
+ private readonly IHttpRequestMessageBuilderFactory httpRequestMessageBuilderFactory;
+ private readonly ILogger logger;
+ private readonly HttpClient httpClient;
+
+ ///
+ /// 通过 GameToken 获取 SToken (V2)
+ ///
+ /// 扫码获得的账户信息
+ /// 取消令牌
+ /// 登录结果
+ public async ValueTask> PostSTokenByGameTokenAsync(QrCodeAccount account, CancellationToken token = default)
+ {
+ GameTokenWrapper wrapper = new()
+ {
+ Stuid = int.Parse(account.Stuid, CultureInfo.CurrentCulture),
+ GameToken = account.GameToken,
+ };
+
+ HttpRequestMessageBuilder builder = httpRequestMessageBuilderFactory.Create()
+ .SetRequestUri(ApiEndpoints.STokenByGameToken)
+ .PostJson(wrapper);
+
+ Response? resp = await builder
+ .TryCatchSendAsync>(httpClient, logger, token)
+ .ConfigureAwait(false);
+
+ return Response.Response.DefaultIfNull(resp);
+ }
+}
diff --git a/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs b/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs
index b5cc3540..d1f701b1 100644
--- a/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs
+++ b/src/Snap.Hutao/Snap.Hutao/Web/Response/KnownReturnCode.cs
@@ -138,4 +138,9 @@ internal enum KnownReturnCode
/// 实时便笺
///
CODE10104 = 10104,
+
+ ///
+ /// 二维码已过期
+ ///
+ QrCodeExpired = -106,
}