Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Javascript proxy support #3

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 16 additions & 35 deletions HybridWebView/HybridWebView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ public partial class HybridWebView : WebView
public string HybridAssetRoot { get; set; }

/// <summary>
/// The target object for JavaScript method invocations. When an "invoke" message is sent from JavaScript,
/// the invoked method will be located on this object, and any specified parameters will be passed in.
/// Hosts objects that are accessible (methods only) to Javascript.
/// </summary>
public object JSInvokeTarget { get; set; }
public HybridWebViewObjectHost ObjectHost { get; private set; }

public event EventHandler<HybridWebViewRawMessageReceivedEventArgs> RawMessageReceived;

public HybridWebView()
{
ObjectHost = new HybridWebViewObjectHost(this);
}

protected override void OnHandlerChanged()
{
base.OnHandlerChanged();
Expand Down Expand Up @@ -61,7 +65,13 @@ public async Task<TReturnType> InvokeJsMethodAsync<TReturnType>(string methodNam
return JsonSerializer.Deserialize<TReturnType>(stringResult);
}

public virtual void OnMessageReceived(string message)
// TODO: Better name of this method
internal void RaiseMessageReceived(string message)
{
OnMessageReceived(message);
}

protected virtual void OnMessageReceived(string message)
{
var messageData = JsonSerializer.Deserialize<WebMessageData>(message);
switch (messageData.MessageType)
Expand All @@ -70,41 +80,12 @@ public virtual void OnMessageReceived(string message)
RawMessageReceived?.Invoke(this, new HybridWebViewRawMessageReceivedEventArgs(messageData.MessageContent));
break;
case 1: // "invoke" message
var invokeData = JsonSerializer.Deserialize<JSInvokeMethodData>(messageData.MessageContent);
InvokeDotNetMethod(invokeData);
var invokeData = JsonSerializer.Deserialize<HybridWebViewObjectHost.JSInvokeMethodData>(messageData.MessageContent);
ObjectHost.InvokeDotNetMethod(invokeData);
break;
default:
throw new InvalidOperationException($"Unknown message type: {messageData.MessageType}. Message contents: {messageData.MessageContent}");
}

}

private void InvokeDotNetMethod(JSInvokeMethodData invokeData)
{
if (JSInvokeTarget is null)
{
throw new NotImplementedException($"The {nameof(JSInvokeTarget)} property must have a value in order to invoke a .NET method from JavaScript.");
}

var invokeMethod = JSInvokeTarget.GetType().GetMethod(invokeData.MethodName, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.InvokeMethod);

if (invokeData.ParamValues != null && invokeMethod.GetParameters().Length != invokeData.ParamValues.Length)
{
throw new InvalidOperationException($"The number of parameters on {nameof(JSInvokeTarget)}'s method {invokeData.MethodName} ({invokeMethod.GetParameters().Length}) doesn't match the number of values passed from JavaScript code ({invokeData.ParamValues.Length}).");
}

var paramObjectValues =
invokeData.ParamValues?
.Zip(invokeMethod.GetParameters(), (s, p) => JsonSerializer.Deserialize(s, p.ParameterType))
.ToArray();

var returnValue = invokeMethod.Invoke(JSInvokeTarget, paramObjectValues);
}

private sealed class JSInvokeMethodData
{
public string MethodName { get; set; }
public string[] ParamValues { get; set; }
}

private sealed class WebMessageData
Expand Down
200 changes: 200 additions & 0 deletions HybridWebView/HybridWebViewObjectHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
using System.Collections.Concurrent;
using System.Reflection;
using System.Text.Json;

