diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4059021
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+################################################################################
+# 此 .gitignore 文件已由 Microsoft(R) Visual Studio 自动创建。
+################################################################################
+
+/Avalonia-PC/bin
+/Avalonia-PC/.vs
+/Avalonia-PC/obj
\ No newline at end of file
diff --git a/Avalonia-PC/App.axaml b/Avalonia-PC/App.axaml
new file mode 100644
index 0000000..97a2bd1
--- /dev/null
+++ b/Avalonia-PC/App.axaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Avalonia-PC/App.axaml.cs b/Avalonia-PC/App.axaml.cs
new file mode 100644
index 0000000..cc86055
--- /dev/null
+++ b/Avalonia-PC/App.axaml.cs
@@ -0,0 +1,29 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia_PC.ViewModels;
+using Avalonia_PC.Views;
+
+namespace Avalonia_PC
+{
+ public partial class App : Application
+ {
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow
+ {
+ DataContext = new MainWindowViewModel(),
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Avalonia-PC/Assets/avalonia-logo.ico b/Avalonia-PC/Assets/avalonia-logo.ico
new file mode 100644
index 0000000..f7da8bb
Binary files /dev/null and b/Avalonia-PC/Assets/avalonia-logo.ico differ
diff --git a/Avalonia-PC/Avalonia-PC.csproj b/Avalonia-PC/Avalonia-PC.csproj
new file mode 100644
index 0000000..ed339ef
--- /dev/null
+++ b/Avalonia-PC/Avalonia-PC.csproj
@@ -0,0 +1,30 @@
+
+
+ WinExe
+ net10.0
+ enable
+ app.manifest
+ true
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+
diff --git a/Avalonia-PC/Avalonia-PC.slnx b/Avalonia-PC/Avalonia-PC.slnx
new file mode 100644
index 0000000..532a743
--- /dev/null
+++ b/Avalonia-PC/Avalonia-PC.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/Avalonia-PC/Program.cs b/Avalonia-PC/Program.cs
new file mode 100644
index 0000000..0a1ed03
--- /dev/null
+++ b/Avalonia-PC/Program.cs
@@ -0,0 +1,35 @@
+using Avalonia;
+using System;
+
+namespace Avalonia_PC
+{
+ internal sealed class Program
+ {
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args)
+ {
+#if DEBUG
+ // 开启 WebView2 远程调试,启动后在 Edge 中访问 edge://inspect 调试网页
+ Environment.SetEnvironmentVariable(
+ "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
+ "--remote-debugging-port=9222 --auto-open-devtools-for-tabs");
+#endif
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+
+
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+#if DEBUG
+ .WithDeveloperTools()
+#endif
+ .WithInterFont()
+ .LogToTrace();
+ }
+}
diff --git a/Avalonia-PC/ViewLocator.cs b/Avalonia-PC/ViewLocator.cs
new file mode 100644
index 0000000..484d1fe
--- /dev/null
+++ b/Avalonia-PC/ViewLocator.cs
@@ -0,0 +1,38 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia_PC.ViewModels;
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Avalonia_PC
+{
+ ///
+ /// Given a view model, returns the corresponding view if possible.
+ ///
+ [RequiresUnreferencedCode(
+ "Default implementation of ViewLocator involves reflection which may be trimmed away.",
+ Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
+ public class ViewLocator : IDataTemplate
+ {
+ public Control? Build(object? param)
+ {
+ if (param is null)
+ return null;
+
+ var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object? data)
+ {
+ return data is ViewModelBase;
+ }
+ }
+}
diff --git a/Avalonia-PC/ViewModels/MainWindowViewModel.cs b/Avalonia-PC/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..812e4a5
--- /dev/null
+++ b/Avalonia-PC/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,7 @@
+namespace Avalonia_PC.ViewModels
+{
+ public partial class MainWindowViewModel : ViewModelBase
+ {
+ public string Greeting { get; } = "Welcome to Avalonia!";
+ }
+}
diff --git a/Avalonia-PC/ViewModels/ViewModelBase.cs b/Avalonia-PC/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..46985b7
--- /dev/null
+++ b/Avalonia-PC/ViewModels/ViewModelBase.cs
@@ -0,0 +1,8 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace Avalonia_PC.ViewModels
+{
+ public abstract class ViewModelBase : ObservableObject
+ {
+ }
+}
diff --git a/Avalonia-PC/Views/MainWindow.axaml b/Avalonia-PC/Views/MainWindow.axaml
new file mode 100644
index 0000000..dee33b6
--- /dev/null
+++ b/Avalonia-PC/Views/MainWindow.axaml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Avalonia-PC/Views/MainWindow.axaml.cs b/Avalonia-PC/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..2964059
--- /dev/null
+++ b/Avalonia-PC/Views/MainWindow.axaml.cs
@@ -0,0 +1,594 @@
+using Avalonia.Controls;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Reflection;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Avalonia_PC.Views
+{
+ public partial class MainWindow : Window
+ {
+ private const string AppScheme = "app";
+ private const string? OnlineStartupUrl = null;
+ private const string? LocalStartupPath = null;
+ private static readonly JsonSerializerOptions BridgeJsonSerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+
+ private NativeWebView? _webView;
+ private bool _eventsAttached;
+ private object? _webViewAdapter;
+ private HttpListener? _localHttpServer;
+ private CancellationTokenSource? _localHttpServerCts;
+ private string? _localHttpBaseUrl;
+ private string? _localHttpRoot;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ Opened += OnOpened;
+ Closed += OnClosed;
+ }
+
+ private async void OnOpened(object? sender, EventArgs e)
+ {
+ if (_eventsAttached)
+ {
+ return;
+ }
+
+ _webView = this.FindControl("WebView");
+ if (_webView is null)
+ {
+ return;
+ }
+
+ _eventsAttached = true;
+ _webView.NavigationCompleted += OnNavigationCompleted;
+ _webView.WebMessageReceived += OnWebMessageReceived;
+ _webView.AdapterCreated += OnAdapterCreated;
+
+ await LoadInitialContentAsync();
+ }
+
+ private void OnAdapterCreated(object? sender, WebViewAdapterEventArgs e)
+ {
+ _webViewAdapter = e.GetType().GetProperty("Adapter")?.GetValue(e);
+ }
+
+ private void OnClosed(object? sender, EventArgs e)
+ {
+ if (_webView is not null)
+ {
+ _webView.NavigationCompleted -= OnNavigationCompleted;
+ _webView.WebMessageReceived -= OnWebMessageReceived;
+ _webView.AdapterCreated -= OnAdapterCreated;
+ }
+
+ _webViewAdapter = null;
+ StopLocalHttpServer();
+ }
+
+ private async void OnNavigationCompleted(object? sender, WebViewNavigationCompletedEventArgs e)
+ {
+ await InjectBridgeScriptAsync();
+ }
+
+ private async void OnWebMessageReceived(object? sender, WebMessageReceivedEventArgs e)
+ {
+ var messageJson = e.Body;
+ if (string.IsNullOrWhiteSpace(messageJson))
+ {
+ return;
+ }
+
+ AppResponse? response = null;
+
+ try
+ {
+ using var document = JsonDocument.Parse(messageJson);
+ var root = document.RootElement;
+
+ if (!root.TryGetProperty("kind", out var kindProperty))
+ {
+ return;
+ }
+
+ var kind = kindProperty.GetString();
+ if (string.Equals(kind, "app-open-devtools", StringComparison.OrdinalIgnoreCase))
+ {
+ TryOpenDevTools();
+ return;
+ }
+
+ if (!string.Equals(kind, "app-request", StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ response = await HandleAppRequestAsync(root);
+ }
+ catch (Exception ex)
+ {
+ response = new AppResponse
+ {
+ Kind = "app-response",
+ Id = TryGetRequestId(messageJson),
+ StatusCode = 500,
+ StatusMessage = "Internal Server Error",
+ Body = JsonSerializer.Serialize(new { success = false, error = ex.Message }),
+ Headers = CreateJsonHeaders(),
+ };
+ }
+
+ if (_webView is not null && response is not null)
+ {
+ var responseJson = JsonSerializer.Serialize(response, BridgeJsonSerializerOptions);
+ var responseJsonLiteral = JsonSerializer.Serialize(responseJson);
+ await _webView.InvokeScript($"window.__dispatchAppResponse({responseJsonLiteral})");
+ }
+ }
+
+ private async Task LoadInitialContentAsync()
+ {
+ if (_webView is null)
+ {
+ return;
+ }
+
+ var onlineUrl = GetConfiguredOnlineStartupUrl();
+ if (onlineUrl is not null)
+ {
+ StopLocalHttpServer();
+ _webView.Source = onlineUrl;
+ return;
+ }
+
+ var localHtmlPath = GetConfiguredLocalStartupPath();
+ if (string.IsNullOrWhiteSpace(localHtmlPath) || !File.Exists(localHtmlPath))
+ {
+ return;
+ }
+
+ var localRoot = Path.GetDirectoryName(localHtmlPath);
+ if (string.IsNullOrWhiteSpace(localRoot))
+ {
+ return;
+ }
+
+ await EnsureLocalHttpServerStartedAsync(localRoot);
+ if (string.IsNullOrWhiteSpace(_localHttpBaseUrl))
+ {
+ _webView.Source = new Uri(localHtmlPath);
+ return;
+ }
+
+ _webView.Source = new Uri(new Uri(_localHttpBaseUrl), Path.GetFileName(localHtmlPath));
+ }
+
+ private async Task InjectBridgeScriptAsync()
+ {
+ if (_webView is null)
+ {
+ return;
+ }
+
+ const string script = """
+if (!window.__appBridgeInstalled) {
+ window.__appBridgeInstalled = true;
+ window.isWebView2 = true;
+ const pending = new Map();
+
+ const tryOpenDevTools = () => {
+ window.invokeCSharpAction(JSON.stringify({ kind: 'app-open-devtools' }));
+ };
+
+ window.__dispatchAppResponse = function(jsonStr) {
+ const payload = JSON.parse(jsonStr);
+ const responseId = payload.id ?? payload.Id;
+ const entry = pending.get(responseId);
+ if (!entry) return;
+ pending.delete(responseId);
+ entry.resolve(new Response(payload.body ?? payload.Body ?? '', {
+ status: payload.statusCode ?? payload.StatusCode ?? 200,
+ statusText: payload.statusMessage ?? payload.StatusMessage ?? 'OK',
+ headers: payload.headers ?? payload.Headers ?? { 'Content-Type': 'application/json' }
+ }));
+ };
+
+ const nativeFetch = window.fetch ? window.fetch.bind(window) : null;
+
+ document.addEventListener('keydown', event => {
+ if (event.key === 'F12' || (event.ctrlKey && event.shiftKey && (event.key === 'I' || event.key === 'i'))) {
+ event.preventDefault();
+ tryOpenDevTools();
+ }
+ }, true);
+
+ document.addEventListener('contextmenu', event => {
+ if (event.shiftKey) {
+ event.preventDefault();
+ tryOpenDevTools();
+ }
+ }, true);
+
+ window.fetch = async (input, init) => {
+ const request = input instanceof Request ? input : null;
+ const requestUrl = typeof input === 'string' || input instanceof URL
+ ? input.toString()
+ : request?.url;
+
+ if (!requestUrl || !requestUrl.startsWith('app://')) {
+ if (!nativeFetch) throw new Error('window.fetch is not available.');
+ return nativeFetch(input, init);
+ }
+
+ const combinedHeaders = new Headers(request?.headers);
+ if (init?.headers) {
+ new Headers(init.headers).forEach((value, key) => combinedHeaders.set(key, value));
+ }
+
+ const headers = {};
+ combinedHeaders.forEach((value, key) => headers[key] = value);
+
+ let body = init?.body;
+ if (body === undefined && request) {
+ body = await request.clone().text();
+ }
+
+ if (body && typeof body !== 'string') {
+ body = await new Response(body).text();
+ }
+
+ const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
+ const responsePromise = new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ pending.delete(id);
+ reject(new Error(`Timed out waiting for ${requestUrl}`));
+ }, 30000);
+
+ pending.set(id, {
+ resolve: response => { clearTimeout(timeoutId); resolve(response); },
+ reject: error => { clearTimeout(timeoutId); reject(error); }
+ });
+ });
+
+ window.invokeCSharpAction(JSON.stringify({
+ kind: 'app-request',
+ id,
+ url: requestUrl,
+ method: init?.method ?? request?.method ?? 'GET',
+ headers,
+ body: body ?? null
+ }));
+
+ return responsePromise;
+ };
+}
+""";
+
+ await _webView.InvokeScript(script);
+ }
+
+ private async Task HandleAppRequestAsync(JsonElement request)
+ {
+ var id = request.TryGetProperty("id", out var idProperty) ? idProperty.GetString() : null;
+ var url = request.TryGetProperty("url", out var urlProperty) ? urlProperty.GetString() : null;
+ var method = request.TryGetProperty("method", out var methodProperty) ? methodProperty.GetString() : "GET";
+ var body = request.TryGetProperty("body", out var bodyProperty) ? bodyProperty.GetString() : null;
+ var headers = ExtractHeaders(request);
+
+ return await HandleAppRequestAsync(id, url, method, body, headers);
+ }
+
+ private async Task HandleAppRequestAsync(
+ string? id,
+ string? rawUrl,
+ string? method,
+ string? body,
+ Dictionary headers)
+ {
+ var response = new AppResponse
+ {
+ Kind = "app-response",
+ Id = id,
+ StatusCode = 200,
+ StatusMessage = "OK",
+ Headers = CreateJsonHeaders(),
+ };
+
+ try
+ {
+ var uri = new Uri(rawUrl ?? throw new InvalidOperationException("请求地址不能为空。"));
+ var path = uri.AbsolutePath.TrimStart('/');
+ var normalizedPath = path.StartsWith("api/", StringComparison.OrdinalIgnoreCase)
+ ? path
+ : $"api/{path}";
+ var authorization = GetAuthorizationHeader(headers);
+ _ = authorization;
+
+ if (string.Equals(method, "OPTIONS", StringComparison.OrdinalIgnoreCase))
+ {
+ response.StatusCode = 200;
+ response.StatusMessage = "OK";
+ response.Body = JsonSerializer.Serialize(new { success = true });
+ return response;
+ }
+
+ if (string.Equals(normalizedPath, "api/getUser", StringComparison.OrdinalIgnoreCase))
+ {
+ var user = await GetUserFromDatabaseAsync();
+ response.Body = JsonSerializer.Serialize(new { success = true, data = user });
+ return response;
+ }
+
+ if (string.Equals(normalizedPath, "api/processData", StringComparison.OrdinalIgnoreCase))
+ {
+ var input = string.Empty;
+ if (!string.IsNullOrWhiteSpace(body))
+ {
+ using var jsonDocument = JsonDocument.Parse(body);
+ if (jsonDocument.RootElement.TryGetProperty("input", out var inputProperty))
+ {
+ input = inputProperty.GetString() ?? string.Empty;
+ }
+ }
+
+ var result = await ProcessDataAsync(input);
+ response.Body = JsonSerializer.Serialize(new { success = true, result });
+ return response;
+ }
+
+ response.StatusCode = 404;
+ response.StatusMessage = "Not Found";
+ response.Body = JsonSerializer.Serialize(new { success = false, error = "API not found" });
+ return response;
+ }
+ catch (Exception ex)
+ {
+ response.StatusCode = 500;
+ response.StatusMessage = "Internal Server Error";
+ response.Body = JsonSerializer.Serialize(new { success = false, error = ex.Message });
+ return response;
+ }
+ }
+
+ private static Dictionary CreateJsonHeaders() => new()
+ {
+ ["Content-Type"] = "application/json; charset=utf-8",
+ ["Access-Control-Allow-Origin"] = "*",
+ ["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS",
+ ["Access-Control-Allow-Headers"] = "Content-Type, Authorization",
+ };
+
+ private static Dictionary ExtractHeaders(JsonElement request)
+ {
+ if (!request.TryGetProperty("headers", out var headersElement) ||
+ headersElement.ValueKind != JsonValueKind.Object)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var property in headersElement.EnumerateObject())
+ {
+ headers[property.Name] = property.Value.GetString() ?? string.Empty;
+ }
+
+ return headers;
+ }
+
+ private static string? GetAuthorizationHeader(Dictionary headers)
+ {
+ return headers.FirstOrDefault(
+ entry => string.Equals(entry.Key, "Authorization", StringComparison.OrdinalIgnoreCase)).Value;
+ }
+
+ private static string? TryGetRequestId(string messageJson)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(messageJson);
+ return document.RootElement.TryGetProperty("id", out var idProperty) ? idProperty.GetString() : null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static Uri? GetConfiguredOnlineStartupUrl()
+ {
+ if (string.IsNullOrWhiteSpace(OnlineStartupUrl))
+ {
+ return null;
+ }
+
+ if (!Uri.TryCreate(OnlineStartupUrl, UriKind.Absolute, out var uri))
+ {
+ return null;
+ }
+
+ return uri.Scheme is "http" or "https" ? uri : null;
+ }
+
+ private static string? GetConfiguredLocalStartupPath()
+ {
+ if (!string.IsNullOrWhiteSpace(LocalStartupPath))
+ {
+ return Path.GetFullPath(LocalStartupPath);
+ }
+
+ return Path.Combine(AppContext.BaseDirectory, "www", "index.html");
+ }
+
+ private async Task EnsureLocalHttpServerStartedAsync(string localRoot)
+ {
+ if (!string.IsNullOrWhiteSpace(_localHttpBaseUrl) &&
+ _localHttpServer is not null &&
+ string.Equals(_localHttpRoot, localRoot, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ StopLocalHttpServer();
+
+ var port = GetAvailableTcpPort();
+ var prefix = $"http://127.0.0.1:{port}/";
+
+ _localHttpServerCts = new CancellationTokenSource();
+ _localHttpServer = new HttpListener();
+ _localHttpServer.Prefixes.Add(prefix);
+ _localHttpServer.Start();
+ _localHttpBaseUrl = prefix;
+ _localHttpRoot = localRoot;
+
+ _ = Task.Run(() => RunLocalHttpServerLoopAsync(_localHttpServer, _localHttpServerCts.Token, localRoot));
+ }
+
+ private static async Task RunLocalHttpServerLoopAsync(HttpListener listener, CancellationToken cancellationToken, string wwwRoot)
+ {
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var context = await listener.GetContextAsync();
+ _ = Task.Run(() => HandleLocalHttpRequest(context, wwwRoot), cancellationToken);
+ }
+ }
+ catch
+ {
+ }
+ }
+
+ private static async Task HandleLocalHttpRequest(HttpListenerContext context, string wwwRoot)
+ {
+ try
+ {
+ var relativePath = context.Request.Url?.AbsolutePath.TrimStart('/') ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(relativePath))
+ {
+ relativePath = "index.html";
+ }
+
+ relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
+ var fullPath = Path.GetFullPath(Path.Combine(wwwRoot, relativePath));
+ var fullRoot = Path.GetFullPath(wwwRoot);
+
+ if (!fullPath.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase) || !File.Exists(fullPath))
+ {
+ context.Response.StatusCode = 404;
+ context.Response.Close();
+ return;
+ }
+
+ context.Response.ContentType = GetContentType(fullPath);
+ await using var input = File.OpenRead(fullPath);
+ context.Response.ContentLength64 = input.Length;
+ await input.CopyToAsync(context.Response.OutputStream);
+ context.Response.OutputStream.Close();
+ }
+ catch
+ {
+ try
+ {
+ context.Response.StatusCode = 500;
+ context.Response.Close();
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ private static string GetContentType(string filePath)
+ {
+ return Path.GetExtension(filePath).ToLowerInvariant() switch
+ {
+ ".html" => "text/html; charset=utf-8",
+ ".js" => "application/javascript; charset=utf-8",
+ ".css" => "text/css; charset=utf-8",
+ ".json" => "application/json; charset=utf-8",
+ _ => "application/octet-stream",
+ };
+ }
+
+ private static int GetAvailableTcpPort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ private void TryOpenDevTools()
+ {
+ if (_webViewAdapter is null)
+ {
+ return;
+ }
+
+ var adapterType = _webViewAdapter.GetType();
+ var method = adapterType.GetMethod("OpenDevTools", BindingFlags.Public | BindingFlags.Instance) ??
+ adapterType.GetMethod("ShowDevTools", BindingFlags.Public | BindingFlags.Instance);
+ method?.Invoke(_webViewAdapter, null);
+ }
+
+ private void StopLocalHttpServer()
+ {
+ try
+ {
+ _localHttpServerCts?.Cancel();
+ _localHttpServer?.Stop();
+ _localHttpServer?.Close();
+ }
+ catch
+ {
+ }
+ finally
+ {
+ _localHttpServerCts?.Dispose();
+ _localHttpServerCts = null;
+ _localHttpServer = null;
+ _localHttpBaseUrl = null;
+ _localHttpRoot = null;
+ }
+ }
+
+ private static async Task