343 lines
13 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|