File: Handlers\HybridWebView\HybridWebViewHandler.cs
Web Access
Project: src\src\Core\src\Core.csproj (Microsoft.Maui)
#if __IOS__ || MACCATALYST
using PlatformView = WebKit.WKWebView;
#elif MONOANDROID
using PlatformView = Android.Webkit.WebView;
#elif WINDOWS
using PlatformView = Microsoft.UI.Xaml.Controls.WebView2;
#elif TIZEN
using PlatformView = Tizen.NUI.BaseComponents.View;
#elif (NETSTANDARD || !PLATFORM) || (NET6_0_OR_GREATER && !IOS && !ANDROID && !TIZEN)
using PlatformView = System.Object;
#endif
#if __ANDROID__
using Android.Webkit;
#elif __IOS__
using WebKit;
#endif
using System.IO;
using System.Threading.Tasks;
using Microsoft.Maui.Storage;
using System;
using System.Collections.Concurrent;
using System.Threading;
using Microsoft.Maui.Devices;
using System.Text.RegularExpressions;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Maui.Hosting;
using System.Collections.Specialized;
using System.Text.Json.Serialization;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.Maui.Handlers
{
	[RequiresUnreferencedCode(DynamicFeatures)]
#if !NETSTANDARD
	[RequiresDynamicCode(DynamicFeatures)]
#endif
	public partial class HybridWebViewHandler : IHybridWebViewHandler
	{
		internal const string DynamicFeatures = "HybridWebView uses dynamic System.Text.Json serialization features.";
		internal const string NotSupportedMessage = DynamicFeatures + " Enable the $(MauiHybridWebViewSupported) property in your .csproj file to use in a trimming unsafe manner.";
 
		// Using an IP address means that the web view 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 AppHostAddress = "0.0.0.1";
 
		private static readonly string AppHostScheme =
#if IOS || MACCATALYST
			"app";
#else
			"https";
#endif
 
		/// <summary>
		/// Gets the application's base URI. Defaults to <c>https://0.0.0.1/</c> on Windows and Android,
		/// and <c>app://0.0.0.1/</c> on iOS and MacCatalyst (because <c>https</c> is reserved).
		/// </summary>
		internal static readonly string AppOrigin = $"{AppHostScheme}://{AppHostAddress}/";
 
		internal static readonly Uri AppOriginUri = new(AppOrigin);
 
		internal const string InvokeDotNetPath = "__hwvInvokeDotNet";
 
		public static IPropertyMapper<IHybridWebView, IHybridWebViewHandler> Mapper = new PropertyMapper<IHybridWebView, IHybridWebViewHandler>(ViewHandler.ViewMapper)
		{
		};
 
		public static CommandMapper<IHybridWebView, IHybridWebViewHandler> CommandMapper = new(ViewCommandMapper)
		{
			[nameof(IHybridWebView.EvaluateJavaScriptAsync)] = MapEvaluateJavaScriptAsync,
			[nameof(IHybridWebView.InvokeJavaScriptAsync)] = MapInvokeJavaScriptAsync,
			[nameof(IHybridWebView.SendRawMessage)] = MapSendRawMessage,
		};
 
		public HybridWebViewHandler() : base(Mapper, CommandMapper)
		{
		}
 
		public HybridWebViewHandler(IPropertyMapper? mapper = null, CommandMapper? commandMapper = null)
			: base(mapper ?? Mapper, commandMapper ?? CommandMapper)
		{
		}
 
		IHybridWebView IHybridWebViewHandler.VirtualView => VirtualView;
 
		PlatformView IHybridWebViewHandler.PlatformView => PlatformView;
 
		internal HybridWebViewDeveloperTools DeveloperTools => MauiContext?.Services.GetService<HybridWebViewDeveloperTools>() ?? new HybridWebViewDeveloperTools();
 
		private const string InvokeJavaScriptThrowsExceptionsSwitch = "HybridWebView.InvokeJavaScriptThrowsExceptions";
 
		// TODO: .NET 10 flip the default to true for .NET 10
		private static bool IsInvokeJavaScriptThrowsExceptionsEnabled =>
			AppContext.TryGetSwitch(InvokeJavaScriptThrowsExceptionsSwitch, out var enabled) && enabled;
 
		void MessageReceived(string rawMessage)
		{
			if (string.IsNullOrEmpty(rawMessage))
			{
				throw new ArgumentException($"The raw message cannot be null or empty.", nameof(rawMessage));
			}
#if !NETSTANDARD2_0
			var indexOfPipe = rawMessage.IndexOf('|', StringComparison.Ordinal);
#else
			var indexOfPipe = rawMessage.IndexOf("|", StringComparison.Ordinal);
#endif
			if (indexOfPipe == -1)
			{
				throw new ArgumentException($"The raw message must contain a pipe character ('|').", nameof(rawMessage));
			}
 
			var messageType = rawMessage.Substring(0, indexOfPipe);
			var messageContent = rawMessage.Substring(indexOfPipe + 1);
 
			switch (messageType)
			{
				case "__InvokeJavaScriptFailed":
				case "__InvokeJavaScriptCompleted":
					{
#if !NETSTANDARD2_0
						var indexOfPipeInContent = messageContent.IndexOf('|', StringComparison.Ordinal);
#else
						var indexOfPipeInContent = messageContent.IndexOf("|", StringComparison.Ordinal);
#endif
						if (indexOfPipeInContent == -1)
						{
							throw new ArgumentException($"The '{messageType}' message content must contain a pipe character ('|').", nameof(rawMessage));
						}
 
						var taskId = messageContent.Substring(0, indexOfPipeInContent);
						var result = messageContent.Substring(indexOfPipeInContent + 1);
 
						var taskManager = this.GetRequiredService<IHybridWebViewTaskManager>();
						if (messageType == "__InvokeJavaScriptFailed")
						{
							if (IsInvokeJavaScriptThrowsExceptionsEnabled)
							{
								if (string.IsNullOrWhiteSpace(result))
								{
									taskManager.SetTaskFailed(taskId, new HybridWebViewInvokeJavaScriptException());
								}
								else
								{
									var jsError = JsonSerializer.Deserialize(result, HybridWebViewHandlerJsonContext.Default.JSInvokeError);
									var jsException = new HybridWebViewInvokeJavaScriptException(jsError?.Message, jsError?.Name, jsError?.StackTrace);
									var ex = new HybridWebViewInvokeJavaScriptException($"InvokeJavaScript threw an exception: {jsException.Message}", jsException);
									taskManager.SetTaskFailed(taskId, ex);
								}
							}
						}
						else
						{
							taskManager.SetTaskCompleted(taskId, result);
						}
					}
					break;
				case "__RawMessage":
					VirtualView?.RawMessageReceived(messageContent);
					break;
				default:
					throw new ArgumentException($"The message type '{messageType}' is not recognized.", nameof(rawMessage));
			}
		}
 
		internal async Task<byte[]?> InvokeDotNetAsync(NameValueCollection invokeQueryString)
		{
			try
			{
				var invokeTarget = VirtualView.InvokeJavaScriptTarget ?? throw new InvalidOperationException($"The {nameof(IHybridWebView)}.{nameof(IHybridWebView.InvokeJavaScriptTarget)} property must have a value in order to invoke a .NET method from JavaScript.");
				var invokeTargetType = VirtualView.InvokeJavaScriptType ?? throw new InvalidOperationException($"The {nameof(IHybridWebView)}.{nameof(IHybridWebView.InvokeJavaScriptType)} property must have a value in order to invoke a .NET method from JavaScript.");
 
				var invokeDataString = invokeQueryString["data"];
				if (string.IsNullOrEmpty(invokeDataString))
				{
					throw new ArgumentException("The 'data' query string parameter is required.", nameof(invokeQueryString));
				}
 
				var invokeData = JsonSerializer.Deserialize<JSInvokeMethodData>(invokeDataString, HybridWebViewHandlerJsonContext.Default.JSInvokeMethodData);
				if (invokeData?.MethodName is null)
				{
					throw new ArgumentException("The invoke data did not provide a method name.", nameof(invokeQueryString));
				}
 
				var invokeResultRaw = await InvokeDotNetMethodAsync(invokeTargetType, invokeTarget, invokeData);
				var invokeResult = CreateInvokeResult(invokeResultRaw);
				var json = JsonSerializer.Serialize(invokeResult);
				var contentBytes = Encoding.UTF8.GetBytes(json);
 
				return contentBytes;
			}
			catch (Exception ex)
			{
				MauiContext?.CreateLogger<HybridWebViewHandler>()?.LogError(ex, "An error occurred while invoking a .NET method from JavaScript: {ErrorMessage}", ex.Message);
			}
 
			return default;
		}
 
		private static DotNetInvokeResult CreateInvokeResult(object? result)
		{
			// null invoke result means an empty result
			if (result is null)
			{
				return new();
			}
 
			// a reference type or an array should be serialized to JSON
			var resultType = result.GetType();
			if (resultType.IsArray || resultType.IsClass)
			{
				return new DotNetInvokeResult()
				{
					Result = JsonSerializer.Serialize(result),
					IsJson = true,
				};
			}
 
			// a value type should be returned as is
			return new DotNetInvokeResult()
			{
				Result = result,
			};
		}
 
		private static async Task<object?> InvokeDotNetMethodAsync(
			[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type targetType,
			object jsInvokeTarget,
			JSInvokeMethodData invokeData)
		{
			var requestMethodName = invokeData.MethodName!;
			var requestParams = invokeData.ParamValues;
 
			// get the method and its parameters from the .NET object instance
			var dotnetMethod = targetType.GetMethod(requestMethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.InvokeMethod);
			if (dotnetMethod is null)
			{
				throw new InvalidOperationException($"The method {requestMethodName} couldn't be found on the {nameof(jsInvokeTarget)} of type {jsInvokeTarget.GetType().FullName}.");
			}
			var dotnetParams = dotnetMethod.GetParameters();
			if (requestParams is not null && dotnetParams.Length != requestParams.Length)
			{
				throw new InvalidOperationException($"The number of parameters on {nameof(jsInvokeTarget)}'s method {requestMethodName} ({dotnetParams.Length}) doesn't match the number of values passed from JavaScript code ({requestParams.Length}).");
			}
 
			// deserialize the parameters from JSON to .NET types
			object?[]? invokeParamValues = null;
			if (requestParams is not null)
			{
				invokeParamValues = new object?[requestParams.Length];
				for (var i = 0; i < requestParams.Length; i++)
				{
					var reqValue = requestParams[i];
					var paramType = dotnetParams[i].ParameterType;
					var deserialized = JsonSerializer.Deserialize(reqValue, paramType);
					invokeParamValues[i] = deserialized;
				}
			}
 
			// invoke the .NET method
			var dotnetReturnValue = dotnetMethod.Invoke(jsInvokeTarget, invokeParamValues);
 
			if (dotnetReturnValue is null) // null result
			{
				return null;
			}
 
			if (dotnetReturnValue is Task task) // Task or Task<T> result
			{
				await task;
 
				// Task<T>
				if (dotnetMethod.ReturnType.IsGenericType)
				{
					var resultProperty = dotnetMethod.ReturnType.GetProperty(nameof(Task<object>.Result));
					return resultProperty?.GetValue(task);
				}
 
				// Task
				return null;
			}
 
			return dotnetReturnValue; // regular result
		}
 
		private sealed class JSInvokeMethodData
		{
			public string? MethodName { get; set; }
			public string[]? ParamValues { get; set; }
		}
 
		private sealed class JSInvokeError
		{
			public string? Name { get; set; }
			public string? Message { get; set; }
			public string? StackTrace { get; set; }
		}
 
		private sealed class DotNetInvokeResult
		{
			public object? Result { get; set; }
			public bool IsJson { get; set; }
		}
 
		[JsonSourceGenerationOptions()]
		[JsonSerializable(typeof(JSInvokeMethodData))]
		[JsonSerializable(typeof(JSInvokeError))]
		private partial class HybridWebViewHandlerJsonContext : JsonSerializerContext
		{
		}
 
 
 
#if PLATFORM && !TIZEN
		public static async void MapEvaluateJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, object? arg)
		{
			if (arg is not EvaluateJavaScriptAsyncRequest request ||
				handler.PlatformView is not MauiHybridWebView hybridPlatformWebView)
			{
				return;
			}
 
			if (handler.PlatformView is null)
			{
				request.SetCanceled();
				return;
			}
 
			var script = request.Script;
			// Make all the platforms mimic Android's implementation, which is by far the most complete.
			if (!OperatingSystem.IsAndroid())
			{
				script = EscapeJsString(script);
 
				if (!OperatingSystem.IsWindows())
				{
					// Use JSON.stringify() method to converts a JavaScript value to a JSON string
					script = "try{JSON.stringify(eval('" + script + "'))}catch(e){'null'};";
				}
				else
				{
					script = "try{eval('" + script + "')}catch(e){'null'};";
				}
			}
 
			// Use the handler command to evaluate the JS
			var innerRequest = new EvaluateJavaScriptAsyncRequest(script);
			EvaluateJavaScript(handler, hybridWebView, innerRequest);
 
			var result = await innerRequest.Task;
 
			//if the js function errored or returned null/undefined treat it as null
			if (result == "null")
			{
				result = null;
			}
			//JSON.stringify wraps the result in literal quotes, we just want the actual returned result
			//note that if the js function returns the string "null" we will get here and not above
			else if (result != null)
			{
				result = result.Trim('"');
			}
 
			request.SetResult(result!);
 
		}
#endif
 
		public static async void MapInvokeJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, object? arg)
		{
#if PLATFORM && !TIZEN
			if (arg is not HybridWebViewInvokeJavaScriptRequest invokeJavaScriptRequest)
			{
				return;
			}
 
			try
			{
				var result = await MapInvokeJavaScriptAsyncImpl(handler, hybridWebView, invokeJavaScriptRequest);
 
				invokeJavaScriptRequest.SetResult(result);
			}
			catch (Exception ex)
			{
				invokeJavaScriptRequest.SetException(ex);
			}
#else
			await Task.CompletedTask;
#endif
		}
 
		static async Task<object?> MapInvokeJavaScriptAsyncImpl(IHybridWebViewHandler handler, IHybridWebView hybridWebView, HybridWebViewInvokeJavaScriptRequest invokeJavaScriptRequest)
		{
			// Create a callback for async JavaScript methods to invoke when they are done
			var taskManager = handler.GetRequiredService<IHybridWebViewTaskManager>();
			var (currentInvokeTaskId, callback) = taskManager.CreateTask();
 
			var paramsValuesStringArray =
				invokeJavaScriptRequest.ParamValues == null
				? string.Empty
				: string.Join(
					", ",
					invokeJavaScriptRequest.ParamValues.Select((v, i) => (v == null ? "null" : JsonSerializer.Serialize(v, invokeJavaScriptRequest.ParamJsonTypeInfos![i]!))));
 
			await handler.InvokeAsync(nameof(IHybridWebView.EvaluateJavaScriptAsync),
				new EvaluateJavaScriptAsyncRequest($"window.HybridWebView.__InvokeJavaScript({currentInvokeTaskId}, {invokeJavaScriptRequest.MethodName}, [{paramsValuesStringArray}])"));
 
			var stringResult = await callback.Task;
 
			// if there is no result or if the result was null/undefined, then treat it as null
			if (stringResult is null || stringResult == "null" || stringResult == "undefined")
			{
				return null;
			}
			// if we are not looking for a return object, then return null
			else if (invokeJavaScriptRequest.ReturnTypeJsonTypeInfo is null)
			{
				return null;
			}
			// if we are expecting a result, then deserialize what we have
			else
			{
				var typedResult = JsonSerializer.Deserialize(stringResult, invokeJavaScriptRequest.ReturnTypeJsonTypeInfo);
				return typedResult;
			}
		}
 
 
#if PLATFORM && !TIZEN
		// Copied from WebView.cs
		internal static string? EscapeJsString(string js)
		{
			if (js == null)
				return null;
 
			if (!js.Contains('\'', StringComparison.Ordinal))
				return js;
 
			//get every quote in the string along with all the backslashes preceding it
			var singleQuotes = Regex.Matches(js, @"(\\*?)'");
 
			var uniqueMatches = new List<string>();
 
			for (var i = 0; i < singleQuotes.Count; i++)
			{
				var matchedString = singleQuotes[i].Value;
				if (!uniqueMatches.Contains(matchedString))
				{
					uniqueMatches.Add(matchedString);
				}
			}
 
			uniqueMatches.Sort((x, y) => y.Length.CompareTo(x.Length));
 
			//escape all quotes from the script as well as add additional escaping to all quotes that were already escaped
			for (var i = 0; i < uniqueMatches.Count; i++)
			{
				var match = uniqueMatches[i];
				var numberOfBackslashes = match.Length - 1;
				var slashesToAdd = (numberOfBackslashes * 2) + 1;
				var replacementStr = "'".PadLeft(slashesToAdd + 1, '\\');
				js = Regex.Replace(js, @"(?<=[^\\])" + Regex.Escape(match), replacementStr);
			}
 
			return js;
		}
#endif
 
		internal static async Task<string?> GetAssetContentAsync(string assetPath)
		{
			using var stream = await GetAssetStreamAsync(assetPath);
			if (stream == null)
			{
				return null;
			}
			using var reader = new StreamReader(stream);
 
			var contents = reader.ReadToEnd();
 
			return contents;
		}
 
		internal static async Task<Stream?> GetAssetStreamAsync(string assetPath)
		{
			if (!await FileSystem.AppPackageFileExistsAsync(assetPath))
			{
				return null;
			}
			return await FileSystem.OpenAppPackageFileAsync(assetPath);
		}
 
#if !NETSTANDARD
		internal static readonly FileExtensionContentTypeProvider ContentTypeProvider = new();
#endif
	}
}