FileShare/Views/MainWindow.axaml.cs
2026-05-21 20:34:06 +08:00

685 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
{
/// <summary>
/// 主窗口,承载 WebView2 控件并管理前后端 Bridge 通信。
/// </summary>
public partial class MainWindow : Window
{
/// <summary>
/// 自定义协议方案名称。
/// </summary>
private const string AppScheme = "app";
/// <summary>
/// 在线模式下的前端启动 URL。
/// </summary>
private const string? OnlineStartupUrl = "http://localhost:51240";
//private const string? OnlineStartupUrl = null;
/// <summary>
/// 离线模式下的前端本地文件路径,为空则使用在线模式。
/// </summary>
private const string? LocalStartupPath = null;
private static readonly JsonSerializerOptions BridgeJsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// WebView2 原生控件实例。
/// </summary>
private NativeWebView? _webView;
/// <summary>
/// 标记 WebView 事件是否已绑定。
/// </summary>
private bool _eventsAttached;
/// <summary>
/// WebView 适配器对象。
/// </summary>
private object? _webViewAdapter;
/// <summary>
/// 本地 HTTP 服务器实例(离线模式)。
/// </summary>
private HttpListener? _localHttpServer;
/// <summary>
/// 本地 HTTP 服务器的取消令牌源。
/// </summary>
private CancellationTokenSource? _localHttpServerCts;
/// <summary>
/// 本地 HTTP 服务器的基础 URL。
/// </summary>
private string? _localHttpBaseUrl;
/// <summary>
/// 本地 HTTP 服务器的根目录路径。
/// </summary>
private string? _localHttpRoot;
#region WebView
/// <summary>
/// 初始化窗口并注册生命周期事件。
/// </summary>
public MainWindow(IServiceProvider services)
{
_services = services;
InitializeComponent();
Opened += OnOpened;
Closed += OnClosed;
RegisterRoutes();
}
/// <summary>
/// 窗口打开后初始化 WebView、挂载事件并加载入口页面。
/// </summary>
private async void OnOpened(object? sender, EventArgs e)
{
if (_eventsAttached)
{
return;
}
_webView = this.FindControl<NativeWebView>("WebView");
if (_webView is null)
{
return;
}
_eventsAttached = true;
_webView.NavigationCompleted += OnNavigationCompleted;
_webView.WebMessageReceived += OnWebMessageReceived;
_webView.AdapterCreated += OnAdapterCreated;
await LoadInitialContentAsync();
}
/// <summary>
/// WebView 适配器创建后缓存实例,用于后续打开开发者工具。
/// </summary>
private void OnAdapterCreated(object? sender, WebViewAdapterEventArgs e)
{
_webViewAdapter = e.GetType().GetProperty("Adapter")?.GetValue(e);
}
/// <summary>
/// 窗口关闭时解绑事件并释放本地资源。
/// </summary>
private void OnClosed(object? sender, EventArgs e)
{
if (_webView is not null)
{
_webView.NavigationCompleted -= OnNavigationCompleted;
_webView.WebMessageReceived -= OnWebMessageReceived;
_webView.AdapterCreated -= OnAdapterCreated;
}
_webViewAdapter = null;
StopLocalHttpServer();
}
/// <summary>
/// 页面导航完成后注入 JS 桥接脚本。
/// </summary>
private async void OnNavigationCompleted(object? sender, WebViewNavigationCompletedEventArgs e)
{
await InjectBridgeScriptAsync();
}
#endregion
#region
/// <summary>
/// 接收前端消息并进行分发(打开调试工具 / 处理 app 请求)。
/// </summary>
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})");
}
}
/// <summary>
/// 加载初始页面:优先在线地址,其次本地路径(通过本地 HTTP 服务托管)。
/// </summary>
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));
}
/// <summary>
/// 向页面注入桥接脚本,接管 app:// 请求并回传到 C# 处理。
/// </summary>
private async Task InjectBridgeScriptAsync()
{
if (_webView is null)
{
return;
}
await _webView.InvokeScript(BridgeScript);
}
#endregion
#region
/// <summary>
/// 解析前端请求消息并转发到统一请求处理入口。
/// </summary>
private async Task<AppResponse> 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);
}
/// <summary>
/// 统一请求处理:构建上下文、处理 OPTIONS、使用统一端点适配器分发。
/// </summary>
private async Task<AppResponse> HandleAppRequestAsync(
string? id,
string? rawUrl,
string? method,
string? body,
Dictionary<string, string> 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("请求地址不能为空。"));
if (string.Equals(method, "OPTIONS", StringComparison.OrdinalIgnoreCase))
{
response.StatusCode = 200;
response.StatusMessage = "OK";
response.Body = JsonSerializer.Serialize(new { success = true });
return response;
}
// 使用统一端点适配器处理请求
var (normalizedPath, queryParams) = ParseRequestUri(uri);
var routeResult = await _endpointAdapter.HandleRequestAsync(
path: normalizedPath,
method: method ?? "GET",
body: body,
headers: headers,
query: queryParams);
if (routeResult.IsMatched)
{
response.StatusCode = routeResult.StatusCode;
response.StatusMessage = routeResult.StatusMessage;
response.Body = BuildSuccessResponseBody(routeResult.Data);
foreach (var kvp in routeResult.ResponseHeaders)
{
response.Headers[kvp.Key] = kvp.Value;
}
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;
}
}
/// <summary>
/// 从 URI 解析规范化路径和查询参数(供统一端点适配器使用)。
/// </summary>
private static (string normalizedPath, Dictionary<string, string> query) ParseRequestUri(Uri uri)
{
var host = uri.Host ?? string.Empty;
var absolutePath = uri.AbsolutePath ?? string.Empty;
var combinedPath = $"{host}/{absolutePath}";
var pathSegments = combinedPath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(Uri.UnescapeDataString)
.ToArray();
var normalizedPath = string.Join('/', pathSegments);
var query = ParseQueryParameters(uri.Query);
return (normalizedPath, query);
}
/// <summary>
/// 统一构建成功响应体,保持前后端响应结构一致。
/// </summary>
private static string BuildSuccessResponseBody(object? data)
{
return JsonSerializer.Serialize(new { success = true, data });
}
/// <summary>
/// 解析查询字符串为忽略大小写的字典。
/// </summary>
private static Dictionary<string, string> ParseQueryParameters(string? queryString)
{
var query = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(queryString))
{
return query;
}
var raw = queryString.TrimStart('?');
foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var separatorIndex = pair.IndexOf('=');
if (separatorIndex < 0)
{
query[Uri.UnescapeDataString(pair)] = string.Empty;
continue;
}
var key = Uri.UnescapeDataString(pair[..separatorIndex]);
var value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]);
query[key] = value;
}
return query;
}
/// <summary>
/// 创建桥接响应的默认 JSON/CORS 头。
/// </summary>
private static Dictionary<string, string> 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",
};
/// <summary>
/// 从前端请求消息中提取请求头。
/// </summary>
private static Dictionary<string, string> ExtractHeaders(JsonElement request)
{
if (!request.TryGetProperty("headers", out var headersElement) ||
headersElement.ValueKind != JsonValueKind.Object)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var property in headersElement.EnumerateObject())
{
headers[property.Name] = property.Value.GetString() ?? string.Empty;
}
return headers;
}
/// <summary>
/// 获取授权头,供鉴权逻辑扩展使用。
/// </summary>
private static string? GetAuthorizationHeader(Dictionary<string, string> headers)
{
return headers.FirstOrDefault(
entry => string.Equals(entry.Key, "Authorization", StringComparison.OrdinalIgnoreCase)).Value;
}
/// <summary>
/// 在异常情况下尝试提取请求 id确保前端可收到对应错误响应。
/// </summary>
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;
}
}
#endregion
#region
/// <summary>
/// 获取在线启动地址配置(仅允许 http/https
/// </summary>
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;
}
/// <summary>
/// 获取本地启动文件路径,未配置时默认使用输出目录 www/index.html。
/// </summary>
private static string? GetConfiguredLocalStartupPath()
{
if (!string.IsNullOrWhiteSpace(LocalStartupPath))
{
return Path.GetFullPath(LocalStartupPath);
}
return Path.Combine(AppContext.BaseDirectory, "www", "index.html");
}
/// <summary>
/// 确保本地 HTTP 静态服务已启动;根目录变化时会重启。
/// </summary>
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));
}
/// <summary>
/// 本地静态服务主循环,持续接收并分发请求。
/// </summary>
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
{
}
}
/// <summary>
/// 处理本地静态资源请求并返回文件内容。
/// </summary>
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
{
}
}
}
/// <summary>
/// 根据后缀返回静态资源 Content-Type。
/// </summary>
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",
};
}
/// <summary>
/// 获取一个可用本地端口,用于启动本地静态服务。
/// </summary>
private static int GetAvailableTcpPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>
/// 尝试打开 WebView 开发者工具(兼容不同适配器方法名)。
/// </summary>
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);
}
/// <summary>
/// 停止并释放本地静态服务资源。
/// </summary>
private void StopLocalHttpServer()
{
try
{
_localHttpServerCts?.Cancel();
_localHttpServer?.Stop();
_localHttpServer?.Close();
}
catch
{
}
finally
{
_localHttpServerCts?.Dispose();
_localHttpServerCts = null;
_localHttpServer = null;
_localHttpBaseUrl = null;
_localHttpRoot = null;
}
}
#endregion
#region DTO /
/// <summary>
/// Bridge 通信响应 DTO用于序列化返回给前端的数据。
/// </summary>
private sealed class AppResponse
{
/// <summary>
/// 获取或设置响应类型标识。
/// </summary>
public string Kind { get; set; } = string.Empty;
/// <summary>
/// 获取或设置请求 ID对应前端请求
/// </summary>
public string? Id { get; set; }
/// <summary>
/// 获取或设置 HTTP 状态码。
/// </summary>
public int StatusCode { get; set; }
/// <summary>
/// 获取或设置状态描述文本。
/// </summary>
public string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// 获取或设置响应体 JSON 字符串。
/// </summary>
public string Body { get; set; } = string.Empty;
/// <summary>
/// 获取或设置响应头字典。
/// </summary>
public Dictionary<string, string> Headers { get; set; } = new();
}
#endregion
}
}