diff --git a/HybridWebView/HybridWebView.cs b/HybridWebView/HybridWebView.cs index 25bb37f..0485e33 100644 --- a/HybridWebView/HybridWebView.cs +++ b/HybridWebView/HybridWebView.cs @@ -13,13 +13,17 @@ public partial class HybridWebView : WebView public string HybridAssetRoot { get; set; } /// - /// 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. /// - public object JSInvokeTarget { get; set; } + public HybridWebViewObjectHost ObjectHost { get; private set; } public event EventHandler RawMessageReceived; + public HybridWebView() + { + ObjectHost = new HybridWebViewObjectHost(this); + } + protected override void OnHandlerChanged() { base.OnHandlerChanged(); @@ -61,7 +65,13 @@ public async Task InvokeJsMethodAsync(string methodNam return JsonSerializer.Deserialize(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(message); switch (messageData.MessageType) @@ -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(messageData.MessageContent); - InvokeDotNetMethod(invokeData); + var invokeData = JsonSerializer.Deserialize(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 diff --git a/HybridWebView/HybridWebViewObjectHost.cs b/HybridWebView/HybridWebViewObjectHost.cs new file mode 100644 index 0000000..cb98df4 --- /dev/null +++ b/HybridWebView/HybridWebViewObjectHost.cs @@ -0,0 +1,200 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Text.Json; + +namespace HybridWebView +{ + // TODO: + // - Name converter + public class HybridWebViewObjectHost + { + /// + /// Dictionary of objects + /// + private readonly ConcurrentDictionary _hostObjects = new(); + + private readonly WebView _webView; + + internal HybridWebViewObjectHost(WebView webView) + { + _webView = webView; + } + + /// + /// Event is raised when a method is invokved from JavaScript and + /// no object with the matching + /// was found. You can call from within this + /// event handler to add an object. + /// + public event EventHandler ResolveObject; + + /// + /// Add a object with the given name + /// + /// name + /// object + /// returns true if successfully added otherwise false if object with name already exists + 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; } + } + } +} diff --git a/HybridWebView/HybridWebViewResolveObjectEventArgs.cs b/HybridWebView/HybridWebViewResolveObjectEventArgs.cs new file mode 100644 index 0000000..2a08ea8 --- /dev/null +++ b/HybridWebView/HybridWebViewResolveObjectEventArgs.cs @@ -0,0 +1,8 @@ +namespace HybridWebView +{ + public class HybridWebViewResolveObjectEventArgs : EventArgs + { + public string ObjectName { get; init; } + public HybridWebViewObjectHost Host { get; init; } + } +} diff --git a/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs b/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs index ea8291d..106150c 100644 --- a/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs +++ b/HybridWebView/Platforms/MacCatalyst/HybridWebViewHandler.MacCatalyst.cs @@ -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 diff --git a/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs b/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs index ea8291d..106150c 100644 --- a/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs +++ b/HybridWebView/Platforms/iOS/HybridWebViewHandler.iOS.cs @@ -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 diff --git a/MauiCSharpInteropWebView/MainPage.xaml.cs b/MauiCSharpInteropWebView/MainPage.xaml.cs index 213408b..9a5e364 100644 --- a/MauiCSharpInteropWebView/MainPage.xaml.cs +++ b/MauiCSharpInteropWebView/MainPage.xaml.cs @@ -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}"; @@ -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 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 diff --git a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/js/HybridWebView.js b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/js/HybridWebView.js index 836106a..59ef490 100644 --- a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/js/HybridWebView.js +++ b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/js/HybridWebView.js @@ -1,32 +1,39 @@ // Standard methods for HybridWebView -window.HybridWebView = { - "SendRawMessageToDotNet": function SendRawMessageToDotNet(message) { - window.HybridWebView.SendMessageToDotNet(0, message); - }, - - "SendInvokeMessageToDotNet": function SendInvokeMessageToDotNet(methodName, paramValues) { - if (typeof paramValues !== 'undefined') { - if (!Array.isArray(paramValues)) { - paramValues = [paramValues]; - } - for (var i = 0; i < paramValues.length; i++) { - paramValues[i] = JSON.stringify(paramValues[i]); - } +class HybridWebViewDotNetHost { + + constructor() { + this._nextCallbackId = 1; + this._callbackMap = new Map(); + } + + // Methods + SendRawMessageToDotNet(message) { + this.SendMessageToDotNet(0, message); + } + + SendInvokeMessageToDotNet(className, methodName, paramValues = []) { + if (paramValues && !Array.isArray(paramValues)) { + paramValues = Array.of(paramValues); } - window.HybridWebView.SendMessageToDotNet(1, JSON.stringify({ "MethodName": methodName, "ParamValues": paramValues })); - }, + let params = paramValues.map(x => JSON.stringify(x)); + + let callback = this.CreateCallback(); - "SendMessageToDotNet": function SendMessageToDotNet(messageType, messageContent) { + this.SendMessageToDotNet(1, JSON.stringify({ "ClassName": className, "MethodName": methodName, "CallbackId": callback.id, "ParamValues": params })); + + return callback.promise; + } + + SendMessageToDotNet(messageType, messageContent) { var message = JSON.stringify({ "MessageType": messageType, "MessageContent": messageContent }); if (window.chrome && window.chrome.webview) { // Windows WebView2 window.chrome.webview.postMessage(message); } - else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.webwindowinterop) - { + else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.webwindowinterop) { // iOS and MacCatalyst WKWebView window.webkit.messageHandlers.webwindowinterop.postMessage(message); } @@ -35,4 +42,51 @@ window.HybridWebView = { hybridWebViewHost.sendMessage(message); } } -}; + + CreateProxy(className) { + const self = this; + const proxy = new Proxy({}, { + get: function (target, methodName) { + return function (...args) { + return self.SendInvokeMessageToDotNet(className, methodName, args); + } + } + }); + + return proxy; + } + + ResolveCallback(id, message) { + const callback = this._callbackMap.get(id); + + if (callback) { + const obj = JSON.parse(message); + callback.resolve(obj); + } + } + + RejectCallback(id, message) { + const callback = this._callbackMap.get(id); + + if (callback) { + callback.resolve(message); + } + } + + CreateCallback() { + let callback = {}; + + callback.id = this._nextCallbackId++; + callback.promise = new Promise((resolve, reject) => { + callback.resolve = resolve; + callback.reject = reject; + });; + + this._callbackMap.set(callback.id, callback); + + return callback; + } +} + +HybridWebViewDotNetHost.Current = new HybridWebViewDotNetHost(); +window.HybridWebView = HybridWebViewDotNetHost.Current; \ No newline at end of file diff --git a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/methodinvoke.html b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/methodinvoke.html index 26d0013..5a5de8d 100644 --- a/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/methodinvoke.html +++ b/MauiCSharpInteropWebView/Resources/Raw/hybrid_root/methodinvoke.html @@ -11,10 +11,20 @@ return sum; } - function CallDotNetMethod() { + async function CallDotNetMethod() { Log('Calling a method in .NET with some parameters'); - HybridWebView.SendInvokeMessageToDotNet("CallMeFromScript", ["msg from js", 987]); + try { + HybridWebView.SendInvokeMessageToDotNet("host", "CallMeFromScript", ["msg from js", 987]); + + const hostObj = HybridWebView.CreateProxy("host"); + const result = await hostObj.CallMeFromScriptReturn("Hello", 42); + + Log("Method call Result was " + result); + } + catch (ex) { + Log(ex); + } } @@ -30,8 +40,19 @@

