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? _petResources; private bool _closing; public MainWindow() { InitializeComponent(); Loaded += OnLoaded; Closed += OnClosed; } private async void OnLoaded(object sender, RoutedEventArgs e) { try { Dictionary 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? 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(), 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(), 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 EnvironmentValues() { Dictionary 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> 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)Array.Empty(); if (dialog.FileNames.Length > maximumCount) throw new HostError( "attachment_count_exceeded", $"本次最多还可选择 {maximumCount} 个附件。"); return dialog.FileNames.ToArray(); }, System.Windows.Threading.DispatcherPriority.Normal, cancellationToken); } } }