diff --git a/Microsoft.Maui.sln b/Microsoft.Maui.sln index 4f5ce334b08a..4f4473863ee5 100644 --- a/Microsoft.Maui.sln +++ b/Microsoft.Maui.sln @@ -200,6 +200,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWebView.DeviceTes EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SharedSource", "SharedSource", "{4F2926C8-43AB-4328-A735-D9EAD699F81D}" ProjectSection(SolutionItems) = preProject + src\BlazorWebView\src\SharedSource\ExternalLinkNavigationEventArgs.cs = src\BlazorWebView\src\SharedSource\ExternalLinkNavigationEventArgs.cs + src\BlazorWebView\src\SharedSource\ExternalLinkNavigationPolicy.cs = src\BlazorWebView\src\SharedSource\ExternalLinkNavigationPolicy.cs src\BlazorWebView\src\SharedSource\QueryStringHelper.cs = src\BlazorWebView\src\SharedSource\QueryStringHelper.cs src\BlazorWebView\src\SharedSource\WebView2WebViewManager.cs = src\BlazorWebView\src\SharedSource\WebView2WebViewManager.cs EndProjectSection diff --git a/src/BlazorWebView/src/Maui/Android/AndroidWebKitWebViewManager.cs b/src/BlazorWebView/src/Maui/Android/AndroidWebKitWebViewManager.cs index 6f70f0cb7a11..d602ded634ec 100644 --- a/src/BlazorWebView/src/Maui/Android/AndroidWebKitWebViewManager.cs +++ b/src/BlazorWebView/src/Maui/Android/AndroidWebKitWebViewManager.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.FileProviders; using AWebView = Android.Webkit.WebView; +using AUri = Android.Net.Uri; namespace Microsoft.AspNetCore.Components.WebView.Maui { @@ -17,9 +18,8 @@ public class AndroidWebKitWebViewManager : WebViewManager // Using an IP address means that WebView doesn't wait for any DNS resolution, // making it substantially faster. Note that this isn't real HTTP traffic, since // we intercept all the requests within this origin. - private const string AppOrigin = "https://0.0.0.0/"; - private static readonly Android.Net.Uri AndroidAppOriginUri = Android.Net.Uri.Parse(AppOrigin)!; - private readonly BlazorWebViewHandler _blazorWebViewHandler; + private static readonly string AppOrigin = $"https://{BlazorWebView.AppHostAddress}/"; + private static readonly AUri AndroidAppOriginUri = AUri.Parse(AppOrigin)!; private readonly AWebView _webview; /// @@ -30,11 +30,10 @@ public class AndroidWebKitWebViewManager : WebViewManager /// A instance that can marshal calls to the required thread or sync context. /// Provides static content to the webview. /// Path to the host page within the . - public AndroidWebKitWebViewManager(BlazorWebViewHandler blazorMauiWebViewHandler, AWebView webview, IServiceProvider services, Dispatcher dispatcher, IFileProvider fileProvider, JSComponentConfigurationStore jsComponents, string hostPageRelativePath) + public AndroidWebKitWebViewManager(AWebView webview!!, IServiceProvider services, Dispatcher dispatcher, IFileProvider fileProvider, JSComponentConfigurationStore jsComponents, string hostPageRelativePath) : base(services, dispatcher, new Uri(AppOrigin), fileProvider, jsComponents, hostPageRelativePath) { - _blazorWebViewHandler = blazorMauiWebViewHandler ?? throw new ArgumentNullException(nameof(blazorMauiWebViewHandler)); - _webview = webview ?? throw new ArgumentNullException(nameof(webview)); + _webview = webview; } /// diff --git a/src/BlazorWebView/src/Maui/Android/BlazorWebChromeClient.cs b/src/BlazorWebView/src/Maui/Android/BlazorWebChromeClient.cs new file mode 100644 index 000000000000..c42b6fb8c4fa --- /dev/null +++ b/src/BlazorWebView/src/Maui/Android/BlazorWebChromeClient.cs @@ -0,0 +1,26 @@ + +using Android.Content; +using Android.Net; +using Android.OS; +using Android.Webkit; + +namespace Microsoft.AspNetCore.Components.WebView.Maui +{ + class BlazorWebChromeClient : WebChromeClient + { + public override bool OnCreateWindow(Android.Webkit.WebView? view, bool isDialog, bool isUserGesture, Message? resultMsg) + { + if (view?.Context is not null) + { + // Intercept _blank target tags to always open in device browser + // regardless of ExternalLinkMode.OpenInWebview + var requestUrl = view.GetHitTestResult().Extra; + var intent = new Intent(Intent.ActionView, Uri.Parse(requestUrl)); + view.Context.StartActivity(intent); + } + + // We don't actually want to create a new WebView window so we just return false + return false; + } + } +} diff --git a/src/BlazorWebView/src/Maui/Android/BlazorWebViewHandler.Android.cs b/src/BlazorWebView/src/Maui/Android/BlazorWebViewHandler.Android.cs index 2a398922e5ef..df9fc286cafc 100644 --- a/src/BlazorWebView/src/Maui/Android/BlazorWebViewHandler.Android.cs +++ b/src/BlazorWebView/src/Maui/Android/BlazorWebViewHandler.Android.cs @@ -1,14 +1,7 @@ using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; using Android.Webkit; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Primitives; +using Microsoft.Maui; using Microsoft.Maui.Handlers; using static Android.Views.ViewGroup; using Path = System.IO.Path; @@ -31,6 +24,9 @@ protected override BlazorAndroidWebView CreatePlatformView() #pragma warning restore 618 }; + // To allow overriding ExternalLinkMode.InsecureOpenInWebView and open links in browser with a _blank target + blazorAndroidWebView.Settings.SetSupportMultipleWindows(true); + BlazorAndroidWebView.SetWebContentsDebuggingEnabled(enabled: true); if (blazorAndroidWebView.Settings != null) @@ -93,7 +89,7 @@ private void StartWebViewCoreIfPossible() var fileProvider = VirtualView.CreateFileProvider(contentRootDir); - _webviewManager = new AndroidWebKitWebViewManager(this, PlatformView, Services!, ComponentsDispatcher, fileProvider, VirtualView.JSComponents, hostPageRelativePath); + _webviewManager = new AndroidWebKitWebViewManager(PlatformView, Services!, ComponentsDispatcher, fileProvider, VirtualView.JSComponents, hostPageRelativePath); if (RootComponents != null) { @@ -116,6 +112,6 @@ protected virtual WebViewClient GetWebViewClient() => new WebKitWebViewClient(this); protected virtual WebChromeClient GetWebChromeClient() => - new WebChromeClient(); + new BlazorWebChromeClient(); } } diff --git a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs index a4ce5d1451ac..74dab83f61fd 100644 --- a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs +++ b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs @@ -1,44 +1,67 @@ using System; +using Android.Content; using Android.Runtime; using Android.Webkit; using AWebView = Android.Webkit.WebView; +using AUri = Android.Net.Uri; namespace Microsoft.AspNetCore.Components.WebView.Maui { internal class WebKitWebViewClient : WebViewClient { - private const string AppOrigin = "https://0.0.0.0/"; + // Using an IP address means that WebView doesn't wait for any DNS resolution, + // making it substantially faster. Note that this isn't real HTTP traffic, since + // we intercept all the requests within this origin. + private static readonly string AppOrigin = $"https://{BlazorWebView.AppHostAddress}/"; private readonly BlazorWebViewHandler? _webViewHandler; - public WebKitWebViewClient(BlazorWebViewHandler webViewHandler) + public WebKitWebViewClient(BlazorWebViewHandler webViewHandler!!) { - _webViewHandler = webViewHandler ?? throw new ArgumentNullException(nameof(webViewHandler)); + _webViewHandler = webViewHandler; } protected WebKitWebViewClient(IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) { // This constructor is called whenever the .NET proxy was disposed, and it was recreated by Java. It also // happens when overridden methods are called between execution of this constructor and the one above. - // because of these facts, we have to check - // all methods below for null field references and properties. + // because of these facts, we have to check all methods below for null field references and properties. } public override bool ShouldOverrideUrlLoading(AWebView? view, IWebResourceRequest? request) { - // handle redirects to the app custom scheme by reloading the url in the view. - // otherwise they will be blocked by Android. + // Handle redirects to the app custom scheme by reloading the URL in the view. + // Handle navigation to external URLs using the system browser, unless overriden. var requestUri = request?.Url?.ToString(); - if (requestUri != null && view != null && - request != null && request.IsRedirect && request.IsForMainFrame) + if (Uri.TryCreate(requestUri, UriKind.RelativeOrAbsolute, out var uri)) { - var uri = new Uri(requestUri); - if (uri.Host == "0.0.0.0") + if (uri.Host == BlazorWebView.AppHostAddress && + view is not null && + request is not null && + request.IsRedirect && + request.IsForMainFrame) { view.LoadUrl(uri.ToString()); return true; } + else if (uri.Host != BlazorWebView.AppHostAddress && _webViewHandler != null) + { + var callbackArgs = new ExternalLinkNavigationEventArgs(uri); + _webViewHandler.ExternalNavigationStarting?.Invoke(callbackArgs); + + if (callbackArgs.ExternalLinkNavigationPolicy == ExternalLinkNavigationPolicy.OpenInExternalBrowser) + { + var intent = new Intent(Intent.ActionView, AUri.Parse(requestUri)); + _webViewHandler.Context.StartActivity(intent); + } + + if (callbackArgs.ExternalLinkNavigationPolicy != ExternalLinkNavigationPolicy.InsecureOpenInWebView) + { + return true; + } + } } + return base.ShouldOverrideUrlLoading(view, request); } @@ -167,9 +190,9 @@ private class JavaScriptValueCallback : Java.Lang.Object, IValueCallback { private readonly Action _callback; - public JavaScriptValueCallback(Action callback) + public JavaScriptValueCallback(Action callback!!) { - _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + _callback = callback; } public void OnReceiveValue(Java.Lang.Object? value) diff --git a/src/BlazorWebView/src/Maui/BlazorWebView.cs b/src/BlazorWebView/src/Maui/BlazorWebView.cs index 1021de60926e..8fcb8bbd1142 100644 --- a/src/BlazorWebView/src/Maui/BlazorWebView.cs +++ b/src/BlazorWebView/src/Maui/BlazorWebView.cs @@ -6,6 +6,8 @@ namespace Microsoft.AspNetCore.Components.WebView.Maui { public class BlazorWebView : Microsoft.Maui.Controls.View, IBlazorWebView { + internal const string AppHostAddress = "0.0.0.0"; + private readonly JSComponentConfigurationStore _jSComponents = new(); public BlazorWebView() @@ -19,11 +21,19 @@ public BlazorWebView() public RootComponentsCollection RootComponents { get; } + /// + public event EventHandler? ExternalNavigationStarting; + /// public virtual IFileProvider CreateFileProvider(string contentRootDir) { // Call into the platform-specific code to get that platform's asset file provider return ((BlazorWebViewHandler)(Handler!)).CreateFileProvider(contentRootDir); } + + internal void NotifyExternalNavigationStarting(ExternalLinkNavigationEventArgs args) + { + ExternalNavigationStarting?.Invoke(this, args); + } } } diff --git a/src/BlazorWebView/src/Maui/BlazorWebViewHandler.cs b/src/BlazorWebView/src/Maui/BlazorWebViewHandler.cs index ebe9dfe6e724..631587316be9 100644 --- a/src/BlazorWebView/src/Maui/BlazorWebViewHandler.cs +++ b/src/BlazorWebView/src/Maui/BlazorWebViewHandler.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System; +using System.Linq; +using Microsoft.AspNetCore.Components.WebView; using Microsoft.Maui; using Microsoft.Maui.Handlers; @@ -6,10 +8,11 @@ namespace Microsoft.AspNetCore.Components.WebView.Maui { public partial class BlazorWebViewHandler { - public static PropertyMapper BlazorWebViewMapper = new(ViewHandler.ViewMapper) + public static readonly PropertyMapper BlazorWebViewMapper = new(ViewMapper) { [nameof(IBlazorWebView.HostPage)] = MapHostPage, [nameof(IBlazorWebView.RootComponents)] = MapRootComponents, + [nameof(IBlazorWebView.ExternalNavigationStarting)] = MapNotifyExternalNavigationStarting, }; public BlazorWebViewHandler() : base(BlazorWebViewMapper) @@ -40,8 +43,19 @@ public static void MapRootComponents(BlazorWebViewHandler handler, IBlazorWebVie #endif } + public static void MapNotifyExternalNavigationStarting(BlazorWebViewHandler handler, IBlazorWebView webView) + { +#if !NETSTANDARD + if (webView is BlazorWebView bwv) + { + handler.ExternalNavigationStarting = bwv.NotifyExternalNavigationStarting; + } +#endif + } + #if !NETSTANDARD private string? HostPage { get; set; } + internal Action? ExternalNavigationStarting; private RootComponentsCollection? _rootComponents; private RootComponentsCollection? RootComponents diff --git a/src/BlazorWebView/src/Maui/IBlazorWebView.cs b/src/BlazorWebView/src/Maui/IBlazorWebView.cs index cf97f062b153..d566b842ed6b 100644 --- a/src/BlazorWebView/src/Maui/IBlazorWebView.cs +++ b/src/BlazorWebView/src/Maui/IBlazorWebView.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Components.Web; +using System; +using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.FileProviders; using Microsoft.Maui; @@ -10,6 +11,12 @@ public interface IBlazorWebView : IView RootComponentsCollection RootComponents { get; } JSComponentConfigurationStore JSComponents { get; } + /// + /// Allows customizing how external links are opened. + /// Opens external links in the system browser by default. + /// + event EventHandler? ExternalNavigationStarting; + /// /// Creates a file provider for static assets used in the . The default implementation /// serves files from a platform-specific location. Override this method to return a custom to serve assets such diff --git a/src/BlazorWebView/src/Maui/Windows/BlazorWebViewHandler.Windows.cs b/src/BlazorWebView/src/Maui/Windows/BlazorWebViewHandler.Windows.cs index 3c272ec858c6..2e8e2eab9d7f 100644 --- a/src/BlazorWebView/src/Maui/Windows/BlazorWebViewHandler.Windows.cs +++ b/src/BlazorWebView/src/Maui/Windows/BlazorWebViewHandler.Windows.cs @@ -57,8 +57,16 @@ private void StartWebViewCoreIfPossible() var hostPageRelativePath = Path.GetRelativePath(contentRootDir, HostPage!); var fileProvider = VirtualView.CreateFileProvider(contentRootDir); - - _webviewManager = new WinUIWebViewManager(PlatformView, Services!, ComponentsDispatcher, fileProvider, VirtualView.JSComponents, hostPageRelativePath, contentRootDir); + + _webviewManager = new WinUIWebViewManager( + PlatformView, + Services!, + ComponentsDispatcher, + fileProvider, + VirtualView.JSComponents, + hostPageRelativePath, + contentRootDir, + this); if (RootComponents != null) { diff --git a/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs b/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs index 9a1378614013..cc8b32ce1a50 100644 --- a/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs +++ b/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs @@ -22,8 +22,16 @@ public class WinUIWebViewManager : WebView2WebViewManager private readonly string _hostPageRelativePath; private readonly string _contentRootDir; - public WinUIWebViewManager(WebView2Control webview, IServiceProvider services, Dispatcher dispatcher, IFileProvider fileProvider, JSComponentConfigurationStore jsComponents, string hostPageRelativePath, string contentRootDir) - : base(webview, services, dispatcher, fileProvider, jsComponents, hostPageRelativePath) + public WinUIWebViewManager( + WebView2Control webview, + IServiceProvider services, + Dispatcher dispatcher, + IFileProvider fileProvider, + JSComponentConfigurationStore jsComponents, + string hostPageRelativePath, + string contentRootDir, + BlazorWebViewHandler webViewHandler) + : base(webview, services, dispatcher, fileProvider, jsComponents, hostPageRelativePath, webViewHandler) { _webview = webview; _hostPageRelativePath = hostPageRelativePath; diff --git a/src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs b/src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs index 34b17b3d556c..28ba8310f9df 100644 --- a/src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs +++ b/src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs @@ -22,7 +22,7 @@ public partial class BlazorWebViewHandler : ViewHandler decisionHandler) { + var callbackArgs = new ExternalLinkNavigationEventArgs(new Uri(navigationAction.Request.Url.ToString())); + // TargetFrame is null for navigation to a new window (`_blank`) if (navigationAction.TargetFrame is null) { - // Open in a new browser window - UIApplication.SharedApplication.OpenUrl(navigationAction.Request.Url); + // Open in a new browser window regardless of ExternalLinkNavigationPolicy + callbackArgs.ExternalLinkNavigationPolicy = ExternalLinkNavigationPolicy.OpenInExternalBrowser; + } + else if (callbackArgs.Uri.Host == BlazorWebView.AppHostAddress) + { + callbackArgs.ExternalLinkNavigationPolicy = ExternalLinkNavigationPolicy.InsecureOpenInWebView; + } + else + { + _webView.ExternalNavigationStarting?.Invoke(callbackArgs); + } + + var url = new NSUrl(callbackArgs.Uri.ToString()); + + if (callbackArgs.ExternalLinkNavigationPolicy == ExternalLinkNavigationPolicy.OpenInExternalBrowser) + { + UIApplication.SharedApplication.OpenUrl(url); + } + + if (callbackArgs.ExternalLinkNavigationPolicy != ExternalLinkNavigationPolicy.InsecureOpenInWebView) + { + // Cancel any further navigation as we've either opened the link in the external browser + // or canceled the underlying navigation action. decisionHandler(WKNavigationActionPolicy.Cancel); return; } - if (navigationAction.TargetFrame.MainFrame) + if (navigationAction.TargetFrame!.MainFrame) { - _currentUri = navigationAction.Request.Url; + _currentUri = url; } decisionHandler(WKNavigationActionPolicy.Allow); @@ -204,7 +225,7 @@ public override void DidReceiveServerRedirectForProvisionalNavigation(WKWebView { // We need to intercept the redirects to the app scheme because Safari will block them. // We will handle these redirects through the Navigation Manager. - if (_currentUri?.Host == "0.0.0.0") + if (_currentUri?.Host == BlazorWebView.AppHostAddress) { var uri = _currentUri; _currentUri = null; diff --git a/src/BlazorWebView/src/SharedSource/ExternalLinkNavigationEventArgs.cs b/src/BlazorWebView/src/SharedSource/ExternalLinkNavigationEventArgs.cs new file mode 100644 index 000000000000..cfd0aab807f8 --- /dev/null +++ b/src/BlazorWebView/src/SharedSource/ExternalLinkNavigationEventArgs.cs @@ -0,0 +1,30 @@ +using System; + +namespace Microsoft.AspNetCore.Components.WebView +{ + /// + /// Used to provide information about a link (]]>) clicked within a Blazor WebView. + /// + /// Anchor tags with target="_blank" will always open in the default + /// browser and the ExternalNavigationStarting event won't be called. + /// + public class ExternalLinkNavigationEventArgs : EventArgs + { + public ExternalLinkNavigationEventArgs(Uri uri) + { + Uri = uri; + } + + /// + /// External URI to be navigated to. + /// + public Uri Uri { get; } + + /// + /// The policy to use when opening external links from the webview. + /// + /// Defaults to opening links in an external browser. + /// + public ExternalLinkNavigationPolicy ExternalLinkNavigationPolicy { get; set; } = ExternalLinkNavigationPolicy.OpenInExternalBrowser; + } +} diff --git a/src/BlazorWebView/src/SharedSource/ExternalLinkNavigationPolicy.cs b/src/BlazorWebView/src/SharedSource/ExternalLinkNavigationPolicy.cs new file mode 100644 index 000000000000..36e1fe4485ee --- /dev/null +++ b/src/BlazorWebView/src/SharedSource/ExternalLinkNavigationPolicy.cs @@ -0,0 +1,28 @@ +namespace Microsoft.AspNetCore.Components.WebView +{ + /// + /// External link handling policy for anchor tags ]]> within a Blazor WebView. + /// + /// Anchor tags with target="_blank" will always open in the default + /// browser and the ExternalNavigationStarting event won't be called. + /// + public enum ExternalLinkNavigationPolicy + { + /// + /// Allows navigation to external links using the system default browser. + /// This is the default navigation policy. + /// + OpenInExternalBrowser, + + /// + /// Allows navigation to external links within the Blazor WebView. + /// This navigation policy can introduce security concerns and should not be enabled unless you can ensure all external links are fully trusted. + /// + InsecureOpenInWebView, + + /// + /// Cancels the current navigation attempt to an external link. + /// + CancelNavigation + } +} diff --git a/src/BlazorWebView/src/SharedSource/WebView2WebViewManager.cs b/src/BlazorWebView/src/SharedSource/WebView2WebViewManager.cs index 67c0caa51161..3dcc70589962 100644 --- a/src/BlazorWebView/src/SharedSource/WebView2WebViewManager.cs +++ b/src/BlazorWebView/src/SharedSource/WebView2WebViewManager.cs @@ -13,20 +13,25 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebView; using Microsoft.Extensions.FileProviders; #if WEBVIEW2_WINFORMS +using System.Diagnostics; using Microsoft.Web.WebView2; using Microsoft.Web.WebView2.Core; using WebView2Control = Microsoft.Web.WebView2.WinForms.WebView2; #elif WEBVIEW2_WPF +using System.Diagnostics; using Microsoft.Web.WebView2; using Microsoft.Web.WebView2.Core; using WebView2Control = Microsoft.Web.WebView2.Wpf.WebView2; #elif WEBVIEW2_MAUI +using Microsoft.AspNetCore.Components.WebView.Maui; using Microsoft.Web.WebView2.Core; using WebView2Control = Microsoft.UI.Xaml.Controls.WebView2; using System.Runtime.InteropServices.WindowsRuntime; using Windows.Storage.Streams; +using Launcher = Windows.System.Launcher; #endif namespace Microsoft.AspNetCore.Components.WebView.WebView2 @@ -40,14 +45,17 @@ public class WebView2WebViewManager : WebViewManager // Using an IP address means that WebView2 doesn't wait for any DNS resolution, // making it substantially faster. Note that this isn't real HTTP traffic, since // we intercept all the requests within this origin. - private protected const string AppOrigin = "https://0.0.0.0/"; + internal static readonly string AppHostAddress = "0.0.0.0"; + protected static readonly string AppOrigin = $"https://{AppHostAddress}/"; private readonly WebView2Control _webview; private readonly Task _webviewReadyTask; #if WEBVIEW2_WINFORMS || WEBVIEW2_WPF private protected CoreWebView2Environment _coreWebView2Environment; + private readonly Action _externalNavigationStarting; #elif WEBVIEW2_MAUI private protected CoreWebView2Environment? _coreWebView2Environment; + private readonly BlazorWebViewHandler _blazorWebViewHandler; #endif /// @@ -58,10 +66,28 @@ public class WebView2WebViewManager : WebViewManager /// A instance that can marshal calls to the required thread or sync context. /// Provides static content to the webview. /// Path to the host page within the . - public WebView2WebViewManager(WebView2Control webview, IServiceProvider services, Dispatcher dispatcher, IFileProvider fileProvider, JSComponentConfigurationStore jsComponents, string hostPageRelativePath) + public WebView2WebViewManager( + WebView2Control webview!!, + IServiceProvider services, + Dispatcher dispatcher, + IFileProvider fileProvider, + JSComponentConfigurationStore jsComponents, + string hostPageRelativePath, +#if WEBVIEW2_WINFORMS || WEBVIEW2_WPF + Action externalNavigationStarting +#elif WEBVIEW2_MAUI + BlazorWebViewHandler blazorWebViewHandler +#endif + ) : base(services, dispatcher, new Uri(AppOrigin), fileProvider, jsComponents, hostPageRelativePath) { - _webview = webview ?? throw new ArgumentNullException(nameof(webview)); + _webview = webview; + +#if WEBVIEW2_WINFORMS || WEBVIEW2_WPF + _externalNavigationStarting = externalNavigationStarting; +#elif WEBVIEW2_MAUI + _blazorWebViewHandler = blazorWebViewHandler; +#endif // Unfortunately the CoreWebView2 can only be instantiated asynchronously. // We want the external API to behave as if initalization is synchronous, @@ -94,11 +120,15 @@ private async Task InitializeWebView2() ApplyDefaultWebViewSettings(); _webview.CoreWebView2.AddWebResourceRequestedFilter($"{AppOrigin}*", CoreWebView2WebResourceContext.All); + _webview.CoreWebView2.WebResourceRequested += async (s, eventArgs) => { await HandleWebResourceRequest(eventArgs); }; + _webview.CoreWebView2.NavigationStarting += CoreWebView2_NavigationStarting; + _webview.CoreWebView2.NewWindowRequested += CoreWebView2_NewWindowRequested; + // The code inside blazor.webview.js is meant to be agnostic to specific webview technologies, // so the following is an adaptor from blazor.webview.js conventions to WebView2 APIs await _webview.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(@" @@ -152,6 +182,52 @@ protected virtual void QueueBlazorStart() { } + private void CoreWebView2_NavigationStarting(object sender, CoreWebView2NavigationStartingEventArgs args) + { + if (Uri.TryCreate(args.Uri, UriKind.RelativeOrAbsolute, out var uri) && uri.Host != AppHostAddress) + { + var callbackArgs = new ExternalLinkNavigationEventArgs(uri); + +#if WEBVIEW2_WINFORMS || WEBVIEW2_WPF + _externalNavigationStarting?.Invoke(callbackArgs); +#elif WEBVIEW2_MAUI + _blazorWebViewHandler.ExternalNavigationStarting?.Invoke(callbackArgs); +#endif + + if (callbackArgs.ExternalLinkNavigationPolicy == ExternalLinkNavigationPolicy.OpenInExternalBrowser) + { + LaunchUriInExternalBrowser(uri); + } + + args.Cancel = callbackArgs.ExternalLinkNavigationPolicy != ExternalLinkNavigationPolicy.InsecureOpenInWebView; + } + } + + private void CoreWebView2_NewWindowRequested(object sender, CoreWebView2NewWindowRequestedEventArgs args) + { + // Intercept _blank target tags to always open in device browser. + // The ExternalLinkCallback is not invoked. + if (Uri.TryCreate(args.Uri, UriKind.RelativeOrAbsolute, out var uri)) + { + LaunchUriInExternalBrowser(uri); + args.Handled = true; + } + } + + private void LaunchUriInExternalBrowser(Uri uri) + { +#if WEBVIEW2_WINFORMS || WEBVIEW2_WPF + using (var launchBrowser = new Process()) + { + launchBrowser.StartInfo.UseShellExecute = true; + launchBrowser.StartInfo.FileName = uri.ToString(); + launchBrowser.Start(); + } +#elif WEBVIEW2_MAUI + _ = Launcher.LaunchUriAsync(uri); +#endif + } + private protected static string GetHeaderString(IDictionary headers) => string.Join(Environment.NewLine, headers.Select(kvp => $"{kvp.Key}: {kvp.Value}")); diff --git a/src/BlazorWebView/src/WindowsForms/BlazorWebView.cs b/src/BlazorWebView/src/WindowsForms/BlazorWebView.cs index 2df9c81f4e7b..07607012a5c5 100644 --- a/src/BlazorWebView/src/WindowsForms/BlazorWebView.cs +++ b/src/BlazorWebView/src/WindowsForms/BlazorWebView.cs @@ -111,6 +111,10 @@ public IServiceProvider Services } } + [Category("Action")] + [Description("Allows customizing how external links are opened. Opens external links in the system browser by default.")] + public EventHandler ExternalNavigationStarting; + private void OnHostPagePropertyChanged() => StartWebViewCoreIfPossible(); private void OnServicesPropertyChanged() => StartWebViewCoreIfPossible(); @@ -154,7 +158,14 @@ private void StartWebViewCoreIfPossible() var fileProvider = CreateFileProvider(contentRootDirFullPath); - _webviewManager = new WebView2WebViewManager(_webview, Services, ComponentsDispatcher, fileProvider, RootComponents.JSComponents, hostPageRelativePath); + _webviewManager = new WebView2WebViewManager( + _webview, + Services, + ComponentsDispatcher, + fileProvider, + RootComponents.JSComponents, + hostPageRelativePath, + (args) => ExternalNavigationStarting?.Invoke(this, args)); foreach (var rootComponent in RootComponents) { diff --git a/src/BlazorWebView/src/Wpf/BlazorWebView.cs b/src/BlazorWebView/src/Wpf/BlazorWebView.cs index a0402ea446b9..b5b81c700de7 100644 --- a/src/BlazorWebView/src/Wpf/BlazorWebView.cs +++ b/src/BlazorWebView/src/Wpf/BlazorWebView.cs @@ -48,9 +48,17 @@ public class BlazorWebView : Control, IAsyncDisposable propertyType: typeof(IServiceProvider), ownerType: typeof(BlazorWebView), typeMetadata: new PropertyMetadata(OnServicesPropertyChanged)); + + /// + /// The backing store for the property. + /// + public static readonly DependencyProperty ExternalNavigationStartingProperty = DependencyProperty.Register( + name: nameof(ExternalNavigationStarting), + propertyType: typeof(EventHandler), + ownerType: typeof(BlazorWebView)); #endregion - private const string webViewTemplateChildName = "WebView"; + private const string WebViewTemplateChildName = "WebView"; private WebView2Control _webview; private WebView2WebViewManager _webviewManager; private bool _isDisposed; @@ -67,7 +75,7 @@ public BlazorWebView() Template = new ControlTemplate { - VisualTree = new FrameworkElementFactory(typeof(WebView2Control), webViewTemplateChildName) + VisualTree = new FrameworkElementFactory(typeof(WebView2Control), WebViewTemplateChildName) }; } @@ -98,6 +106,16 @@ public string HostPage public RootComponentsCollection RootComponents => (RootComponentsCollection)GetValue(RootComponentsProperty); + /// + /// Allows customizing how external links are opened. + /// Opens external links in the system browser by default. + /// + public EventHandler ExternalNavigationStarting + { + get => (EventHandler)GetValue(ExternalNavigationStartingProperty); + set => SetValue(ExternalNavigationStartingProperty, value); + } + /// /// Gets or sets an containing services to be used by this control and also by application code. /// This property must be set to a valid value for the Blazor components to start. @@ -131,7 +149,7 @@ public override void OnApplyTemplate() if (_webview == null) { - _webview = (WebView2Control)GetTemplateChild(webViewTemplateChildName); + _webview = (WebView2Control)GetTemplateChild(WebViewTemplateChildName); StartWebViewCoreIfPossible(); } } @@ -171,7 +189,15 @@ private void StartWebViewCoreIfPossible() var fileProvider = CreateFileProvider(contentRootDirFullPath); - _webviewManager = new WebView2WebViewManager(_webview, Services, ComponentsDispatcher, fileProvider, RootComponents.JSComponents, hostPageRelativePath); + _webviewManager = new WebView2WebViewManager( + _webview, + Services, + ComponentsDispatcher, + fileProvider, + RootComponents.JSComponents, + hostPageRelativePath, + (args) => ExternalNavigationStarting?.Invoke(this, args)); + foreach (var rootComponent in RootComponents) { // Since the page isn't loaded yet, this will always complete synchronously