Files
lserp_cs_6.0/插件库/Lskj.AgentPet.Host/MainWindow.xaml.cs
2026-08-14 14:28:28 +08:00

343 lines
13 KiB
C#

using System.Collections;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Windows;
using Lskj.AgentPet.Host.Core;
using Lskj.AgentPet.Host.Core.AstrBot;
using Lskj.AgentPet.Host.Core.Attachments;
using Lskj.AgentPet.Host.Core.Configuration;
using Lskj.AgentPet.Host.Core.ErpBridge;
using Lskj.AgentPet.Host.Core.Security;
using Lskj.AgentPet.Host.Core.WebViewHost;
using Microsoft.Win32;
using Microsoft.Web.WebView2.Core;
namespace Lskj.AgentPet.Host;
public partial class MainWindow : Window
{
private const string TrustedPageUrl = EmbeddedPetResources.PageUrl;
private const string SpriteResourceUrl = "https://lserp-sprite.local/atlas.webp";
private const string PetContentSecurityPolicy =
"default-src 'self'; img-src 'self' https://lserp-sprite.local data:; "
+ "style-src 'self'; script-src 'self'; connect-src 'none'; object-src 'none'; "
+ "base-uri 'none'; form-action 'none'; frame-src 'none'; child-src 'none'; "
+ "worker-src 'none'; media-src 'none'; manifest-src 'none'";
private HostConfiguration? _configuration;
private WebMessageCoordinator? _coordinator;
private HttpClient? _httpClient;
private readonly CancellationTokenSource _lifetime = new();
private byte[]? _spriteBytes;
private IReadOnlyDictionary<string, EmbeddedPetResource>? _petResources;
private bool _closing;
public MainWindow()
{
InitializeComponent();
Loaded += OnLoaded;
Closed += OnClosed;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
try
{
Dictionary<string, string?> environment = EnvironmentValues();
WindowsCredentialStore.PopulateAstrBotApiKey(environment);
_configuration = HostConfiguration.Load(environment, AppContext.BaseDirectory);
_spriteBytes = _configuration.ValidateFiles();
_petResources = EmbeddedPetResources.Load();
HttpClientHandler handler = new()
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};
_httpClient = new HttpClient(handler, disposeHandler: true);
_coordinator = new WebMessageCoordinator(
new SessionBoundErpBridgeClient(
new ErpBridgeClient(_configuration),
_configuration.ExpectedSessionScope),
new AstrBotChatClient(_httpClient, _configuration),
new WebViewSink(PetWebView),
new PlanTrustStore(),
_configuration.BridgeClientSessionId,
new AttachmentSession(
new NativeAttachmentPicker(this),
new AstrBotAttachmentUploader(_httpClient, _configuration),
_configuration.MaximumAttachmentCount,
_configuration.MaximumAttachmentFileBytes,
_configuration.MaximumAttachmentTotalBytes));
await PetWebView.EnsureCoreWebView2Async();
ConfigureWebView(PetWebView.CoreWebView2);
PositionAtBottomRight();
PetWebView.Source = new Uri(TrustedPageUrl);
}
catch (HostError error)
{
MessageBox.Show(error.Message, "朗速 ERP 智能桌宠", MessageBoxButton.OK, MessageBoxImage.Error);
Close();
}
catch (Exception)
{
MessageBox.Show(
"桌宠启动失败,请联系管理员检查宿主日志和 WebView2 Runtime。",
"朗速 ERP 智能桌宠",
MessageBoxButton.OK,
MessageBoxImage.Error);
Close();
}
}
private void ConfigureWebView(CoreWebView2 webView)
{
webView.Settings.AreDefaultContextMenusEnabled = false;
webView.Settings.AreDevToolsEnabled = false;
webView.Settings.AreHostObjectsAllowed = false;
webView.Settings.IsPasswordAutosaveEnabled = false;
webView.Settings.IsGeneralAutofillEnabled = false;
webView.Settings.IsStatusBarEnabled = false;
webView.Settings.IsZoomControlEnabled = false;
webView.Settings.AreBrowserAcceleratorKeysEnabled = false;
webView.Settings.IsNonClientRegionSupportEnabled = true;
webView.AddWebResourceRequestedFilter(
"https://lserp-pet.local/*",
CoreWebView2WebResourceContext.All);
webView.AddWebResourceRequestedFilter(
"https://lserp-sprite.local/*",
CoreWebView2WebResourceContext.All);
webView.WebResourceRequested += OnPetResourceRequested;
webView.WebResourceRequested += OnSpriteResourceRequested;
webView.NavigationStarting += (_, args) =>
{
if (!string.Equals(args.Uri, TrustedPageUrl, StringComparison.OrdinalIgnoreCase))
args.Cancel = true;
};
webView.NewWindowRequested += (_, args) => args.Handled = true;
webView.WebMessageReceived += OnWebMessageReceived;
webView.NavigationCompleted += (_, args) =>
{
if (!args.IsSuccess) return;
webView.PostWebMessageAsJson(JsonSerializer.Serialize(new
{
type = "lserp.pet.configure",
spriteUrl = SpriteResourceUrl
}));
};
}
private void OnPetResourceRequested(
object? sender,
CoreWebView2WebResourceRequestedEventArgs e)
{
if (sender is not CoreWebView2 webView
|| !e.Request.Uri.StartsWith(
"https://lserp-pet.local/",
StringComparison.Ordinal)) return;
IReadOnlyDictionary<string, EmbeddedPetResource>? resources = _petResources;
if (resources is not null
&& string.Equals(e.Request.Method, "GET", StringComparison.OrdinalIgnoreCase)
&& resources.TryGetValue(e.Request.Uri, out EmbeddedPetResource? resource))
{
MemoryStream content = new(
resource.Bytes,
0,
resource.Bytes.Length,
writable: false,
publiclyVisible: false);
string headers = "Content-Type: " + resource.ContentType + "\r\n"
+ "Content-Length: " + resource.Bytes.Length + "\r\n"
+ "Cache-Control: no-store\r\n"
+ "X-Content-Type-Options: nosniff\r\n"
+ "Cross-Origin-Resource-Policy: same-origin";
if (resource.IsDocument)
headers += "\r\nContent-Security-Policy: " + PetContentSecurityPolicy;
e.Response = webView.Environment.CreateWebResourceResponse(
content,
200,
"OK",
headers);
return;
}
e.Response = webView.Environment.CreateWebResourceResponse(
new MemoryStream(Array.Empty<byte>(), writable: false),
404,
"Not Found",
"Content-Length: 0\r\nCache-Control: no-store");
}
private void OnSpriteResourceRequested(
object? sender,
CoreWebView2WebResourceRequestedEventArgs e)
{
if (sender is not CoreWebView2 webView
|| !e.Request.Uri.StartsWith(
"https://lserp-sprite.local/",
StringComparison.Ordinal)) return;
byte[]? sprite = _spriteBytes;
if (sprite is not null
&& string.Equals(e.Request.Method, "GET", StringComparison.OrdinalIgnoreCase)
&& string.Equals(e.Request.Uri, SpriteResourceUrl, StringComparison.Ordinal))
{
MemoryStream content = new(
sprite,
0,
sprite.Length,
writable: false,
publiclyVisible: false);
string headers = "Content-Type: image/webp\r\n"
+ "Content-Length: " + sprite.Length + "\r\n"
+ "Cache-Control: no-store\r\n"
+ "X-Content-Type-Options: nosniff\r\n"
+ "Access-Control-Allow-Origin: https://lserp-pet.local\r\n"
+ "Cross-Origin-Resource-Policy: cross-origin";
e.Response = webView.Environment.CreateWebResourceResponse(
content,
200,
"OK",
headers);
return;
}
e.Response = webView.Environment.CreateWebResourceResponse(
new MemoryStream(Array.Empty<byte>(), writable: false),
404,
"Not Found",
"Content-Length: 0\r\nCache-Control: no-store");
}
private async void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
if (_coordinator is null
|| !string.Equals(e.Source, TrustedPageUrl, StringComparison.OrdinalIgnoreCase)) return;
if (HostWindowCommandParser.TryParse(
e.WebMessageAsJson,
out HostWindowCommand windowCommand))
{
if (windowCommand == HostWindowCommand.Close) Close();
return;
}
try
{
await _coordinator.HandleAsync(e.WebMessageAsJson, _lifetime.Token);
}
catch (OperationCanceledException) when (_closing)
{
return;
}
catch (HostError)
{
if (_closing) return;
PetWebView.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(new
{
type = "lserp.chat.error",
code = "host_message_rejected",
message = "桌宠消息被宿主安全策略拒绝。"
}));
}
catch (Exception)
{
if (_closing) return;
PetWebView.CoreWebView2.PostWebMessageAsJson(JsonSerializer.Serialize(new
{
type = "lserp.chat.error",
code = "host_internal_error",
message = "桌宠宿主发生内部错误。"
}));
}
}
private void PositionAtBottomRight()
{
Rect area = SystemParameters.WorkArea;
Left = Math.Max(area.Left, area.Right - Width - 20);
Top = Math.Max(area.Top, area.Bottom - Height - 20);
}
private void OnClosed(object? sender, EventArgs e)
{
_closing = true;
_lifetime.Cancel();
if (PetWebView.CoreWebView2 is not null)
{
PetWebView.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
PetWebView.CoreWebView2.WebResourceRequested -= OnPetResourceRequested;
PetWebView.CoreWebView2.WebResourceRequested -= OnSpriteResourceRequested;
}
_spriteBytes = null;
_petResources = null;
_httpClient?.Dispose();
PetWebView.Dispose();
}
private static Dictionary<string, string?> EnvironmentValues()
{
Dictionary<string, string?> result = new(StringComparer.OrdinalIgnoreCase);
foreach (DictionaryEntry item in Environment.GetEnvironmentVariables())
{
if (item.Key is string name) result[name] = item.Value?.ToString();
}
return result;
}
private sealed class WebViewSink : IWebViewSink
{
private readonly Microsoft.Web.WebView2.Wpf.WebView2CompositionControl _webView;
public WebViewSink(Microsoft.Web.WebView2.Wpf.WebView2CompositionControl webView)
{
_webView = webView;
}
public async Task PostAsync(object message, CancellationToken cancellationToken = default)
{
string json = JsonSerializer.Serialize(message);
await _webView.Dispatcher.InvokeAsync(() =>
_webView.CoreWebView2.PostWebMessageAsJson(json),
System.Windows.Threading.DispatcherPriority.Normal,
cancellationToken);
}
}
private sealed class NativeAttachmentPicker : IAttachmentPicker
{
private readonly Window _owner;
public NativeAttachmentPicker(Window owner)
{
_owner = owner;
}
public async Task<IReadOnlyList<string>> PickAsync(
int maximumCount,
CancellationToken cancellationToken = default)
{
return await _owner.Dispatcher.InvokeAsync(() =>
{
cancellationToken.ThrowIfCancellationRequested();
OpenFileDialog dialog = new()
{
Title = "选择发票或业务明细",
CheckFileExists = true,
CheckPathExists = true,
Multiselect = true,
ValidateNames = true,
DereferenceLinks = false,
Filter = "支持的附件|*.png;*.jpg;*.jpeg;*.webp;*.pdf;*.xlsx;*.csv|"
+ "图片|*.png;*.jpg;*.jpeg;*.webp|PDF|*.pdf|Excel/CSV|*.xlsx;*.csv"
};
if (dialog.ShowDialog(_owner) != true)
return (IReadOnlyList<string>)Array.Empty<string>();
if (dialog.FileNames.Length > maximumCount)
throw new HostError(
"attachment_count_exceeded",
$"本次最多还可选择 {maximumCount} 个附件。");
return dialog.FileNames.ToArray();
}, System.Windows.Threading.DispatcherPriority.Normal, cancellationToken);
}
}
}