- 新增 VideoThumbnailService,基于 ffmpeg 截取视频缩略图,ffprobe 提取时长
- 新增 ManagedThumbnailMap 模型及多数据库迁移,存储缩略图元数据
- 新增 /api/thumbnails/{id} 缩略图流端点
- 新增最近添加/最近播放 API 与前端面板,支持列表/网格双视图切换
- FileRecordDto 扩展 thumbnailUrl、videoDuration、lastPlayedAt 字段
- 前端新增文件库 Tab 导航、卡片网格视图、视频海报与时长信息栏
- 添加文件库目录不再同步全量扫描,改为后台异步自动扫描
957 lines
35 KiB
C#
957 lines
35 KiB
C#
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;
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|