Skip to content

Instantly share code, notes, and snippets.

@R0Wi
Last active December 6, 2023 05:36
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save R0Wi/e1266fa4ca0dfa5a65a5f05c188f18b6 to your computer and use it in GitHub Desktop.
Save R0Wi/e1266fa4ca0dfa5a65a5f05c188f18b6 to your computer and use it in GitHub Desktop.
Simple .NET WebView2 DevTools helper to make it possible to invoke async Javascript code (see https://github.com/MicrosoftEdge/WebView2Feedback/issues/416#issuecomment-1073533384)
public static async Task Main()
{
/*
* Use this codesnippet inside your WinForm/WPF WebView2 application
*/
var wv2 = new WebView2();
await wv2.EnsureCoreWebView2Async();
var devTools = new WebView2DevTools(wv2.CoreWebView2);
// Some result vom Javascript
var result = int.Parse(await devTools.EvaluateAsync("(function() { return Promise.resolve(42); })()"));
// A function without result but some execution time (2 seconds)
await devTools.EvaluateAsync("new Promise(resolve => setTimeout(resolve, 2000))");
}
/*
* @copyright Copyright (c) 2022 Robin Windey <ro.windey@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
* ---------------------------------------------------------------------------
* This helper makes it possible to invoke and await async Javascript code via
* WebView2 DevTools interface to overcome the issue mentioned at
* https://github.com/MicrosoftEdge/WebView2Feedback/issues/416#issuecomment-1073533384
*
* To use this class make sure you have the following NuGet packages installed in your project:
* - https://www.nuget.org/packages/Newtonsoft.Json/
* - https://www.nuget.org/packages/Microsoft.Web.WebView2/
*
* Class is highly inspired by
* - https://docs.microsoft.com/en-us/microsoft-edge/webview2/how-to/chromium-devtools-protocol
* - https://github.com/ChromiumDotNet/WebView2.DevTools.Dom
* - https://github.com/hardkoded/puppeteer-sharp
*/
using System;
using System.Threading.Tasks;
using Microsoft.Web.WebView2.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace MyNamespace
{
/// <summary>
/// Implements the <see cref="EvaluateAsync(string)"/> function via chrome
/// devtools protocol to ensure WebView2 async script results can be awaited.
/// Javascript code is always executed against main window.
/// Highly inspired by Microsoft.Web.WebView2.Core.DevToolsProtocolExtension.DevToolsProtocolHelper.Runtime.EvaluateAsync (https://docs.microsoft.com/en-us/microsoft-edge/webview2/how-to/chromium-devtools-protocol)
/// See also https://github.com/MicrosoftEdge/WebView2Feedback/issues/416#issuecomment-1073533384.
/// </summary>
internal sealed class WebView2DevTools
{
private readonly CoreWebView2 _coreWebView2;
public WebView2DevTools(CoreWebView2 coreWebView2)
{
_coreWebView2 = coreWebView2;
}
#region public
public async Task<object> EvaluateAsync(string jsFunctionCode)
{
var callParams = new EvaluateAsyncParameters { Expression = jsFunctionCode };
var jsonCallParams = JsonConvert.SerializeObject(callParams, GetSerializerSettings());
// https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#method-evaluate
var retValJson = await _coreWebView2.CallDevToolsProtocolMethodAsync("Runtime.evaluate", jsonCallParams);
var retVal = JsonConvert.DeserializeObject<FunctionCallResult>(retValJson);
ThrowJavascriptErrorIfAny(retVal);
return retVal.Result.Value;
}
#endregion
#region private
private JsonSerializerSettings GetSerializerSettings() => new JsonSerializerSettings
{
Formatting = Formatting.None,
NullValueHandling = NullValueHandling.Ignore,
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
private static void ThrowJavascriptErrorIfAny(FunctionCallResult functionCallResult)
{
if (functionCallResult.ExceptionDetails == null)
return;
var msg = $"Javascript error: {functionCallResult.ExceptionDetails.Text}";
if (functionCallResult.Result != null)
msg += $" ({functionCallResult.Result.Description})";
throw new InvalidOperationException(msg);
}
#endregion
#region Json messages and types
/*
* Types are defined at https://github.com/ChromeDevTools/devtools-protocol/blob/master/json/js_protocol.json#L2760
*/
private class Context
{
public AuxData AuxData { get; set; }
public int Id { get; set; }
public string Name { get; set; }
public string Origin { get; set; }
public string UniqueId { get; set; }
}
private class ExecutionContextCreatedEventArgs : EventArgs
{
public Context Context { get; set; }
}
private class RemoteObject
{
public string Type { get; set; }
public string Subtype { get; set; }
public string ClassName { get; set; }
public object Value { get; set; }
public string UnserializableValue { get; set; }
public string Description { get; set; }
public string ObjectId { get; set; }
}
private class ExceptionDetails
{
public int ExceptionId { get; set; }
public string Text { get; set; }
public int LineNumber { get; set; }
public int ColumnNumber { get; set; }
public string ScriptId { get; set; }
public string Url { get; set; }
public int? ExecutionContextId { get; set; }
}
private class FunctionCallResult
{
public RemoteObject Result { get; set; }
public ExceptionDetails ExceptionDetails { get; set; }
}
private class EvaluateAsyncParameters
{
public string Expression { get; set; }
public string ObjectGroup { get; set; }
public bool? Silent { get; set; }
public int? ContextId { get; set; } // If this param is omitted, the evaluation will be performed in the context of the inspected page.
public bool? ReturnByValue { get; set; } = true;
public bool? AwaitPromise { get; set; } = true;
public bool? UserGesture { get; set; }
}
private class AuxData
{
public string FrameId { get; set; }
public bool IsDefault { get; set; }
public string Type { get; set; }
}
#endregion
}
}
@jkoehoorn
Copy link

if you have a iframe on your page this will mess with your _mainFrameExecutionContextId. I made a small change to the code to ignore the iframe context.

public string frameId { get; private set; }

    private void DevToolsProtocolEventReceived(object sender, CoreWebView2DevToolsProtocolEventReceivedEventArgs e)
    {
        var createdEventArgs = JsonConvert.DeserializeObject<ExecutionContextCreatedEventArgs>(e.ParameterObjectAsJson, GetSerializerSettings());
        if (frameId == null)
        {
            frameId = createdEventArgs.Context.AuxData.FrameId;
        }
        if (createdEventArgs.Context.AuxData.IsDefault && frameId == createdEventArgs.Context.AuxData.FrameId)
            _mainFrameExecutionContextId = createdEventArgs.Context.Id;
    }

@R0Wi
Copy link
Author

R0Wi commented Jun 20, 2022

if you have a iframe on your page this will mess with your _mainFrameExecutionContextId. I made a small change to the code to ignore the iframe context.

public string frameId { get; private set; }

    private void DevToolsProtocolEventReceived(object sender, CoreWebView2DevToolsProtocolEventReceivedEventArgs e)
    {
        var createdEventArgs = JsonConvert.DeserializeObject<ExecutionContextCreatedEventArgs>(e.ParameterObjectAsJson, GetSerializerSettings());
        if (frameId == null)
        {
            frameId = createdEventArgs.Context.AuxData.FrameId;
        }
        if (createdEventArgs.Context.AuxData.IsDefault && frameId == createdEventArgs.Context.AuxData.FrameId)
            _mainFrameExecutionContextId = createdEventArgs.Context.Id;
    }

I edited the snippet to behave the same way as Microsoft.Web.WebView2.Core.DevToolsProtocolExtension.DevToolsProtocolHelper.Runtime.EvaluateAsync so no tracking of the contextId is needed anymore. JS code is always executed against the main frame.

@masterofllamas
Copy link

If I try something like:

await devTools.EvaluateAsync((function() { return Promise.resolve(fetch('https://stackoverflow.com')); })());

This causes WebView2 to hang. Do you know how to get around that? Basically I'm trying to use this to workaround the fact that WebView2's ExecuteScriptAsync doesn't support top level await, but I can't figure out how to get the result of a fetch.

@R0Wi
Copy link
Author

R0Wi commented Jul 23, 2022

If I try something like:

await devTools.EvaluateAsync((function() { return Promise.resolve(fetch('https://stackoverflow.com')); })());

This causes WebView2 to hang. Do you know how to get around that? Basically I'm trying to use this to workaround the fact that WebView2's ExecuteScriptAsync doesn't support top level await, but I can't figure out how to get the result of a fetch.

I also noticed that WebView2 is picky sometimes.

I don't know your full code but i think you'll first have to navigate to a page before you're able to fetch a result. What's working for me is something like this:

        private async void Form1_Load(object sender, EventArgs e)
        {
            await webView.EnsureCoreWebView2Async();
            var devTools = new WebView2DevTools(webView.CoreWebView2);

            // Callback when navigation finished
            webView.NavigationCompleted += async (__, _) =>
            {
                var htmlBody = await devTools.EvaluateAsync(@"new Promise(resolve => {
                    fetch('https://stackoverflow.com')
                        .then(response => {
                            resolve(response.text());
                        });
                })");
            };

            // Navigate for fetch
            webView.CoreWebView2.Navigate("https://stackoverflow.com");
        }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment