File: BlazorWebView.cs
Web Access
Project: src\src\BlazorWebView\src\WindowsForms\Microsoft.AspNetCore.Components.WebView.WindowsForms.csproj (Microsoft.AspNetCore.Components.WebView.WindowsForms)
// 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.Specialized;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using System.Windows.Forms;
using Microsoft.AspNetCore.Components.WebView.WebView2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using WebView2Control = Microsoft.Web.WebView2.WinForms.WebView2;
 
namespace Microsoft.AspNetCore.Components.WebView.WindowsForms
{
	/// <summary>
	/// A Windows Forms control for hosting Razor components locally in Windows desktop applications.
	/// </summary>
	public class BlazorWebView : ContainerControl
	{
		private readonly WebView2Control _webview;
		private WebView2WebViewManager? _webviewManager;
		private string? _hostPage;
		private IServiceProvider? _services;
 
		/// <summary>
		/// Creates a new instance of <see cref="BlazorWebView"/>.
		/// </summary>
		public BlazorWebView()
		{
			ComponentsDispatcher = new WindowsFormsDispatcher(this);
 
			RootComponents.CollectionChanged += HandleRootComponentsCollectionChanged;
 
			_webview = new WebView2Control()
			{
				Dock = DockStyle.Fill,
			};
			((BlazorWebViewControlCollection)Controls).AddInternal(_webview);
		}
 
