FileShare/FileShare-PC/Views/MainWindow.axaml.cs
2026-05-22 17:11:11 +08:00

962 lines
36 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 FileShare_Common.Infrastructure;
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;
using FileShare_Services.Services.FileLibrary;
using Microsoft.Extensions.DependencyInjection;
namespace FileShare_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)
{
AppLog.Error(ex, "Bridge AppRequest 处理失败");
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 localHtmlPath = GetConfiguredLocalStartupPath();
var localRoot = !string.IsNullOrWhiteSpace(localHtmlPath)
? Path.GetDirectoryName(localHtmlPath)
: null;
if (!string.IsNullOrWhiteSpace(localRoot))
{
await EnsureLocalHttpServerStartedAsync(localRoot);
}
var onlineUrl = GetConfiguredOnlineStartupUrl();
if (onlineUrl is not null)
{
_webView.Source = onlineUrl;
return;
}
if (string.IsNullOrWhiteSpace(localHtmlPath) || !File.Exists(localHtmlPath))
{
return;
}
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);
if (!string.IsNullOrWhiteSpace(_localHttpBaseUrl))
{
var mediaOriginLiteral = JsonSerializer.Serialize(_localHttpBaseUrl.TrimEnd('/'));
await _webView.InvokeScript($"window.__pcMediaOrigin = {mediaOriginLiteral}");
}
}
#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)
{
AppLog.Error(ex, "本地 HTTP 请求处理失败");
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 (Exception ex)
{
AppLog.Information("解析 Bridge 请求 ID 失败: {Error}", ex.Message);
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 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 (Exception ex) when (ex is not OperationCanceledException)
{
AppLog.Error(ex, "本地 HTTP 服务循环异常退出");
}
}
/// <summary>
/// 处理本地静态资源请求并返回文件内容。
/// </summary>
private async Task HandleLocalHttpRequest(HttpListenerContext context, string wwwRoot)
{
try
{
if (await TryHandleLocalMediaStreamAsync(context)
|| await TryHandleLocalThumbnailAsync(context))
{
return;
}
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 (Exception ex)
{
AppLog.Error(ex, "本地静态文件请求处理失败");
try
{
context.Response.StatusCode = 500;
context.Response.Close();
}
catch (Exception closeEx)
{
AppLog.Warning("关闭 500 响应失败: {Error}", closeEx.Message);
}
}
}
/// <summary>
/// Handle media element requests using the same stream path as FileShare-API.
/// </summary>
private async Task<bool> TryHandleLocalMediaStreamAsync(HttpListenerContext context)
{
var request = context.Request;
var response = context.Response;
var segments = (request.Url?.AbsolutePath ?? string.Empty)
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length != 4
|| !string.Equals(segments[0], "api", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(segments[1], "files", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(segments[3], "stream", StringComparison.OrdinalIgnoreCase))
{
return false;
}
AddLocalMediaHeaders(response);
if (string.Equals(request.HttpMethod, "OPTIONS", StringComparison.OrdinalIgnoreCase))
{
response.StatusCode = (int)HttpStatusCode.NoContent;
response.Close();
return true;
}
if (!string.Equals(request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase))
{
response.StatusCode = (int)HttpStatusCode.MethodNotAllowed;
response.Close();
return true;
}
if (!int.TryParse(segments[2], out var id) || id <= 0)
{
response.StatusCode = (int)HttpStatusCode.NotFound;
response.Close();
return true;
}
using var scope = _services.CreateScope();
var fileStreamService = scope.ServiceProvider.GetService<IFileStreamService>();
var fileResponse = fileStreamService is null ? null : await fileStreamService.GetFileStreamAsync(id);
if (fileResponse is null || !File.Exists(fileResponse.FilePath))
{
response.StatusCode = (int)HttpStatusCode.NotFound;
response.Close();
return true;
}
var length = new FileInfo(fileResponse.FilePath).Length;
var start = 0L;
var end = length > 0 ? length - 1 : 0;
var isRange = TryParseByteRange(request.Headers["Range"], length, out start, out end);
if (!string.IsNullOrWhiteSpace(request.Headers["Range"]) && !isRange)
{
response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable;
response.Headers["Content-Range"] = $"bytes */{length}";
response.Close();
return true;
}
var contentLength = length == 0 ? 0 : end - start + 1;
response.StatusCode = isRange
? (int)HttpStatusCode.PartialContent
: (int)HttpStatusCode.OK;
response.ContentType = fileResponse.ContentType;
response.ContentLength64 = contentLength;
response.Headers["Accept-Ranges"] = "bytes";
response.Headers["Cache-Control"] = "public, max-age=3600";
response.Headers["Content-Disposition"] =
$"inline; filename=\"{Uri.EscapeDataString(fileResponse.FileName)}\"";
response.Headers["Last-Modified"] = fileResponse.LastModified.ToUniversalTime().ToString("R");
if (isRange)
{
response.Headers["Content-Range"] = $"bytes {start}-{end}/{length}";
}
if (string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase)
|| contentLength == 0)
{
response.Close();
return true;
}
await using var input = File.Open(fileResponse.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
input.Seek(start, SeekOrigin.Begin);
await CopyRangeAsync(input, response.OutputStream, contentLength);
response.OutputStream.Close();
return true;
}
/// <summary>
/// 尝试处理本地缩略图请求,匹配 /api/thumbnails/{id} 路径并返回缩略图文件流。
/// </summary>
/// <param name="context">HTTP 监听器上下文。</param>
/// <returns>如果请求路径匹配缩略图端点则返回 true否则返回 false。</returns>
private async Task<bool> TryHandleLocalThumbnailAsync(HttpListenerContext context)
{
var request = context.Request;
var response = context.Response;
var segments = (request.Url?.AbsolutePath ?? string.Empty)
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length != 3
|| !string.Equals(segments[0], "api", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(segments[1], "thumbnails", StringComparison.OrdinalIgnoreCase))
{
return false;
}
AddLocalMediaHeaders(response);
if (!string.Equals(request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase))
{
response.StatusCode = (int)HttpStatusCode.MethodNotAllowed;
response.Close();
return true;
}
if (!int.TryParse(segments[2], out var id) || id <= 0)
{
response.StatusCode = (int)HttpStatusCode.NotFound;
response.Close();
return true;
}
using var scope = _services.CreateScope();
var thumbnailService = scope.ServiceProvider.GetService<IThumbnailStreamService>();
var thumbnail = thumbnailService is null ? null : await thumbnailService.GetThumbnailAsync(id);
if (thumbnail is null || !File.Exists(thumbnail.FilePath))
{
response.StatusCode = (int)HttpStatusCode.NotFound;
response.Close();
return true;
}
var fileInfo = new FileInfo(thumbnail.FilePath);
response.StatusCode = (int)HttpStatusCode.OK;
response.ContentType = thumbnail.ContentType;
response.ContentLength64 = fileInfo.Length;
response.Headers["Cache-Control"] = "public, max-age=3600";
response.Headers["Content-Disposition"] =
$"inline; filename=\"{Uri.EscapeDataString(thumbnail.FileName)}\"";
response.Headers["Last-Modified"] = thumbnail.LastModified.ToUniversalTime().ToString("R");
if (string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase)
|| fileInfo.Length == 0)
{
response.Close();
return true;
}
await using var input = File.Open(thumbnail.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await input.CopyToAsync(response.OutputStream);
response.OutputStream.Close();
return true;
}
/// <summary>
/// 为媒体流响应添加 CORS 和 Range 请求头,允许浏览器跨域访问和分段请求。
/// </summary>
/// <param name="response">HTTP 响应对象。</param>
private static void AddLocalMediaHeaders(HttpListenerResponse response)
{
response.Headers["Access-Control-Allow-Origin"] = "*";
response.Headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS";
response.Headers["Access-Control-Allow-Headers"] = "Range, Content-Type, Authorization";
response.Headers["Access-Control-Expose-Headers"] = "Accept-Ranges, Content-Length, Content-Range";
}
/// <summary>
/// 解析 HTTP Range 请求头中的 <c>bytes=start-end</c> 格式,支持两端省略和后缀长度(如 <c>bytes=-500</c>)。
/// </summary>
/// <param name="value">Range 请求头值。</param>
/// <param name="length">资源总字节数。</param>
/// <param name="start">解析出的起始字节偏移。</param>
/// <param name="end">解析出的结束字节偏移。</param>
/// <returns>解析成功返回 true否则 false。</returns>
private static bool TryParseByteRange(string? value, long length, out long start, out long end)
{
start = 0;
end = length > 0 ? length - 1 : 0;
if (length <= 0 || string.IsNullOrWhiteSpace(value) || !value.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var range = value["bytes=".Length..].Split(',', 2)[0].Trim();
var separatorIndex = range.IndexOf('-');
if (separatorIndex < 0)
{
return false;
}
var startValue = range[..separatorIndex];
var endValue = range[(separatorIndex + 1)..];
if (string.IsNullOrWhiteSpace(startValue))
{
if (!long.TryParse(endValue, out var suffixLength) || suffixLength <= 0)
{
return false;
}
start = Math.Max(0, length - suffixLength);
end = length - 1;
return true;
}
if (!long.TryParse(startValue, out start) || start < 0 || start >= length)
{
return false;
}
if (!string.IsNullOrWhiteSpace(endValue)
&& (!long.TryParse(endValue, out end) || end < start))
{
return false;
}
end = Math.Min(end, length - 1);
return true;
}
/// <summary>
/// 从输入流读取指定字节数并写入输出流,用于实现 HTTP Range 分段响应。
/// </summary>
/// <param name="input">源文件流。</param>
/// <param name="output">HTTP 响应输出流。</param>
/// <param name="bytesRemaining">需要传输的剩余字节数。</param>
private static async Task CopyRangeAsync(Stream input, Stream output, long bytesRemaining)
{
var buffer = new byte[64 * 1024];
while (bytesRemaining > 0)
{
var readLength = (int)Math.Min(buffer.Length, bytesRemaining);
var read = await input.ReadAsync(buffer.AsMemory(0, readLength));
if (read <= 0)
{
break;
}
await output.WriteAsync(buffer.AsMemory(0, read));
bytesRemaining -= read;
}
}
/// <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 (Exception ex)
{
AppLog.Warning("停止本地 HTTP 服务时出错: {Error}", ex.Message);
}
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
}
}