diff --git a/src/Controls/src/Core/WebView/WebView.cs b/src/Controls/src/Core/WebView/WebView.cs index 48bbb4aedca9..617d89ca0d51 100644 --- a/src/Controls/src/Core/WebView/WebView.cs +++ b/src/Controls/src/Core/WebView/WebView.cs @@ -4,11 +4,11 @@ using System.ComponentModel; using System.Diagnostics; using System.Net; -using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Devices; +using Microsoft.Maui.Handlers; namespace Microsoft.Maui.Controls { @@ -125,7 +125,7 @@ public async Task EvaluateJavaScriptAsync(string script) // Make all the platforms mimic Android's implementation, which is by far the most complete. if (DeviceInfo.Platform != DevicePlatform.Android) { - script = EscapeJsString(script); + script = WebViewHelper.EscapeJsString(script); if (DeviceInfo.Platform != DevicePlatform.WinUI) { @@ -290,43 +290,6 @@ public IPlatformElementConfiguration On() where T : IConfigPlatfo return _platformConfigurationRegistry.Value.On(); } - private static string EscapeJsString(string js) - { - if (js == null) - return null; - - if (js.IndexOf("'", StringComparison.Ordinal) == -1) - return js; - - //get every quote in the string along with all the backslashes preceding it - var singleQuotes = Regex.Matches(js, @"(\\*?)'"); - - var uniqueMatches = new List(); - - for (var i = 0; i < singleQuotes.Count; i++) - { - var matchedString = singleQuotes[i].Value; - if (!uniqueMatches.Contains(matchedString)) - { - uniqueMatches.Add(matchedString); - } - } - - uniqueMatches.Sort((x, y) => y.Length.CompareTo(x.Length)); - - //escape all quotes from the script as well as add additional escaping to all quotes that were already escaped - for (var i = 0; i < uniqueMatches.Count; i++) - { - var match = uniqueMatches[i]; - var numberOfBackslashes = match.Length - 1; - var slashesToAdd = (numberOfBackslashes * 2) + 1; - var replacementStr = "'".PadLeft(slashesToAdd + 1, '\\'); - js = Regex.Replace(js, @"(?<=[^\\])" + Regex.Escape(match), replacementStr); - } - - return js; - } - /// IWebViewSource IWebView.Source => Source; diff --git a/src/Controls/tests/Core.UnitTests/WebViewHelperTests.cs b/src/Controls/tests/Core.UnitTests/WebViewHelperTests.cs new file mode 100644 index 000000000000..2862f6c586d7 --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/WebViewHelperTests.cs @@ -0,0 +1,107 @@ +using Xunit; +using Microsoft.Maui.Handlers; + +namespace Microsoft.Maui.Controls.Core.UnitTests; + +public class WebViewHelperTests +{ + [Fact] + public void EscapeJsString_NullInput_ReturnsNull() + { + const string input = null; + var result = WebViewHelper.EscapeJsString(input); + Assert.Null(result); + } + + [Fact] + public void EscapeJsString_NoSingleQuote_ReturnsSameString() + { + const string input = """console.log("Hello, world!");"""; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(input, result); + } + + [Fact] + public void EscapeJsString_UnescapedQuote_EscapesCorrectly() + { + // Each unescaped single quote should be preceded by one backslash. + const string input = """console.log('Hello, world!');"""; + // Expected: each occurrence of "'" becomes "\'" + const string expected = """console.log(\'Hello, world!\');"""; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_AlreadyEscapedQuote_EscapesFurther() + { + const string input = """var str = 'Don\'t do that';"""; + const string expected = """var str = \'Don\\\'t do that\';"""; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_MultipleLinesAndMixedQuotes() + { + const string input = """ + function test() { + console.log('Test "string" with a single quote'); + var example = 'It\\'s tricky!'; + } + """; + const string expected = """ + function test() { + console.log(\'Test "string" with a single quote\'); + var example = \'It\\\\\'s tricky!\'; + } + """; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_MultipleBackslashesBeforeQuote() + { + const string input = @"var tricky = 'Backslash: \\\' tricky!';"; + const string expected = @"var tricky = \'Backslash: \\\\\\\' tricky!\';"; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_QuoteAtBeginning() + { + const string input = @"'Start with quote"; + const string expected = @"\'Start with quote"; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_QuoteAtEnd() + { + const string input = @"Ends with a quote'"; + const string expected = @"Ends with a quote\'"; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_OnlyQuote() + { + const string input = @"'"; + const string expected = @"\'"; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } + + [Fact] + public void EscapeJsString_RepeatedEscapedQuotes() + { + const string input = @"'Quote' and again \'Quote\'"; + const string expected = @"\'Quote\' and again \\\'Quote\\\'"; + var result = WebViewHelper.EscapeJsString(input); + Assert.Equal(expected, result); + } +} diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs index ae70bca1ff65..20b7014d4f39 100644 --- a/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs +++ b/src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs @@ -335,7 +335,7 @@ public static async void MapEvaluateJavaScriptAsync(IHybridWebViewHandler handle // Make all the platforms mimic Android's implementation, which is by far the most complete. if (!OperatingSystem.IsAndroid()) { - script = EscapeJsString(script); + script = WebViewHelper.EscapeJsString(script); if (!OperatingSystem.IsWindows()) { @@ -430,47 +430,6 @@ await handler.InvokeAsync(nameof(IHybridWebView.EvaluateJavaScriptAsync), } } - -#if PLATFORM && !TIZEN - // Copied from WebView.cs - internal static string? EscapeJsString(string js) - { - if (js == null) - return null; - - if (!js.Contains('\'', StringComparison.Ordinal)) - return js; - - //get every quote in the string along with all the backslashes preceding it - var singleQuotes = Regex.Matches(js, @"(\\*?)'"); - - var uniqueMatches = new List(); - - for (var i = 0; i < singleQuotes.Count; i++) - { - var matchedString = singleQuotes[i].Value; - if (!uniqueMatches.Contains(matchedString)) - { - uniqueMatches.Add(matchedString); - } - } - - uniqueMatches.Sort((x, y) => y.Length.CompareTo(x.Length)); - - //escape all quotes from the script as well as add additional escaping to all quotes that were already escaped - for (var i = 0; i < uniqueMatches.Count; i++) - { - var match = uniqueMatches[i]; - var numberOfBackslashes = match.Length - 1; - var slashesToAdd = (numberOfBackslashes * 2) + 1; - var replacementStr = "'".PadLeft(slashesToAdd + 1, '\\'); - js = Regex.Replace(js, @"(?<=[^\\])" + Regex.Escape(match), replacementStr); - } - - return js; - } -#endif - internal static async Task GetAssetContentAsync(string assetPath) { using var stream = await GetAssetStreamAsync(assetPath); diff --git a/src/Core/src/Handlers/WebView/WebViewHelper.cs b/src/Core/src/Handlers/WebView/WebViewHelper.cs new file mode 100644 index 000000000000..febd2f14a373 --- /dev/null +++ b/src/Core/src/Handlers/WebView/WebViewHelper.cs @@ -0,0 +1,36 @@ +namespace Microsoft.Maui.Handlers; + +using System; +using System.Text.RegularExpressions; + +internal static partial class WebViewHelper +{ + internal static string? EscapeJsString(string js) + { + if (js == null) + return null; + +#if NET6_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER + if (!js.Contains('\'', StringComparison.Ordinal)) +#else + if (js.IndexOf('\'') == -1) +#endif + return js; + + return EscapeJsStringRegex().Replace(js, m => + { + int count = m.Groups[1].Value.Length; + // Replace with doubled backslashes plus one extra backslash, then the quote. + return new string('\\', (count * 2) + 1) + "'"; + }); + } + +#if NET6_0_OR_GREATER + [GeneratedRegex(@"(\\*)'")] + private static partial Regex EscapeJsStringRegex(); +#else + static Regex? EscapeJsStringRegexCached; + private static Regex EscapeJsStringRegex() => + EscapeJsStringRegexCached ??= new Regex(@"(\\*)'", RegexOptions.Compiled | RegexOptions.CultureInvariant); +#endif +}