		/// <summary>
		/// Returns the inner <see cref="WebView2Control"/> used by this control.
		/// </summary>
		/// <remarks>
		/// Directly using some functionality of the inner web view can cause unexpected results because its behavior
		/// is controlled by the <see cref="BlazorWebView"/> that is hosting it.
		/// </remarks>
		[Browsable(false)]
		[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
		public WebView2Control WebView => _webview;
 
		private WindowsFormsDispatcher ComponentsDispatcher { get; }
 
		/// <inheritdoc cref="Control.OnCreateControl" />
		protected override void OnCreateControl()
		{
			base.OnCreateControl();
 
			StartWebViewCoreIfPossible();
		}
 
		/// <summary>
		/// Path to the host page within the application's static files. For example, <code>wwwroot\index.html</code>.
		/// This property must be set to a valid value for the Razor components to start.
		/// </summary>
		[Category("Behavior")]
		[DefaultValue(null)]
		[Description(@"Path to the host page within the application's static files. Example: wwwroot\index.html.")]
		public string? HostPage
		{
			get => _hostPage;
			set
			{
				_hostPage = value;
				OnHostPagePropertyChanged();
			}
		}
 
		/// <summary>
		/// Path for initial Blazor navigation when the Blazor component is finished loading.
		/// </summary>
		[Category("Behavior")]
		[DefaultValue("/")]
		[Description(@"Path for initial Blazor navigation when the Blazor component is finished loading.")]
		public string StartPath { get; set; } = "/";
 
		// Learn more about these methods here: https://docs.microsoft.com/en-us/dotnet/desktop/winforms/controls/defining-default-values-with-the-shouldserialize-and-reset-methods?view=netframeworkdesktop-4.8
		private void ResetHostPage() => HostPage = null;
		private bool ShouldSerializeHostPage() => !string.IsNullOrEmpty(HostPage);
 
		/// <summary>
		/// A collection of <see cref="RootComponent"/> instances that specify the Blazor <see cref="IComponent"/> types
		/// to be used directly in the specified <see cref="HostPage"/>.
		/// </summary>
		[Browsable(false)]
		[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
		public RootComponentsCollection RootComponents { get; } = new();
 
		/// <summary>
		/// Gets or sets an <see cref="IServiceProvider"/> containing services to be used by this control and also by application code.
		/// This property must be set to a valid value for the Razor components to start.
		/// </summary>
		[Browsable(false)]
		[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
		[DisallowNull]
		public IServiceProvider Services
		{
			get => _services!;
			set
			{
				_services = value;
				OnServicesPropertyChanged();
			}
		}
 
		/// <summary>
		/// Allows customizing how links are opened.
		/// By default, opens internal links in the webview and external links in an external app.
		/// </summary>
		[Category("Action")]
		[Description("Allows customizing how links are opened. By default, opens internal links in the webview and external links in an external app.")]
		public EventHandler<UrlLoadingEventArgs>? UrlLoading;
 
		/// <summary>
		/// Allows customizing the web view before it is created.
		/// </summary>
		[Category("Action")]
		[Description("Allows customizing the web view before it is created.")]
		public EventHandler<BlazorWebViewInitializingEventArgs>? BlazorWebViewInitializing;
 
		/// <summary>
		/// Allows customizing the web view after it is created.
		/// </summary>
		[Category("Action")]
		[Description("Allows customizing the web view after it is created.")]
		public EventHandler<BlazorWebViewInitializedEventArgs>? BlazorWebViewInitialized;
 
		private void OnHostPagePropertyChanged() => StartWebViewCoreIfPossible();
 
		private void OnServicesPropertyChanged() => StartWebViewCoreIfPossible();
 
		private bool RequiredStartupPropertiesSet =>
			Created &&
			_webview != null &&
			HostPage != null &&
			Services != null;
 
		private void StartWebViewCoreIfPossible()
		{
			// We never start the Blazor code in design time because it doesn't make sense to run
			// a Razor component in the designer.
			if (IsAncestorSiteInDesignMode)
			{
				return;
			}
 
			// If we don't have all the required properties, or if there's already a WebViewManager, do nothing
			if (!RequiredStartupPropertiesSet || _webviewManager != null)
			{
				return;
			}
 
			var logger = Services.GetService<ILogger<BlazorWebView>>() ?? NullLogger<BlazorWebView>.Instance;
 
			// We assume the host page is always in the root of the content directory, because it's
			// unclear there's any other use case. We can add more options later if so.
			string appRootDir;
#pragma warning disable IL3000 // 'System.Reflection.Assembly.Location.get' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.
			var entryAssemblyLocation = Assembly.GetEntryAssembly()?.Location;
#pragma warning restore IL3000
			if (!string.IsNullOrEmpty(entryAssemblyLocation))
			{
				appRootDir = Path.GetDirectoryName(entryAssemblyLocation)!;
			}
			else
			{
				appRootDir = AppContext.BaseDirectory;
			}
			var hostPageFullPath = Path.GetFullPath(Path.Combine(appRootDir, HostPage!)); // HostPage is nonnull because RequiredStartupPropertiesSet is checked above
			var contentRootDirFullPath = Path.GetDirectoryName(hostPageFullPath)!;
			var contentRootRelativePath = Path.GetRelativePath(appRootDir, contentRootDirFullPath);
			var hostPageRelativePath = Path.GetRelativePath(contentRootDirFullPath, hostPageFullPath);
 
			logger.CreatingFileProvider(contentRootDirFullPath, hostPageRelativePath);
			var fileProvider = CreateFileProvider(contentRootDirFullPath);
 
			_webviewManager = new WebView2WebViewManager(
				_webview,
				Services,
				ComponentsDispatcher,
				fileProvider,
				RootComponents.JSComponents,
				contentRootRelativePath,
				hostPageRelativePath,
				(args) => UrlLoading?.Invoke(this, args),
				(args) => BlazorWebViewInitializing?.Invoke(this, args),
				(args) => BlazorWebViewInitialized?.Invoke(this, args),
				logger);
 
			StaticContentHotReloadManager.AttachToWebViewManagerIfEnabled(_webviewManager);
 
			foreach (var rootComponent in RootComponents)
			{
				logger.AddingRootComponent(rootComponent.ComponentType.FullName ?? string.Empty, rootComponent.Selector, rootComponent.Parameters?.Count ?? 0);
 
				// Since the page isn't loaded yet, this will always complete synchronously
				_ = rootComponent.AddToWebViewManagerAsync(_webviewManager);
			}
 
			logger.StartingInitialNavigation(StartPath);
			_webviewManager.Navigate(StartPath);
		}
 
		private void HandleRootComponentsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs eventArgs)
		{
			// If we haven't initialized yet, this is a no-op
			if (_webviewManager != null)
			{
				// Dispatch because this is going to be async, and we want to catch any errors
				_ = ComponentsDispatcher.InvokeAsync(async () =>
				{
					var newItems = (eventArgs.NewItems ?? Array.Empty<object>()).Cast<RootComponent>();
					var oldItems = (eventArgs.OldItems ?? Array.Empty<object>()).Cast<RootComponent>();
 
					foreach (var item in newItems.Except(oldItems))
					{
						await item.AddToWebViewManagerAsync(_webviewManager);
					}
 
					foreach (var item in oldItems.Except(newItems))
					{
						await item.RemoveFromWebViewManagerAsync(_webviewManager);
					}
				});
			}
		}
 
		/// <summary>
		/// Creates a file provider for static assets used in the <see cref="BlazorWebView"/>. The default implementation
		/// serves files from disk. Override this method to return a custom <see cref="IFileProvider"/> to serve assets such
		/// as <c>wwwroot/index.html</c>. Call the base method and combine its return value with a <see cref="CompositeFileProvider"/>
		/// to use both custom assets and default assets.
		/// </summary>
		/// <param name="contentRootDir">The base directory to use for all requested assets, such as <c>wwwroot</c>.</param>
		/// <returns>Returns a <see cref="IFileProvider"/> for static assets.</returns>
		public virtual IFileProvider CreateFileProvider(string contentRootDir)
		{
			if (Directory.Exists(contentRootDir))
			{
				// Typical case after publishing, or if you're copying content to the bin dir in development for some nonstandard reason
				return new PhysicalFileProvider(contentRootDir);
			}
			else
			{
				// Typical case in development, as the files come from Microsoft.AspNetCore.Components.WebView.StaticContentProvider
				// instead and aren't copied to the bin dir
				return new NullFileProvider();
			}
		}
 
		/// <inheritdoc cref="Control.Dispose(bool)" />
		protected override void Dispose(bool disposing)
		{
			if (disposing && _webviewManager is not null)
			{
				// Await disposal of this component's contents so that user-written disposal logic and
				// Razor component disposal logic will complete first. Then call base.Dispose(), which will dispose
				// the WebView2 control. This order is critical because once the WebView2 is disposed it will prevent
				// Razor component code from working because it requires the WebView to exist.
				// Dispatch because this is going to be async, and we want to catch any errors.
				_ = ComponentsDispatcher.InvokeAsync(async () =>
				{
					await _webviewManager.DisposeAsync();
					base.Dispose(disposing);
				});
				return;
			}
 
			base.Dispose(disposing);
		}
 
		/// <inheritdoc cref="Control.CreateControlsInstance" />
		protected override ControlCollection CreateControlsInstance()
		{
			return new BlazorWebViewControlCollection(this);
		}
 
		/// <summary>
		/// Calls the specified <paramref name="workItem"/> asynchronously and passes in the scoped services available to Razor components.
		/// </summary>
		/// <param name="workItem">The action to call.</param>
		/// <returns>Returns a <see cref="Task"/> representing <c>true</c> if the <paramref name="workItem"/> was called, or <c>false</c> if it was not called because Blazor is not currently running.</returns>
		/// <exception cref="ArgumentNullException">Thrown if <paramref name="workItem"/> is <c>null</c>.</exception>
		public virtual async Task<bool> TryDispatchAsync(Action<IServiceProvider> workItem)
		{
			ArgumentNullException.ThrowIfNull(workItem);
			if (_webviewManager is null)
			{
				return false;
			}
 
			return await _webviewManager.TryDispatchAsync(workItem);
		}
 
		/// <summary>
		/// Custom control collection that ensures that only the owning <see cref="BlazorWebView"/> can add
		/// controls to it.
		/// </summary>
		private sealed class BlazorWebViewControlCollection : ControlCollection
		{
			public BlazorWebViewControlCollection(BlazorWebView owner) : base(owner)
			{
			}
 
			/// <summary>
			/// This is the only API we use; everything else is blocked.
			/// </summary>
			/// <param name="value"></param>
			internal void AddInternal(Control value) => base.Add(value);
 
			// Everything below is overridden to protect the control collection as read-only.
			public override bool IsReadOnly => true;
 
			public override void Add(Control? value) => throw new NotSupportedException();
			public override void Clear() => throw new NotSupportedException();
			public override void Remove(Control? value) => throw new NotSupportedException();
			public override void SetChildIndex(Control child, int newIndex) => throw new NotSupportedException();
		}
	}
}