// Copyright © 2014 The CefSharp Authors. All rights reserved. // // Use of this source code is governed by a BSD-style license that can be found in the LICENSE file. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using CefSharp.Event; namespace CefSharp.Internals { /// /// This class manages the registration of objects in the browser /// process to be exposed to JavaScript in the renderer process. /// Registration performs method, parameter, property type analysis /// of the registered objects into meta-data tied to reflection data /// for later use. /// /// This class also is the adaptation layer between the BrowserProcessService /// and the registered objects. This means when the renderer wants to call an /// exposed method, get a property of an object, or /// set a property of an object in the browser process, that this /// class does deals with the previously created meta-data and invokes the correct /// behavior via reflection APIs. /// /// All of the registered objects are tracked via meta-data for the objects /// expressed starting with the JavaScriptObject type. /// public class JavascriptObjectRepository : IJavascriptObjectRepository { public const string AllObjects = "All"; private static long lastId; public event EventHandler ResolveObject; public event EventHandler ObjectBoundInJavascript; public event EventHandler ObjectsBoundInJavascript; /// /// A hash from assigned object ids to the objects, /// this is done to speed up finding the object in O(1) time /// instead of traversing the JavaScriptRootObject tree. /// private readonly ConcurrentDictionary objects = new ConcurrentDictionary(); /// /// Has the browser this repository is associated with been initilized (set in OnAfterCreated) /// public bool IsBrowserInitialized { get; set; } public void Dispose() { ResolveObject = null; ObjectBoundInJavascript = null; ObjectsBoundInJavascript = null; } public bool HasBoundObjects { get { return objects.Count > 0; } } public bool IsBound(string name) { return objects.Values.Any(x => x.Name == name); } //Ideally this would internal, unfurtunately it's used in C++ //and it's hard to expose internals public List GetObjects(List names = null) { //If there are no objects names or the count is 0 then we will raise //the resolve event then return all objects that are registered, //we'll only perform checking if object(s) of specific name is requested. var getAllObjects = names == null || names.Count == 0; if (getAllObjects) { RaiseResolveObjectEvent(AllObjects); return objects.Values.Where(x => x.RootObject).ToList(); } foreach (var name in names) { if (!IsBound(name)) { RaiseResolveObjectEvent(name); } } var objectsByName = objects.Values.Where(x => names.Contains(x.JavascriptName) && x.RootObject).ToList(); //TODO: JSB Add another event that signals when no object matching a name //in the list was provided. return objectsByName; } public void ObjectsBound(List> objs) { var boundObjectHandler = ObjectBoundInJavascript; var boundObjectsHandler = ObjectsBoundInJavascript; if (boundObjectHandler != null || boundObjectsHandler != null) { //Execute on Threadpool so we don't unnessicarily block the CEF IO thread Task.Run(() => { foreach (var obj in objs) { boundObjectHandler?.Invoke(this, new JavascriptBindingCompleteEventArgs(this, obj.Item1, obj.Item2, obj.Item3)); } boundObjectsHandler?.Invoke(this, new JavascriptBindingMultipleCompleteEventArgs(this, objs.Select(x => x.Item1).ToList())); }); } } private JavascriptObject CreateJavascriptObject(bool camelCaseJavascriptNames, bool rootObject) { var id = Interlocked.Increment(ref lastId); var result = new JavascriptObject { Id = id, CamelCaseJavascriptNames = camelCaseJavascriptNames, RootObject = rootObject }; objects[id] = result; return result; } public void Register(string name, object value, bool isAsync, BindingOptions options) { if (name == null) { throw new ArgumentNullException("name"); } if (value == null) { throw new ArgumentNullException("value"); } //Enable WCF if not already enabled - can only be done before the browser has been initliazed //if done after the subprocess won't be WCF enabled it we'll have to throw an exception if (!IsBrowserInitialized && !isAsync) { CefSharpSettings.WcfEnabled = true; } if (!CefSharpSettings.WcfEnabled && !isAsync) { throw new InvalidOperationException(@"To enable synchronous JS bindings set WcfEnabled true in CefSharpSettings before you create your ChromiumWebBrowser instances."); } //Validation name is unique if (objects.Values.Count(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)) > 0) { throw new ArgumentException("Object already bound with name:" + name, name); } //Binding of System types is problematic, so we don't support it var type = value.GetType(); if (type.IsPrimitive || type.BaseType.Namespace.StartsWith("System.")) { throw new ArgumentException("Registering of .Net framework built in types is not supported, " + "create your own Object and proxy the calls if you need to access a Window/Form/Control.", "value"); } var camelCaseJavascriptNames = options == null ? true : options.CamelCaseJavascriptNames; var jsObject = CreateJavascriptObject(camelCaseJavascriptNames, rootObject: true); jsObject.Value = value; jsObject.Name = name; jsObject.JavascriptName = name; jsObject.IsAsync = isAsync; jsObject.Binder = options?.Binder; jsObject.MethodInterceptor = options?.MethodInterceptor; AnalyseObjectForBinding(jsObject, analyseMethods: true, analyseProperties: !isAsync, readPropertyValue: false, camelCaseJavascriptNames: camelCaseJavascriptNames); } public void UnRegisterAll() { objects.Clear(); } public bool UnRegister(string name) { foreach (var kvp in objects) { if (string.Equals(kvp.Value.Name, name, StringComparison.OrdinalIgnoreCase)) { JavascriptObject obj; objects.TryRemove(kvp.Key, out obj); return true; } } return false; } internal bool TryCallMethod(long objectId, string name, object[] parameters, out object result, out string exception) { exception = ""; result = null; JavascriptObject obj; if (!objects.TryGetValue(objectId, out obj)) { return false; } var method = obj.Methods.FirstOrDefault(p => p.JavascriptName == name); if (method == null) { throw new InvalidOperationException(string.Format("Method {0} not found on Object of Type {1}", name, obj.Value.GetType())); } try { //Check if the bound object method contains a ParamArray as the last parameter on the method signature. //NOTE: No additional parameters are permitted after the params keyword in a method declaration, //and only one params keyword is permitted in a method declaration. //https://msdn.microsoft.com/en-AU/library/w5zay9db.aspx if (method.HasParamArray) { var paramList = new List(method.Parameters.Count); //Loop through all of the method parameters on the bound object. for (var i = 0; i < method.Parameters.Count; i++) { //If the method parameter is a paramArray IE: (params string[] args) //grab the parameters from the javascript function starting at the current bound object parameter index //and add create an array that will be passed in as the last bound object method parameter. if (method.Parameters[i].IsParamArray) { var convertedParams = new List(); for (var s = i; s < parameters.Length; s++) { convertedParams.Add(parameters[s]); } paramList.Add(convertedParams.ToArray()); } else { var jsParam = parameters.ElementAtOrDefault(i); paramList.Add(jsParam); } } parameters = paramList.ToArray(); } int missingParams = 0; try { if (obj.Binder != null) { for (var i = 0; i < parameters.Length; i++) { var paramExpectedType = method.Parameters[i].Type; //Previously only IDictionary and IList called Binder.Bind //Now every param is bound to allow for type conversion parameters[i] = obj.Binder.Bind(parameters[i], paramExpectedType); } } //Check for parameter count missmatch between the parameters on the javascript function and the //number of parameters on the bound object method. (This is relevant for methods that have default values) //NOTE it's possible to have default params and a paramArray, so check missing params last missingParams = method.ParameterCount - parameters.Length; if (missingParams > 0) { var paramList = new List(parameters); for (var i = 0; i < missingParams; i++) { paramList.Add(Type.Missing); } parameters = paramList.ToArray(); } if (obj.MethodInterceptor == null) { result = method.Function(obj.Value, parameters); } else { result = obj.MethodInterceptor.Intercept((p) => method.Function(obj.Value, p), parameters, method.ManagedName); } } catch (Exception e) { throw new InvalidOperationException("Could not execute method: " + name + "(" + String.Join(", ", parameters) + ") " + (missingParams > 0 ? "- Missing Parameters: " + missingParams : ""), e); } //For sync binding with methods that return a complex property we create a new JavascriptObject //TODO: Fix the memory leak, every call to a method that returns an object will create a new //JavascriptObject and they are never released if (!obj.IsAsync && result != null && IsComplexType(result.GetType())) { var jsObject = CreateJavascriptObject(obj.CamelCaseJavascriptNames, rootObject: false); jsObject.Value = result; jsObject.Name = "FunctionResult(" + name + ")"; jsObject.JavascriptName = jsObject.Name; AnalyseObjectForBinding(jsObject, analyseMethods: false, analyseProperties: true, readPropertyValue: true, camelCaseJavascriptNames: obj.CamelCaseJavascriptNames); result = jsObject; } return true; } catch (TargetInvocationException e) { var baseException = e.GetBaseException(); exception = baseException.ToString(); } catch (Exception ex) { exception = ex.ToString(); } return false; } internal bool TryGetProperty(long objectId, string name, out object result, out string exception) { exception = ""; result = null; JavascriptObject obj; if (!objects.TryGetValue(objectId, out obj)) { return false; } var property = obj.Properties.FirstOrDefault(p => p.JavascriptName == name); if (property == null) { throw new InvalidOperationException(string.Format("Property {0} not found on Object of Type {1}", name, obj.Value.GetType())); } try { result = property.GetValue(obj.Value); return true; } catch (Exception ex) { exception = ex.ToString(); } return false; } internal bool TrySetProperty(long objectId, string name, object value, out string exception) { exception = ""; JavascriptObject obj; if (!objects.TryGetValue(objectId, out obj)) { return false; } var property = obj.Properties.FirstOrDefault(p => p.JavascriptName == name); if (property == null) { throw new InvalidOperationException(string.Format("Property {0} not found on Object of Type {1}", name, obj.Value.GetType())); } try { property.SetValue(obj.Value, value); return true; } catch (Exception ex) { exception = ex.ToString(); } return false; } /// /// Analyse the object and generate metadata which will /// be used by the browser subprocess to interact with Cef. /// Method is called recursively /// /// Javascript object /// Analyse methods for inclusion in metadata model /// Analyse properties for inclusion in metadata model /// When analysis is done on a property, if true then get it's value for transmission over WCF /// camel case the javascript names of properties/methods private void AnalyseObjectForBinding(JavascriptObject obj, bool analyseMethods, bool analyseProperties, bool readPropertyValue, bool camelCaseJavascriptNames) { if (obj.Value == null) { return; } var type = obj.Value.GetType(); if (type.IsPrimitive || type == typeof(string)) { return; } if (analyseMethods) { foreach (var methodInfo in type.GetMethods(BindingFlags.Instance | BindingFlags.Public).Where(p => !p.IsSpecialName)) { // Type objects can not be serialized. if (methodInfo.ReturnType == typeof(Type) || Attribute.IsDefined(methodInfo, typeof(JavascriptIgnoreAttribute))) { continue; } var jsMethod = CreateJavaScriptMethod(methodInfo, camelCaseJavascriptNames); obj.Methods.Add(jsMethod); } } if (analyseProperties) { foreach (var propertyInfo in type.GetProperties(BindingFlags.Instance | BindingFlags.Public).Where(p => !p.IsSpecialName)) { //https://msdn.microsoft.com/en-us/library/system.reflection.propertyinfo.getindexparameters(v=vs.110).aspx //An array of type ParameterInfo containing the parameters for the indexes. If the property is not indexed, the array has 0 (zero) elements. //According to MSDN array has zero elements when it's not an indexer, so in theory no null check is required var isIndexer = propertyInfo.GetIndexParameters().Length > 0; var hasIgnoreAttribute = Attribute.IsDefined(propertyInfo, typeof(JavascriptIgnoreAttribute)); if (propertyInfo.PropertyType == typeof(Type) || isIndexer || hasIgnoreAttribute) { continue; } var jsProperty = CreateJavaScriptProperty(propertyInfo, camelCaseJavascriptNames); if (jsProperty.IsComplexType) { var jsObject = CreateJavascriptObject(camelCaseJavascriptNames, rootObject: false); jsObject.Name = propertyInfo.Name; jsObject.JavascriptName = GetJavascriptName(propertyInfo.Name, camelCaseJavascriptNames); jsObject.Value = jsProperty.GetValue(obj.Value); jsProperty.JsObject = jsObject; AnalyseObjectForBinding(jsProperty.JsObject, analyseMethods, analyseProperties: true, readPropertyValue: readPropertyValue, camelCaseJavascriptNames: camelCaseJavascriptNames); } else if (readPropertyValue) { jsProperty.PropertyValue = jsProperty.GetValue(obj.Value); } obj.Properties.Add(jsProperty); } } } private void RaiseResolveObjectEvent(string name) { ResolveObject?.Invoke(this, new JavascriptBindingEventArgs(this, name)); } private static JavascriptMethod CreateJavaScriptMethod(MethodInfo methodInfo, bool camelCaseJavascriptNames) { var jsMethod = new JavascriptMethod(); jsMethod.ManagedName = methodInfo.Name; jsMethod.JavascriptName = GetJavascriptName(methodInfo.Name, camelCaseJavascriptNames); jsMethod.Function = methodInfo.Invoke; jsMethod.ParameterCount = methodInfo.GetParameters().Length; jsMethod.Parameters = methodInfo.GetParameters() .Select(t => new MethodParameter() { IsParamArray = t.GetCustomAttributes(typeof(ParamArrayAttribute), false).Length > 0, Type = t.ParameterType }).ToList(); //Pre compute HasParamArray for a very minor performance gain jsMethod.HasParamArray = jsMethod.Parameters.LastOrDefault(t => t.IsParamArray) != null; return jsMethod; } private static JavascriptProperty CreateJavaScriptProperty(PropertyInfo propertyInfo, bool camelCaseJavascriptNames) { var jsProperty = new JavascriptProperty(); jsProperty.ManagedName = propertyInfo.Name; jsProperty.JavascriptName = GetJavascriptName(propertyInfo.Name, camelCaseJavascriptNames); jsProperty.SetValue = (o, v) => propertyInfo.SetValue(o, v, null); jsProperty.GetValue = (o) => propertyInfo.GetValue(o, null); jsProperty.IsComplexType = IsComplexType(propertyInfo.PropertyType); jsProperty.IsReadOnly = !propertyInfo.CanWrite; return jsProperty; } private static bool IsComplexType(Type type) { if (type == typeof(void)) { return false; } var baseType = type; var nullable = type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); if (nullable) { baseType = Nullable.GetUnderlyingType(type); } if (baseType == null || baseType.IsArray || baseType.Namespace.StartsWith("System")) { return false; } if (baseType.IsValueType && !baseType.IsPrimitive && !baseType.IsEnum) { return false; } return !baseType.IsPrimitive && baseType != typeof(string); } private static string GetJavascriptName(string str, bool camelCaseJavascriptNames) { if (!camelCaseJavascriptNames) { return str; } if (string.IsNullOrEmpty(str)) { return string.Empty; } return char.ToLowerInvariant(str[0]) + str.Substring(1); } } }