Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 2 additions & 39 deletions src/Controls/src/Core/WebView/WebView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -125,7 +125,7 @@ public async Task<string> 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)
{
Expand Down Expand Up @@ -290,43 +290,6 @@ public IPlatformElementConfiguration<T, WebView> On<T>() where T : IConfigPlatfo
return _platformConfigurationRegistry.Value.On<T>();
}

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<string>();

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;
}

/// <inheritdoc/>
IWebViewSource IWebView.Source => Source;

Expand Down
153 changes: 153 additions & 0 deletions src/Controls/tests/Core.UnitTests/WebViewHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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);
}

[Fact]
public void EscapeJsString_SimpleJavaScriptWithNewlines()
{
const string input = "var x = 5;\r\n" +
"var y = 10;\r" +
"var z = x + y;\n";

const string expected = "var x = 5;\\nvar y = 10;\\nvar z = x + y;\\n";
var result = WebViewHelper.EscapeJsString(input);
Assert.Equal(expected, result);
}

[Fact]
public void EscapeJsString_TemplateLiterals()
{
const string input = @"let poll = `Is .NET MAUI cool?
- Yes!
- Yes!
- Yes!
Wow, so it is!
`
console.log(poll);";

const string expected = "let poll = \\`Is .NET MAUI cool?\\n- Yes!\\n- Yes!\\n- Yes!\\n" +
"Wow, so it is!\\n\\`\\nconsole.log(poll);";
var result = WebViewHelper.EscapeJsString(input);
Assert.Equal(expected, result);
}

[Fact]
public void EscapeJsString_BackslashContinuations()
{
const string input = @"let poll = 'Is .NET MAUI cool? \n\
- Yes! \n\
- Yes! \n\
- Yes! \n\
Wow, so it is! \n\
'
console.log(poll);";

const string expected = "let poll = \\'Is .NET MAUI cool? \\\\n- Yes! \\\\n- Yes! \\\\n" +
"- Yes! \\\\nWow, so it is! \\\\n\\'\\nconsole.log(poll);";
var result = WebViewHelper.EscapeJsString(input);
Assert.Equal(expected, result);
}
}
43 changes: 1 addition & 42 deletions src/Core/src/Handlers/HybridWebView/HybridWebViewHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
{
Expand Down Expand Up @@ -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<string>();

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<string?> GetAssetContentAsync(string assetPath)
{
using var stream = await GetAssetStreamAsync(assetPath);
Expand Down
77 changes: 77 additions & 0 deletions src/Core/src/Handlers/WebView/WebViewHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
namespace Microsoft.Maui.Handlers;

using System;
using System.Text.RegularExpressions;

internal static partial class WebViewHelper
{
const string NewlineMarker = "##NL##";

internal static string? EscapeJsString(string js)
{
if (string.IsNullOrEmpty(js))
return js;

// Normalize line endings
js = Regex.Replace(js, @"\r\n|\r", "\n");

#if NET6_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER
bool hasBacktick = js.Contains('`', StringComparison.Ordinal);
#else
bool hasBacktick = js.IndexOf('`') != -1;
#endif

// Escape sequence marker
js = Regex.Replace(js, @"\\n", NewlineMarker);

// Escape backticks if present
if (hasBacktick)
{
#if NET6_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER
js = js.Replace("`", "\\`", StringComparison.Ordinal);
#else
js = js.Replace("`", "\\`");
#endif
}

// Remove backslash-newline continuation
js = Regex.Replace(js, @"\\[ \t]*\n", string.Empty);

// Replace literal newlines with \n
#if NET6_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER
js = js.Replace("\n", "\\n", StringComparison.Ordinal);
#else
js = js.Replace("\n", "\\n");
#endif

// Restore original escape sequences
#if NET6_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER
js = js.Replace(NewlineMarker, "\\\\n", StringComparison.Ordinal);
#else
js = js.Replace(NewlineMarker, "\\\\n");
#endif

#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
}