feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user