using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.Serialization; using System.Runtime.Serialization.Json; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Xilium.CefGlue; using Xilium.CefGlue.Common.Events; namespace WebViewControl { partial class WebView { [DataContract] internal class JsError { [DataMember(Name = "stack")] public string Stack; [DataMember(Name = "name")] public string Name; [DataMember(Name = "message")] public string Message; } internal class ScriptTask { public ScriptTask(string script, string functionName, Action evaluate = null) { Script = script; Evaluate = evaluate; FunctionName = functionName; } public string Script { get; } /// /// We store the function name apart from the script and use it later in the exception details /// this prevents any params to be shown in the message because they can contain sensitive information /// public string FunctionName { get; } public Action Evaluate { get; } } internal class JavascriptExecutor : IDisposable { private const string InternalException = "|WebViewInternalException"; private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60); private static readonly TimeSpan InitializationTimeout = TimeSpan.FromSeconds(15); private static Regex StackFrameRegex { get; } = new Regex(@"at\s*(?.*?)\s\(?(?[^\s]+):(?\d+):(?\d+)", RegexOptions.Compiled); private BlockingCollection PendingScripts { get; } = new BlockingCollection(); private CancellationTokenSource FlushTaskCancelationToken { get; } = new CancellationTokenSource(); private WebView OwnerWebView { get; } #if DEBUG private string Id { get; } = Guid.NewGuid().ToString(); #endif private CefFrame frame; private Task flushTask; public JavascriptExecutor(WebView owner, CefFrame frame = null) { OwnerWebView = owner; this.frame = frame; } public bool IsValid => frame == null || frame.IsValid; // consider valid when not bound (yet) or frame is valid private bool IsFlushTaskInitializing => flushTask == null || flushTask.Status < TaskStatus.Running; public void StartFlush(CefFrame frame) { #if DEBUG System.Diagnostics.Debug.WriteLine($"{nameof(StartFlush)} ('{Id}')"); #endif lock (FlushTaskCancelationToken) { if (flushTask != null || !frame.IsValid || FlushTaskCancelationToken.IsCancellationRequested) { return; } this.frame = frame; flushTask = Task.Factory.StartNew(FlushScripts, FlushTaskCancelationToken.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); } } private void StopFlush() { #if DEBUG System.Diagnostics.Debug.WriteLine($"{nameof(StopFlush)} ('{Id}')"); #endif lock (FlushTaskCancelationToken) { if (FlushTaskCancelationToken.IsCancellationRequested) { return; } FlushTaskCancelationToken.Cancel(); PendingScripts.CompleteAdding(); } } private ScriptTask QueueScript(string script, string functionName = null, Action evaluate = null) { lock (FlushTaskCancelationToken) { if (FlushTaskCancelationToken.IsCancellationRequested) { return null; } var scriptTask = new ScriptTask(script, functionName, evaluate); PendingScripts.Add(scriptTask); return scriptTask; } } private void FlushScripts() { #if DEBUG System.Diagnostics.Debug.WriteLine($"{nameof(FlushScripts)} running ('{Id}')"); #endif OwnerWebView.ExecuteWithAsyncErrorHandling(InnerFlushScripts); } private void InnerFlushScripts() { try { var scriptsToExecute = new List(); foreach (var scriptTask in PendingScripts.GetConsumingEnumerable()) { if (scriptTask.Evaluate == null) { scriptsToExecute.Add(scriptTask); } if ((PendingScripts.Count == 0 || scriptTask.Evaluate != null) && scriptsToExecute.Count > 0) { BulkExecuteScripts(scriptsToExecute); scriptsToExecute.Clear(); } scriptTask.Evaluate?.Invoke(scriptTask.Script); } } catch (OperationCanceledException) { // stop } finally { PendingScripts.Dispose(); FlushTaskCancelationToken.Dispose(); } } private void BulkExecuteScripts(IEnumerable scriptsToExecute) { var script = string.Join(";" + Environment.NewLine, scriptsToExecute.Select(s => s.Script)); if (frame.IsValid) { var frameName = frame.Name; try { var timeout = OwnerWebView.DefaultScriptsExecutionTimeout ?? DefaultTimeout; var task = OwnerWebView.chromium.EvaluateJavaScript(WrapScriptWithErrorHandling(script), timeout: timeout); task.Wait(FlushTaskCancelationToken.Token); } catch (OperationCanceledException) { // ignore } catch (Exception e) { var evaluatedScriptFunctions = scriptsToExecute.Select(s => s.FunctionName); OwnerWebView.ForwardUnhandledAsyncException(ParseException(e, evaluatedScriptFunctions), frameName); } } } public async Task EvaluateScript(string script, string functionName = null, TimeSpan? timeout = null) { const string TimeoutExceptionName = "Timeout"; #if DEBUG System.Diagnostics.Debug.WriteLine($"{nameof(EvaluateScript)} '{script}' on ('{Id}')"); #endif var evaluationTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); void Evaluate(string scriptToEvaluate) { #if DEBUG System.Diagnostics.Debug.WriteLine($"Evaluating '{script}' on ('{Id}')"); #endif try { var innerEvaluationTask = OwnerWebView.chromium.EvaluateJavaScript(WrapScriptWithErrorHandling(scriptToEvaluate), timeout: timeout); innerEvaluationTask.Wait(FlushTaskCancelationToken.Token); evaluationTask.SetResult(GetResult(innerEvaluationTask.Result)); } catch (Exception e) { if (FlushTaskCancelationToken.IsCancellationRequested) { evaluationTask.SetResult(GetResult(default(T))); } else if (e.InnerException is TaskCanceledException) { evaluationTask.SetException(new JavascriptException(TimeoutExceptionName, "Script evaluation timed out")); } else { evaluationTask.SetException(ParseException(e, new[] { functionName })); } } } var scriptTask = QueueScript(script, functionName, Evaluate); if (scriptTask == null) { return default; } if (timeout.HasValue) { var tasks = new [] { evaluationTask.Task, Task.Delay(timeout.Value) }; // wait with timeout if flush is not running yet to avoid hanging forever var task = await Task.WhenAny(tasks).ConfigureAwait(false); if (task != evaluationTask.Task) { if (IsFlushTaskInitializing) { throw new JavascriptException(TimeoutExceptionName, $"Javascript engine is not initialized after {InitializationTimeout.Seconds}s"); } // flush is already running, timeout will fire from the evaluation } } return await evaluationTask.Task; } public Task EvaluateScriptFunction(string functionName, bool serializeParams, params object[] args) { return EvaluateScript(MakeScript(functionName, serializeParams, args, emitReturn: true), functionName); } public void ExecuteScriptFunction(string functionName, bool serializeParams, params object[] args) { QueueScript(MakeScript(functionName, serializeParams, args, emitReturn: false), functionName); } public void ExecuteScript(string script) { QueueScript(script); } private T GetResult(object result) { var targetType = typeof(T); if (result == null) { if (targetType.IsArray) { // return empty arrays when value is null and return type is array return (T)(object)Array.CreateInstance(targetType.GetElementType(), 0); } return default(T); // return default T (its safer, because we allow returning null and converting into a default struct value) } if (IsBasicType(targetType)) { return (T)result; } return (T)result; } public void Dispose() { StopFlush(); } private static bool IsBasicType(Type type) { return type.IsPrimitive || type.IsEnum || type == typeof(string); } private static string MakeScript(string functionName, bool serializeParams, object[] args, bool emitReturn) { string SerializeParam(object value) { if (serializeParams || value == null) { return JavascriptSerializer.Serialize(value); } // TODO complex types return value.ToString(); } var argsSerialized = args.Select(SerializeParam); return $"{(emitReturn ? "return " : string.Empty)}{functionName}({string.Join(",", argsSerialized)})"; } private static string WrapScriptWithErrorHandling(string script) { return "try {" + script + Environment.NewLine + "} catch (e) { throw JSON.stringify({ stack: e.stack, message: e.message, name: e.name }) + '" + InternalException + "' }"; } private static T DeserializeJSON(string json) { var serializer = new DataContractJsonSerializer(typeof(JsError)); using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(json))) { return (T)serializer.ReadObject(stream); } } private static Exception MakeTimeoutException(string functionName, TimeSpan timeout) { return new JavascriptException("Timeout", $"More than {timeout.TotalMilliseconds}ms elapsed evaluating: '{functionName}'"); } private static Exception ParseException(Exception exception, IEnumerable evaluatedScriptFunctions) { var jsErrorJSON = ((exception is AggregateException aggregateException) ? aggregateException.InnerExceptions.FirstOrDefault(e => IsInternalException(e.Message))?.Message : exception.Message) ?? ""; // try parse js exception jsErrorJSON = jsErrorJSON.Substring(Math.Max(0, jsErrorJSON.IndexOf("{"))); jsErrorJSON = jsErrorJSON.Substring(0, jsErrorJSON.LastIndexOf("}") + 1); var evaluatedStackFrames = evaluatedScriptFunctions.Where(f => !string.IsNullOrEmpty(f)) .Select(f => new JavascriptStackFrame(f, "eval", 0, 0)); if (!string.IsNullOrEmpty(jsErrorJSON)) { JsError jsError = null; try { jsError = DeserializeJSON(jsErrorJSON); } catch { // ignore will throw error at the end } if (jsError != null) { jsError.Name = jsError.Name ?? ""; jsError.Message = jsError.Message ?? ""; jsError.Stack = jsError.Stack ?? ""; var jsStack = jsError.Stack.Substring(Math.Min(jsError.Stack.Length, (jsError.Name + ": " + jsError.Message).Length)) .Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); var parsedStack = new List(); parsedStack.AddRange(evaluatedStackFrames); foreach (var stackFrame in jsStack) { var frameParts = StackFrameRegex.Match(stackFrame); if (frameParts.Success) { parsedStack.Add(new JavascriptStackFrame(frameParts.Groups["method"].Value, frameParts.Groups["location"].Value, int.Parse(frameParts.Groups["column"].Value), int.Parse(frameParts.Groups["line"].Value))); } } return new JavascriptException(jsError.Name, jsError.Message, parsedStack); } } return new JavascriptException(exception.Message, evaluatedStackFrames, exception.StackTrace); } internal static bool IsInternalException(string exceptionMessage) { return exceptionMessage.EndsWith(InternalException); } } } }