namespace HybridWebView
{
// TODO:
// - Name converter
public class HybridWebViewObjectHost
{
/// <summary>
/// Dictionary of objects
/// </summary>
private readonly ConcurrentDictionary<string, object> _hostObjects = new();

private readonly WebView _webView;

internal HybridWebViewObjectHost(WebView webView)
{
_webView = webView;
}

/// <summary>
/// Event is raised when a method is invokved from JavaScript and
/// no object with the matching <see cref="HybridWebViewResolveObjectEventArgs.ObjectName"/>
/// was found. You can call <see cref="AddObject(string, object)"/> from within this
/// event handler to add an object.
/// </summary>
public event EventHandler<HybridWebViewResolveObjectEventArgs> ResolveObject;

/// <summary>
/// Add a object with the given name
/// </summary>
/// <param name="name">name</param>
/// <param name="obj">object</param>
/// <returns>returns true if successfully added otherwise false if object with name already exists</returns>
public bool AddObject(string name, object obj)
{
return _hostObjects.TryAdd(name, obj);
}

public bool RemoveObject(string name)
{
return _hostObjects.TryRemove(name, out _);
}

internal void InvokeDotNetMethod(JSInvokeMethodData invokeData)
{
//TODO: validate invokeData
if(!_hostObjects.ContainsKey(invokeData.ClassName))
{
// Give the user an opportunity to call AddObject
ResolveObject?.Invoke(this, new HybridWebViewResolveObjectEventArgs { ObjectName = invokeData.ClassName, Host = this });
}

if(!_hostObjects.TryGetValue(invokeData.ClassName, out var target))
{
RejectCallback(invokeData.CallbackId, $"Invalid class name {invokeData.ClassName}.");

return;
}

var invokeMethod = target.GetType().GetMethod(invokeData.MethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.InvokeMethod);

if(invokeMethod == null)
{
RejectCallback(invokeData.CallbackId, $"Invalid method {invokeData.ClassName}.{invokeData.MethodName}.");

return;
}

try
{
var parameters = GetMethodParams(invokeData.ClassName, invokeData.MethodName, invokeMethod, invokeData.ParamValues);

var returnValue = invokeMethod.Invoke(target, parameters);

switch (returnValue)
{
case ValueTask valueTask:
_ = ResolveTask(invokeData.CallbackId, valueTask.AsTask());
break;

case Task task:
_ = ResolveTask(invokeData.CallbackId, task);
break;

default:
ResolveCallback(invokeData.CallbackId, JsonSerializer.Serialize(returnValue));
break;
}
}
catch (Exception ex)
{
RejectCallback(invokeData.CallbackId, ex.ToString());
}
}

private static object[] GetMethodParams(string className, string methodName, MethodInfo invokeMethod, string[] paramValues)
{
var dotNetMethodParams = invokeMethod.GetParameters();

if (dotNetMethodParams.Length == 0 && paramValues.Length == 0)
{
return null;
}

if (dotNetMethodParams.Length == 0 && paramValues.Length > 0)
{
throw new InvalidOperationException($"The method {className}.{methodName} takes Zero(0) parameters, was called {paramValues.Length} parameter(s).");
}

var hasParamArray = dotNetMethodParams.Last().GetCustomAttributes(typeof(ParamArrayAttribute), false).Length > 0;

if (hasParamArray)
{
throw new InvalidOperationException($"The method {className}.{methodName} has a parameter array as it's last argument which is not currently supported.");
}

if (dotNetMethodParams.Length == paramValues.Length)
{
return paramValues
.Zip(dotNetMethodParams, (s, p) => JsonSerializer.Deserialize(s, p.ParameterType))
.ToArray();
}

var methodParams = new object[dotNetMethodParams.Length];
var missingParams = dotNetMethodParams.Length - paramValues.Length;

for (var i = 0; i < paramValues.Length; i++)
{
var paramType = dotNetMethodParams[i].ParameterType;
var paramValue = paramValues[i];
methodParams[i] = JsonSerializer.Deserialize(paramValue, paramType);
}

Array.Fill(methodParams, Type.Missing, paramValues.Length, missingParams);

return methodParams;
}

private async Task ResolveTask(int callbackId, Task task)
{
await task;

object result = null;

if (task.GetType().IsGenericType)
{
result = task.GetType().GetProperty("Result").GetValue(task);
}

ResolveCallback(callbackId, JsonSerializer.Serialize(result));
}

private void ResolveCallback(int id, string json)
{
if (_webView.Dispatcher.IsDispatchRequired)
{
_webView.Dispatcher.Dispatch(() => { ResolveCallback(id, json); });
return;
}

_webView.EvaluateJavaScriptAsync($"HybridWebViewDotNetHost.Current.ResolveCallback({id}, '{json}')").ContinueWith(t =>
{
if(t.Status == TaskStatus.Faulted)
{
//TODO: Report error, add a new event maybe?
var ex = t.Exception;
}
});
}

private void RejectCallback(int id, string message)
{
if (_webView.Dispatcher.IsDispatchRequired)
{
_webView.Dispatcher.Dispatch(() => { RejectCallback(id, message); });
return;
}

_webView.EvaluateJavaScriptAsync($"HybridWebViewDotNetHost.Current.RejectCallback({id}, '{message}')").ContinueWith(t =>
{
if (t.Status == TaskStatus.Faulted)
{
//TODO: Report error, add a new event maybe?
var ex = t.Exception;
}
});
}

internal sealed class JSInvokeMethodData
{
public string ClassName { get; set; }
public string MethodName { get; set; }
public int CallbackId { get; set; }
public string[] ParamValues { get; set; }
}
}
}
8 changes: 8 additions & 0 deletions HybridWebView/HybridWebViewResolveObjectEventArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace HybridWebView
{
public class HybridWebViewResolveObjectEventArgs : EventArgs
{
public string ObjectName { get; init; }
public HybridWebViewObjectHost Host { get; init; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ protected override WKWebView CreatePlatformView()

private void MessageReceived(Uri uri, string message)
{
((HybridWebView)VirtualView).OnMessageReceived(message);
((HybridWebView)VirtualView).RaiseMessageReceived(message);
}

private sealed class WebViewScriptMessageHandler : NSObject, IWKScriptMessageHandler
Expand Down
2 changes: 1 addition & 1 deletion HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ protected override WKWebView CreatePlatformView()

private void MessageReceived(Uri uri, string message)
{
((HybridWebView)VirtualView).OnMessageReceived(message);
((HybridWebView)VirtualView).RaiseMessageReceived(message);
}

private sealed class WebViewScriptMessageHandler : NSObject, IWKScriptMessageHandler
Expand Down
11 changes: 10 additions & 1 deletion MauiCSharpInteropWebView/MainPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public MainPage()

BindingContext = this;

myHybridWebView.JSInvokeTarget = new MyJSInvokeTarget(this);
myHybridWebView.ObjectHost.AddObject("host", new MyJSInvokeTarget(this));
}

public string CurrentPageName => $"Current hybrid page: {_currentPage}";
Expand Down Expand Up @@ -69,6 +69,15 @@ public void CallMeFromScript(string message, int value)
{
_mainPage.WriteToLog($"I'm a .NET method called from JavaScript with message='{message}' and value={value}");
}

public async Task<int> CallMeFromScriptReturn(string message, int value, int? optional = 0)
{
_mainPage.WriteToLog($"I'm a .NET method called from JavaScript with message='{message}' and value={value} and optional={optional}");

await Task.Delay(50);

return value;
}
}

private enum HybridAppPageID
Expand Down
Loading