From 03b7ab22b4058798a8989545933238d8d12cb03e Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 11 Nov 2025 03:53:46 +0200 Subject: [PATCH 01/11] Add tests for checking quotes --- .../HybridWebView/HybridWebViewTestsBase.cs | 8 +- ...ybridWebViewTests_InvokeJavaScriptAsync.cs | 154 ++++++++++++++++++ .../Resources/Raw/HybridTestRoot/index.html | 51 ++++++ 3 files changed, 212 insertions(+), 1 deletion(-) diff --git a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTestsBase.cs b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTestsBase.cs index f5c3e3609b37..68522216d53e 100644 --- a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTestsBase.cs +++ b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTestsBase.cs @@ -40,6 +40,7 @@ protected async Task RunTest(string? defaultFile, Func test HybridRoot = "HybridTestRoot", DefaultFile = defaultFile ?? "index.html", }; + await RunTest(hybridWebView, (handler, view) => test(view)); } @@ -58,7 +59,12 @@ await AttachAndRun(hybridWebView, async handler => { await WebViewHelpers.WaitForHybridWebViewLoaded(hybridWebView); - await test((HybridWebViewHandler)handler, hybridWebView); + // Use a cancellation token with a timeout + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + var testWrapper = test((HybridWebViewHandler)handler, hybridWebView); + + await testWrapper.WaitAsync(cts.Token); }); } diff --git a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs index 1d0b36a7faad..d774cd752a33 100644 --- a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs +++ b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs @@ -1,6 +1,8 @@ #nullable enable using System; using System.Collections.Generic; +using System.Text; +using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using Xunit; @@ -358,6 +360,158 @@ public Task InvokeJavaScriptMethodThatThrowsTypedNumber(string type) => Assert.NotNull(ex.InnerException?.StackTrace); }); + [Fact] + public Task InvokeJavaScriptWithJsonStringArgument() => + RunTest(async (hybridWebView) => + { + // Create a dictionary that will be serialized to JSON + var contextArg = new Dictionary + { + { "userId", "userIdValue" }, + { "sessionId", "session123" }, + { "timestamp", "2025-11-11T01:30:00Z" } + }; + + // Serialize to JSON string (without base64 encoding) + string contextArgString = JsonSerializer.Serialize(contextArg); + + // This should not timeout - the JSON string should be handled correctly + var result = await hybridWebView.InvokeJavaScriptAsync( + "EchoJsonParameter", + InvokeJsonContext.Default.String, + [contextArgString], + [InvokeJsonContext.Default.String]); + + // Verify the result matches the input + Assert.Equal(contextArgString, result); + }); + + [Fact] + public Task InvokeJavaScriptWithComplexJsonString() => + RunTest(async (hybridWebView) => + { + // Create a more complex JSON with special characters that might cause escaping issues + var complexObject = new Dictionary + { + { "string", "value with \"quotes\" and 'apostrophes'" }, + { "number", 123.456 }, + { "boolean", true }, + { "nested", new Dictionary + { + { "key1", "value1" }, + { "key2", "value2" } + } + } + }; + + string jsonString = JsonSerializer.Serialize(complexObject); + + var result = await hybridWebView.InvokeJavaScriptAsync( + "ParseAndStringifyJson", + InvokeJsonContext.Default.String, + [jsonString], + [InvokeJsonContext.Default.String]); + + // The JavaScript function should parse and re-stringify the JSON + // The result should be equivalent (though formatting might differ) + Assert.NotNull(result); + Assert.Contains("quotes", result, StringComparison.Ordinal); + Assert.Contains("apostrophes", result, StringComparison.Ordinal); + }); + + [Fact] + public Task InvokeJavaScriptWithMultipleJsonStringArguments() => + RunTest(async (hybridWebView) => + { + var firstJson = JsonSerializer.Serialize(new { type = "user", id = 1 }); + var secondJson = JsonSerializer.Serialize(new { type = "session", id = 2 }); + + var result = await hybridWebView.InvokeJavaScriptAsync( + "ConcatenateJsonStrings", + InvokeJsonContext.Default.String, + [firstJson, secondJson], + [InvokeJsonContext.Default.String, InvokeJsonContext.Default.String]); + + Assert.NotNull(result); + Assert.Contains("user", result, StringComparison.Ordinal); + Assert.Contains("session", result, StringComparison.Ordinal); + }); + + [Fact] + public Task InvokeJavaScriptWithJsonStringDoesNotTimeout() => + RunTest(async (hybridWebView) => + { + var contextArg = new Dictionary + { + { "userId", "userIdValue" } + }; + + string contextArgString = JsonSerializer.Serialize(contextArg); + + // This should complete within the timeout + var result = await hybridWebView.InvokeJavaScriptAsync( + "EchoJsonParameter", + InvokeJsonContext.Default.String, + [contextArgString], + [InvokeJsonContext.Default.String]); + + Assert.Equal(contextArgString, result); + }); + + [Fact] + public Task InvokeJavaScriptWithBase64EncodedJsonString() => + RunTest(async (hybridWebView) => + { + var contextArg = new Dictionary + { + { "userId", "userIdValue" } + }; + + string contextArgString = JsonSerializer.Serialize(contextArg); + + // Base64 encode (the workaround from the issue) + string base64String = Convert.ToBase64String(Encoding.UTF8.GetBytes(contextArgString)); + + var result = await hybridWebView.InvokeJavaScriptAsync( + "DecodeBase64AndEcho", + InvokeJsonContext.Default.String, + [base64String], + [InvokeJsonContext.Default.String]); + + // The JavaScript function should decode and return the original JSON + Assert.Equal(contextArgString, result); + }); + + [Fact] + public Task InvokeJavaScriptWithJsonArrayArgument() => + RunTest(async (hybridWebView) => + { + var jsonArray = JsonSerializer.Serialize(new[] { "item1", "item2", "item3" }); + + var result = await hybridWebView.InvokeJavaScriptAsync( + "CountJsonArrayItems", + InvokeJsonContext.Default.Int32, + [jsonArray], + [InvokeJsonContext.Default.String]); + + Assert.Equal(3, result); + }); + + [Fact] + public Task InvokeJavaScriptWithEmptyJsonObject() => + RunTest(async (hybridWebView) => + { + var emptyJson = JsonSerializer.Serialize(new Dictionary()); + + var result = await hybridWebView.InvokeJavaScriptAsync( + "EchoJsonParameter", + InvokeJsonContext.Default.String, + [emptyJson], + [InvokeJsonContext.Default.String]); + + Assert.Equal("{}", result); + }); + Task RunExceptionTest(string method, int errorType, Action test) => RunTest(async (hybridWebView) => { diff --git a/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html b/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html index bd5ee137d8d4..04a4c27a51ca 100644 --- a/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html +++ b/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html @@ -158,6 +158,57 @@ // test evaluate arbitrary javascript window.TestKey = 'test_value'; + // Test functions for JSON string arguments (issue 32438) + + // Echo back the JSON parameter as-is + function EchoJsonParameter(jsonString) { + return jsonString; + } + + // Parse JSON string and stringify it back + function ParseAndStringifyJson(jsonString) { + try { + const parsed = JSON.parse(jsonString); + return JSON.stringify(parsed); + } catch (e) { + throw new Error(`Failed to parse JSON: ${e.message}`); + } + } + + // Concatenate two JSON strings into an array + function ConcatenateJsonStrings(json1, json2) { + try { + const obj1 = JSON.parse(json1); + const obj2 = JSON.parse(json2); + return JSON.stringify([obj1, obj2]); + } catch (e) { + throw new Error(`Failed to concatenate JSON strings: ${e.message}`); + } + } + + // Decode base64 string and echo back the decoded JSON + function DecodeBase64AndEcho(base64String) { + try { + const decoded = atob(base64String); + return decoded; + } catch (e) { + throw new Error(`Failed to decode base64: ${e.message}`); + } + } + + // Count items in a JSON array + function CountJsonArrayItems(jsonArrayString) { + try { + const array = JSON.parse(jsonArrayString); + if (!Array.isArray(array)) { + throw new Error('Argument is not a JSON array'); + } + return array.length; + } catch (e) { + throw new Error(`Failed to count array items: ${e.message}`); + } + } + From ed4c62637795bd6461dfbf56d54f3f242c2d3968 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 11 Nov 2025 03:56:42 +0200 Subject: [PATCH 02/11] Add some more tests --- ...HybridWebViewTests_EvaluateJavaScriptAsync.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_EvaluateJavaScriptAsync.cs b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_EvaluateJavaScriptAsync.cs index fcd6b52346cf..6022241e35a5 100644 --- a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_EvaluateJavaScriptAsync.cs +++ b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_EvaluateJavaScriptAsync.cs @@ -11,15 +11,29 @@ namespace Microsoft.Maui.DeviceTests; public partial class HybridWebViewTests_EvaluateJavaScriptAsync : HybridWebViewTestsBase { [Fact] - public Task EvaluateJavaScriptAndGetResult() => + public Task EvaluateJavaScriptAndGetResultFromFunction() => RunTest(async (hybridWebView) => { // Run some JavaScript to call a method and get result var result1 = await hybridWebView.EvaluateJavaScriptAsync("EvaluateMeWithParamsAndReturn('abc', 'def')"); Assert.Equal("abcdef", result1); + }); + [Fact] + public Task EvaluateJavaScriptAndGetResultFromProperty() => + RunTest(async (hybridWebView) => + { // Run some JavaScript to get an arbitrary result by running JavaScript var result2 = await hybridWebView.EvaluateJavaScriptAsync("window.TestKey"); Assert.Equal("test_value", result2); }); + + [Fact] + public Task EvaluateJavaScriptAndPassStrings() => + RunTest(async (hybridWebView) => + { + // Run some JavaScript to call a method and get result + var result1 = await hybridWebView.EvaluateJavaScriptAsync("EvaluateMeWithParamsAndReturn('\"Hel', 'lo!\"')"); + Assert.Equal("\"Hello!\"", result1); + }); } From 649dbb8f7d6afdec1033a1f7a39b153ee2093b52 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 11 Nov 2025 01:05:38 +0200 Subject: [PATCH 03/11] Fix the JS invoke --- .../Controls.Sample.Sandbox/App.xaml.cs | 113 +++++++++++++--- .../Maui.Controls.Sample.Sandbox.csproj | 1 + .../Controls.Sample.Sandbox/MauiProgram.cs | 12 +- .../Resources/Raw/test/index.html | 99 ++++++++++++++ .../HybridWebView/HybridWebViewHandler.cs | 126 +++++++++++++----- 5 files changed, 296 insertions(+), 55 deletions(-) create mode 100644 src/Controls/samples/Controls.Sample.Sandbox/Resources/Raw/test/index.html diff --git a/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs index 9512dea98e39..e46794978f65 100644 --- a/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs +++ b/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs @@ -1,24 +1,97 @@ -namespace Maui.Controls.Sample; +using Microsoft.Maui.Controls; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Maui.Controls.Sample; + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(string))] +internal partial class HybridWebViewTypes : JsonSerializerContext { + // This type's attributes specify JSON serialization info to preserve type structure + // for trimmed builds. +} public partial class App : Application { - public App() - { - InitializeComponent(); - } - - protected override Window CreateWindow(IActivationState? activationState) - { - // To test shell scenarios, change this to true - bool useShell = false; - - if (!useShell) - { - return new Window(new NavigationPage(new MainPage())); - } - else - { - return new Window(new SandboxShell()); - } - } + public App() + { + //InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window(new MyMainPage()); + } } + +public class MyMainPage: ContentPage { + HybridWebView hybridWebView; + public MyMainPage() { + AbsoluteLayout abs = new(); + this.Content = abs; + + hybridWebView = new(); + hybridWebView.HybridRoot = "test"; + hybridWebView.DefaultFile= "index.html"; + abs.Add(hybridWebView); + hybridWebView.RawMessageReceived += async delegate (object? sender, HybridWebViewRawMessageReceivedEventArgs e) { + Debug.WriteLine("C# RAW MESSAGE RECEIVED: " + e?.Message); + }; + + this.SizeChanged += delegate (object? o, EventArgs e) { + if (this.Width > 0 && this.Height > 0) { + double screenWidth = this.Width; + double screenHeight = this.Height; + hybridWebView.HeightRequest = screenHeight; + hybridWebView.WidthRequest = screenWidth; + } + }; + + var timer = Application.Current!.Dispatcher.CreateTimer(); + timer.Interval = TimeSpan.FromSeconds(1); + timer.IsRepeating = true; + timer.Start(); + timer.Tick += async delegate { + Debug.WriteLine("Timer tick"); + + // create test object + Dictionary contextArg = new() { + { "userId", "userIdValue" }, + }; + + // can't pass in dictionary to hybridwebview - must convert to string first + string contextArgString = JsonSerializer.Serialize(contextArg); + + //==================================================================== + // COMMENT THIS LINE OUT AND IT FAILS IN WINDOWS BUT NOT ANDROID + //==================================================================== + // windows glitches and times out when you feed it a json string as argument - must use base64 + // contextArgString = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(contextArgString)); + //==================================================================== + + TimeSpan timeout = TimeSpan.FromSeconds(5); + using CancellationTokenSource cts = new CancellationTokenSource(timeout); + try { + Debug.WriteLine("START SET TEST FUNCTION with arg: " + contextArgString); + // must await or can't catch the error + var reply = await hybridWebView.InvokeJavaScriptAsync( + "window.testFunctionName", + HybridWebViewTypes.Default.String, // return type + new object[] { contextArgString }, // arguments as [] + new[] { HybridWebViewTypes.Default.String } // argument types as [] + ); + + Debug.WriteLine("Set test function, reply: " + reply?.ToString()); + } + catch (OperationCanceledException) { Debug.WriteLine($"testFunctionName timed out"); } + catch (Exception ex) { Debug.WriteLine("EXCEPTION WRITING testFunctionName: " + ex.Message); } + + }; + + } + +} \ No newline at end of file diff --git a/src/Controls/samples/Controls.Sample.Sandbox/Maui.Controls.Sample.Sandbox.csproj b/src/Controls/samples/Controls.Sample.Sandbox/Maui.Controls.Sample.Sandbox.csproj index c78b4e2b2a4e..cd0f781591f0 100644 --- a/src/Controls/samples/Controls.Sample.Sandbox/Maui.Controls.Sample.Sandbox.csproj +++ b/src/Controls/samples/Controls.Sample.Sandbox/Maui.Controls.Sample.Sandbox.csproj @@ -10,6 +10,7 @@ enable enable $(NoWarn);XC0022 + false maccatalyst-x64 maccatalyst-arm64 diff --git a/src/Controls/samples/Controls.Sample.Sandbox/MauiProgram.cs b/src/Controls/samples/Controls.Sample.Sandbox/MauiProgram.cs index d4941633bbd8..b7d6b05c9373 100644 --- a/src/Controls/samples/Controls.Sample.Sandbox/MauiProgram.cs +++ b/src/Controls/samples/Controls.Sample.Sandbox/MauiProgram.cs @@ -2,8 +2,9 @@ public static class MauiProgram { - public static MauiApp CreateMauiApp() => - MauiApp + public static MauiApp CreateMauiApp() + { + MauiAppBuilder mauiAppBuilder = MauiApp .CreateBuilder() #if __ANDROID__ || __IOS__ .UseMauiMaps() @@ -21,6 +22,11 @@ public static MauiApp CreateMauiApp() => fonts.AddFont("SegoeUI-Bold.ttf", "Segoe UI Bold"); fonts.AddFont("SegoeUI-Italic.ttf", "Segoe UI Italic"); fonts.AddFont("SegoeUI-Bold-Italic.ttf", "Segoe UI Bold Italic"); - }) + }); + + mauiAppBuilder.Services.AddHybridWebViewDeveloperTools(); + + return mauiAppBuilder .Build(); + } } diff --git a/src/Controls/samples/Controls.Sample.Sandbox/Resources/Raw/test/index.html b/src/Controls/samples/Controls.Sample.Sandbox/Resources/Raw/test/index.html new file mode 100644 index 000000000000..b5780db67811 --- /dev/null +++ b/src/Controls/samples/Controls.Sample.Sandbox/Resources/Raw/test/index.html @@ -0,0 +1,99 @@ + + + + + HybridWebView Test + + + + + + + + + + + +
+
+
HybridWebView test page
+
Waiting for native call… check console logs.
+
+
+ + + + diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs index 0ed4846412ae..e2d2961366e2 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs @@ -165,7 +165,7 @@ void MessageReceived(string rawMessage) { var jsError = JsonSerializer.Deserialize(result, HybridWebViewHandlerJsonContext.Default.JSInvokeError); var jsException = new HybridWebViewInvokeJavaScriptException(jsError?.Message, jsError?.Name, jsError?.StackTrace); - var ex = new HybridWebViewInvokeJavaScriptException($"InvokeJavaScript threw an exception: {jsException.Message}", jsException); + var ex = new HybridWebViewInvokeJavaScriptException($"InvokeJavaScriptAsync threw an exception: {jsException.Message}", jsException); taskManager.SetTaskFailed(taskId, ex); } } @@ -358,6 +358,15 @@ private sealed class JSInvokeError public string? StackTrace { get; set; } } + private sealed class JSInvokeResult + { + public string? Result { get; set; } + public bool IsError { get; set; } + public string? Name { get; set; } + public string? Message { get; set; } + public string? StackTrace { get; set; } + } + private sealed class DotNetInvokeResult { public object? Result { get; set; } @@ -371,13 +380,12 @@ private sealed class DotNetInvokeResult [JsonSourceGenerationOptions()] [JsonSerializable(typeof(JSInvokeMethodData))] [JsonSerializable(typeof(JSInvokeError))] + [JsonSerializable(typeof(JSInvokeResult))] [JsonSerializable(typeof(DotNetInvokeResult))] private partial class HybridWebViewHandlerJsonContext : JsonSerializerContext { } - - #if PLATFORM && !TIZEN public static async void MapEvaluateJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, object? arg) { @@ -393,43 +401,85 @@ public static async void MapEvaluateJavaScriptAsync(IHybridWebViewHandler handle return; } - var script = request.Script; - // Make all the platforms mimic Android's implementation, which is by far the most complete. - if (!OperatingSystem.IsAndroid()) - { - script = WebViewHelper.EscapeJsString(script); - - if (!OperatingSystem.IsWindows()) - { - // Use JSON.stringify() method to converts a JavaScript value to a JSON string - script = "try{JSON.stringify(eval('" + script + "'))}catch(e){'null'};"; - } - else - { - script = "try{eval('" + script + "')}catch(e){'null'};"; - } - } - + // Escape and wrap script with try-catch and error handling for all platforms + var escapedScript = WebViewHelper.EscapeJsString(request.Script); + var script = + $$""" + (function() { + try { + console.warn('{{escapedScript}}'); + + let result = eval('{{escapedScript}}'); + let resultObj = { + IsError: false, + Result: JSON.stringify(result) + }; + return JSON.stringify(resultObj); + } catch (error) { + console.error(error); + let errorObj; + if (!error) { + errorObj = { + IsError: true, + Message: 'Unknown error', + StackTrace: Error().stack + }; + } else if (error instanceof Error) { + errorObj = { + IsError: true, + Name: error.name, + Message: error.message, + StackTrace: error.stack + }; + } else if (typeof error === 'string') { + errorObj = { + IsError: true, + Message: error, + StackTrace: Error().stack + }; + } else { + errorObj = { + IsError: true, + Message: JSON.stringify(error), + StackTrace: Error().stack + }; + } + return JSON.stringify(errorObj); + } + })() + """; + // Use the handler command to evaluate the JS var innerRequest = new EvaluateJavaScriptAsyncRequest(script); EvaluateJavaScript(handler, hybridWebView, innerRequest); var result = await innerRequest.Task; - //if the js function errored or returned null/undefined treat it as null - if (result == "null") + var jsResult = JsonSerializer.Deserialize(result, HybridWebViewHandlerJsonContext.Default.JSInvokeResult); + if (jsResult?.IsError == true) { - result = null; + var jsException = new HybridWebViewInvokeJavaScriptException(jsResult?.Message, jsResult?.Name, jsResult?.StackTrace); + var ex = new HybridWebViewInvokeJavaScriptException($"EvaluateJavaScriptAsync threw an exception: {jsException.Message}", jsException); + request.SetException(ex); } - //JSON.stringify wraps the result in literal quotes, we just want the actual returned result - //note that if the js function returns the string "null" we will get here and not above - else if (result != null) + else { - result = result.Trim('"'); - } + var returnValue = jsResult?.Result; - request.SetResult(result!); + //if the js function errored or returned null/undefined treat it as null + if (returnValue == "null" || returnValue == "undefined") + { + returnValue = null; + } + //JSON.stringify wraps the result in literal quotes, we just want the actual returned result + //note that if the js function returns the string "null" we will get here and not above + else if (returnValue != null) + { + returnValue = returnValue.Trim('"'); + } + request.SetResult(returnValue!); + } } #endif @@ -467,10 +517,22 @@ public static async void MapInvokeJavaScriptAsync(IHybridWebViewHandler handler, ? string.Empty : string.Join( ", ", - invokeJavaScriptRequest.ParamValues.Select((v, i) => (v == null ? "null" : JsonSerializer.Serialize(v, invokeJavaScriptRequest.ParamJsonTypeInfos![i]!)))); + invokeJavaScriptRequest.ParamValues.Select((v, i) => + { + if (v == null) + return "null"; + + var serialized = JsonSerializer.Serialize(v, invokeJavaScriptRequest.ParamJsonTypeInfos![i]!); + + // Escape the JSON string for JavaScript so it can be passed as a string literal + var escaped = WebViewHelper.EscapeJsString(serialized); + + return $"'{escaped}'"; + })); + + var js = $"window.HybridWebView.__InvokeJavaScript({currentInvokeTaskId}, {invokeJavaScriptRequest.MethodName}, [{paramsValuesStringArray}])"; - await handler.InvokeAsync(nameof(IHybridWebView.EvaluateJavaScriptAsync), - new EvaluateJavaScriptAsyncRequest($"window.HybridWebView.__InvokeJavaScript({currentInvokeTaskId}, {invokeJavaScriptRequest.MethodName}, [{paramsValuesStringArray}])")); + await handler.InvokeAsync(nameof(IHybridWebView.EvaluateJavaScriptAsync), new EvaluateJavaScriptAsyncRequest(js)); var stringResult = await callback.Task; From dc23e6b93155c24757ecaad1fd1ee18f29a3eaa1 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 11 Nov 2025 03:29:46 +0200 Subject: [PATCH 04/11] More cool fixes --- .../HybridWebViewHandler.Standard.cs | 2 + .../HybridWebViewHandler.Tizen.cs | 2 + .../HybridWebView/HybridWebViewHandler.cs | 454 ++--------------- .../HybridWebView/HybridWebViewHelper.cs | 476 ++++++++++++++++++ 4 files changed, 509 insertions(+), 425 deletions(-) create mode 100644 src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Standard.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Standard.cs index 876cb0f223f1..862117e34d24 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Standard.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Standard.cs @@ -8,6 +8,8 @@ public partial class HybridWebViewHandler : ViewHandler public static void MapEvaluateJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, object? arg) { } + public static void MapInvokeJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, object? arg) { } + public static void MapSendRawMessage(IHybridWebViewHandler handler, IHybridWebView hybridWebView, object? arg) { } } } \ No newline at end of file diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Tizen.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Tizen.cs index c686585c373e..14330d546594 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Tizen.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.Tizen.cs @@ -8,6 +8,8 @@ public partial class HybridWebViewHandler : ViewHandler !AppContext.TryGetSwitch(InvokeJavaScriptThrowsExceptionsSwitch, out var enabled) || enabled; - void MessageReceived(string rawMessage) - { - if (string.IsNullOrEmpty(rawMessage)) - { - throw new ArgumentException($"The raw message cannot be null or empty.", nameof(rawMessage)); - } -#if !NETSTANDARD2_0 - var indexOfPipe = rawMessage.IndexOf('|', StringComparison.Ordinal); -#else - var indexOfPipe = rawMessage.IndexOf("|", StringComparison.Ordinal); -#endif - if (indexOfPipe == -1) - { - throw new ArgumentException($"The raw message must contain a pipe character ('|').", nameof(rawMessage)); - } - - var messageType = rawMessage.Substring(0, indexOfPipe); - var messageContent = rawMessage.Substring(indexOfPipe + 1); - - switch (messageType) - { - case "__InvokeJavaScriptFailed": - case "__InvokeJavaScriptCompleted": - { -#if !NETSTANDARD2_0 - var indexOfPipeInContent = messageContent.IndexOf('|', StringComparison.Ordinal); -#else - var indexOfPipeInContent = messageContent.IndexOf("|", StringComparison.Ordinal); -#endif - if (indexOfPipeInContent == -1) - { - throw new ArgumentException($"The '{messageType}' message content must contain a pipe character ('|').", nameof(rawMessage)); - } - - var taskId = messageContent.Substring(0, indexOfPipeInContent); - var result = messageContent.Substring(indexOfPipeInContent + 1); - - var taskManager = this.GetRequiredService(); - if (messageType == "__InvokeJavaScriptFailed") - { - if (IsInvokeJavaScriptThrowsExceptionsEnabled) - { - if (string.IsNullOrWhiteSpace(result)) - { - taskManager.SetTaskFailed(taskId, new HybridWebViewInvokeJavaScriptException()); - } - else - { - var jsError = JsonSerializer.Deserialize(result, HybridWebViewHandlerJsonContext.Default.JSInvokeError); - var jsException = new HybridWebViewInvokeJavaScriptException(jsError?.Message, jsError?.Name, jsError?.StackTrace); - var ex = new HybridWebViewInvokeJavaScriptException($"InvokeJavaScriptAsync threw an exception: {jsException.Message}", jsException); - taskManager.SetTaskFailed(taskId, ex); - } - } - } - else - { - taskManager.SetTaskCompleted(taskId, result); - } - } - break; - case "__RawMessage": - VirtualView?.RawMessageReceived(messageContent); - break; - default: - throw new ArgumentException($"The message type '{messageType}' is not recognized.", nameof(rawMessage)); - } - } + void MessageReceived(string rawMessage) => + HybridWebViewHelper.ProcessRawMessage(this, VirtualView, rawMessage); internal async Task InvokeDotNetAsync(Stream? streamBody = null, string? stringBody = null) { - try - { - var invokeTarget = VirtualView.InvokeJavaScriptTarget ?? throw new InvalidOperationException($"The {nameof(IHybridWebView)}.{nameof(IHybridWebView.InvokeJavaScriptTarget)} property must have a value in order to invoke a .NET method from JavaScript."); - var invokeTargetType = VirtualView.InvokeJavaScriptType ?? throw new InvalidOperationException($"The {nameof(IHybridWebView)}.{nameof(IHybridWebView.InvokeJavaScriptType)} property must have a value in order to invoke a .NET method from JavaScript."); - - JSInvokeMethodData? invokeData = null; - if (streamBody is not null) - { - invokeData = await JsonSerializer.DeserializeAsync(streamBody, HybridWebViewHandlerJsonContext.Default.JSInvokeMethodData); - } - else if (stringBody is not null && !string.IsNullOrWhiteSpace(stringBody)) - { - invokeData = JsonSerializer.Deserialize(stringBody, HybridWebViewHandlerJsonContext.Default.JSInvokeMethodData); - } - - if (invokeData?.MethodName is null) - { - throw new InvalidOperationException("The invoke data did not provide a method name."); - } - - var invokeResultRaw = await InvokeDotNetMethodAsync(invokeTargetType, invokeTarget, invokeData); - var invokeResult = CreateInvokeResult(invokeResultRaw); - var json = JsonSerializer.Serialize(invokeResult); - var contentBytes = Encoding.UTF8.GetBytes(json); - - return contentBytes; - } - catch (Exception ex) - { - MauiContext?.CreateLogger()?.LogError(ex, "An error occurred while invoking a .NET method from JavaScript: {ErrorMessage}", ex.Message); - - // Return error response instead of null so JavaScript can handle the error - var errorResult = CreateErrorResult(ex); - var errorJson = JsonSerializer.Serialize(errorResult, HybridWebViewHandlerJsonContext.Default.DotNetInvokeResult); - var errorBytes = Encoding.UTF8.GetBytes(errorJson); - return errorBytes; - } - } - - private static DotNetInvokeResult CreateInvokeResult(object? result) - { - // null invoke result means an empty result - if (result is null) - { - return new(); - } - - // a reference type or an array should be serialized to JSON - var resultType = result.GetType(); - if (resultType.IsArray || resultType.IsClass) - { - return new DotNetInvokeResult() - { - Result = JsonSerializer.Serialize(result), - IsJson = true, - }; - } - - // a value type should be returned as is - return new DotNetInvokeResult() - { - Result = result, - }; - } - - private static DotNetInvokeResult CreateErrorResult(Exception ex) - { - return new DotNetInvokeResult() - { - IsError = true, - ErrorMessage = ex.Message, - ErrorType = ex.GetType().Name, - ErrorStackTrace = ex.StackTrace - }; - } - - private static async Task InvokeDotNetMethodAsync( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type targetType, - object jsInvokeTarget, - JSInvokeMethodData invokeData) - { - var requestMethodName = invokeData.MethodName!; - var requestParams = invokeData.ParamValues; - - // get the method and its parameters from the .NET object instance - var dotnetMethod = targetType.GetMethod(requestMethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.InvokeMethod); - if (dotnetMethod is null) - { - throw new InvalidOperationException($"The method {requestMethodName} couldn't be found on the {nameof(jsInvokeTarget)} of type {jsInvokeTarget.GetType().FullName}."); - } - var dotnetParams = dotnetMethod.GetParameters(); - if (requestParams is not null && dotnetParams.Length != requestParams.Length) - { - throw new InvalidOperationException($"The number of parameters on {nameof(jsInvokeTarget)}'s method {requestMethodName} ({dotnetParams.Length}) doesn't match the number of values passed from JavaScript code ({requestParams.Length})."); - } - - // deserialize the parameters from JSON to .NET types - object?[]? invokeParamValues = null; - if (requestParams is not null) - { - invokeParamValues = new object?[requestParams.Length]; - for (var i = 0; i < requestParams.Length; i++) - { - var reqValue = requestParams[i]; - var paramType = dotnetParams[i].ParameterType; - var deserialized = JsonSerializer.Deserialize(reqValue, paramType); - invokeParamValues[i] = deserialized; - } - } - - // invoke the .NET method - var dotnetReturnValue = GetDotNetMethodReturnValue(jsInvokeTarget, dotnetMethod, invokeParamValues); - - if (dotnetReturnValue is null) // null result - { - return null; - } - - if (dotnetReturnValue is Task task) // Task or Task result - { - await task; - - // Task - if (dotnetMethod.ReturnType.IsGenericType) - { - var resultProperty = dotnetMethod.ReturnType.GetProperty(nameof(Task.Result)); - return resultProperty?.GetValue(task); - } - - // Task - return null; - } - - return dotnetReturnValue; // regular result - } - - private static object? GetDotNetMethodReturnValue(object jsInvokeTarget, MethodInfo dotnetMethod, object?[]? invokeParamValues) - { - try - { - // invoke the .NET method - return dotnetMethod.Invoke(jsInvokeTarget, invokeParamValues); - } - catch (TargetInvocationException tie) // unwrap while preserving original stack trace - { - if (tie.InnerException is not null) - { - // Rethrow the underlying exception without losing its original stack trace - ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); - - // unreachable, but required for compiler flow analysis - throw; - } - - // no inner exception; rethrow the TargetInvocationException itself preserving its stack - throw; - } - } - - private sealed class JSInvokeMethodData - { - public string? MethodName { get; set; } - public string[]? ParamValues { get; set; } - } - - private sealed class JSInvokeError - { - public string? Name { get; set; } - public string? Message { get; set; } - public string? StackTrace { get; set; } - } - - private sealed class JSInvokeResult - { - public string? Result { get; set; } - public bool IsError { get; set; } - public string? Name { get; set; } - public string? Message { get; set; } - public string? StackTrace { get; set; } - } - - private sealed class DotNetInvokeResult - { - public object? Result { get; set; } - public bool IsJson { get; set; } - public bool IsError { get; set; } - public string? ErrorMessage { get; set; } - public string? ErrorType { get; set; } - public string? ErrorStackTrace { get; set; } - } - - [JsonSourceGenerationOptions()] - [JsonSerializable(typeof(JSInvokeMethodData))] - [JsonSerializable(typeof(JSInvokeError))] - [JsonSerializable(typeof(JSInvokeResult))] - [JsonSerializable(typeof(DotNetInvokeResult))] - private partial class HybridWebViewHandlerJsonContext : JsonSerializerContext - { + var logger = MauiContext?.CreateLogger(); + return await HybridWebViewHelper.ProcessInvokeDotNetAsync( + VirtualView?.InvokeJavaScriptTarget, + VirtualView?.InvokeJavaScriptType, + logger, + streamBody, + stringBody); } #if PLATFORM && !TIZEN public static async void MapEvaluateJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, object? arg) { - if (arg is not EvaluateJavaScriptAsyncRequest request || - handler.PlatformView is not MauiHybridWebView hybridPlatformWebView) + if (arg is not EvaluateJavaScriptAsyncRequest request) { return; } @@ -401,172 +132,45 @@ public static async void MapEvaluateJavaScriptAsync(IHybridWebViewHandler handle return; } - // Escape and wrap script with try-catch and error handling for all platforms - var escapedScript = WebViewHelper.EscapeJsString(request.Script); - var script = - $$""" - (function() { - try { - console.warn('{{escapedScript}}'); - - let result = eval('{{escapedScript}}'); - let resultObj = { - IsError: false, - Result: JSON.stringify(result) - }; - return JSON.stringify(resultObj); - } catch (error) { - console.error(error); - let errorObj; - if (!error) { - errorObj = { - IsError: true, - Message: 'Unknown error', - StackTrace: Error().stack - }; - } else if (error instanceof Error) { - errorObj = { - IsError: true, - Name: error.name, - Message: error.message, - StackTrace: error.stack - }; - } else if (typeof error === 'string') { - errorObj = { - IsError: true, - Message: error, - StackTrace: Error().stack - }; - } else { - errorObj = { - IsError: true, - Message: JSON.stringify(error), - StackTrace: Error().stack - }; - } - return JSON.stringify(errorObj); - } - })() - """; - - // Use the handler command to evaluate the JS - var innerRequest = new EvaluateJavaScriptAsyncRequest(script); - EvaluateJavaScript(handler, hybridWebView, innerRequest); - - var result = await innerRequest.Task; - - var jsResult = JsonSerializer.Deserialize(result, HybridWebViewHandlerJsonContext.Default.JSInvokeResult); - if (jsResult?.IsError == true) + try { - var jsException = new HybridWebViewInvokeJavaScriptException(jsResult?.Message, jsResult?.Name, jsResult?.StackTrace); - var ex = new HybridWebViewInvokeJavaScriptException($"EvaluateJavaScriptAsync threw an exception: {jsException.Message}", jsException); - request.SetException(ex); + // Delegate to helper for all processing logic + var result = await HybridWebViewHelper.ProcessEvaluateJavaScriptAsync(handler, hybridWebView, request); + + request.SetResult(result!); } - else + catch (Exception ex) { - var returnValue = jsResult?.Result; - - //if the js function errored or returned null/undefined treat it as null - if (returnValue == "null" || returnValue == "undefined") - { - returnValue = null; - } - //JSON.stringify wraps the result in literal quotes, we just want the actual returned result - //note that if the js function returns the string "null" we will get here and not above - else if (returnValue != null) - { - returnValue = returnValue.Trim('"'); - } - - request.SetResult(returnValue!); + request.SetException(ex); } } -#endif public static async void MapInvokeJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, object? arg) { -#if PLATFORM && !TIZEN - if (arg is not HybridWebViewInvokeJavaScriptRequest invokeJavaScriptRequest) + if (arg is not HybridWebViewInvokeJavaScriptRequest request) { return; } - try - { - var result = await MapInvokeJavaScriptAsyncImpl(handler, hybridWebView, invokeJavaScriptRequest); - - invokeJavaScriptRequest.SetResult(result); - } - catch (Exception ex) + if (handler.PlatformView is null) { - invokeJavaScriptRequest.SetException(ex); + request.SetCanceled(); + return; } -#else - await Task.CompletedTask; -#endif - } - - static async Task MapInvokeJavaScriptAsyncImpl(IHybridWebViewHandler handler, IHybridWebView hybridWebView, HybridWebViewInvokeJavaScriptRequest invokeJavaScriptRequest) - { - // Create a callback for async JavaScript methods to invoke when they are done - var taskManager = handler.GetRequiredService(); - var (currentInvokeTaskId, callback) = taskManager.CreateTask(); - - var paramsValuesStringArray = - invokeJavaScriptRequest.ParamValues == null - ? string.Empty - : string.Join( - ", ", - invokeJavaScriptRequest.ParamValues.Select((v, i) => - { - if (v == null) - return "null"; - var serialized = JsonSerializer.Serialize(v, invokeJavaScriptRequest.ParamJsonTypeInfos![i]!); - - // Escape the JSON string for JavaScript so it can be passed as a string literal - var escaped = WebViewHelper.EscapeJsString(serialized); - - return $"'{escaped}'"; - })); - - var js = $"window.HybridWebView.__InvokeJavaScript({currentInvokeTaskId}, {invokeJavaScriptRequest.MethodName}, [{paramsValuesStringArray}])"; - - await handler.InvokeAsync(nameof(IHybridWebView.EvaluateJavaScriptAsync), new EvaluateJavaScriptAsyncRequest(js)); - - var stringResult = await callback.Task; - - // if there is no result or if the result was null/undefined, then treat it as null - if (stringResult is null || stringResult == "null" || stringResult == "undefined") - { - return null; - } - // if we are not looking for a return object, then return null - else if (invokeJavaScriptRequest.ReturnTypeJsonTypeInfo is null) - { - return null; - } - // if we are expecting a result, then deserialize what we have - else + try { - var typedResult = JsonSerializer.Deserialize(stringResult, invokeJavaScriptRequest.ReturnTypeJsonTypeInfo); - return typedResult; - } - } + // Delegate to helper for all processing logic + var result = await HybridWebViewHelper.ProcessInvokeJavaScriptAsync(handler, hybridWebView, request); - internal static async Task GetAssetContentAsync(string assetPath) - { - using var stream = await GetAssetStreamAsync(assetPath); - if (stream == null) + request.SetResult(result); + } + catch (Exception ex) { - return null; + request.SetException(ex); } - using var reader = new StreamReader(stream); - - var contents = reader.ReadToEnd(); - - return contents; } +#endif internal static async Task GetAssetStreamAsync(string assetPath) { diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs new file mode 100644 index 000000000000..70e64e1a103d --- /dev/null +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs @@ -0,0 +1,476 @@ +#if PLATFORM && !TIZEN +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Maui.Handlers; + +namespace Microsoft.Maui; + +/// +/// Helper class containing all business logic for HybridWebView operations. +/// Keeps both Controls and Handler layers thin by centralizing processing logic. +/// +[RequiresUnreferencedCode("HybridWebView uses dynamic System.Text.Json serialization features.")] +#if !NETSTANDARD +[RequiresDynamicCode("HybridWebView uses dynamic System.Text.Json serialization features.")] +#endif +internal static partial class HybridWebViewHelper +{ + /// + /// Processes an EvaluateJavaScriptAsync request by wrapping the script with error handling + /// and processing the result. + /// + public static async Task ProcessEvaluateJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, EvaluateJavaScriptAsyncRequest request) + { + var script = request.Script; + + if (script == null) + { + return null; + } + + // Escape and wrap script with try-catch and error handling + var escapedScript = WebViewHelper.EscapeJsString(script); + var wrappedScript = + $$""" + (function() { + try { + console.warn('{{escapedScript}}'); + + let result = eval('{{escapedScript}}'); + let resultObj = { + IsError: false, + Result: JSON.stringify(result) + }; + return JSON.stringify(resultObj); + } catch (error) { + console.error(error); + let errorObj; + if (!error) { + errorObj = { + IsError: true, + Message: 'Unknown error', + StackTrace: Error().stack + }; + } else if (error instanceof Error) { + errorObj = { + IsError: true, + Name: error.name, + Message: error.message, + StackTrace: error.stack + }; + } else if (typeof error === 'string') { + errorObj = { + IsError: true, + Message: error, + StackTrace: Error().stack + }; + } else { + errorObj = { + IsError: true, + Message: JSON.stringify(error), + StackTrace: Error().stack + }; + } + return JSON.stringify(errorObj); + } + })() + """; + + // Use the handler command to evaluate the JS + var innerRequest = new EvaluateJavaScriptAsyncRequest(wrappedScript); + + // Execute via platform evaluator + handler.PlatformView.EvaluateJavaScript(innerRequest); + + var result = await innerRequest.Task; + + if (result == null) + return null; + + var jsResult = JsonSerializer.Deserialize(result); + if (jsResult?.IsError == true) + { + var jsException = new HybridWebViewInvokeJavaScriptException(jsResult?.Message, jsResult?.Name, jsResult?.StackTrace); + throw new HybridWebViewInvokeJavaScriptException($"EvaluateJavaScriptAsync threw an exception: {jsException.Message}", jsException); + } + + var returnValue = jsResult?.Result; + + //if the js function errored or returned null/undefined treat it as null + if (returnValue == "null" || returnValue == "undefined") + { + returnValue = null; + } + //JSON.stringify wraps the result in literal quotes, we just want the actual returned result + //note that if the js function returns the string "null" we will get here and not above + else if (returnValue != null) + { + returnValue = returnValue.Trim('"'); + } + + return returnValue; + } + + /// + /// Processes an InvokeJavaScriptAsync request by building the JS call string, + /// executing it, and processing the result. + /// + public static async Task ProcessInvokeJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, HybridWebViewInvokeJavaScriptRequest request) + { + var taskManager = handler.GetRequiredService(); + + // Create a callback for async JavaScript methods to invoke when they are done + var task = taskManager.CreateTask(); + + var paramsValuesStringArray = request.ParamValues == null + ? string.Empty + : string.Join( + ", ", + request.ParamValues.Select((v, i) => + { + if (v == null) + { + return "null"; + } + + var serialized = JsonSerializer.Serialize(v, request.ParamJsonTypeInfos![i]!); + + // Escape the JSON string for JavaScript so it can be passed as a string literal + var escaped = WebViewHelper.EscapeJsString(serialized); + + return $"'{escaped}'"; + })); + + var js = $"window.HybridWebView.__InvokeJavaScript({task.TaskId}, {request.MethodName}, [{paramsValuesStringArray}])"; + + var innerRequest = new EvaluateJavaScriptAsyncRequest(js); + + handler.PlatformView.EvaluateJavaScript(innerRequest); + + await innerRequest.Task; + + var stringResult = await task.TaskCompletionSource.Task; + + // if there is no result or if the result was null/undefined, then treat it as null + if (stringResult is null || stringResult == "null" || stringResult == "undefined") + { + return null; + } + // if we are not looking for a return object, then return null + else if (request.ReturnTypeJsonTypeInfo is null) + { + return null; + } + // if we are expecting a result, then deserialize what we have + else + { + var typedResult = JsonSerializer.Deserialize(stringResult, request.ReturnTypeJsonTypeInfo); + return typedResult; + } + } + + /// + /// Invokes a .NET method from JavaScript. + /// + public static async Task ProcessInvokeDotNetAsync( + object? invokeTarget, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type? invokeTargetType, + ILogger? logger, + Stream? streamBody = null, + string? stringBody = null) + { + try + { + if (invokeTarget is null) + { + throw new InvalidOperationException($"The InvokeJavaScriptTarget property must have a value in order to invoke a .NET method from JavaScript."); + } + + if (invokeTargetType is null) + { + throw new InvalidOperationException($"The InvokeJavaScriptType property must have a value in order to invoke a .NET method from JavaScript."); + } + + JSInvokeMethodData? invokeData = null; + if (streamBody is not null) + { + invokeData = await JsonSerializer.DeserializeAsync(streamBody, HybridWebViewHelperJsonContext.Default.JSInvokeMethodData); + } + else if (stringBody is not null && !string.IsNullOrWhiteSpace(stringBody)) + { + invokeData = JsonSerializer.Deserialize(stringBody, HybridWebViewHelperJsonContext.Default.JSInvokeMethodData); + } + + if (invokeData?.MethodName is null) + { + throw new InvalidOperationException("The invoke data did not provide a method name."); + } + + var invokeResultRaw = await InvokeDotNetMethodAsync(invokeTargetType, invokeTarget, invokeData); + var invokeResult = CreateInvokeResult(invokeResultRaw); + var json = JsonSerializer.Serialize(invokeResult, HybridWebViewHelperJsonContext.Default.DotNetInvokeResult); + var contentBytes = Encoding.UTF8.GetBytes(json); + + return contentBytes; + } + catch (Exception ex) + { + logger?.LogError(ex, "An error occurred while invoking a .NET method from JavaScript: {ErrorMessage}", ex.Message); + + // Return error response instead of null so JavaScript can handle the error + var errorResult = CreateErrorResult(ex); + var errorJson = JsonSerializer.Serialize(errorResult, HybridWebViewHelperJsonContext.Default.DotNetInvokeResult); + var errorBytes = Encoding.UTF8.GetBytes(errorJson); + return errorBytes; + } + } + + private static DotNetInvokeResult CreateInvokeResult(object? result) + { + // null invoke result means an empty result + if (result is null) + { + return new(); + } + + // a reference type or an array should be serialized to JSON + var resultType = result.GetType(); + if (resultType.IsArray || resultType.IsClass) + { + return new DotNetInvokeResult() + { + Result = JsonSerializer.Serialize(result), + IsJson = true, + }; + } + + // a value type should be returned as is + return new DotNetInvokeResult() + { + Result = result, + }; + } + + private static DotNetInvokeResult CreateErrorResult(Exception ex) + { + return new DotNetInvokeResult() + { + IsError = true, + ErrorMessage = ex.Message, + ErrorType = ex.GetType().Name, + ErrorStackTrace = ex.StackTrace + }; + } + + private static async Task InvokeDotNetMethodAsync( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type targetType, + object jsInvokeTarget, + JSInvokeMethodData invokeData) + { + var requestMethodName = invokeData.MethodName!; + var requestParams = invokeData.ParamValues; + + // get the method and its parameters from the .NET object instance + var dotnetMethod = targetType.GetMethod(requestMethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.InvokeMethod); + if (dotnetMethod is null) + { + throw new InvalidOperationException($"The method {requestMethodName} couldn't be found on the {nameof(jsInvokeTarget)} of type {jsInvokeTarget.GetType().FullName}."); + } + var dotnetParams = dotnetMethod.GetParameters(); + if (requestParams is not null && dotnetParams.Length != requestParams.Length) + { + throw new InvalidOperationException($"The number of parameters on {nameof(jsInvokeTarget)}'s method {requestMethodName} ({dotnetParams.Length}) doesn't match the number of values passed from JavaScript code ({requestParams.Length})."); + } + + // deserialize the parameters from JSON to .NET types + object?[]? invokeParamValues = null; + if (requestParams is not null) + { + invokeParamValues = new object?[requestParams.Length]; + for (var i = 0; i < requestParams.Length; i++) + { + var reqValue = requestParams[i]; + var paramType = dotnetParams[i].ParameterType; + var deserialized = JsonSerializer.Deserialize(reqValue, paramType); + invokeParamValues[i] = deserialized; + } + } + + // invoke the .NET method + var dotnetReturnValue = GetDotNetMethodReturnValue(jsInvokeTarget, dotnetMethod, invokeParamValues); + + if (dotnetReturnValue is null) // null result + { + return null; + } + + if (dotnetReturnValue is Task task) // Task or Task result + { + await task; + + // Task + if (dotnetMethod.ReturnType.IsGenericType) + { + var resultProperty = dotnetMethod.ReturnType.GetProperty(nameof(Task.Result)); + return resultProperty?.GetValue(task); + } + + // Task + return null; + } + + return dotnetReturnValue; // regular result + } + + private static object? GetDotNetMethodReturnValue(object jsInvokeTarget, MethodInfo dotnetMethod, object?[]? invokeParamValues) + { + try + { + // invoke the .NET method + return dotnetMethod.Invoke(jsInvokeTarget, invokeParamValues); + } + catch (TargetInvocationException tie) // unwrap while preserving original stack trace + { + if (tie.InnerException is not null) + { + // Rethrow the underlying exception without losing its original stack trace + ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + + // unreachable, but required for compiler flow analysis + throw; + } + + // no inner exception; rethrow the TargetInvocationException itself preserving its stack + throw; + } + } + + /// + /// Processes raw messages from the web view, handling special message types like JavaScript invoke results. + /// + public static void ProcessRawMessage(IHybridWebViewHandler handler, IHybridWebView virtualView, string rawMessage) + { + if (string.IsNullOrEmpty(rawMessage)) + { + throw new ArgumentException($"The raw message cannot be null or empty.", nameof(rawMessage)); + } +#if !NETSTANDARD2_0 + var indexOfPipe = rawMessage.IndexOf('|', StringComparison.Ordinal); +#else + var indexOfPipe = rawMessage.IndexOf("|", StringComparison.Ordinal); +#endif + if (indexOfPipe == -1) + { + throw new ArgumentException($"The raw message must contain a pipe character ('|').", nameof(rawMessage)); + } + + var messageType = rawMessage.Substring(0, indexOfPipe); + var messageContent = rawMessage.Substring(indexOfPipe + 1); + + switch (messageType) + { + case "__InvokeJavaScriptFailed": + case "__InvokeJavaScriptCompleted": + { +#if !NETSTANDARD2_0 + var indexOfPipeInContent = messageContent.IndexOf('|', StringComparison.Ordinal); +#else + var indexOfPipeInContent = messageContent.IndexOf("|", StringComparison.Ordinal); +#endif + if (indexOfPipeInContent == -1) + { + throw new ArgumentException($"The '{messageType}' message content must contain a pipe character ('|').", nameof(rawMessage)); + } + + var taskId = messageContent.Substring(0, indexOfPipeInContent); + var result = messageContent.Substring(indexOfPipeInContent + 1); + + var taskManager = handler.GetRequiredService(); + if (messageType == "__InvokeJavaScriptFailed") + { + if (IsInvokeJavaScriptThrowsExceptionsEnabled) + { + if (string.IsNullOrWhiteSpace(result)) + { + taskManager.SetTaskFailed(taskId, new HybridWebViewInvokeJavaScriptException()); + } + else + { + var jsError = JsonSerializer.Deserialize(result, HybridWebViewHelperJsonContext.Default.JSInvokeError); + var jsException = new HybridWebViewInvokeJavaScriptException(jsError?.Message, jsError?.Name, jsError?.StackTrace); + var ex = new HybridWebViewInvokeJavaScriptException($"InvokeJavaScriptAsync threw an exception: {jsException.Message}", jsException); + taskManager.SetTaskFailed(taskId, ex); + } + } + } + else + { + taskManager.SetTaskCompleted(taskId, result); + } + } + break; + case "__RawMessage": + virtualView?.RawMessageReceived(messageContent); + break; + default: + throw new ArgumentException($"The message type '{messageType}' is not recognized.", nameof(rawMessage)); + } + } + + private const string InvokeJavaScriptThrowsExceptionsSwitch = "HybridWebView.InvokeJavaScriptThrowsExceptions"; + + private static bool IsInvokeJavaScriptThrowsExceptionsEnabled => + !AppContext.TryGetSwitch(InvokeJavaScriptThrowsExceptionsSwitch, out var enabled) || enabled; + + // DTOs for JSON serialization + internal sealed class JSInvokeResult + { + public string? Result { get; set; } + public bool IsError { get; set; } + public string? Name { get; set; } + public string? Message { get; set; } + public string? StackTrace { get; set; } + } + + internal sealed class JSInvokeMethodData + { + public string? MethodName { get; set; } + public string[]? ParamValues { get; set; } + } + + internal sealed class JSInvokeError + { + public string? Name { get; set; } + public string? Message { get; set; } + public string? StackTrace { get; set; } + } + + internal sealed class DotNetInvokeResult + { + public object? Result { get; set; } + public bool IsJson { get; set; } + public bool IsError { get; set; } + public string? ErrorMessage { get; set; } + public string? ErrorType { get; set; } + public string? ErrorStackTrace { get; set; } + } + + [JsonSourceGenerationOptions()] + [JsonSerializable(typeof(JSInvokeMethodData))] + [JsonSerializable(typeof(JSInvokeError))] + [JsonSerializable(typeof(DotNetInvokeResult))] + internal partial class HybridWebViewHelperJsonContext : JsonSerializerContext + { + } +} +#endif From f52aa36e6f97f0abaae8694e9122700cf8fc0bb2 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 11 Nov 2025 04:45:27 +0200 Subject: [PATCH 05/11] Much more fixes --- .../DeviceTests/Controls.DeviceTests.csproj | 1 + ...ridWebViewTests_EvaluateJavaScriptAsync.cs | 35 +++++++- ...ybridWebViewTests_InvokeJavaScriptAsync.cs | 79 +++++++------------ .../HybridWebView/HybridWebViewHelper.cs | 32 +++----- 4 files changed, 74 insertions(+), 73 deletions(-) diff --git a/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj b/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj index 286bc101440d..f9395208f7ef 100644 --- a/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj +++ b/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj @@ -15,6 +15,7 @@ android-arm64;android-x64 true true + false diff --git a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_EvaluateJavaScriptAsync.cs b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_EvaluateJavaScriptAsync.cs index 6022241e35a5..9b6332be89e6 100644 --- a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_EvaluateJavaScriptAsync.cs +++ b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_EvaluateJavaScriptAsync.cs @@ -11,7 +11,7 @@ namespace Microsoft.Maui.DeviceTests; public partial class HybridWebViewTests_EvaluateJavaScriptAsync : HybridWebViewTestsBase { [Fact] - public Task EvaluateJavaScriptAndGetResultFromFunction() => + public Task EvaluateJavaScriptAsync_WithStringParameters() => RunTest(async (hybridWebView) => { // Run some JavaScript to call a method and get result @@ -20,20 +20,47 @@ public Task EvaluateJavaScriptAndGetResultFromFunction() => }); [Fact] - public Task EvaluateJavaScriptAndGetResultFromProperty() => + public Task EvaluateJavaScriptAsync_WithNumberParameters() => + RunTest(async (hybridWebView) => + { + // Run some JavaScript to call a method and get result + var result1 = await hybridWebView.EvaluateJavaScriptAsync("EvaluateMeWithParamsAndReturn(1, 2)"); + Assert.Equal("3", result1); + }); + + [Fact] + public Task EvaluateJavaScriptAsync_GetsProperty() => RunTest(async (hybridWebView) => { // Run some JavaScript to get an arbitrary result by running JavaScript var result2 = await hybridWebView.EvaluateJavaScriptAsync("window.TestKey"); Assert.Equal("test_value", result2); }); - + [Fact] - public Task EvaluateJavaScriptAndPassStrings() => + public Task EvaluateJavaScriptAsync_HandlesDoubleQuotes() => RunTest(async (hybridWebView) => { // Run some JavaScript to call a method and get result var result1 = await hybridWebView.EvaluateJavaScriptAsync("EvaluateMeWithParamsAndReturn('\"Hel', 'lo!\"')"); Assert.Equal("\"Hello!\"", result1); }); + + [Fact] + public Task EvaluateJavaScriptAsync_HandlesSingleQuotes() => + RunTest(async (hybridWebView) => + { + // Run some JavaScript to call a method and get result + var result1 = await hybridWebView.EvaluateJavaScriptAsync("EvaluateMeWithParamsAndReturn('\\'Hel', 'lo!\\'')"); + Assert.Equal("'Hello!'", result1); + }); + + [Fact] + public Task EvaluateJavaScriptAsync_HandlesDoubleAndSingleQuotes() => + RunTest(async (hybridWebView) => + { + // Run some JavaScript to call a method and get result + var result1 = await hybridWebView.EvaluateJavaScriptAsync("EvaluateMeWithParamsAndReturn('\"Hel', 'lo!\\'')"); + Assert.Equal("\"Hello!'", result1); + }); } diff --git a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs index d774cd752a33..9d611ad8d839 100644 --- a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs +++ b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs @@ -31,7 +31,7 @@ public Task RequestFileFromJS(string url, int expectedStatus) => }); [Fact] - public Task InvokeJavaScriptMethodWithParametersAndNullsAndComplexResult() => + public Task InvokeJavaScript_WithParametersAndNulls_AndComplexResult() => RunTest(async (hybridWebView) => { var x = 123.456m; @@ -49,7 +49,7 @@ public Task InvokeJavaScriptMethodWithParametersAndNullsAndComplexResult() => }); [Fact] - public Task InvokeJavaScriptMethodWithParametersAndDecimalResult() => + public Task InvokeJavaScript_WithParameters_AndDecimalResult() => RunTest(async (hybridWebView) => { var x = 123.456m; @@ -68,7 +68,7 @@ public Task InvokeJavaScriptMethodWithParametersAndDecimalResult() => [InlineData(-123.456)] [InlineData(0.0)] [InlineData(123.456)] - public Task InvokeJavaScriptMethodWithParametersAndDoubleResult(double expected) => + public Task InvokeJavaScript_WithParameters_AndDoubleResult(double expected) => RunTest(async (hybridWebView) => { var result = await hybridWebView.InvokeJavaScriptAsync( @@ -85,7 +85,7 @@ public Task InvokeJavaScriptMethodWithParametersAndDoubleResult(double expected) [InlineData(-123.456)] [InlineData(0.0)] [InlineData(123.456)] - public Task InvokeJavaScriptMethodWithParametersAndNullableDoubleResult(double? expected) => + public Task InvokeJavaScript_WithParameters_AndNullableDoubleResult(double? expected) => RunTest(async (hybridWebView) => { var result = await hybridWebView.InvokeJavaScriptAsync( @@ -98,7 +98,7 @@ public Task InvokeJavaScriptMethodWithParametersAndNullableDoubleResult(double? }); [Fact] - public Task InvokeJavaScriptMethodWithParametersAndNewDoubleResult() => + public Task InvokeJavaScript_WithParameters_AndNewDoubleResult() => RunTest(async (hybridWebView) => { var x = 123.456m; @@ -117,7 +117,7 @@ public Task InvokeJavaScriptMethodWithParametersAndNewDoubleResult() => [InlineData(-123)] [InlineData(0)] [InlineData(123)] - public Task InvokeJavaScriptMethodWithParametersAndIntResult(int expected) => + public Task InvokeJavaScript_WithParameters_AndIntResult(int expected) => RunTest(async (hybridWebView) => { var result = await hybridWebView.InvokeJavaScriptAsync( @@ -134,7 +134,7 @@ public Task InvokeJavaScriptMethodWithParametersAndIntResult(int expected) => [InlineData(-123)] [InlineData(0)] [InlineData(123)] - public Task InvokeJavaScriptMethodWithParametersAndNullableIntResult(int? expected) => + public Task InvokeJavaScript_WithParameters_AndNullableIntResult(int? expected) => RunTest(async (hybridWebView) => { var result = await hybridWebView.InvokeJavaScriptAsync( @@ -147,7 +147,7 @@ public Task InvokeJavaScriptMethodWithParametersAndNullableIntResult(int? expect }); [Fact] - public Task InvokeJavaScriptMethodWithParametersAndNewIntResult() => + public Task InvokeJavaScript_WithParameters_AndNewIntResult() => RunTest(async (hybridWebView) => { var x = 123; @@ -168,7 +168,7 @@ public Task InvokeJavaScriptMethodWithParametersAndNewIntResult() => [InlineData("foo")] [InlineData("null")] [InlineData("undefined")] - public Task InvokeJavaScriptMethodWithParametersAndStringResult(string? expected) => + public Task InvokeJavaScript_WithParameters_AndStringResult(string? expected) => RunTest(async (hybridWebView) => { var result = await hybridWebView.InvokeJavaScriptAsync( @@ -181,7 +181,7 @@ public Task InvokeJavaScriptMethodWithParametersAndStringResult(string? expected }); [Fact] - public Task InvokeJavaScriptMethodWithParametersAndNewStringResult() => + public Task InvokeJavaScript_WithParameters_AndNewStringResult() => RunTest(async (hybridWebView) => { var x = "abc"; @@ -199,7 +199,7 @@ public Task InvokeJavaScriptMethodWithParametersAndNewStringResult() => [Theory] [InlineData(true)] [InlineData(false)] - public Task InvokeJavaScriptMethodWithParametersAndBoolResult(bool expected) => + public Task InvokeJavaScript_WithParameters_AndBoolResult(bool expected) => RunTest(async (hybridWebView) => { var result = await hybridWebView.InvokeJavaScriptAsync( @@ -212,7 +212,7 @@ public Task InvokeJavaScriptMethodWithParametersAndBoolResult(bool expected) => }); [Fact] - public Task InvokeJavaScriptMethodWithParametersAndComplexResult() => + public Task InvokeJavaScript_WithParameters_AndComplexResult() => RunTest(async (hybridWebView) => { var x = 123.456m; @@ -230,7 +230,7 @@ public Task InvokeJavaScriptMethodWithParametersAndComplexResult() => }); [Fact] - public Task InvokeAsyncJavaScriptMethodWithParametersAndComplexResult() => + public Task InvokeJavaScript_WithParameters_AndAsyncComplexResult() => RunTest(async (hybridWebView) => { var s1 = "new_key"; @@ -250,7 +250,7 @@ public Task InvokeAsyncJavaScriptMethodWithParametersAndComplexResult() => }); [Fact] - public Task InvokeJavaScriptMethodWithParametersAndVoidReturn() => + public Task InvokeJavaScript_WithParameters_AndVoidReturn() => RunTest(async (hybridWebView) => { var x = 123.456m; @@ -269,7 +269,7 @@ await hybridWebView.InvokeJavaScriptAsync( }); [Fact] - public Task InvokeJavaScriptMethodWithParametersAndVoidReturnUsingObjectReturnMethod() => + public Task InvokeJavaScript_WithParameters_AndVoidReturn_UsingObjectReturnMethod() => RunTest(async (hybridWebView) => { var x = 123.456m; @@ -291,7 +291,7 @@ public Task InvokeJavaScriptMethodWithParametersAndVoidReturnUsingObjectReturnMe }); [Fact] - public Task InvokeJavaScriptMethodWithParametersAndVoidReturnUsingNullReturnMethod() => + public Task InvokeJavaScript_WithParameters_AndVoidReturn_UsingNullReturnMethod() => RunTest(async (hybridWebView) => { var x = 123.456m; @@ -315,10 +315,10 @@ public Task InvokeJavaScriptMethodWithParametersAndVoidReturnUsingNullReturnMeth [Theory] [InlineData("")] [InlineData("Async")] - public Task InvokeJavaScriptMethodThatThrowsNumber(string type) => + public Task InvokeJavaScript_ThatThrowsNumber(string type) => RunExceptionTest("EvaluateMeWithParamsThatThrows" + type, 1, ex => { - Assert.Equal("InvokeJavaScript threw an exception: 777.777", ex.Message); + Assert.Equal("InvokeJavaScriptAsync threw an exception: 777.777", ex.Message); Assert.Equal("777.777", ex.InnerException?.Message); Assert.Null(ex.InnerException?.Data["JavaScriptErrorName"]); Assert.NotNull(ex.InnerException?.StackTrace); @@ -327,10 +327,10 @@ public Task InvokeJavaScriptMethodThatThrowsNumber(string type) => [Theory] [InlineData("")] [InlineData("Async")] - public Task InvokeJavaScriptMethodThatThrowsString(string type) => + public Task InvokeJavaScript_ThatThrowsString(string type) => RunExceptionTest("EvaluateMeWithParamsThatThrows" + type, 2, ex => { - Assert.Equal("InvokeJavaScript threw an exception: String: 777.777", ex.Message); + Assert.Equal("InvokeJavaScriptAsync threw an exception: String: 777.777", ex.Message); Assert.Equal("String: 777.777", ex.InnerException?.Message); Assert.Null(ex.InnerException?.Data["JavaScriptErrorName"]); Assert.NotNull(ex.InnerException?.StackTrace); @@ -339,10 +339,10 @@ public Task InvokeJavaScriptMethodThatThrowsString(string type) => [Theory] [InlineData("")] [InlineData("Async")] - public Task InvokeJavaScriptMethodThatThrowsError(string type) => + public Task InvokeJavaScript_ThatThrowsError(string type) => RunExceptionTest("EvaluateMeWithParamsThatThrows" + type, 3, ex => { - Assert.Equal("InvokeJavaScript threw an exception: Generic Error: 777.777", ex.Message); + Assert.Equal("InvokeJavaScriptAsync threw an exception: Generic Error: 777.777", ex.Message); Assert.Equal("Generic Error: 777.777", ex.InnerException?.Message); Assert.Equal("Error", ex.InnerException?.Data["JavaScriptErrorName"]); Assert.NotNull(ex.InnerException?.StackTrace); @@ -351,7 +351,7 @@ public Task InvokeJavaScriptMethodThatThrowsError(string type) => [Theory] [InlineData("")] [InlineData("Async")] - public Task InvokeJavaScriptMethodThatThrowsTypedNumber(string type) => + public Task InvokeJavaScript_ThatThrowsTypedNumber(string type) => RunExceptionTest("EvaluateMeWithParamsThatThrows" + type, 4, ex => { Assert.Contains("undefined", ex.Message, StringComparison.OrdinalIgnoreCase); @@ -361,7 +361,7 @@ public Task InvokeJavaScriptMethodThatThrowsTypedNumber(string type) => }); [Fact] - public Task InvokeJavaScriptWithJsonStringArgument() => + public Task InvokeJavaScript_WithJsonStringArgument() => RunTest(async (hybridWebView) => { // Create a dictionary that will be serialized to JSON @@ -387,7 +387,7 @@ public Task InvokeJavaScriptWithJsonStringArgument() => }); [Fact] - public Task InvokeJavaScriptWithComplexJsonString() => + public Task InvokeJavaScript_WithComplexJsonString() => RunTest(async (hybridWebView) => { // Create a more complex JSON with special characters that might cause escaping issues @@ -420,7 +420,7 @@ public Task InvokeJavaScriptWithComplexJsonString() => }); [Fact] - public Task InvokeJavaScriptWithMultipleJsonStringArguments() => + public Task InvokeJavaScript_WithMultipleJsonStringArguments() => RunTest(async (hybridWebView) => { var firstJson = JsonSerializer.Serialize(new { type = "user", id = 1 }); @@ -438,28 +438,7 @@ public Task InvokeJavaScriptWithMultipleJsonStringArguments() => }); [Fact] - public Task InvokeJavaScriptWithJsonStringDoesNotTimeout() => - RunTest(async (hybridWebView) => - { - var contextArg = new Dictionary - { - { "userId", "userIdValue" } - }; - - string contextArgString = JsonSerializer.Serialize(contextArg); - - // This should complete within the timeout - var result = await hybridWebView.InvokeJavaScriptAsync( - "EchoJsonParameter", - InvokeJsonContext.Default.String, - [contextArgString], - [InvokeJsonContext.Default.String]); - - Assert.Equal(contextArgString, result); - }); - - [Fact] - public Task InvokeJavaScriptWithBase64EncodedJsonString() => + public Task InvokeJavaScript_WithBase64EncodedJsonString() => RunTest(async (hybridWebView) => { var contextArg = new Dictionary @@ -483,7 +462,7 @@ public Task InvokeJavaScriptWithBase64EncodedJsonString() => }); [Fact] - public Task InvokeJavaScriptWithJsonArrayArgument() => + public Task InvokeJavaScript_WithJsonArrayArgument() => RunTest(async (hybridWebView) => { var jsonArray = JsonSerializer.Serialize(new[] { "item1", "item2", "item3" }); @@ -498,7 +477,7 @@ public Task InvokeJavaScriptWithJsonArrayArgument() => }); [Fact] - public Task InvokeJavaScriptWithEmptyJsonObject() => + public Task InvokeJavaScript_WithEmptyJsonObject() => RunTest(async (hybridWebView) => { var emptyJson = JsonSerializer.Serialize(new Dictionary()); diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs index 70e64e1a103d..21e88832f1e9 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs @@ -44,8 +44,6 @@ internal static partial class HybridWebViewHelper $$""" (function() { try { - console.warn('{{escapedScript}}'); - let result = eval('{{escapedScript}}'); let resultObj = { IsError: false, @@ -111,11 +109,18 @@ internal static partial class HybridWebViewHelper { returnValue = null; } - //JSON.stringify wraps the result in literal quotes, we just want the actual returned result + //JSON.stringify wraps the result - we need to unwrap it properly //note that if the js function returns the string "null" we will get here and not above else if (returnValue != null) { - returnValue = returnValue.Trim('"'); + // Check if the result is a JSON string (starts and ends with quotes) + if (returnValue.Length >= 2 && returnValue[0] == '"' && returnValue[^1] == '"') + { + // Properly deserialize the JSON string to handle escaped characters + returnValue = JsonSerializer.Deserialize(returnValue); + } + // Otherwise it's a primitive value (number, boolean, etc.) that's already in string form + // No need to deserialize - just return as-is } return returnValue; @@ -136,20 +141,7 @@ internal static partial class HybridWebViewHelper ? string.Empty : string.Join( ", ", - request.ParamValues.Select((v, i) => - { - if (v == null) - { - return "null"; - } - - var serialized = JsonSerializer.Serialize(v, request.ParamJsonTypeInfos![i]!); - - // Escape the JSON string for JavaScript so it can be passed as a string literal - var escaped = WebViewHelper.EscapeJsString(serialized); - - return $"'{escaped}'"; - })); + request.ParamValues.Select((v, i) => v is null ? "null" : JsonSerializer.Serialize(v, request.ParamJsonTypeInfos![i]!))); var js = $"window.HybridWebView.__InvokeJavaScript({task.TaskId}, {request.MethodName}, [{paramsValuesStringArray}])"; @@ -157,7 +149,9 @@ internal static partial class HybridWebViewHelper handler.PlatformView.EvaluateJavaScript(innerRequest); - await innerRequest.Task; + // Don't await innerRequest.Task because __InvokeJavaScript is async and returns a Promise, + // which iOS can't convert to a string. Instead, we wait for the callback message from JavaScript. + // The JavaScript function will call invokeJavaScriptCallbackInDotNet() when done. var stringResult = await task.TaskCompletionSource.Task; From 5204ccc524e1a844ed6f4d98ffa79a9aa5846a36 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 11 Nov 2025 04:52:23 +0200 Subject: [PATCH 06/11] revert sandbox --- .../Controls.Sample.Sandbox/App.xaml.cs | 113 ++++-------------- .../Maui.Controls.Sample.Sandbox.csproj | 1 - .../Controls.Sample.Sandbox/MauiProgram.cs | 12 +- .../Resources/Raw/test/index.html | 99 --------------- 4 files changed, 23 insertions(+), 202 deletions(-) delete mode 100644 src/Controls/samples/Controls.Sample.Sandbox/Resources/Raw/test/index.html diff --git a/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs index e46794978f65..9512dea98e39 100644 --- a/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs +++ b/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs @@ -1,97 +1,24 @@ -using Microsoft.Maui.Controls; -using System.Collections.Generic; -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Maui.Controls.Sample; - -[JsonSourceGenerationOptions(WriteIndented = true)] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(string))] -internal partial class HybridWebViewTypes : JsonSerializerContext { - // This type's attributes specify JSON serialization info to preserve type structure - // for trimmed builds. -} +namespace Maui.Controls.Sample; public partial class App : Application { - public App() - { - //InitializeComponent(); - } - - protected override Window CreateWindow(IActivationState? activationState) - { - return new Window(new MyMainPage()); - } + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + // To test shell scenarios, change this to true + bool useShell = false; + + if (!useShell) + { + return new Window(new NavigationPage(new MainPage())); + } + else + { + return new Window(new SandboxShell()); + } + } } - -public class MyMainPage: ContentPage { - HybridWebView hybridWebView; - public MyMainPage() { - AbsoluteLayout abs = new(); - this.Content = abs; - - hybridWebView = new(); - hybridWebView.HybridRoot = "test"; - hybridWebView.DefaultFile= "index.html"; - abs.Add(hybridWebView); - hybridWebView.RawMessageReceived += async delegate (object? sender, HybridWebViewRawMessageReceivedEventArgs e) { - Debug.WriteLine("C# RAW MESSAGE RECEIVED: " + e?.Message); - }; - - this.SizeChanged += delegate (object? o, EventArgs e) { - if (this.Width > 0 && this.Height > 0) { - double screenWidth = this.Width; - double screenHeight = this.Height; - hybridWebView.HeightRequest = screenHeight; - hybridWebView.WidthRequest = screenWidth; - } - }; - - var timer = Application.Current!.Dispatcher.CreateTimer(); - timer.Interval = TimeSpan.FromSeconds(1); - timer.IsRepeating = true; - timer.Start(); - timer.Tick += async delegate { - Debug.WriteLine("Timer tick"); - - // create test object - Dictionary contextArg = new() { - { "userId", "userIdValue" }, - }; - - // can't pass in dictionary to hybridwebview - must convert to string first - string contextArgString = JsonSerializer.Serialize(contextArg); - - //==================================================================== - // COMMENT THIS LINE OUT AND IT FAILS IN WINDOWS BUT NOT ANDROID - //==================================================================== - // windows glitches and times out when you feed it a json string as argument - must use base64 - // contextArgString = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(contextArgString)); - //==================================================================== - - TimeSpan timeout = TimeSpan.FromSeconds(5); - using CancellationTokenSource cts = new CancellationTokenSource(timeout); - try { - Debug.WriteLine("START SET TEST FUNCTION with arg: " + contextArgString); - // must await or can't catch the error - var reply = await hybridWebView.InvokeJavaScriptAsync( - "window.testFunctionName", - HybridWebViewTypes.Default.String, // return type - new object[] { contextArgString }, // arguments as [] - new[] { HybridWebViewTypes.Default.String } // argument types as [] - ); - - Debug.WriteLine("Set test function, reply: " + reply?.ToString()); - } - catch (OperationCanceledException) { Debug.WriteLine($"testFunctionName timed out"); } - catch (Exception ex) { Debug.WriteLine("EXCEPTION WRITING testFunctionName: " + ex.Message); } - - }; - - } - -} \ No newline at end of file diff --git a/src/Controls/samples/Controls.Sample.Sandbox/Maui.Controls.Sample.Sandbox.csproj b/src/Controls/samples/Controls.Sample.Sandbox/Maui.Controls.Sample.Sandbox.csproj index cd0f781591f0..c78b4e2b2a4e 100644 --- a/src/Controls/samples/Controls.Sample.Sandbox/Maui.Controls.Sample.Sandbox.csproj +++ b/src/Controls/samples/Controls.Sample.Sandbox/Maui.Controls.Sample.Sandbox.csproj @@ -10,7 +10,6 @@ enable enable $(NoWarn);XC0022 - false maccatalyst-x64 maccatalyst-arm64 diff --git a/src/Controls/samples/Controls.Sample.Sandbox/MauiProgram.cs b/src/Controls/samples/Controls.Sample.Sandbox/MauiProgram.cs index b7d6b05c9373..d4941633bbd8 100644 --- a/src/Controls/samples/Controls.Sample.Sandbox/MauiProgram.cs +++ b/src/Controls/samples/Controls.Sample.Sandbox/MauiProgram.cs @@ -2,9 +2,8 @@ public static class MauiProgram { - public static MauiApp CreateMauiApp() - { - MauiAppBuilder mauiAppBuilder = MauiApp + public static MauiApp CreateMauiApp() => + MauiApp .CreateBuilder() #if __ANDROID__ || __IOS__ .UseMauiMaps() @@ -22,11 +21,6 @@ public static MauiApp CreateMauiApp() fonts.AddFont("SegoeUI-Bold.ttf", "Segoe UI Bold"); fonts.AddFont("SegoeUI-Italic.ttf", "Segoe UI Italic"); fonts.AddFont("SegoeUI-Bold-Italic.ttf", "Segoe UI Bold Italic"); - }); - - mauiAppBuilder.Services.AddHybridWebViewDeveloperTools(); - - return mauiAppBuilder + }) .Build(); - } } diff --git a/src/Controls/samples/Controls.Sample.Sandbox/Resources/Raw/test/index.html b/src/Controls/samples/Controls.Sample.Sandbox/Resources/Raw/test/index.html deleted file mode 100644 index b5780db67811..000000000000 --- a/src/Controls/samples/Controls.Sample.Sandbox/Resources/Raw/test/index.html +++ /dev/null @@ -1,99 +0,0 @@ - - - - - HybridWebView Test - - - - - - - - - - - -
-
-
HybridWebView test page
-
Waiting for native call… check console logs.
-
-
- - - - From 93419bdbd1c66abbee2594b6721daa9e4f586d7e Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 11 Nov 2025 04:52:50 +0200 Subject: [PATCH 07/11] revert --- src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj b/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj index f9395208f7ef..286bc101440d 100644 --- a/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj +++ b/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj @@ -15,7 +15,6 @@ android-arm64;android-x64 true true - false From 3ee5fd999c478111735cbed51a64281ec5b83c69 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Wed, 12 Nov 2025 02:48:20 +0200 Subject: [PATCH 08/11] also this --- ...ybridWebViewTests_InvokeJavaScriptAsync.cs | 26 +++++++++++++++++++ .../Resources/Raw/HybridTestRoot/index.html | 6 +++++ 2 files changed, 32 insertions(+) diff --git a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs index 9d611ad8d839..95fe209dd847 100644 --- a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs +++ b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_InvokeJavaScriptAsync.cs @@ -386,6 +386,32 @@ public Task InvokeJavaScript_WithJsonStringArgument() => Assert.Equal(contextArgString, result); }); + [Fact] + public Task InvokeJavaScript_WithDictionaryArgument() => + RunTest(async (hybridWebView) => + { + // Create a dictionary that will be serialized to JSON + var contextArg = new Dictionary + { + { "userId", "userIdValue" }, + { "sessionId", "session123" }, + { "timestamp", "2025-11-11T01:30:00Z" } + }; + + // This should not timeout - the JSON string should be handled correctly + var result = await hybridWebView.InvokeJavaScriptAsync( + "EchoJsonStringifyParameter", + InvokeJsonContext.Default.String, + [contextArg], + [InvokeJsonContext.Default.DictionaryStringString]); + + // Serialize to JSON string (without base64 encoding) + string contextArgString = JsonSerializer.Serialize(contextArg); + + // Verify the result matches the input + Assert.Equal(contextArgString, result); + }); + [Fact] public Task InvokeJavaScript_WithComplexJsonString() => RunTest(async (hybridWebView) => diff --git a/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html b/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html index 04a4c27a51ca..93353adb79f5 100644 --- a/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html +++ b/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html @@ -165,6 +165,12 @@ return jsonString; } + // Echo back the parameter as a JSON string + function EchoJsonStringifyParameter(jsonString) { + return JSON.stringify(parsed); + } + + // Parse JSON string and stringify it back function ParseAndStringifyJson(jsonString) { try { From 1adedc438483994e0ab2439d07aa30d82a6ef05e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:58:45 +0200 Subject: [PATCH 09/11] Fix HybridWebView build error - missing using directive (#32716) * Initial plan * Fix build error: Add missing using Microsoft.Maui.Platform directive to HybridWebViewHelper.cs Co-authored-by: mattleibow <1096616+mattleibow@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mattleibow <1096616+mattleibow@users.noreply.github.com> --- src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs index 21e88832f1e9..9c085407ded9 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Maui.Handlers; +using Microsoft.Maui.Platform; namespace Microsoft.Maui; From cac9656de0951533042b12612ff80f9d6ec60ce6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:00:08 +0200 Subject: [PATCH 10/11] Fix HybridWebView build errors for non-platform targets (#32754) * Initial plan * Fix HybridWebView build errors by wrapping platform-specific code Co-authored-by: mattleibow <1096616+mattleibow@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mattleibow <1096616+mattleibow@users.noreply.github.com> --- src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs index f9a7b52628b5..e5e4afa7570c 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs @@ -104,6 +104,7 @@ public HybridWebViewHandler(IPropertyMapper? mapper = null, CommandMapper? comma private static bool IsInvokeJavaScriptThrowsExceptionsEnabled => !AppContext.TryGetSwitch(InvokeJavaScriptThrowsExceptionsSwitch, out var enabled) || enabled; +#if PLATFORM && !TIZEN void MessageReceived(string rawMessage) => HybridWebViewHelper.ProcessRawMessage(this, VirtualView, rawMessage); @@ -118,6 +119,8 @@ void MessageReceived(string rawMessage) => stringBody); } +#endif + #if PLATFORM && !TIZEN public static async void MapEvaluateJavaScriptAsync(IHybridWebViewHandler handler, IHybridWebView hybridWebView, object? arg) { From 8c782a2b2e4cc23048a4e4a1ccb5b6f77949a0c9 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Tue, 25 Nov 2025 03:23:29 +0200 Subject: [PATCH 11/11] Fix a few isues, android difference and ai --- ...ybridWebViewTests_SetInvokeJavaScriptTarget.cs | 4 ++-- .../Resources/Raw/HybridTestRoot/index.html | 2 +- .../Handlers/HybridWebView/HybridWebViewHelper.cs | 15 +++++++++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_SetInvokeJavaScriptTarget.cs b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_SetInvokeJavaScriptTarget.cs index a904e3ccf467..2a9439ed743f 100644 --- a/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_SetInvokeJavaScriptTarget.cs +++ b/src/Controls/tests/DeviceTests/Elements/HybridWebView/HybridWebViewTests_SetInvokeJavaScriptTarget.cs @@ -139,8 +139,8 @@ private class InvokeJavaScriptAsyncTestData : IEnumerable { public IEnumerator GetEnumerator() { - const string ComplexResult = "{\\\"result\\\":123,\\\"operationName\\\":\\\"Test\\\"}"; - const string DictionaryResult = "{\\\"first\\\":111,\\\"second\\\":222,\\\"third\\\":333}"; + const string ComplexResult = "{\"result\":123,\"operationName\":\"Test\"}"; + const string DictionaryResult = "{\"first\":111,\"second\":222,\"third\":333}"; const int ValueTypeResult = 2; // Test variations of: diff --git a/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html b/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html index 93353adb79f5..49709cfa07da 100644 --- a/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html +++ b/src/Controls/tests/DeviceTests/Resources/Raw/HybridTestRoot/index.html @@ -167,7 +167,7 @@ // Echo back the parameter as a JSON string function EchoJsonStringifyParameter(jsonString) { - return JSON.stringify(parsed); + return JSON.stringify(jsonString); } diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs index 9c085407ded9..65b82df20bf9 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHelper.cs @@ -96,7 +96,17 @@ internal static partial class HybridWebViewHelper if (result == null) return null; - var jsResult = JsonSerializer.Deserialize(result); + // Android's WebView automatically JSON-encodes the return value, so we need to unwrap it + // Check if the result is a JSON-encoded string (starts and ends with quotes) + if (OperatingSystem.IsAndroid()) + { + // Deserialize once to unwrap the JSON string + result = JsonSerializer.Deserialize(result); + if (result == null) + return null; + } + + var jsResult = JsonSerializer.Deserialize(result, HybridWebViewHelperJsonContext.Default.JSInvokeResult); if (jsResult?.IsError == true) { var jsException = new HybridWebViewInvokeJavaScriptException(jsResult?.Message, jsResult?.Name, jsResult?.StackTrace); @@ -213,7 +223,7 @@ internal static partial class HybridWebViewHelper var invokeResultRaw = await InvokeDotNetMethodAsync(invokeTargetType, invokeTarget, invokeData); var invokeResult = CreateInvokeResult(invokeResultRaw); - var json = JsonSerializer.Serialize(invokeResult, HybridWebViewHelperJsonContext.Default.DotNetInvokeResult); + var json = JsonSerializer.Serialize(invokeResult); var contentBytes = Encoding.UTF8.GetBytes(json); return contentBytes; @@ -461,6 +471,7 @@ internal sealed class DotNetInvokeResult } [JsonSourceGenerationOptions()] + [JsonSerializable(typeof(JSInvokeResult))] [JsonSerializable(typeof(JSInvokeMethodData))] [JsonSerializable(typeof(JSInvokeError))] [JsonSerializable(typeof(DotNetInvokeResult))]