diff --git a/Library/RSBot.Core/Extensions/SocketExtensions.cs b/Library/RSBot.Core/Extensions/SocketExtensions.cs index c5770abe..b8522b3c 100644 --- a/Library/RSBot.Core/Extensions/SocketExtensions.cs +++ b/Library/RSBot.Core/Extensions/SocketExtensions.cs @@ -25,6 +25,11 @@ public struct ProxyConfig public byte Version; public int TimeOut; + public static bool TryGetProxy(out ProxyConfig config) + { + return TryGetProxy(null, 0, out config); + } + public static bool TryGetProxy(string ip, int port, out ProxyConfig config) { config = new ProxyConfig @@ -53,6 +58,17 @@ public static bool TryGetProxy(string ip, int port, out ProxyConfig config) return true; } + + public readonly WebProxy CreateWebProxy() + { + string scheme = Version == 5 ? "socks5" : "socks4"; + var proxy = new WebProxy(new UriBuilder(scheme, ProxyIp, ProxyPort).Uri); + + if (!string.IsNullOrEmpty(UserName) || !string.IsNullOrEmpty(Password)) + proxy.Credentials = new NetworkCredential(UserName ?? string.Empty, Password ?? string.Empty); + + return proxy; + } } public static class SocketExtensions @@ -72,16 +88,21 @@ public static void Connect(this Socket socket, string host, int port, int timeou throw new SocketException(10060); // Connection timed out. } - public static async Task ConnectViaProxy(this Socket socket, ProxyConfig config) + public static async Task ConnectViaProxy(this Socket socket, ProxyConfig config, bool logConnection = true) { - Log.Notify( - $"Trying to connect to proxy server [{config.ProxyIp}:{config.ProxyPort} / Version: {config.Version}]..." - ); + if (logConnection) + { + Log.Notify( + $"Trying to connect to proxy server [{config.ProxyIp}:{config.ProxyPort} / Version: {config.Version}]..." + ); + } + await socket.ConnectAsync(config.ProxyIp, config.ProxyPort); if (!socket.Connected) return false; - Log.Notify("Successfully connected to proxy server..."); + if (logConnection) + Log.Notify("Successfully connected to proxy server..."); var portBuffer = new byte[2] { Convert.ToByte(config.Port / 256), Convert.ToByte(config.Port % 256) }; @@ -180,6 +201,13 @@ await socket.SendAsync( // +----+----+----+----+----+----+----+----+----+----+....+----+ // 1 1 2 4 variable 1 + if (addressType == 3) + { + addressBytes = Dns.GetHostAddresses(config.Ip) + .First(address => address.AddressFamily == AddressFamily.InterNetwork) + .GetAddressBytes(); + } + var userIdBytes = Encoding.ASCII.GetBytes(config.UserName); var request = new byte[9 + userIdBytes.Length]; diff --git a/Plugins/RSBot.General/Components/ConfiguredProxyBridge.cs b/Plugins/RSBot.General/Components/ConfiguredProxyBridge.cs new file mode 100644 index 00000000..3f595f0c --- /dev/null +++ b/Plugins/RSBot.General/Components/ConfiguredProxyBridge.cs @@ -0,0 +1,234 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using RSBot.Core.Extensions; + +namespace RSBot.General.Components; + +/// +/// Exposes a loopback HTTP proxy for browser components that cannot authenticate with SOCKS proxies. +/// Each outgoing connection uses the current RSBot.Network.Proxy configuration. +/// +internal sealed class ConfiguredProxyBridge +{ + private const int MaxHeaderSize = 64 * 1024; + private static readonly Lazy LazyInstance = new(() => new ConfiguredProxyBridge()); + + private readonly TcpListener _listener; + private readonly object _proxyConfigLock = new(); + private ProxyConfig _proxyConfig; + + private ConfiguredProxyBridge() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Address = new Uri($"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}"); + _ = AcceptConnectionsAsync(); + } + + public static ConfiguredProxyBridge Instance => LazyInstance.Value; + + public Uri Address { get; } + + public Uri Configure(ProxyConfig proxyConfig) + { + lock (_proxyConfigLock) + _proxyConfig = proxyConfig; + + return Address; + } + + private async Task AcceptConnectionsAsync() + { + while (true) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) + { + return; + } + + _ = HandleClientAsync(client); + } + } + + private async Task HandleClientAsync(TcpClient client) + { + using (client) + { + try + { + NetworkStream clientStream = client.GetStream(); + byte[] request = await ReadRequestHeaderAsync(clientStream); + if ( + !TryParseDestination(request, out string host, out int port, out bool isConnect, out byte[] payload) + ) + { + await WriteErrorAsync(clientStream, "400 Bad Request"); + return; + } + + ProxyConfig proxyConfig; + lock (_proxyConfigLock) + { + proxyConfig = _proxyConfig; + } + + proxyConfig.Ip = host; + proxyConfig.Port = port; + + using var destination = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + if (!await destination.ConnectViaProxy(proxyConfig, logConnection: false)) + { + await WriteErrorAsync(clientStream, "502 Bad Gateway"); + return; + } + + using var destinationStream = new NetworkStream(destination, ownsSocket: false); + if (isConnect) + { + byte[] established = Encoding.ASCII.GetBytes("HTTP/1.1 200 Connection Established\r\n\r\n"); + await clientStream.WriteAsync(established); + } + else + { + await destinationStream.WriteAsync(payload); + } + + Task upload = clientStream.CopyToAsync(destinationStream); + Task download = destinationStream.CopyToAsync(clientStream); + await Task.WhenAny(upload, download); + } + catch (Exception ex) when (ex is IOException or SocketException or SocketProxyException) + { + try + { + await WriteErrorAsync(client.GetStream(), "502 Bad Gateway"); + } + catch + { + // The browser already closed the connection. + } + } + } + } + + private static async Task ReadRequestHeaderAsync(NetworkStream stream) + { + using var request = new MemoryStream(); + var buffer = new byte[4096]; + + while (request.Length < MaxHeaderSize) + { + int read = await stream.ReadAsync(buffer); + if (read == 0) + break; + + request.Write(buffer, 0, read); + if (FindHeaderEnd(request.GetBuffer(), (int)request.Length) >= 0) + return request.ToArray(); + } + + throw new IOException("The local proxy received an incomplete or oversized HTTP request header."); + } + + private static bool TryParseDestination( + byte[] request, + out string host, + out int port, + out bool isConnect, + out byte[] payload + ) + { + host = null; + port = 0; + isConnect = false; + payload = null; + + int headerEnd = FindHeaderEnd(request, request.Length); + if (headerEnd < 0) + return false; + + string header = Encoding.ASCII.GetString(request, 0, headerEnd); + string[] lines = header.Split(["\r\n"], StringSplitOptions.None); + string[] requestLine = lines[0].Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (requestLine.Length != 3) + return false; + + isConnect = string.Equals(requestLine[0], "CONNECT", StringComparison.OrdinalIgnoreCase); + if (isConnect) + { + if (!TryParseAuthority(requestLine[1], 443, out host, out port)) + return false; + + payload = Array.Empty(); + return true; + } + + if (!Uri.TryCreate(requestLine[1], UriKind.Absolute, out Uri requestUri)) + return false; + + host = requestUri.Host; + port = requestUri.Port; + string pathAndQuery = string.IsNullOrEmpty(requestUri.PathAndQuery) ? "/" : requestUri.PathAndQuery; + string rewrittenHeader = + $"{requestLine[0]} {pathAndQuery} {requestLine[2]}\r\n" + string.Join("\r\n", lines.Skip(1)); + byte[] rewrittenHeaderBytes = Encoding.ASCII.GetBytes(rewrittenHeader); + int bodyLength = request.Length - headerEnd; + payload = new byte[rewrittenHeaderBytes.Length + bodyLength]; + rewrittenHeaderBytes.CopyTo(payload, 0); + request.AsSpan(headerEnd, bodyLength).CopyTo(payload.AsSpan(rewrittenHeaderBytes.Length)); + return true; + } + + private static bool TryParseAuthority(string authority, int defaultPort, out string host, out int port) + { + host = null; + port = 0; + if (!Uri.TryCreate($"http://{authority}", UriKind.Absolute, out Uri uri)) + return false; + + host = uri.Host; + port = uri.IsDefaultPort ? defaultPort : uri.Port; + return !string.IsNullOrWhiteSpace(host) && port is > 0 and <= ushort.MaxValue; + } + + private static int FindHeaderEnd(byte[] buffer, int length) + { + for (int index = 3; index < length; index++) + { + if ( + buffer[index - 3] == '\r' + && buffer[index - 2] == '\n' + && buffer[index - 1] == '\r' + && buffer[index] == '\n' + ) + { + return index + 1; + } + } + + return -1; + } + + private static Task WriteErrorAsync(NetworkStream stream, string status) + { + byte[] response = Encoding.ASCII.GetBytes( + $"HTTP/1.1 {status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n" + ); + return stream.WriteAsync(response).AsTask(); + } +} diff --git a/Plugins/RSBot.General/Components/JSROAuthService.cs b/Plugins/RSBot.General/Components/JSROAuthService.cs index a9ee1cfd..614fe33d 100644 --- a/Plugins/RSBot.General/Components/JSROAuthService.cs +++ b/Plugins/RSBot.General/Components/JSROAuthService.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using System.Windows.Forms; using RSBot.Core; +using RSBot.Core.Extensions; namespace RSBot.General.Components; @@ -22,6 +23,9 @@ public static async Task GetTokenAsync() AutomaticDecompression = DecompressionMethods.All, }; + if (ProxyConfig.TryGetProxy(out ProxyConfig proxyConfig)) + handler.Proxy = proxyConfig.CreateWebProxy(); + HttpClient client = new(handler); client.DefaultRequestHeaders.TryAddWithoutValidation( diff --git a/Plugins/RSBot.General/Components/RuSroAuthService.cs b/Plugins/RSBot.General/Components/RuSroAuthService.cs index d6222ac5..71ea221c 100644 --- a/Plugins/RSBot.General/Components/RuSroAuthService.cs +++ b/Plugins/RSBot.General/Components/RuSroAuthService.cs @@ -15,6 +15,8 @@ using Newtonsoft.Json.Linq; using RSBot.Core; using RSBot.Core.Components; +using RSBot.Core.Extensions; +using RSBot.General.Views; using SDUI.Controls; namespace RSBot.General.Components; @@ -41,6 +43,7 @@ internal static class RuSroAuthService private const string EmailRequestGroupIdConfigKey = "RSBot.RuSro.emailRequestGroupId"; private const string EmailCodeRequestedAtConfigKey = "RSBot.RuSro.emailCodeRequestedAt"; private const int EmailCodeResendDelaySeconds = 30; + private const int MaxCaptchaAttempts = 2; private const string Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; @@ -153,8 +156,9 @@ out DateTimeOffset codeRequestedAt if (!resumePendingCode) requestGroupId = Guid.NewGuid().ToString(); - using HttpClient authClient = CreateAuthClient(requestGroupId); - await InitializeAuthPageAsync(authClient, state); + using HttpClient authClient = CreateAuthClient(requestGroupId, out CookieContainer cookieContainer); + if (!await InitializeAuthPageAsync(authClient, cookieContainer, state)) + return null; if (resumePendingCode) { @@ -240,25 +244,37 @@ await SendAuthJsonRequestAsync( private static HttpClient CreateAuthClient(string trackingId = null) { - var cookies = new CookieContainer(); + return CreateAuthClient(trackingId, out _); + } + + private static HttpClient CreateAuthClient(string trackingId, out CookieContainer cookieContainer) + { + cookieContainer = new CookieContainer(); if (!string.IsNullOrWhiteSpace(trackingId)) - cookies.Add(new Cookie("trk", trackingId, "/", ".4game.ru")); + cookieContainer.Add(new Cookie("trk", trackingId, "/", ".4game.ru")); var handler = new HttpClientHandler { - CookieContainer = cookies, + CookieContainer = cookieContainer, UseCookies = true, AllowAutoRedirect = false, AutomaticDecompression = DecompressionMethods.All, }; + if (ProxyConfig.TryGetProxy(out ProxyConfig proxyConfig)) + handler.Proxy = proxyConfig.CreateWebProxy(); + var authClient = new HttpClient(handler); authClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json, text/plain, */*"); authClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept-Language", "ru"); return authClient; } - private static async Task InitializeAuthPageAsync(HttpClient authClient, string state) + private static async Task InitializeAuthPageAsync( + HttpClient authClient, + CookieContainer cookieContainer, + string state + ) { string url = $"{AuthPageEndpoint}?scope={Uri.EscapeDataString(Scope)}" @@ -268,11 +284,32 @@ private static async Task InitializeAuthPageAsync(HttpClient authClient, string + $"&redirect_uri={Uri.EscapeDataString(RedirectUri)}" + $"&back={Uri.EscapeDataString(BackUri)}"; - using HttpResponseMessage response = await authClient.GetAsync(url); - if (!response.IsSuccessStatusCode) + var requestUri = new Uri(url); + for (int captchaAttempt = 0; ; captchaAttempt++) { - string content = await response.Content.ReadAsStringAsync(); - throw CreateRequestException("Opening the 4game authorization page", response.StatusCode, content); + Uri captchaUri; + using (HttpResponseMessage response = await authClient.GetAsync(requestUri)) + { + if (response.IsSuccessStatusCode) + return true; + + if (!TryGetCaptchaRedirect(response, requestUri, out captchaUri)) + { + string content = await response.Content.ReadAsStringAsync(); + throw CreateRequestException("Opening the 4game authorization page", response.StatusCode, content); + } + } + + if (captchaAttempt >= MaxCaptchaAttempts) + { + throw new InvalidOperationException( + $"4game requested verification more than {MaxCaptchaAttempts} times." + ); + } + + Log.Notify("4game requires additional verification. Complete it in the opened window."); + if (!await CaptchaWindow.ShowAsync(captchaUri, requestUri, cookieContainer)) + return false; } } @@ -600,6 +637,30 @@ private static bool IsRedirect(HttpStatusCode statusCode) || statusCode == HttpStatusCode.PermanentRedirect; } + private static bool TryGetCaptchaRedirect(HttpResponseMessage response, Uri requestUri, out Uri captchaUri) + { + captchaUri = null; + if (!IsRedirect(response.StatusCode) || response.Headers.Location == null) + return false; + + Uri location = response.Headers.Location; + captchaUri = location.IsAbsoluteUri ? location : new Uri(requestUri, location); + + bool isCaptcha = + string.Equals(captchaUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && string.Equals(captchaUri.Host, "id.4game.ru", StringComparison.OrdinalIgnoreCase) + && string.Equals( + captchaUri.AbsolutePath.TrimEnd('/'), + "/tmgrdfrend/showcaptcha", + StringComparison.OrdinalIgnoreCase + ); + + if (!isCaptcha) + captchaUri = null; + + return isCaptcha; + } + private static string GetQueryParameter(Uri uri, string name) { foreach (string pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) @@ -693,6 +754,9 @@ private static async Task ConnectToWSAndSend() { try { + if (ProxyConfig.TryGetProxy(out ProxyConfig proxyConfig)) + clientWebSocket.Options.Proxy = proxyConfig.CreateWebProxy(); + await ConnectToWebSocket(clientWebSocket, serverUri); string login = await SendGetGameAccountRequest(clientWebSocket, sub); diff --git a/Plugins/RSBot.General/RSBot.General.csproj b/Plugins/RSBot.General/RSBot.General.csproj index 9031482d..5cc37309 100644 --- a/Plugins/RSBot.General/RSBot.General.csproj +++ b/Plugins/RSBot.General/RSBot.General.csproj @@ -15,6 +15,7 @@ + @@ -26,4 +27,14 @@ Form + + + + <_WebView2WpfReference + Include="@(Reference)" + Condition="'%(Reference.Filename)' == 'Microsoft.Web.WebView2.Wpf'" + /> + + + diff --git a/Plugins/RSBot.General/Views/CaptchaWindow.cs b/Plugins/RSBot.General/Views/CaptchaWindow.cs new file mode 100644 index 00000000..591c312f --- /dev/null +++ b/Plugins/RSBot.General/Views/CaptchaWindow.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Net; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using Microsoft.Web.WebView2.Core; +using Microsoft.Web.WebView2.WinForms; +using RSBot.Core.Extensions; +using RSBot.General.Components; +using SDUI; +using SDUI.Controls; +using UIButton = SDUI.Controls.Button; +using UIFlowLayoutPanel = SDUI.Controls.FlowLayoutPanel; +using UILabel = SDUI.Controls.Label; + +namespace RSBot.General.Views; + +internal sealed class CaptchaWindow : UIWindowBase +{ + private static readonly object WebView2LoaderLock = new(); + private static bool _webView2LoaderConfigured; + + private readonly Uri _captchaUri; + private readonly Uri _returnUri; + private readonly CookieContainer _cookieContainer; + private readonly WebView2 _webView; + private readonly UILabel _statusLabel; + private readonly UIButton _continueButton; + private Exception _initializationException; + private int _isCompleting; + + private CaptchaWindow(Uri captchaUri, Uri returnUri, CookieContainer cookieContainer) + { + _captchaUri = captchaUri ?? throw new ArgumentNullException(nameof(captchaUri)); + _returnUri = returnUri ?? throw new ArgumentNullException(nameof(returnUri)); + _cookieContainer = cookieContainer ?? throw new ArgumentNullException(nameof(cookieContainer)); + + Text = "4game verification"; + StartPosition = FormStartPosition.CenterParent; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + ShowIcon = false; + ShowInTaskbar = false; + MinimumSize = new Size(760, 560); + ClientSize = new Size(900, 700); + BackColor = ColorScheme.BackColor; + ForeColor = ColorScheme.ForeColor; + Padding = new Padding(12); + + var titleLabel = new UILabel + { + ApplyGradient = false, + AutoSize = true, + Dock = DockStyle.Fill, + Font = new Font("Segoe UI Semibold", 11.25F, FontStyle.Bold, GraphicsUnit.Point), + ForeColor = ColorScheme.ForeColor, + Text = "Complete the 4game verification in the window below.", + TextAlign = ContentAlignment.MiddleLeft, + }; + + _statusLabel = new UILabel + { + ApplyGradient = false, + AutoEllipsis = true, + Dock = DockStyle.Fill, + ForeColor = ColorScheme.ForeColor, + Text = "Opening the verification page...", + TextAlign = ContentAlignment.MiddleLeft, + }; + + _webView = new WebView2 + { + AllowExternalDrop = false, + Dock = DockStyle.Fill, + Margin = new Padding(0, 8, 0, 8), + }; + + var cancelButton = new UIButton + { + AutoSize = false, + DialogResult = DialogResult.Cancel, + Margin = new Padding(8, 0, 0, 0), + Size = new Size(110, 32), + Text = "Cancel", + }; + + _continueButton = new UIButton + { + AutoSize = false, + Enabled = false, + Margin = new Padding(8, 0, 0, 0), + Size = new Size(110, 32), + Text = "Continue", + }; + _continueButton.Click += ContinueButton_Click; + + var buttonPanel = new UIFlowLayoutPanel + { + AutoSize = false, + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.RightToLeft, + Margin = Padding.Empty, + WrapContents = false, + }; + buttonPanel.Controls.Add(cancelButton); + buttonPanel.Controls.Add(_continueButton); + + var layout = new TableLayoutPanel + { + ColumnCount = 1, + Dock = DockStyle.Fill, + Margin = Padding.Empty, + RowCount = 4, + }; + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 32F)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 26F)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40F)); + layout.Controls.Add(titleLabel, 0, 0); + layout.Controls.Add(_webView, 0, 1); + layout.Controls.Add(_statusLabel, 0, 2); + layout.Controls.Add(buttonPanel, 0, 3); + Controls.Add(layout); + + CancelButton = cancelButton; + Shown += CaptchaWindow_Shown; + } + + internal static Task ShowAsync(Uri captchaUri, Uri returnUri, CookieContainer cookieContainer) + { + Control uiControl = View.Instance; + if (uiControl.IsDisposed || !uiControl.IsHandleCreated) + { + return Task.FromException( + new InvalidOperationException("The main application window is not available for 4game verification.") + ); + } + + if (!uiControl.InvokeRequired) + return ShowOnUiThreadAsync(uiControl, captchaUri, returnUri, cookieContainer); + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try + { + uiControl.BeginInvoke( + new Action(async () => + { + try + { + completion.SetResult( + await ShowOnUiThreadAsync(uiControl, captchaUri, returnUri, cookieContainer) + ); + } + catch (Exception ex) + { + completion.SetException(ex); + } + }) + ); + } + catch (Exception ex) + { + completion.SetException(ex); + } + + return completion.Task; + } + + private static Task ShowOnUiThreadAsync( + Control uiControl, + Uri captchaUri, + Uri returnUri, + CookieContainer cookieContainer + ) + { + using var window = new CaptchaWindow(captchaUri, returnUri, cookieContainer); + Form owner = uiControl.FindForm(); + DialogResult result = owner == null ? window.ShowDialog() : window.ShowDialog(owner); + + if (window._initializationException != null) + return Task.FromException(window._initializationException); + + return Task.FromResult(result == DialogResult.OK); + } + + private async void CaptchaWindow_Shown(object sender, EventArgs e) + { + try + { + ConfigureWebView2Loader(); + + string userDataFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OasisBot", + "WebView2", + "4game-captcha" + ); + CoreWebView2EnvironmentOptions environmentOptions = null; + if (ProxyConfig.TryGetProxy(out ProxyConfig proxyConfig)) + { + userDataFolder += "-proxy"; + string proxyAddress = ConfiguredProxyBridge + .Instance.Configure(proxyConfig) + .GetComponents(UriComponents.SchemeAndServer, UriFormat.UriEscaped); + environmentOptions = new CoreWebView2EnvironmentOptions + { + AdditionalBrowserArguments = + $"--proxy-server={proxyAddress} --host-resolver-rules=\"MAP * 0.0.0.0, EXCLUDE 127.0.0.1\" --disable-quic", + }; + } + + CoreWebView2Environment environment = await CoreWebView2Environment.CreateAsync( + userDataFolder: userDataFolder, + options: environmentOptions + ); + + await _webView.EnsureCoreWebView2Async(environment); + ConfigureBrowser(); + ImportCookies(); + + _continueButton.Enabled = true; + _statusLabel.Text = "Complete the verification. This window will close automatically when it succeeds."; + _webView.CoreWebView2.Navigate(_captchaUri.AbsoluteUri); + } + catch (Exception ex) + { + _initializationException = + ex is WebView2RuntimeNotFoundException + ? new InvalidOperationException( + "Microsoft Edge WebView2 Runtime is required to display the 4game verification page.", + ex + ) + : new InvalidOperationException("Could not open the 4game verification page.", ex); + + DialogResult = DialogResult.Abort; + Close(); + } + } + + private static void ConfigureWebView2Loader() + { + lock (WebView2LoaderLock) + { + if (_webView2LoaderConfigured) + return; + + string runtimeDirectory = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X86 => "win-x86", + Architecture.X64 => "win-x64", + Architecture.Arm64 => "win-arm64", + _ => throw new PlatformNotSupportedException( + $"WebView2 does not support the current process architecture: {RuntimeInformation.ProcessArchitecture}." + ), + }; + string assemblyDirectory = Path.GetDirectoryName(typeof(CoreWebView2Environment).Assembly.Location); + string loaderDirectory = Path.Combine(assemblyDirectory, "runtimes", runtimeDirectory, "native"); + string loaderPath = Path.Combine(loaderDirectory, "WebView2Loader.dll"); + if (!File.Exists(loaderPath)) + { + throw new FileNotFoundException( + $"The {RuntimeInformation.ProcessArchitecture} WebView2 loader was not found.", + loaderPath + ); + } + + CoreWebView2Environment.SetLoaderDllFolderPath(loaderDirectory); + _webView2LoaderConfigured = true; + } + } + + private void ConfigureBrowser() + { + _webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; + _webView.CoreWebView2.Settings.AreDevToolsEnabled = false; + _webView.CoreWebView2.Settings.IsStatusBarEnabled = false; + _webView.CoreWebView2.CookieManager.DeleteAllCookies(); + _webView.CoreWebView2.NavigationCompleted += WebView_NavigationCompleted; + _webView.CoreWebView2.NewWindowRequested += (_, eventArgs) => + { + eventArgs.Handled = true; + _webView.CoreWebView2.Navigate(eventArgs.Uri); + }; + } + + private void ImportCookies() + { + var importedCookies = new HashSet(StringComparer.Ordinal); + ImportCookies(_captchaUri, importedCookies); + ImportCookies(_returnUri, importedCookies); + } + + private void ImportCookies(Uri uri, HashSet importedCookies) + { + CoreWebView2CookieManager cookieManager = _webView.CoreWebView2.CookieManager; + foreach (Cookie cookie in _cookieContainer.GetCookies(uri)) + { + string key = $"{cookie.Name}\n{cookie.Domain}\n{cookie.Path}"; + if (!importedCookies.Add(key)) + continue; + + CoreWebView2Cookie webViewCookie = cookieManager.CreateCookieWithSystemNetCookie(cookie); + cookieManager.AddOrUpdateCookie(webViewCookie); + } + } + + private async void WebView_NavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e) + { + if (!e.IsSuccess) + { + _statusLabel.Text = $"The verification page failed to load ({e.WebErrorStatus})."; + return; + } + + if (IsReturnUri(_webView.Source)) + await CompleteAsync(); + } + + private async void ContinueButton_Click(object sender, EventArgs e) + { + await CompleteAsync(); + } + + private async Task CompleteAsync() + { + if (Interlocked.Exchange(ref _isCompleting, 1) != 0) + return; + + try + { + _continueButton.Enabled = false; + _statusLabel.Text = "Saving the verification result..."; + + IReadOnlyList cookies = await _webView.CoreWebView2.CookieManager.GetCookiesAsync(null); + foreach (CoreWebView2Cookie cookie in cookies) + { + if (!Is4GameDomain(cookie.Domain)) + continue; + + _cookieContainer.Add(cookie.ToSystemNetCookie()); + } + + DialogResult = DialogResult.OK; + Close(); + } + catch (Exception ex) + { + _statusLabel.Text = "Could not save the verification result: " + ex.Message; + _continueButton.Enabled = true; + Interlocked.Exchange(ref _isCompleting, 0); + } + } + + private bool IsReturnUri(Uri uri) + { + if (uri == null) + return false; + + return string.Equals(uri.Scheme, _returnUri.Scheme, StringComparison.OrdinalIgnoreCase) + && string.Equals(uri.Host, _returnUri.Host, StringComparison.OrdinalIgnoreCase) + && string.Equals( + uri.AbsolutePath.TrimEnd('/'), + _returnUri.AbsolutePath.TrimEnd('/'), + StringComparison.OrdinalIgnoreCase + ); + } + + private static bool Is4GameDomain(string domain) + { + string normalizedDomain = domain?.TrimStart('.'); + return string.Equals(normalizedDomain, "4game.ru", StringComparison.OrdinalIgnoreCase) + || normalizedDomain?.EndsWith(".4game.ru", StringComparison.OrdinalIgnoreCase) == true; + } +}