+
+
+
+
+
diff --git a/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs b/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs
new file mode 100644
index 000000000000..c627c279b1c4
--- /dev/null
+++ b/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs
@@ -0,0 +1,31 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Routing;
+using Microsoft.AspNetCore.Components.WebView.Services;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.JSInterop;
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ ///
+ /// Extensions for adding component webview services to the .
+ ///
+ public static class ComponentsWebViewServiceCollectionExtensions
+ {
+ ///
+ /// Adds component webview services to the collection.
+ ///
+ /// The to add the component webview services to.
+ ///
+ public static IServiceCollection AddBlazorWebView(this IServiceCollection services)
+ {
+ services.AddLogging();
+ services.TryAddScoped();
+ services.TryAddScoped();
+ services.TryAddScoped();
+ return services;
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/src/Directory.Build.targets b/src/Components/WebView/WebView/src/Directory.Build.targets
new file mode 100644
index 000000000000..09953b9b6c9d
--- /dev/null
+++ b/src/Components/WebView/WebView/src/Directory.Build.targets
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/src/Components/WebView/WebView/src/IpcCommon.cs b/src/Components/WebView/WebView/src/IpcCommon.cs
new file mode 100644
index 000000000000..475cd8e6b24b
--- /dev/null
+++ b/src/Components/WebView/WebView/src/IpcCommon.cs
@@ -0,0 +1,75 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Linq;
+using System.Text.Json;
+
+namespace Microsoft.AspNetCore.Components.WebView
+{
+ internal class IpcCommon
+ {
+ private const string _ipcMessagePrefix = "__bwv:";
+
+ public static string Serialize(OutgoingMessageType messageType, params object[] args)
+ => Serialize(messageType.ToString(), args);
+
+ public static string Serialize(IncomingMessageType messageType, params object[] args)
+ => Serialize(messageType.ToString(), args);
+
+ public static bool TryDeserializeIncoming(string message, out IncomingMessageType messageType, out ArraySegment args)
+ => TryDeserialize(message, out messageType, out args);
+
+ public static bool TryDeserializeOutgoing(string message, out OutgoingMessageType messageType, out ArraySegment args)
+ => TryDeserialize(message, out messageType, out args);
+
+ private static string Serialize(string messageType, object[] args)
+ {
+ // We could come up with something a little more low-level here if we
+ // wanted to avoid a couple of allocations
+ var messageTypeAndArgs = args.Prepend(messageType);
+ return $"{_ipcMessagePrefix}{JsonSerializer.Serialize(messageTypeAndArgs, JsonSerializerOptionsProvider.Options)}";
+ }
+
+ private static bool TryDeserialize(string message, out T messageType, out ArraySegment args)
+ {
+ // We don't want to get confused by unrelated messages that the developer is sending
+ // over the same webview IPC channel, so ignore anything else
+ if (message != null && message.StartsWith(_ipcMessagePrefix, StringComparison.Ordinal))
+ {
+ var messageAfterPrefix = message.AsSpan(_ipcMessagePrefix.Length);
+ var parsed = (JsonElement[])JsonSerializer.Deserialize(messageAfterPrefix, typeof(JsonElement[]), JsonSerializerOptionsProvider.Options);
+ messageType = (T)Enum.Parse(typeof(T), parsed[0].GetString());
+ args = new ArraySegment(parsed, 1, parsed.Length - 1);
+ return true;
+ }
+ else
+ {
+ messageType = default;
+ args = default;
+ return false;
+ }
+ }
+
+ public enum IncomingMessageType
+ {
+ AttachPage,
+ BeginInvokeDotNet,
+ EndInvokeJS,
+ DispatchBrowserEvent,
+ OnRenderCompleted,
+ OnLocationChanged,
+ }
+
+ public enum OutgoingMessageType
+ {
+ RenderBatch,
+ Navigate,
+ AttachToDocument,
+ DetachFromDocument,
+ EndInvokeDotNet,
+ NotifyUnhandledException,
+ BeginInvokeJS,
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/src/IpcReceiver.cs b/src/Components/WebView/WebView/src/IpcReceiver.cs
new file mode 100644
index 000000000000..ebea13922f98
--- /dev/null
+++ b/src/Components/WebView/WebView/src/IpcReceiver.cs
@@ -0,0 +1,122 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.JSInterop.Infrastructure;
+
+// Sync vs Async APIs for this.
+// This mainly depends on the underlying support for the browser. Assuming that there is no synchronous API
+// communication is safer, since it's not guaranteed.
+// In that scenario, some APIs need to expose the async nature of the communication. That happens when some
+// component like the renderer needs to know the results of the operation. For example when updating the UI
+// since more code needs to execute afterwards.
+// In other cases like when we try to attach a component to the document, we don't necessarily need to do that
+// since we only care about errors that might happen while attaching the component and the renderer doesn't
+// necessarily need to know about those if we are terminating the component/host as a result.
+// If we decide we need to expose the async nature of the communication channel, then we will need to keep track
+// of all the message pairs/completions across the IPC channel.
+namespace Microsoft.AspNetCore.Components.WebView
+{
+ // These are all the messages .NET Host needs to know how to receive from JS
+
+ // This class is a "Proxy" or "front-controller" for the incoming messages from the Browser via the transport channel.
+ // It receives messages on OnMessageReceived, interprets the payload and dispatches them to the appropriate method
+ internal class IpcReceiver
+ {
+ private readonly Func _onAttachMessage;
+
+ public IpcReceiver(Func onAttachMessage)
+ {
+ _onAttachMessage = onAttachMessage;
+ }
+
+ public async Task OnMessageReceivedAsync(PageContext pageContext, string message)
+ {
+ // Ignore other messages as they may be unrelated to Blazor WebView
+ if (IpcCommon.TryDeserializeIncoming(message, out var messageType, out var args))
+ {
+ if (messageType == IpcCommon.IncomingMessageType.AttachPage)
+ {
+ await _onAttachMessage(args[0].GetString(), args[1].GetString());
+ return;
+ }
+
+ // For any other message, you have to have a page attached already
+ if (pageContext == null)
+ {
+ // TODO: Should we just ignore these messages? Is there any way their delivery
+ // might be delayed until after a page has detached?
+ throw new InvalidOperationException("Cannot receive IPC messages when no page is attached");
+ }
+
+ switch (messageType)
+ {
+ case IpcCommon.IncomingMessageType.BeginInvokeDotNet:
+ BeginInvokeDotNet(pageContext, args[0].GetString(), args[1].GetString(), args[2].GetString(), args[3].GetInt64(), args[4].GetString());
+ break;
+ case IpcCommon.IncomingMessageType.EndInvokeJS:
+ EndInvokeJS(pageContext, args[0].GetInt64(), args[1].GetBoolean(), args[2].GetString());
+ break;
+ case IpcCommon.IncomingMessageType.DispatchBrowserEvent:
+ await DispatchBrowserEventAsync(pageContext, args[0].GetRawText(), args[1].GetRawText());
+ break;
+ case IpcCommon.IncomingMessageType.OnRenderCompleted:
+ OnRenderCompleted(pageContext, args[0].GetInt64(), args[1].GetString());
+ break;
+ case IpcCommon.IncomingMessageType.OnLocationChanged:
+ OnLocationChanged(pageContext, args[0].GetString(), args[1].GetBoolean());
+ break;
+ default:
+ throw new InvalidOperationException($"Unknown message type '{messageType}'.");
+ }
+ }
+ }
+
+ private void BeginInvokeDotNet(PageContext pageContext, string callId, string assemblyName, string methodIdentifier, long dotNetObjectId, string argsJson)
+ {
+ DotNetDispatcher.BeginInvokeDotNet(
+ pageContext.JSRuntime,
+ new DotNetInvocationInfo(assemblyName, methodIdentifier, dotNetObjectId, callId),
+ argsJson);
+ }
+
+ private void EndInvokeJS(PageContext pageContext, long asyncHandle, bool succeeded, string argumentsOrError)
+ {
+ if (succeeded)
+ {
+ DotNetDispatcher.EndInvokeJS(pageContext.JSRuntime, argumentsOrError);
+ }
+ else
+ {
+ throw new InvalidOperationException(argumentsOrError);
+ }
+ }
+
+ private Task DispatchBrowserEventAsync(PageContext pageContext, string eventDescriptor, string eventArgs)
+ {
+ var renderer = pageContext.Renderer;
+ var webEventData = WebEventData.Parse(renderer, eventDescriptor, eventArgs);
+ return renderer.DispatchEventAsync(
+ webEventData.EventHandlerId,
+ webEventData.EventFieldInfo,
+ webEventData.EventArgs);
+ }
+
+ private void OnRenderCompleted(PageContext pageContext, long batchId, string errorMessageOrNull)
+ {
+ if (errorMessageOrNull != null)
+ {
+ throw new InvalidOperationException(errorMessageOrNull);
+ }
+
+ pageContext.Renderer.NotifyRenderCompleted(batchId);
+ }
+
+ private void OnLocationChanged(PageContext pageContext, string uri, bool intercepted)
+ {
+ pageContext.NavigationManager.LocationUpdated(uri, intercepted);
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/src/IpcSender.cs b/src/Components/WebView/WebView/src/IpcSender.cs
new file mode 100644
index 000000000000..0dfced973ac6
--- /dev/null
+++ b/src/Components/WebView/WebView/src/IpcSender.cs
@@ -0,0 +1,116 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.JSInterop;
+
+// Sync vs Async APIs for this.
+// This mainly depends on the underlying support for the browser. Assuming that there is no synchronous API
+// communication is safer, since it's not guaranteed.
+// In that scenario, some APIs need to expose the async nature of the communication. That happens when some
+// component like the renderer needs to know the results of the operation. For example when updating the UI
+// since more code needs to execute afterwards.
+// In other cases like when we try to attach a component to the document, we don't necessarily need to do that
+// since we only care about errors that might happen while attaching the component and the renderer doesn't
+// necessarily need to know about those if we are terminating the component/host as a result.
+// If we decide we need to expose the async nature of the communication channel, then we will need to keep track
+// of all the message pairs/completions across the IPC channel.
+namespace Microsoft.AspNetCore.Components.WebView
+{
+ // These are all the messages .NET needs to know how to dispatch to JS
+ // TODO: Proper serialization, error handling, etc.
+
+ // Handles comunication between the component abstractions (Renderer, NavigationManager, JSInterop, etc.)
+ // and the underlying transport channel
+ internal class IpcSender
+ {
+ private readonly Dispatcher _dispatcher;
+ private readonly Action _messageDispatcher;
+
+ public IpcSender(Dispatcher dispatcher, Action messageDispatcher)
+ {
+ _dispatcher = dispatcher;
+ _messageDispatcher = messageDispatcher;
+ }
+
+ public void ApplyRenderBatch(long batchId, RenderBatch renderBatch)
+ {
+ var arrayBuilder = new ArrayBuilder(2048);
+ using var memoryStream = new ArrayBuilderMemoryStream(arrayBuilder);
+ using (var renderBatchWriter = new RenderBatchWriter(memoryStream, false))
+ {
+ renderBatchWriter.Write(in renderBatch);
+ }
+ var message = IpcCommon.Serialize(IpcCommon.OutgoingMessageType.RenderBatch, batchId, Convert.ToBase64String(arrayBuilder.Buffer, 0, arrayBuilder.Count));
+ DispatchMessageWithErrorHandling(message);
+ }
+
+ // This is called by the navigation manager and needs to be forwarded to the WebView
+ // It might trigger the WebView to change the location of the URL and cause a LocationUpdated event.
+ public void Navigate(string uri, bool forceLoad)
+ {
+ DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.Navigate, uri, forceLoad));
+ }
+
+ // TODO: Make these APIs async if we want the renderer to be able to deal with errors.
+ // Called from Renderer to attach a new component ID to a given selector.
+ public void AttachToDocument(int componentId, string selector)
+ {
+ DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.AttachToDocument, componentId, selector));
+ }
+
+ // Called from the WebView to detach a root component from the document.
+ public void DetachFromDocument(int componentId)
+ {
+ DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.DetachFromDocument, componentId));
+ }
+
+ // Interop calls emitted by the JSRuntime
+ public void BeginInvokeJS(long taskId, string identifier, string argsJson, JSCallResultType resultType, long targetInstanceId)
+ {
+ DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.BeginInvokeJS, taskId, identifier, argsJson, resultType, targetInstanceId));
+ }
+
+ // TODO: We need to think about this, the invocation result contains the triplet [callId, successOrError, resultOrError]
+ // serialized as JSON with the options provided by the JSRuntime. The host can't operate on the "unserialized"
+ // data since it needs to deal with DotNetObjectReferences and JSObjectReference which the host doesn't have any visibility
+ // over how to serialize or deserialize.
+ // The strongest limitation we can find on a platform is that we might only be able to communicate with the host via "strings" (post-message)
+ // and in that situation we can define a separator within the string like (callId,success,resultOrError) that the
+ // side running in the browser can parse for processing.
+ public void EndInvokeDotNet(string callId, bool success, string invocationResultOrError)
+ {
+ DispatchMessageWithErrorHandling(IpcCommon.Serialize(IpcCommon.OutgoingMessageType.EndInvokeDotNet, callId, success, invocationResultOrError));
+ }
+
+ public void NotifyUnhandledException(Exception exception)
+ {
+ var message = IpcCommon.Serialize(IpcCommon.OutgoingMessageType.NotifyUnhandledException, exception.Message, exception.StackTrace);
+ _dispatcher.InvokeAsync(() => _messageDispatcher(message));
+ }
+
+ private void DispatchMessageWithErrorHandling(string message)
+ {
+ NotifyErrors(_dispatcher.InvokeAsync(() => _messageDispatcher(message)));
+ }
+
+ private void NotifyErrors(Task task)
+ {
+ _ = AwaitAndNotify();
+
+ async Task AwaitAndNotify()
+ {
+ try
+ {
+ await task;
+ }
+ catch (Exception ex)
+ {
+ NotifyUnhandledException(ex);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj
new file mode 100644
index 000000000000..af89f25af168
--- /dev/null
+++ b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj
@@ -0,0 +1,75 @@
+
+
+
+ $(DefaultNetCoreTargetFramework)
+ Build desktop applications with Blazor and a webview.
+ true
+ Microsoft.Extensions.FileProviders.Embedded.Manifest.xml
+ true
+ $(DefineConstants);BLAZOR_WEBVIEW
+ $(NoWarn);BL0006
+ true
+ annotations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_FileProviderTaskAssembly>$(ArtifactsDir)bin\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task\$(Configuration)\netstandard2.0\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.dll
+
+
+
+
+
+ blazor.webview.js
+
+ ..\..\..\Web.JS\dist\Debug\$(BlazorWebViewJSFilename)
+ ..\..\..\Web.JS\dist\Release\$(BlazorWebViewJSFilename)
+
+
+
+
+
+
+ ..\..\..\Web.JS\dist\Release\$(BlazorWebViewJSFilename)
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Components/WebView/WebView/src/PageContext.cs b/src/Components/WebView/WebView/src/PageContext.cs
new file mode 100644
index 000000000000..4ab64181c886
--- /dev/null
+++ b/src/Components/WebView/WebView/src/PageContext.cs
@@ -0,0 +1,54 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using Microsoft.AspNetCore.Components.WebView.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.JSInterop;
+
+namespace Microsoft.AspNetCore.Components.WebView
+{
+ ///
+ /// Represents the services that are scoped to a single page load. Grouping them like this
+ /// means we don't have to check that each of them are available individually.
+ ///
+ /// This has roughly the same role as a circuit in Blazor Server. One key difference is that,
+ /// for web views, the IPC channel is outside the page context, whereas in Blazor Server,
+ /// the IPC channel is within the circuit.
+ ///
+ internal class PageContext : IDisposable
+ {
+ private readonly IServiceScope _serviceScope;
+
+ public WebViewNavigationManager NavigationManager { get; }
+ public WebViewJSRuntime JSRuntime { get; }
+ public WebViewRenderer Renderer { get; }
+
+ public PageContext(
+ Dispatcher dispatcher,
+ IServiceScope serviceScope,
+ IpcSender ipcSender,
+ string baseUrl,
+ string startUrl)
+ {
+ _serviceScope = serviceScope;
+ var services = serviceScope.ServiceProvider;
+
+ NavigationManager = (WebViewNavigationManager)services.GetRequiredService();
+ NavigationManager.AttachToWebView(ipcSender, baseUrl, startUrl);
+
+ JSRuntime = (WebViewJSRuntime)services.GetRequiredService();
+ JSRuntime.AttachToWebView(ipcSender);
+
+ var loggerFactory = services.GetRequiredService();
+ Renderer = new WebViewRenderer(services, dispatcher, ipcSender, loggerFactory);
+ }
+
+ public void Dispose()
+ {
+ Renderer.Dispose();
+ _serviceScope.Dispose();
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/src/PublicAPI.Shipped.txt b/src/Components/WebView/WebView/src/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..ab058de62d44
--- /dev/null
+++ b/src/Components/WebView/WebView/src/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/Components/WebView/WebView/src/PublicAPI.Unshipped.txt b/src/Components/WebView/WebView/src/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..599a60601862
--- /dev/null
+++ b/src/Components/WebView/WebView/src/PublicAPI.Unshipped.txt
@@ -0,0 +1,14 @@
+Microsoft.AspNetCore.Components.WebView.WebViewManager
+Microsoft.AspNetCore.Components.WebView.WebViewManager.AddRootComponentAsync(System.Type! componentType, string! selector, Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Components.WebView.WebViewManager.Dispatcher.get -> Microsoft.AspNetCore.Components.Dispatcher!
+Microsoft.AspNetCore.Components.WebView.WebViewManager.Dispose() -> void
+Microsoft.AspNetCore.Components.WebView.WebViewManager.MessageReceived(System.Uri! sourceUri, string! message) -> void
+Microsoft.AspNetCore.Components.WebView.WebViewManager.Navigate(string! url) -> void
+Microsoft.AspNetCore.Components.WebView.WebViewManager.RemoveRootComponentAsync(string! selector) -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Components.WebView.WebViewManager.TryGetResponseContent(string! uri, bool allowFallbackOnHostPage, out int statusCode, out string! statusMessage, out System.IO.Stream! content, out string! headers) -> bool
+Microsoft.AspNetCore.Components.WebView.WebViewManager.WebViewManager(System.IServiceProvider! provider, Microsoft.AspNetCore.Components.Dispatcher! dispatcher, System.Uri! appBaseUri, Microsoft.Extensions.FileProviders.IFileProvider! fileProvider, string! hostPageRelativePath) -> void
+Microsoft.Extensions.DependencyInjection.ComponentsWebViewServiceCollectionExtensions
+abstract Microsoft.AspNetCore.Components.WebView.WebViewManager.NavigateCore(System.Uri! absoluteUri) -> void
+abstract Microsoft.AspNetCore.Components.WebView.WebViewManager.SendMessage(string! message) -> void
+static Microsoft.Extensions.DependencyInjection.ComponentsWebViewServiceCollectionExtensions.AddBlazorWebView(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+virtual Microsoft.AspNetCore.Components.WebView.WebViewManager.Dispose(bool disposing) -> void
\ No newline at end of file
diff --git a/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs b/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs
new file mode 100644
index 000000000000..d3a338310369
--- /dev/null
+++ b/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs
@@ -0,0 +1,45 @@
+using System.Text.Json;
+using Microsoft.JSInterop;
+using Microsoft.JSInterop.Infrastructure;
+
+namespace Microsoft.AspNetCore.Components.WebView.Services
+{
+ internal class WebViewJSRuntime : JSRuntime
+ {
+ private IpcSender _ipcSender;
+
+ public WebViewJSRuntime()
+ {
+ JsonSerializerOptions.Converters.Add(
+ new ElementReferenceJsonConverter(
+ new WebElementReferenceContext(this)));
+ }
+
+ public void AttachToWebView(IpcSender ipcSender)
+ {
+ _ipcSender = ipcSender;
+ }
+
+ protected override void BeginInvokeJS(long taskId, string identifier, string argsJson, JSCallResultType resultType, long targetInstanceId)
+ {
+ _ipcSender.BeginInvokeJS(taskId, identifier, argsJson, resultType, targetInstanceId);
+ }
+
+ protected override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in DotNetInvocationResult invocationResult)
+ {
+ if (!invocationResult.Success)
+ {
+ EndInvokeDotNetCore(invocationInfo.CallId, success: false, invocationResult.Exception.ToString());
+ }
+ else
+ {
+ EndInvokeDotNetCore(invocationInfo.CallId, success: true, invocationResult.Result);
+ }
+
+ void EndInvokeDotNetCore(string callId, bool success, object resultOrError)
+ {
+ _ipcSender.EndInvokeDotNet(callId, success, JsonSerializer.Serialize(resultOrError, JsonSerializerOptions));
+ }
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/src/Services/WebViewNavigationInterception.cs b/src/Components/WebView/WebView/src/Services/WebViewNavigationInterception.cs
new file mode 100644
index 000000000000..884efd3fefe4
--- /dev/null
+++ b/src/Components/WebView/WebView/src/Services/WebViewNavigationInterception.cs
@@ -0,0 +1,15 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components.Routing;
+
+namespace Microsoft.AspNetCore.Components.WebView.Services
+{
+ internal class WebViewNavigationInterception : INavigationInterception
+ {
+ // On this platform, it's sufficient for the JS-side code to enable it unconditionally,
+ // so there's no need to send a notification.
+ public Task EnableNavigationInterceptionAsync() => Task.CompletedTask;
+ }
+}
diff --git a/src/Components/WebView/WebView/src/Services/WebViewNavigationManager.cs b/src/Components/WebView/WebView/src/Services/WebViewNavigationManager.cs
new file mode 100644
index 000000000000..69baf3f3c700
--- /dev/null
+++ b/src/Components/WebView/WebView/src/Services/WebViewNavigationManager.cs
@@ -0,0 +1,27 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.Components.WebView.Services
+{
+ internal class WebViewNavigationManager : NavigationManager
+ {
+ private IpcSender _ipcSender;
+
+ public void AttachToWebView(IpcSender ipcSender, string baseUrl, string initialUrl)
+ {
+ _ipcSender = ipcSender;
+ Initialize(baseUrl, initialUrl);
+ }
+
+ public void LocationUpdated(string newUrl, bool intercepted)
+ {
+ Uri = newUrl;
+ NotifyLocationChanged(intercepted);
+ }
+
+ protected override void NavigateToCore(string uri, bool forceLoad)
+ {
+ _ipcSender.Navigate(uri, forceLoad);
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/src/Services/WebViewRenderer.cs b/src/Components/WebView/WebView/src/Services/WebViewRenderer.cs
new file mode 100644
index 000000000000..b9fbf6bee594
--- /dev/null
+++ b/src/Components/WebView/WebView/src/Services/WebViewRenderer.cs
@@ -0,0 +1,103 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.ExceptionServices;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Components.WebView.Services
+{
+ internal class WebViewRenderer : Renderer
+ {
+ private readonly Queue _unacknowledgedRenderBatches = new();
+ private readonly Dictionary _componentIdBySelector = new();
+ private readonly Dispatcher _dispatcher;
+ private readonly IpcSender _ipcSender;
+ private long nextRenderBatchId = 1;
+
+ public WebViewRenderer(
+ IServiceProvider serviceProvider,
+ Dispatcher dispatcher,
+ IpcSender ipcSender,
+ ILoggerFactory loggerFactory) :
+ base(serviceProvider, loggerFactory)
+ {
+ _dispatcher = dispatcher;
+ _ipcSender = ipcSender;
+ }
+
+ public override Dispatcher Dispatcher => _dispatcher;
+
+ protected override void HandleException(Exception exception)
+ {
+ // Notify the JS code so it can show the in-app UI
+ _ipcSender.NotifyUnhandledException(exception);
+
+ // Also rethrow so the AppDomain's UnhandledException handler gets notified
+ ExceptionDispatchInfo.Capture(exception).Throw();
+ }
+
+ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
+ {
+ var batchId = nextRenderBatchId++;
+ var tcs = new TaskCompletionSource();
+ _unacknowledgedRenderBatches.Enqueue(new UnacknowledgedRenderBatch
+ {
+ BatchId = batchId,
+ CompletionSource = tcs,
+ });
+
+ _ipcSender.ApplyRenderBatch(batchId, renderBatch);
+ return tcs.Task;
+ }
+
+ public async Task AddRootComponentAsync(Type componentType, string selector, ParameterView parameters)
+ {
+ if (_componentIdBySelector.ContainsKey(selector))
+ {
+ throw new InvalidOperationException("A component is already associated with the given selector.");
+ }
+
+ var component = InstantiateComponent(componentType);
+ var componentId = AssignRootComponentId(component);
+
+ _componentIdBySelector.Add(selector, componentId);
+ _ipcSender.AttachToDocument(componentId, selector);
+
+ await RenderRootComponentAsync(componentId, parameters);
+ }
+
+ public async Task RemoveRootComponentAsync(string selector)
+ {
+ if (!_componentIdBySelector.TryGetValue(selector, out var componentId))
+ {
+ throw new InvalidOperationException("Could not find a component Id associated with the given selector.");
+ }
+
+ // TODO: The renderer needs an API to do trigger the disposal of the component tree.
+ await Task.CompletedTask;
+
+ _ipcSender.DetachFromDocument(componentId);
+ }
+
+ public void NotifyRenderCompleted(long batchId)
+ {
+ var nextUnacknowledgedBatch = _unacknowledgedRenderBatches.Dequeue();
+ if (nextUnacknowledgedBatch.BatchId != batchId)
+ {
+ throw new InvalidOperationException($"Received unexpected acknowledgement for render batch {batchId} (next batch should be {nextUnacknowledgedBatch.BatchId})");
+ }
+
+ nextUnacknowledgedBatch.CompletionSource.SetResult();
+ }
+
+ record UnacknowledgedRenderBatch
+ {
+ public long BatchId { get; init; }
+ public TaskCompletionSource CompletionSource { get; init; }
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/src/StaticContentProvider.cs b/src/Components/WebView/WebView/src/StaticContentProvider.cs
new file mode 100644
index 000000000000..fb4aa1db96a1
--- /dev/null
+++ b/src/Components/WebView/WebView/src/StaticContentProvider.cs
@@ -0,0 +1,114 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Text;
+using Microsoft.AspNetCore.StaticFiles;
+using Microsoft.Extensions.FileProviders;
+
+namespace Microsoft.AspNetCore.Components.WebView
+{
+ internal class StaticContentProvider
+ {
+ private readonly IFileProvider _fileProvider;
+ private readonly Uri _appBaseUri;
+ private readonly string _hostPageRelativePath;
+ private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new();
+ private static ManifestEmbeddedFileProvider _manifestProvider =
+ new ManifestEmbeddedFileProvider(typeof(StaticContentProvider).Assembly);
+
+ public StaticContentProvider(IFileProvider fileProvider, Uri appBaseUri, string hostPageRelativePath)
+ {
+ _fileProvider = fileProvider ?? throw new ArgumentNullException(nameof(fileProvider));
+ _appBaseUri = appBaseUri ?? throw new ArgumentNullException(nameof(appBaseUri));
+ _hostPageRelativePath = hostPageRelativePath ?? throw new ArgumentNullException(nameof(hostPageRelativePath));
+ }
+
+ public bool TryGetResponseContent(string requestUri, bool allowFallbackOnHostPage, out int statusCode, out string statusMessage, out Stream content, out string headers)
+ {
+ var fileUri = new Uri(requestUri);
+ if (_appBaseUri.IsBaseOf(fileUri))
+ {
+ var relativePath = _appBaseUri.MakeRelativeUri(fileUri).ToString();
+
+ // Content in the file provider takes first priority
+ // Next we may fall back on supplying the host page to support deep linking
+ // If there's no match, fall back on serving embedded framework content
+ string contentType;
+ var found = TryGetFromFileProvider(relativePath, out content, out contentType)
+ || (allowFallbackOnHostPage && TryGetFromFileProvider(_hostPageRelativePath, out content, out contentType))
+ || TryGetFrameworkFile(relativePath, out content, out contentType);
+
+ if (found)
+ {
+ statusCode = 200;
+ statusMessage = "OK";
+ headers = GetResponseHeaders(contentType);
+ }
+ else
+ {
+ content = new MemoryStream(Encoding.UTF8.GetBytes($"There is no content at {relativePath}"));
+ statusCode = 404;
+ statusMessage = "Not found";
+ headers = GetResponseHeaders("text/plain");
+ }
+
+ // Always respond to requests within the base URI, even if there's no matching file
+ return true;
+ }
+ else
+ {
+ // URL isn't within application base path, so let the network handle it
+ statusCode = default;
+ statusMessage = default;
+ headers = default;
+ content = default;
+ return false;
+ }
+ }
+
+ private bool TryGetFromFileProvider(string relativePath, out Stream content, out string contentType)
+ {
+ if (!string.IsNullOrEmpty(relativePath))
+ {
+ var fileInfo = _fileProvider.GetFileInfo(relativePath);
+ if (fileInfo.Exists)
+ {
+ content = fileInfo.CreateReadStream();
+ contentType = GetResponseContentTypeOrDefault(fileInfo.PhysicalPath);
+ return true;
+ }
+ }
+
+ content = default;
+ contentType = default;
+ return false;
+ }
+
+ private static bool TryGetFrameworkFile(string relativePath, out Stream content, out string contentType)
+ {
+ // We're not trying to simulate everything a real webserver does. We don't need to
+ // support querystring parameters, for example. It's enough to require an exact match.
+ var file = _manifestProvider.GetFileInfo(relativePath);
+ if (file.Exists)
+ {
+ content = file.CreateReadStream();
+ contentType = GetResponseContentTypeOrDefault(relativePath);
+ return true;
+ }
+
+ content = default;
+ contentType = default;
+ return false;
+ }
+
+ private static string GetResponseContentTypeOrDefault(string path)
+ => ContentTypeProvider.TryGetContentType(path, out var matchedContentType)
+ ? matchedContentType
+ : "application/octet-stream";
+
+ private static string GetResponseHeaders(string contentType)
+ => $"Content-Type: {contentType}{Environment.NewLine}Cache-Control: no-cache, max-age=0, must-revalidate, no-store";
+ }
+}
diff --git a/src/Components/WebView/WebView/src/WebViewManager.cs b/src/Components/WebView/WebView/src/WebViewManager.cs
new file mode 100644
index 000000000000..1fb19a48d332
--- /dev/null
+++ b/src/Components/WebView/WebView/src/WebViewManager.cs
@@ -0,0 +1,226 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.FileProviders;
+
+namespace Microsoft.AspNetCore.Components.WebView
+{
+ ///
+ /// Manages activities within a web view that hosts Blazor components. Platform authors
+ /// should subclass this to wire up the abstract and protected methods to the APIs of
+ /// the platform's web view.
+ ///
+ public abstract class WebViewManager : IDisposable
+ {
+ // These services are not DI services, because their lifetime isn't limited to a single
+ // per-page-load scope. Instead, their lifetime matches the webview itself.
+ private readonly IServiceProvider _provider;
+ private readonly Dispatcher _dispatcher;
+ private readonly IpcSender _ipcSender;
+ private readonly IpcReceiver _ipcReceiver;
+ private readonly Uri _appBaseUri;
+ private readonly StaticContentProvider _staticContentProvider;
+ private readonly Dictionary _rootComponentsBySelector = new();
+
+ // Each time a web page connects, we establish a new per-page context
+ private PageContext _currentPageContext;
+ private bool _disposed;
+
+ ///
+ /// Constructs an instance of .
+ ///
+ /// The for the application.
+ /// A instance that can marshal calls to the required thread or sync context.
+ /// The base URI for the application. Since this is a webview, the base URI is typically on a private origin such as http://0.0.0.0/ or app://example/
+ /// Provides static content to the webview.
+ /// Path to the host page within the .
+ public WebViewManager(IServiceProvider provider, Dispatcher dispatcher, Uri appBaseUri, IFileProvider fileProvider, string hostPageRelativePath)
+ {
+ _provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+ _appBaseUri = EnsureTrailingSlash(appBaseUri ?? throw new ArgumentNullException(nameof(appBaseUri)));
+ _staticContentProvider = new StaticContentProvider(fileProvider, appBaseUri, hostPageRelativePath);
+ _ipcSender = new IpcSender(_dispatcher, SendMessage);
+ _ipcReceiver = new IpcReceiver(AttachToPageAsync);
+ }
+
+ ///
+ /// Gets the used by this instance.
+ ///
+ public Dispatcher Dispatcher => _dispatcher;
+
+ ///
+ /// Instructs the web view to navigate to the specified location, bypassing any
+ /// client-side routing.
+ ///
+ /// The URL, which may be absolute or relative to the application root.
+ public void Navigate(string url)
+ => NavigateCore(new Uri(_appBaseUri, url));
+
+ ///
+ /// Instructs the web view to navigate to the specified location, bypassing any
+ /// client-side routing.
+ ///
+ /// The absolute URI.
+ protected abstract void NavigateCore(Uri absoluteUri);
+
+ ///
+ /// Sends a message to JavaScript code running in the attached web view. This must
+ /// be forwarded to the Blazor JavaScript code.
+ ///
+ /// The message.
+ protected abstract void SendMessage(string message);
+
+ ///
+ /// Adds a root component to the attached page.
+ ///
+ /// The type of the root component. This must implement .
+ /// The CSS selector describing where in the page the component should be placed.
+ /// Parameters for the component.
+ public Task AddRootComponentAsync(Type componentType, string selector, ParameterView parameters)
+ {
+ var rootComponent = new RootComponent { ComponentType = componentType, Parameters = parameters };
+ if (!_rootComponentsBySelector.TryAdd(selector, rootComponent))
+ {
+ throw new InvalidOperationException($"There is already a root component with selector '{selector}'.");
+ }
+
+ // If the page is already attached, add the root component to it now. Otherwise we'll
+ // add it when the page attaches later.
+ if (_currentPageContext != null)
+ {
+ return Dispatcher.InvokeAsync(() => _currentPageContext.Renderer.AddRootComponentAsync(componentType, selector, parameters));
+ }
+ else
+ {
+ return Task.CompletedTask;
+ }
+ }
+
+ ///
+ /// Removes a previously-attached root component from the current page.
+ ///
+ /// The CSS selector describing where in the page the component was placed. This must exactly match the selector provided on an earlier call to .
+ public Task RemoveRootComponentAsync(string selector)
+ {
+ if (!_rootComponentsBySelector.Remove(selector))
+ {
+ throw new InvalidOperationException($"There is no root component with selector '{selector}'.");
+ }
+
+ // If the page is already attached, remove the root component from it now. Otherwise it's
+ // enough to have updated the dictionary.
+ if (_currentPageContext != null)
+ {
+ return Dispatcher.InvokeAsync(() => _currentPageContext.Renderer.RemoveRootComponentAsync(selector));
+ }
+ else
+ {
+ return Task.CompletedTask;
+ }
+ }
+
+ ///
+ /// Notifies the about a message from JavaScript running within the web view.
+ ///
+ /// The source URI for the message.
+ /// The message.
+ protected void MessageReceived(Uri sourceUri, string message)
+ {
+ if (!_appBaseUri.IsBaseOf(sourceUri))
+ {
+ // It's important that we ignore messages from other origins, otherwise if the webview
+ // navigates to a remote location, it could send commands that execute locally
+ return;
+ }
+
+ _ = _dispatcher.InvokeAsync(async () =>
+ {
+ // TODO: Verify this produces the correct exception-surfacing behaviors.
+ // For example, JS interop exceptions should flow back into JS, whereas
+ // renderer exceptions should be fatal.
+ try
+ {
+ await _ipcReceiver.OnMessageReceivedAsync(_currentPageContext, message);
+ }
+ catch (Exception ex)
+ {
+ _ipcSender.NotifyUnhandledException(ex);
+ throw;
+ }
+ });
+ }
+
+ ///
+ /// Tries to provide the response content for a given network request.
+ ///
+ /// The uri of the request
+ /// Whether or not to fallback to the host page.
+ /// The status code of the response.
+ /// The response status message.
+ /// The response content
+ /// The response headers
+ /// true if the response can be provided; false otherwise.
+ protected bool TryGetResponseContent(string uri, bool allowFallbackOnHostPage, out int statusCode, out string statusMessage, out Stream content, out string headers)
+ => _staticContentProvider.TryGetResponseContent(uri, allowFallbackOnHostPage, out statusCode, out statusMessage, out content, out headers);
+
+ internal async Task AttachToPageAsync(string baseUrl, string startUrl)
+ {
+ // If there was some previous attached page, dispose all its resources. TODO: Are we happy
+ // with this pattern? The alternative would be requiring the platform author to notify us
+ // when the webview is navigating away so we could dispose more eagerly then.
+ _currentPageContext?.Dispose();
+
+ var serviceScope = _provider.CreateScope();
+ _currentPageContext = new PageContext(_dispatcher, serviceScope, _ipcSender, baseUrl, startUrl);
+
+ // Add any root components that were registered before the page attached
+ foreach (var (selector, rootComponent) in _rootComponentsBySelector)
+ {
+ await _currentPageContext.Renderer.AddRootComponentAsync(
+ rootComponent.ComponentType,
+ selector,
+ rootComponent.Parameters);
+ }
+ }
+
+ private static Uri EnsureTrailingSlash(Uri uri)
+ => uri.AbsoluteUri.EndsWith('/') ? uri : new Uri(uri.AbsoluteUri + '/');
+
+ record RootComponent
+ {
+ public Type ComponentType { get; init; }
+ public ParameterView Parameters { get; set; }
+ }
+
+ ///
+ /// Disposes the current instance.
+ ///
+ /// true when dispose was called explicitly; false when it is called as part of the finalizer.
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing)
+ {
+ _currentPageContext?.Dispose();
+ }
+
+ _disposed = true;
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/test/Infrastructure/AssertHelpers.cs b/src/Components/WebView/WebView/test/Infrastructure/AssertHelpers.cs
new file mode 100644
index 000000000000..1e4b08cc6725
--- /dev/null
+++ b/src/Components/WebView/WebView/test/Infrastructure/AssertHelpers.cs
@@ -0,0 +1,34 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using Microsoft.AspNetCore.Components.RenderTree;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.WebView
+{
+ public class AssertHelpers
+ {
+ internal static void IsAttachToDocumentMessage(string message, int componentId, string selector)
+ {
+ Assert.True(IpcCommon.TryDeserializeOutgoing(message, out var messageType, out var args));
+ Assert.Equal(IpcCommon.OutgoingMessageType.AttachToDocument, messageType);
+ Assert.Equal(2, args.Count);
+ Assert.Equal(componentId, args[0].GetInt32());
+ Assert.Equal(selector, args[1].GetString());
+ }
+
+ internal static RenderBatch IsRenderBatch(string message)
+ {
+ Assert.True(IpcCommon.TryDeserializeOutgoing(message, out var messageType, out var args));
+ Assert.Equal(IpcCommon.OutgoingMessageType.RenderBatch, messageType);
+ Assert.Equal(2, args.Count);
+ Assert.Equal(1, args[0].GetInt64()); // Batch ID
+
+ // At least validate we can base64 decode the batch data
+ var _ = Convert.FromBase64String(args[1].GetString());
+ // TODO: Produce the render batch if we want to grab info from it.
+ return default;
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/test/Infrastructure/ComponentNode.cs b/src/Components/WebView/WebView/test/Infrastructure/ComponentNode.cs
new file mode 100644
index 000000000000..02b1e91fd8db
--- /dev/null
+++ b/src/Components/WebView/WebView/test/Infrastructure/ComponentNode.cs
@@ -0,0 +1,15 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.Components.WebView.Document
+{
+ internal class ComponentNode : ContainerNode
+ {
+ public ComponentNode(int componentId)
+ {
+ ComponentId = componentId;
+ }
+
+ public int ComponentId { get; }
+ }
+}
diff --git a/src/Components/WebView/WebView/test/Infrastructure/ContainerNode.cs b/src/Components/WebView/WebView/test/Infrastructure/ContainerNode.cs
new file mode 100644
index 000000000000..0029f1b21b5c
--- /dev/null
+++ b/src/Components/WebView/WebView/test/Infrastructure/ContainerNode.cs
@@ -0,0 +1,77 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+
+namespace Microsoft.AspNetCore.Components.WebView.Document
+{
+ internal class ContainerNode : TestNode
+ {
+ public List Children { get; } = new();
+
+ internal void RemoveLogicalChild(int childIndex)
+ {
+ var childToRemove = Children[childIndex];
+ Children.RemoveAt(childIndex);
+
+ // If it's a logical container, also remove its descendants
+ if (childToRemove is LogicalContainerNode container)
+ {
+ while (container.Children.Count > 0)
+ {
+ container.RemoveLogicalChild(0);
+ }
+ }
+ }
+
+ internal ContainerNode CreateAndInsertContainer(int childIndex)
+ {
+ var containerElement = new LogicalContainerNode();
+ InsertLogicalChild(containerElement, childIndex);
+ return containerElement;
+ }
+
+ internal void InsertLogicalChild(TestNode child, int childIndex)
+ {
+ if (child is LogicalContainerNode comment && comment.Children.Count > 0)
+ {
+ // There's nothing to stop us implementing support for this scenario, and it's not difficult
+ // (after inserting 'child' itself, also iterate through its logical children and physically
+ // put them as following-siblings in the DOM). However there's no scenario that requires it
+ // presently, so if we did implement it there'd be no good way to have tests for it.
+ throw new Exception("Not implemented: inserting non-empty logical container");
+ }
+
+ if (child.Parent != null)
+ {
+ // Likewise, we could easily support this scenario too (in this 'if' block, just splice
+ // out 'child' from the logical children array of its previous logical parent by using
+ // Array.prototype.indexOf to determine its previous sibling index).
+ // But again, since there's not currently any scenario that would use it, we would not
+ // have any test coverage for such an implementation.
+ throw new NotSupportedException("Not implemented: moving existing logical children");
+ }
+
+ if (childIndex < Children.Count)
+ {
+ // Insert
+ Children.Insert(childIndex, child);
+ }
+ else
+ {
+ // Append
+ Children.Add(child);
+ }
+
+ child.Parent = this;
+ }
+
+ internal ComponentNode CreateAndInsertComponent(int childComponentId, int childIndex)
+ {
+ var componentElement = new ComponentNode(childComponentId);
+ InsertLogicalChild(componentElement, childIndex);
+ return componentElement;
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/test/Infrastructure/ElementNode.cs b/src/Components/WebView/WebView/test/Infrastructure/ElementNode.cs
new file mode 100644
index 000000000000..c72ad89dbb70
--- /dev/null
+++ b/src/Components/WebView/WebView/test/Infrastructure/ElementNode.cs
@@ -0,0 +1,74 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+
+namespace Microsoft.AspNetCore.Components.WebView.Document
+{
+ internal class ElementNode : ContainerNode
+ {
+ private readonly Dictionary _attributes;
+ private readonly Dictionary _properties;
+ private readonly Dictionary _events;
+
+ public ElementNode(string elementName)
+ {
+ TagName = elementName;
+ _attributes = new Dictionary(StringComparer.Ordinal);
+ _properties = new Dictionary(StringComparer.Ordinal);
+ _events = new Dictionary(StringComparer.Ordinal);
+ }
+
+ public string TagName { get; }
+
+ public IReadOnlyDictionary Attributes => _attributes;
+
+ public IReadOnlyDictionary Properties => _properties;
+
+ public IReadOnlyDictionary Events => _events;
+
+ internal void RemoveAttribute(string key)
+ {
+ _attributes.Remove(key);
+ }
+
+ internal void SetAttribute(string key, object value)
+ {
+ _attributes[key] = value;
+ }
+
+ internal void SetEvent(string eventName, ElementEventDescriptor descriptor)
+ {
+ if (eventName is null)
+ {
+ throw new ArgumentNullException(nameof(eventName));
+ }
+
+ if (descriptor is null)
+ {
+ throw new ArgumentNullException(nameof(descriptor));
+ }
+
+ _events[eventName] = descriptor;
+ }
+
+ internal void SetProperty(string key, object value)
+ {
+ _properties[key] = value;
+ }
+
+ public class ElementEventDescriptor
+ {
+ public ElementEventDescriptor(string eventName, ulong eventId)
+ {
+ EventName = eventName ?? throw new ArgumentNullException(nameof(eventName));
+ EventId = eventId;
+ }
+
+ public string EventName { get; }
+
+ public ulong EventId { get; }
+ }
+ }
+}
diff --git a/src/Components/WebView/WebView/test/Infrastructure/LogicalContainerNode.cs b/src/Components/WebView/WebView/test/Infrastructure/LogicalContainerNode.cs
new file mode 100644
index 000000000000..18d5724f4f27
--- /dev/null
+++ b/src/Components/WebView/WebView/test/Infrastructure/LogicalContainerNode.cs
@@ -0,0 +1,9 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.Components.WebView.Document
+{
+ internal class LogicalContainerNode : ContainerNode
+ {
+ }
+}
diff --git a/src/Components/WebView/WebView/test/Infrastructure/MarkupNode.cs b/src/Components/WebView/WebView/test/Infrastructure/MarkupNode.cs
new file mode 100644
index 000000000000..0c7308be5612
--- /dev/null
+++ b/src/Components/WebView/WebView/test/Infrastructure/MarkupNode.cs
@@ -0,0 +1,15 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.Components.WebView.Document
+{
+ internal class MarkupNode : TestNode
+ {
+ public MarkupNode(string markupContent)
+ {
+ Content = markupContent;
+ }
+
+ public string Content { get; }
+ }
+}
diff --git a/src/Components/WebView/WebView/test/Infrastructure/RootComponentNode.cs b/src/Components/WebView/WebView/test/Infrastructure/RootComponentNode.cs
new file mode 100644
index 000000000000..6279a4519e43
--- /dev/null
+++ b/src/Components/WebView/WebView/test/Infrastructure/RootComponentNode.cs
@@ -0,0 +1,15 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.Components.WebView.Document
+{
+ internal class RootComponentNode : ComponentNode
+ {
+ public RootComponentNode(int componentId, string selector) : base(componentId)
+ {
+ Selector = selector;
+ }
+
+ public string Selector { get; }
+ }
+}
diff --git a/src/Components/WebView/WebView/test/Infrastructure/TestDocument.cs b/src/Components/WebView/WebView/test/Infrastructure/TestDocument.cs
new file mode 100644
index 000000000000..ff7552890f80
--- /dev/null
+++ b/src/Components/WebView/WebView/test/Infrastructure/TestDocument.cs
@@ -0,0 +1,512 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.WebView.Document
+{
+ public class TestDocument
+ {
+ private const string SelectValuePropname = "_blazorSelectValue";
+
+ private readonly Dictionary _componentsById = new();
+
+ public void AddRootComponent(int componentId, string selector)
+ {
+ if (_componentsById.ContainsKey(componentId))
+ {
+ throw new InvalidOperationException($"Component with Id '{componentId}' already exists.");
+ }
+
+ _componentsById.Add(componentId, new RootComponentNode(componentId, selector));
+ }
+
+ public void ApplyChanges(RenderBatch batch)
+ {
+ for (var i = 0; i < batch.UpdatedComponents.Count; i++)
+ {
+ var diff = batch.UpdatedComponents.Array[i];
+ var componentId = diff.ComponentId;
+ var edits = diff.Edits;
+ UpdateComponent(batch, componentId, edits);
+ }
+
+ for (var i = 0; i < batch.DisposedComponentIDs.Count; i++)
+ {
+ DisposeComponent(batch.DisposedComponentIDs.Array[i]);
+ }
+
+ for (var i = 0; i < batch.DisposedEventHandlerIDs.Count; i++)
+ {
+ DisposeEventHandler(batch.DisposedEventHandlerIDs.Array[i]);
+ }
+ }
+
+ private void UpdateComponent(RenderBatch batch, int componentId, ArrayBuilderSegment edits)
+ {
+ if (!_componentsById.TryGetValue(componentId, out var component))
+ {
+ component = new ComponentNode(componentId);
+ _componentsById.Add(componentId, component);
+ }
+
+ ApplyEdits(batch, component, 0, edits);
+ }
+
+ private void DisposeComponent(int componentId)
+ {
+
+ }
+
+ private void DisposeEventHandler(ulong eventHandlerId)
+ {
+
+ }
+
+ private void ApplyEdits(RenderBatch batch, ContainerNode parent, int childIndex, ArrayBuilderSegment edits)
+ {
+ var currentDepth = 0;
+ var childIndexAtCurrentDepth = childIndex;
+ var permutations = new List();
+
+ for (var editIndex = edits.Offset; editIndex < edits.Offset + edits.Count; editIndex++)
+ {
+ var edit = edits.Array[editIndex];
+ switch (edit.Type)
+ {
+ case RenderTreeEditType.PrependFrame:
+ {
+ var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
+ var siblingIndex = edit.SiblingIndex;
+ InsertFrame(batch, parent, childIndexAtCurrentDepth + siblingIndex, batch.ReferenceFrames.Array, frame, edit.ReferenceFrameIndex);
+ break;
+ }
+
+ case RenderTreeEditType.RemoveFrame:
+ {
+ var siblingIndex = edit.SiblingIndex;
+ parent.RemoveLogicalChild(childIndexAtCurrentDepth + siblingIndex);
+ break;
+ }
+
+ case RenderTreeEditType.SetAttribute:
+ {
+ var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
+ var siblingIndex = edit.SiblingIndex;
+ var node = parent.Children[childIndexAtCurrentDepth + siblingIndex];
+ if (node is ElementNode element)
+ {
+ ApplyAttribute(batch, element, frame);
+ }
+ else
+ {
+ throw new Exception("Cannot set attribute on non-element child");
+ }
+ break;
+ }
+
+ case RenderTreeEditType.RemoveAttribute:
+ {
+ // Note that we don't have to dispose the info we track about event handlers here, because the
+ // disposed event handler IDs are delivered separately (in the 'disposedEventHandlerIds' array)
+ var siblingIndex = edit.SiblingIndex;
+ var node = parent.Children[childIndexAtCurrentDepth + siblingIndex];
+ if (node is ElementNode element)
+ {
+ var attributeName = edit.RemovedAttributeName;
+
+ // First try to remove any special property we use for this attribute
+ if (!TryApplySpecialProperty(batch, element, attributeName, default))
+ {
+ // If that's not applicable, it's a regular DOM attribute so remove that
+ element.RemoveAttribute(attributeName);
+ }
+ }
+ else
+ {
+ throw new Exception("Cannot remove attribute from non-element child");
+ }
+ break;
+ }
+
+ case RenderTreeEditType.UpdateText:
+ {
+ var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
+ var siblingIndex = edit.SiblingIndex;
+ var node = parent.Children[childIndexAtCurrentDepth + siblingIndex];
+ if (node is TextNode textNode)
+ {
+ textNode.Text = frame.TextContent;
+ }
+ else
+ {
+ throw new Exception("Cannot set text content on non-text child");
+ }
+ break;
+ }
+
+
+ case RenderTreeEditType.UpdateMarkup:
+ {
+ var frame = batch.ReferenceFrames.Array[edit.ReferenceFrameIndex];
+ var siblingIndex = edit.SiblingIndex;
+ parent.RemoveLogicalChild(childIndexAtCurrentDepth + siblingIndex);
+ InsertMarkup(parent, childIndexAtCurrentDepth + siblingIndex, frame);
+ break;
+ }
+
+ case RenderTreeEditType.StepIn:
+ {
+ var siblingIndex = edit.SiblingIndex;
+ parent = (ContainerNode)parent.Children[childIndexAtCurrentDepth + siblingIndex];
+ currentDepth++;
+ childIndexAtCurrentDepth = 0;
+ break;
+ }
+
+ case RenderTreeEditType.StepOut:
+ {
+ parent = parent.Parent ?? throw new InvalidOperationException($"Cannot step out of {parent}");
+ currentDepth--;
+ childIndexAtCurrentDepth = currentDepth == 0 ? childIndex : 0; // The childIndex is only ever nonzero at zero depth
+ break;
+ }
+
+ case RenderTreeEditType.PermutationListEntry:
+ {
+ permutations.Add(new PermutationListEntry(childIndexAtCurrentDepth + edit.SiblingIndex, childIndexAtCurrentDepth + edit.MoveToSiblingIndex));
+ break;
+ }
+
+ case RenderTreeEditType.PermutationListEnd:
+ {
+ throw new NotSupportedException();
+ //permuteLogicalChildren(parent, permutations!);
+ //permutations.Clear();
+ //break;
+ }
+
+ default:
+ {
+ throw new Exception($"Unknown edit type: '{edit.Type}'");
+ }
+ }
+ }
+ }
+
+ private int InsertFrame(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment frames, RenderTreeFrame frame, int frameIndex)
+ {
+ switch (frame.FrameType)
+ {
+ case RenderTreeFrameType.Element:
+ {
+ InsertElement(batch, parent, childIndex, frames, frame, frameIndex);
+ return 1;
+ }
+
+ case RenderTreeFrameType.Text:
+ {
+ InsertText(parent, childIndex, frame);
+ return 1;
+ }
+
+ case RenderTreeFrameType.Attribute:
+ {
+ throw new Exception("Attribute frames should only be present as leading children of element frames.");
+ }
+
+ case RenderTreeFrameType.Component:
+ {
+ InsertComponent(parent, childIndex, frame);
+ return 1;
+ }
+
+ case RenderTreeFrameType.Region:
+ {
+ return InsertFrameRange(batch, parent, childIndex, frames, frameIndex + 1, frameIndex + frame.RegionSubtreeLength);
+ }
+
+ case RenderTreeFrameType.ElementReferenceCapture:
+ {
+ if (parent is ElementNode)
+ {
+ return 0; // A "capture" is a child in the diff, but has no node in the DOM
+ }
+ else
+ {
+ throw new Exception("Reference capture frames can only be children of element frames.");
+ }
+ }
+
+ case RenderTreeFrameType.Markup:
+ {
+ InsertMarkup(parent, childIndex, frame);
+ return 1;
+ }
+
+ }
+
+ throw new Exception($"Unknown frame type: {frame.FrameType}");
+ }
+
+ private void InsertText(ContainerNode parent, int childIndex, RenderTreeFrame frame)
+ {
+ var textContent = frame.TextContent;
+ var newTextNode = new TextNode(textContent);
+ parent.InsertLogicalChild(newTextNode, childIndex);
+ }
+
+ private void InsertComponent(ContainerNode parent, int childIndex, RenderTreeFrame frame)
+ {
+ // All we have to do is associate the child component ID with its location. We don't actually
+ // do any rendering here, because the diff for the child will appear later in the render batch.
+ var childComponentId = frame.ComponentId;
+ var containerElement = parent.CreateAndInsertComponent(childComponentId, childIndex);
+
+ _componentsById[childComponentId] = containerElement;
+ }
+
+ private int InsertFrameRange(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment frames, int startIndex, int endIndexExcl)
+ {
+ var origChildIndex = childIndex;
+ for (var index = startIndex; index < endIndexExcl; index++)
+ {
+ var frame = batch.ReferenceFrames.Array[index];
+ var numChildrenInserted = InsertFrame(batch, parent, childIndex, frames, frame, index);
+ childIndex += numChildrenInserted;
+
+ // Skip over any descendants, since they are already dealt with recursively
+ index += CountDescendantFrames(frame);
+ }
+
+ return childIndex - origChildIndex; // Total number of children inserted
+ }
+
+ private void InsertElement(RenderBatch batch, ContainerNode parent, int childIndex, ArraySegment frames, RenderTreeFrame frame, int frameIndex)
+ {
+ // Note: we don't handle SVG here
+ var newElement = new ElementNode(frame.ElementName);
+
+ var inserted = false;
+
+ // Apply attributes
+ for (var i = frameIndex + 1; i < frameIndex + frame.ElementSubtreeLength; i++)
+ {
+ var descendantFrame = batch.ReferenceFrames.Array[i];
+ if (descendantFrame.FrameType == RenderTreeFrameType.Attribute)
+ {
+ ApplyAttribute(batch, newElement, descendantFrame);
+ }
+ else
+ {
+ parent.InsertLogicalChild(newElement, childIndex);
+ inserted = true;
+
+ // As soon as we see a non-attribute child, all the subsequent child frames are
+ // not attributes, so bail out and insert the remnants recursively
+ InsertFrameRange(batch, newElement, 0, frames, i, frameIndex + frame.ElementSubtreeLength);
+ break;
+ }
+ }
+
+ // this element did not have any children, so it's not inserted yet.
+ if (!inserted)
+ {
+ parent.InsertLogicalChild(newElement, childIndex);
+ }
+ }
+
+ private void ApplyAttribute(RenderBatch batch, ElementNode elementNode, RenderTreeFrame attributeFrame)
+ {
+ var attributeName = attributeFrame.AttributeName;
+ var eventHandlerId = attributeFrame.AttributeEventHandlerId;
+
+ if (eventHandlerId != 0)
+ {
+ var firstTwoChars = attributeName.Substring(0, 2);
+ var eventName = attributeName.Substring(2);
+ if (firstTwoChars != "on" || string.IsNullOrEmpty(eventName))
+ {
+ throw new InvalidOperationException($"Attribute has nonzero event handler ID, but attribute name '${attributeName}' does not start with 'on'.");
+ }
+ var descriptor = new ElementNode.ElementEventDescriptor(eventName, eventHandlerId);
+ elementNode.SetEvent(eventName, descriptor);
+
+ return;
+ }
+
+ // First see if we have special handling for this attribute
+ if (!TryApplySpecialProperty(batch, elementNode, attributeName, attributeFrame))
+ {
+ // If not, treat it as a regular string-valued attribute
+ elementNode.SetAttribute(
+ attributeName,
+ attributeFrame.AttributeValue);
+ }
+ }
+
+ private bool TryApplySpecialProperty(RenderBatch batch, ElementNode element, string attributeName, RenderTreeFrame attributeFrame)
+ {
+ switch (attributeName)
+ {
+ case "value":
+ return TryApplyValueProperty(element, attributeFrame);
+ case "checked":
+ return TryApplyCheckedProperty(element, attributeFrame);
+ default:
+ return false;
+ }
+ }
+
+ private bool TryApplyValueProperty(ElementNode element, RenderTreeFrame attributeFrame)
+ {
+ // Certain elements have built-in behaviour for their 'value' property
+ switch (element.TagName)
+ {
+ case "INPUT":
+ case "SELECT":
+ case "TEXTAREA":
+ {
+ var value = attributeFrame.AttributeValue;
+ element.SetProperty("value", value);
+
+ if (element.TagName == "SELECT")
+ {
+ //