HybridWebView demo: Method invoke

Methods can be invoked in both directions:
    -
  • JavaScript can invoke .NET methods by calling HybridWebView.SendInvokeMessageToDotNet("DotNetMethodName", ["param1", 123]);.
  • .NET can invoke JavaScript methods by calling var sum = await webView.InvokeJsMethodAsync("JsAddNumbers", 123, 456);.
  • +
  • + JavaScript can invoke .NET methods by calling + + // Call the method directly + HybridWebView.SendInvokeMessageToDotNet("HostClassName", "DotNetMethodName", ["param1", 123]); + + // Create a proxy + const hostObj = HybridWebView.CreateProxy("host"); + const result = await hostObj.CallMeFromScriptReturn("Hello", 42); + . +
  • +
diff --git a/MauiReactJSHybridApp/MainPage.xaml.cs b/MauiReactJSHybridApp/MainPage.xaml.cs index 802af7e..0805164 100644 --- a/MauiReactJSHybridApp/MainPage.xaml.cs +++ b/MauiReactJSHybridApp/MainPage.xaml.cs @@ -13,7 +13,7 @@ public MainPage() _todoDataStore = new TodoDataStore(); _todoDataStore.TaskDataChanged += OnTodoDataChanged; - myHybridWebView.JSInvokeTarget = new TodoJSInvokeTarget(this, _todoDataStore); + myHybridWebView.ObjectHost.AddObject("host", new TodoJSInvokeTarget(this, _todoDataStore)); BindingContext = this; } diff --git a/README.md b/README.md index 4ce821f..279ff1f 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,19 @@ var sum = await myHybridWebView.InvokeJsMethodAsync("JsAddNumbers", 123, 45 And the reverse, JavaScript code calling a .NET method: +```c# +// Add an object called 'host' that can be called from JavaScript +myHybridWebView.ObjectHost.AddObject("host", new MyJSInvokeTarget(this)); +``` + ```js -HybridWebView.SendInvokeMessageToDotNet("CallMeFromScript", ["msg from js", 987]); +// Directly call a .Net method called CallMeFromScript +HybridWebView.SendInvokeMessageToDotNet("host", "CallMeFromScript", ["msg from js", 987]); + +// Create a proxy +const myDotNetObjectProxy = HybridWebView.CreateProxy("host"); +// Call the method, await the result (promise) to get the returned value. +const result = await myDotNetObjectProxy.CallMeFromScriptReturn("Hello", 42); ``` In addition to method invocation, sending "raw" messages is also supported.