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 GetUserFromDatabaseAsync() { await Task.Delay(100); return new { id = 1, name = "张三", email = "zhangsan@example.com" }; } private static async Task ProcessDataAsync(string? input) { await Task.Delay(200); return $"Processed: {input?.ToUpperInvariant()}"; } private sealed class AppResponse { public string Kind { get; set; } = string.Empty; public string? Id { get; set; } public int StatusCode { get; set; } public string StatusMessage { get; set; } = string.Empty; public string Body { get; set; } = string.Empty; public Dictionary Headers { get; set; } = new(); } } }