)8r&*L3PA
zGNYd+_^;nNKmTI~dY_~zXvYtC&`V1XP`{HN)&XeWGrj5UlH
zZee!1VNvH+5`n^X)VT_uHU@y|7KTyy(Z?5qDg$KFEcW)=_8v;X0rL`a)!hGn@n}0a
z!pymqFnJDNAMzAKwQ-_dH6PqoTW*@0bIGbgg_4V*9x0{sQ*CWmB{
zCeF05wqYfu%aUy;K=dliZ4bLVR6F%cgcF4tU#>fXa5Jho-GFog`9%anGa*?tt#`gv51zbJFbZpG+=eKbo&brl>piU(=FUrL*v%^1KT3SwBH<`1;$<}htc5GWLgY8n5X8dRx6S|dRbF$(&MAHwb{23GZb^tG;PV4|Q)OA)CH&$x5x
z^6A@po{orr<=f}~?N`B}df>Ti>g_7WArV5DI`Trc&QEbgq*`4VnY)=@(#yaHX^V!r
zbo+-j2rVb$%#S2pGbKPbZ)8D`hqBPEdT+)>YFSB!-rhmAHjS!yyDInZtEXnXt^9c&
zU)dIgeaii;y+7{#f=8PD`~G#$CK0;|*4f0gy~S5`iK?oCG{r%CriEw#NZIGH=7QR8
zwmXwIhA$ili;#J>xLoOKaP)Xx0ZSNmFV68J@XqI6eE>l8x?q>`Q1BaA`9;IOAqO)Y
z`-g#*1AQlkop833=9a0X_WK<)qZ$4u4Kb|@w!~L`Kl_u!og!QcNMFlRtpK%V#zEKd
z9`F?8_RCfii3E^sbSIuY$PbLg3^0qTd6?^*Bz<#w{WGw(NzIxBoM71GHc$F_CCDK|
zD#O*CdpRM40kPB2W;pi&D?_|p-{Y}rB&9furJCJ_BzE`$JErD%1mj7dvytXv}NCML!)g&Fjnqns32!WA~{ZIEY3HLstQ&@55lm4W9r
zQF(g9g`z+N>UVvVsPLmnVA?8(2_dO
z@L(I;NA&Ec9)Nbkw?FG>EBUA2@XihhLMM6rL>=%s8qRV|uI%V6lvM?QfYjKZNS|Ne
z@78l?1G?&t4{^)ENK2xK9-^CxwB?9mAkdc%%1EHnd8G1N0Rx7HNbkuA0ba0V=rQ|
zVt>}Ta!mPuQ*+dDGRjJrDviGR48=Gk`%;Ca(=GQWony#EsRSr4;h~H`q$FGGaq`mN
z0DwZyyop0a|BGgrb+rK1qj#R9I2IGM%G8Jw<__NI>4HDVRC~a2h=Fb1ySb^RuQnM?
zJ4NxA<&_KVpg$Q>{)&?0%8FaV9hs%j;5pnz`j@|
zF+1rzyYz;z)9R%&42Qrj!I$v5PVFLfoL_LiQNw592kSC){6wLA+e`Y)Kxg84j{{>^
z@Z>0DC{e%GEP30%6e1XUbqvGae@NgNFM#IANo)k9cIRnP-}2$Yx*)!`Vk9l^-8)cP
zbY|#7f5HzI1};cY`6te0UR6Bp21_DRC34xzXpi|
zd63C~WVSvzthcq09*E%CyU6+|`bK3=%OTX>jFE2Cv4prn?R7&_dm--vM@dpBJi_mq
zff)M+PtK_~`vdAUqM7?cEAYVSESgJG*9K!B(QuN(-rmuxDofgKzfwBSp#PE|i(
zB;r7|D`RL7DZAc1t6Ur|vjKR`Jfw?^hWMopf~-gQb_x5M1Bd`AN?j}mkil!@f6Cn4
zo^(kIJJT5f8>jS-@>C~RTUjq%Edd5k=8o24R>e6rvl}U*Gj1E}%(c#9YC)K2;)Trs
zq1nhdW{%?n(#N^m<~mF`kvf}pW`$g*_-vMub%IZmE_Zz`Z~LzAp|PFSF46Q0+L?eU
zR9$s;^ePI#%Eh~$YwrMF2%E8g_lj`$RA*aOdZ6y51jvXoae&
z&i=gm4N$PP190>KO=ySBI<3uU@~?j8aF6-P@(kdyNFtz0JJ
zQ@%~Rl~vh9BsPFaVx^8aBBs&?T1d$$O78P%*=>GKEscN{@TSS`R^UVF%CXENN5$-)
ztYD0$Pz_a8b&dZ5YZd5+V28!WW!rA%fHsPh1k_cXku)*^;*L@u-|&T!^q3D{4?EHe
zK`Q&3!#AC|mIlEvzH}U8#w_p{EireN{skqK?Z3u}3^X8oCBHD&YyjY6pz}z`5pH38
z`fCpsLtbJ}KJQ(l_yt&!v^NYa&BvR5M7Iuzzhwf7T7=3*5kb
zYI}2TTe$^U(|^zAgOd$Qz*lFSpXRFHRhyk~u1me9r}ovMMw}xFwJIB1r0*WvO&A`}
zlHG08^aKdiACDQy$Ay}7Wi^YD^o>GJ5t>qLbrr|i?!K1$pVTQGzy4q+1TaO$(ovhX
zR8#g&(wwt{iDdO;u5r~Cj@DMeADcP(r=~LxypIWCH|VxDptC+>)vkwnk6*{CpZt;0
z>r>E773uH3AwL-RMhguH@ss@v=!CraOe|&YSELx2p1#dk!*`?kM9}#ADJp8)Nczyk
zo(~iWwwo=&)!^Z6(ZmY0I_r3P3o@MB1R7AEOT$*{j?0&=1^AhK7%)(@!weecbiwV0
z9;_g7!s+uKnhsNoXWW$nJOOZie~j}
zpDKFj?*H11EuU9S5M7kLFt`JxBTCtip}2mW%?=k_?(|to{=WMcTyTFwcpzi
zJ-Kqr`U#W%OgLK0)o;83=hbeGs9^Tdp~>1{!aOp^N5O+YQmyR3bY7N3bVtM0PXol)wjH7(C45kZYIsHj3}AV89^$9?~Ifmv%UpAd}6EvRuc@8pyq
zOpUe-bn@WX#DNrWFv6`pYgl{5C!dEw>`l`c#OC0pctZzPs8sN^vu7>n%E@{IxCX(>
zT*4$DiDgPB(Qe$DsJlf5?x0Y@eDL83eoHvn6-pp+?GzwIK+4XMnd*D
zq~MsiUX(y%+nC=i8p3g#RJAwQUK#S>>Wn+e=r}(li)P~i!*njngB8oys<#G9yj{xx
zU`wzWHOydZ>`Y)%A0o4m1$K%_Az$=B{O~txVgyUdM^*tZz+KfeXe9L1A$VU=5U5IK_-l
zR#^ENCit=Le$l?XGlXLyF!`@f_hq)x
+
+ 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