diff --git a/src/Components/Blazor.sln b/src/Components/Blazor.sln index f203a56ddfce..e31a67540866 100644 --- a/src/Components/Blazor.sln +++ b/src/Components/Blazor.sln @@ -103,6 +103,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Authentication.Msal", "Auth EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Authentication.WebAssembly.Msal", "WebAssembly\Authentication.Msal\src\Microsoft.Authentication.WebAssembly.Msal.csproj", "{2F105FA7-74DA-4855-9D8E-818DEE1F8D43}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DebugProxy", "DebugProxy", "{96DE9B14-D81F-422E-A33A-728BFB9C153A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Components.WebAssembly.DebugProxy", "WebAssembly\DebugProxy\src\Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.csproj", "{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -521,6 +525,18 @@ Global {2F105FA7-74DA-4855-9D8E-818DEE1F8D43}.Release|x64.Build.0 = Release|Any CPU {2F105FA7-74DA-4855-9D8E-818DEE1F8D43}.Release|x86.ActiveCfg = Release|Any CPU {2F105FA7-74DA-4855-9D8E-818DEE1F8D43}.Release|x86.Build.0 = Release|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|x64.ActiveCfg = Debug|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|x64.Build.0 = Debug|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|x86.ActiveCfg = Debug|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Debug|x86.Build.0 = Debug|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|Any CPU.Build.0 = Release|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|x64.ActiveCfg = Release|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|x64.Build.0 = Release|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|x86.ActiveCfg = Release|Any CPU + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -573,6 +589,8 @@ Global {EAF50654-98ED-44BB-A120-0436EC0CD3E0} = {CBD2BB24-3EC3-4950-ABE4-8C521D258DCD} {E4D756A7-A934-4D7F-BC6E-7B95FE4098AB} = {B29FB58D-FAE5-405E-9695-BCF93582BE9A} {2F105FA7-74DA-4855-9D8E-818DEE1F8D43} = {E4D756A7-A934-4D7F-BC6E-7B95FE4098AB} + {96DE9B14-D81F-422E-A33A-728BFB9C153A} = {B29FB58D-FAE5-405E-9695-BCF93582BE9A} + {8BB1A8BE-F002-40A2-9B8E-439284B21C1C} = {96DE9B14-D81F-422E-A33A-728BFB9C153A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {27A36094-AA50-4FFD-ADE6-C055E391F741} diff --git a/src/Components/WebAssembly/DebugProxy/src/DebugProxyOptions.cs b/src/Components/WebAssembly/DebugProxy/src/DebugProxyOptions.cs new file mode 100644 index 000000000000..70a76258c6cf --- /dev/null +++ b/src/Components/WebAssembly/DebugProxy/src/DebugProxyOptions.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.WebAssembly.DebugProxy +{ + public class DebugProxyOptions + { + public string BrowserHost { get; set; } + } +} diff --git a/src/Components/WebAssembly/DebugProxy/src/Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.csproj b/src/Components/WebAssembly/DebugProxy/src/Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.csproj new file mode 100644 index 000000000000..933fcb938685 --- /dev/null +++ b/src/Components/WebAssembly/DebugProxy/src/Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.csproj @@ -0,0 +1,26 @@ + + + + $(DefaultNetCoreTargetFramework) + Exe + Microsoft.AspNetCore.Components.WebAssembly.DebugProxy + true + false + Debug proxy for use when building Blazor applications. + + false + + + true + 3.1.0 + + + + + + + + + + + diff --git a/src/Components/WebAssembly/Server/src/MonoDebugProxy/ws-proxy/DebugStore.cs b/src/Components/WebAssembly/DebugProxy/src/MonoDebugProxy/ws-proxy/DebugStore.cs similarity index 100% rename from src/Components/WebAssembly/Server/src/MonoDebugProxy/ws-proxy/DebugStore.cs rename to src/Components/WebAssembly/DebugProxy/src/MonoDebugProxy/ws-proxy/DebugStore.cs diff --git a/src/Components/WebAssembly/Server/src/MonoDebugProxy/ws-proxy/DevToolsProxy.cs b/src/Components/WebAssembly/DebugProxy/src/MonoDebugProxy/ws-proxy/DevToolsProxy.cs similarity index 99% rename from src/Components/WebAssembly/Server/src/MonoDebugProxy/ws-proxy/DevToolsProxy.cs rename to src/Components/WebAssembly/DebugProxy/src/MonoDebugProxy/ws-proxy/DevToolsProxy.cs index e6aee451de1f..a46b0a312829 100644 --- a/src/Components/WebAssembly/Server/src/MonoDebugProxy/ws-proxy/DevToolsProxy.cs +++ b/src/Components/WebAssembly/DebugProxy/src/MonoDebugProxy/ws-proxy/DevToolsProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json.Linq; diff --git a/src/Components/WebAssembly/Server/src/MonoDebugProxy/ws-proxy/MonoProxy.cs b/src/Components/WebAssembly/DebugProxy/src/MonoDebugProxy/ws-proxy/MonoProxy.cs similarity index 100% rename from src/Components/WebAssembly/Server/src/MonoDebugProxy/ws-proxy/MonoProxy.cs rename to src/Components/WebAssembly/DebugProxy/src/MonoDebugProxy/ws-proxy/MonoProxy.cs diff --git a/src/Components/WebAssembly/DebugProxy/src/Program.cs b/src/Components/WebAssembly/DebugProxy/src/Program.cs new file mode 100644 index 000000000000..0507d82130b0 --- /dev/null +++ b/src/Components/WebAssembly/DebugProxy/src/Program.cs @@ -0,0 +1,91 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.AspNetCore.Components.WebAssembly.DebugProxy +{ + public class Program + { + static int Main(string[] args) + { + var app = new CommandLineApplication(throwOnUnexpectedArg: false) + { + Name = "webassembly-debugproxy" + }; + app.HelpOption("-?|-h|--help"); + + var browserHostOption = new CommandOption("-b|--browser-host", CommandOptionType.SingleValue) + { + Description = "Host on which the browser is listening for debug connections. Example: http://localhost:9300" + }; + + var ownerPidOption = new CommandOption("-op|--owner-pid", CommandOptionType.SingleValue) + { + Description = "ID of the owner process. The debug proxy will shut down if this process exits." + }; + + app.Options.Add(browserHostOption); + app.Options.Add(ownerPidOption); + + app.OnExecute(() => + { + var host = Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((hostingContext, config) => + { + config.AddCommandLine(args); + }) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + + // By default we bind to a dyamic port + // This can be overridden using an option like "--urls http://localhost:9500" + webBuilder.UseUrls("http://127.0.0.1:0"); + }) + .ConfigureServices(serviceCollection => + { + serviceCollection.AddSingleton(new DebugProxyOptions + { + BrowserHost = browserHostOption.HasValue() + ? browserHostOption.Value() + : "http://127.0.0.1:9222", + }); + }) + .Build(); + + if (ownerPidOption.HasValue()) + { + var ownerProcess = Process.GetProcessById(int.Parse(ownerPidOption.Value())); + ownerProcess.EnableRaisingEvents = true; + ownerProcess.Exited += async (sender, eventArgs) => + { + Console.WriteLine("Exiting because parent process has exited"); + await host.StopAsync(); + }; + } + + host.Run(); + + return 0; + }); + + try + { + return app.Execute(args); + } + catch (CommandParsingException cex) + { + app.Error.WriteLine(cex.Message); + app.ShowHelp(); + return 1; + } + } + } +} diff --git a/src/Components/WebAssembly/DebugProxy/src/Startup.cs b/src/Components/WebAssembly/DebugProxy/src/Startup.cs new file mode 100644 index 000000000000..9a80b2a2527a --- /dev/null +++ b/src/Components/WebAssembly/DebugProxy/src/Startup.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using WebAssembly.Net.Debugging; + +namespace Microsoft.AspNetCore.Components.WebAssembly.DebugProxy +{ + public class Startup + { + public void Configure(IApplicationBuilder app, DebugProxyOptions debugProxyOptions) + { + app.UseDeveloperExceptionPage(); + app.UseWebSockets(); + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + // At the homepage, we check whether we can uniquely identify the target tab + // - If yes, we redirect directly to the debug tools, proxying to that tab + // - If no, we present a list of available tabs for the user to pick from + endpoints.MapGet("/", new TargetPickerUi(debugProxyOptions).Display); + + // At this URL, we wire up the actual WebAssembly proxy + endpoints.MapGet("/ws-proxy", async (context) => + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + return; + } + + var loggerFactory = context.RequestServices.GetRequiredService(); + var browserUri = new Uri(context.Request.Query["browser"]); + var ideSocket = await context.WebSockets.AcceptWebSocketAsync(); + await new MonoProxy(loggerFactory).Run(browserUri, ideSocket); + }); + }); + } + } +} diff --git a/src/Components/WebAssembly/DebugProxy/src/TargetPickerUi.cs b/src/Components/WebAssembly/DebugProxy/src/TargetPickerUi.cs new file mode 100644 index 000000000000..54d1a47e5532 --- /dev/null +++ b/src/Components/WebAssembly/DebugProxy/src/TargetPickerUi.cs @@ -0,0 +1,226 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components.WebAssembly.DebugProxy +{ + public class TargetPickerUi + { + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + IgnoreNullValues = true + }; + + private readonly DebugProxyOptions _options; + + public TargetPickerUi(DebugProxyOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task Display(HttpContext context) + { + context.Response.ContentType = "text/html"; + + var request = context.Request; + var targetApplicationUrl = request.Query["url"]; + + var debuggerTabsListUrl = $"{_options.BrowserHost}/json"; + IEnumerable availableTabs; + + try + { + availableTabs = await GetOpenedBrowserTabs(); + } + catch (Exception ex) + { + await context.Response.WriteAsync($@" +

Unable to find debuggable browser tab

+

+ Could not get a list of browser tabs from {debuggerTabsListUrl}. + Ensure your browser is running with debugging enabled. +

+

Resolution

+

+

If you are using Google Chrome for your development, follow these instructions:

+ {GetLaunchChromeInstructions(targetApplicationUrl)} +

+

+

If you are using Microsoft Edge (Chromium) for your development, follow these instructions:

+ {GetLaunchEdgeInstructions(targetApplicationUrl)} +

+This should launch a new browser window with debugging enabled..

+

Underlying exception:

+
{ex}
+ "); + + return; + } + + var matchingTabs = string.IsNullOrEmpty(targetApplicationUrl) + ? availableTabs.ToList() + : availableTabs.Where(t => t.Url.Equals(targetApplicationUrl, StringComparison.Ordinal)).ToList(); + + if (matchingTabs.Count == 1) + { + // We know uniquely which tab to debug, so just redirect + var devToolsUrlWithProxy = GetDevToolsUrlWithProxy(request, matchingTabs.Single()); + context.Response.Redirect(devToolsUrlWithProxy); + } + else if (matchingTabs.Count == 0) + { + await context.Response.WriteAsync("

No inspectable pages found

"); + + var suffix = string.IsNullOrEmpty(targetApplicationUrl) + ? string.Empty + : $" matching the URL {WebUtility.HtmlEncode(targetApplicationUrl)}"; + await context.Response.WriteAsync($"

The list of targets returned by {WebUtility.HtmlEncode(debuggerTabsListUrl)} contains no entries{suffix}.

"); + await context.Response.WriteAsync("

Make sure your browser is displaying the target application.

"); + } + else + { + await context.Response.WriteAsync("

Inspectable pages

"); + await context.Response.WriteAsync(@" + + "); + + foreach (var tab in matchingTabs) + { + var devToolsUrlWithProxy = GetDevToolsUrlWithProxy(request, tab); + await context.Response.WriteAsync( + $"" + + $"

{WebUtility.HtmlEncode(tab.Title)}

{WebUtility.HtmlEncode(tab.Url)}" + + $"
"); + } + } + } + + private string GetDevToolsUrlWithProxy(HttpRequest request, BrowserTab tabToDebug) + { + var underlyingV8Endpoint = tabToDebug.WebSocketDebuggerUrl; + var proxyEndpoint = GetProxyEndpoint(request, underlyingV8Endpoint); + var devToolsUrlAbsolute = new Uri(_options.BrowserHost + tabToDebug.DevtoolsFrontendUrl); + var devToolsUrlWithProxy = $"{devToolsUrlAbsolute.Scheme}://{devToolsUrlAbsolute.Authority}{devToolsUrlAbsolute.AbsolutePath}?{proxyEndpoint.Scheme}={proxyEndpoint.Authority}{proxyEndpoint.PathAndQuery}"; + return devToolsUrlWithProxy; + } + + private string GetLaunchChromeInstructions(string targetApplicationUrl) + { + var profilePath = Path.Combine(Path.GetTempPath(), "blazor-chrome-debug"); + var debuggerPort = new Uri(_options.BrowserHost).Port; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return $@"

Press Win+R and enter the following:

+

chrome --remote-debugging-port={debuggerPort} --user-data-dir=""{profilePath}"" {targetApplicationUrl}

"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return $@"

In a terminal window execute the following:

+

google-chrome --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {targetApplicationUrl}

"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return $@"

Execute the following:

+

open /Applications/Google\ Chrome.app --args --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {targetApplicationUrl}

"; + } + else + { + throw new InvalidOperationException("Unknown OS platform"); + } + } + + private string GetLaunchEdgeInstructions(string targetApplicationUrl) + { + var profilePath = Path.Combine(Path.GetTempPath(), "blazor-edge-debug"); + var debuggerPort = new Uri(_options.BrowserHost).Port; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return $@"

Press Win+R and enter the following:

+

msedge --remote-debugging-port={debuggerPort} --user-data-dir=""{profilePath}"" --no-first-run {targetApplicationUrl}

"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return $@"

In a terminal window execute the following:

+

open /Applications/Microsoft\ Edge\ Dev.app --args --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {targetApplicationUrl}

"; + } + else + { + return $@"

Edge is not current supported on your platform

"; + } + } + + private static Uri GetProxyEndpoint(HttpRequest incomingRequest, string browserEndpoint) + { + var builder = new UriBuilder( + schemeName: incomingRequest.IsHttps ? "wss" : "ws", + hostName: incomingRequest.Host.Host) + { + Path = $"{incomingRequest.PathBase}/ws-proxy", + Query = $"browser={WebUtility.UrlEncode(browserEndpoint)}" + }; + + if (incomingRequest.Host.Port.HasValue) + { + builder.Port = incomingRequest.Host.Port.Value; + } + + return builder.Uri; + } + + private async Task> GetOpenedBrowserTabs() + { + using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var jsonResponse = await httpClient.GetStringAsync($"{_options.BrowserHost}/json"); + return JsonSerializer.Deserialize(jsonResponse, JsonOptions); + } + + class BrowserTab + { + public string Id { get; set; } + public string Type { get; set; } + public string Url { get; set; } + public string Title { get; set; } + public string DevtoolsFrontendUrl { get; set; } + public string WebSocketDebuggerUrl { get; set; } + } + } +} diff --git a/src/Components/WebAssembly/Server/src/DebugProxyLauncher.cs b/src/Components/WebAssembly/Server/src/DebugProxyLauncher.cs new file mode 100644 index 000000000000..fc55e94aec0c --- /dev/null +++ b/src/Components/WebAssembly/Server/src/DebugProxyLauncher.cs @@ -0,0 +1,131 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Builder +{ + internal static class DebugProxyLauncher + { + private static readonly object LaunchLock = new object(); + private static readonly TimeSpan DebugProxyLaunchTimeout = TimeSpan.FromSeconds(10); + private static Task LaunchedDebugProxyUrl; + private static readonly Regex NowListeningRegex = new Regex(@"^\s*Now listening on: (?.*)$", RegexOptions.None, TimeSpan.FromSeconds(10)); + private static readonly Regex ApplicationStartedRegex = new Regex(@"^\s*Application started\. Press Ctrl\+C to shut down\.$", RegexOptions.None, TimeSpan.FromSeconds(10)); + + public static Task EnsureLaunchedAndGetUrl(IServiceProvider serviceProvider) + { + lock (LaunchLock) + { + if (LaunchedDebugProxyUrl == null) + { + LaunchedDebugProxyUrl = LaunchAndGetUrl(serviceProvider); + } + + return LaunchedDebugProxyUrl; + } + } + + private static async Task LaunchAndGetUrl(IServiceProvider serviceProvider) + { + var tcs = new TaskCompletionSource(); + + var environment = serviceProvider.GetRequiredService(); + var executablePath = LocateDebugProxyExecutable(environment); + var muxerPath = DotNetMuxer.MuxerPathOrDefault(); + var ownerPid = Process.GetCurrentProcess().Id; + var processStartInfo = new ProcessStartInfo + { + FileName = muxerPath, + Arguments = $"exec \"{executablePath}\" --owner-pid {ownerPid}", + UseShellExecute = false, + RedirectStandardOutput = true, + }; + RemoveUnwantedEnvironmentVariables(processStartInfo.Environment); + + var debugProxyProcess = Process.Start(processStartInfo); + CompleteTaskWhenServerIsReady(debugProxyProcess, tcs); + + new CancellationTokenSource(DebugProxyLaunchTimeout).Token.Register(() => + { + tcs.TrySetException(new TimeoutException($"Failed to start the debug proxy within the timeout period of {DebugProxyLaunchTimeout.TotalSeconds} seconds.")); + }); + + return await tcs.Task; + } + + private static void RemoveUnwantedEnvironmentVariables(IDictionary environment) + { + // Generally we expect to pass through most environment variables, since dotnet might + // need them for arbitrary reasons to function correctly. However, we specifically don't + // want to pass through any ASP.NET Core hosting related ones, since the child process + // shouldn't be trying to use the same port numbers, etc. In particular we need to break + // the association with IISExpress and the MS-ASPNETCORE-TOKEN check. + var keysToRemove = environment.Keys.Where(key => key.StartsWith("ASPNETCORE_")).ToList(); + foreach (var key in keysToRemove) + { + environment.Remove(key); + } + } + + private static string LocateDebugProxyExecutable(IWebHostEnvironment environment) + { + var assembly = Assembly.Load(environment.ApplicationName); + var debugProxyPath = Path.Combine( + Path.GetDirectoryName(assembly.Location), + "BlazorDebugProxy", + "Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.dll"); + + if (!File.Exists(debugProxyPath)) + { + throw new FileNotFoundException( + $"Cannot start debug proxy because it cannot be found at '{debugProxyPath}'"); + } + + return debugProxyPath; + } + + private static void CompleteTaskWhenServerIsReady(Process aspNetProcess, TaskCompletionSource taskCompletionSource) + { + string capturedUrl = null; + aspNetProcess.OutputDataReceived += OnOutputDataReceived; + aspNetProcess.BeginOutputReadLine(); + + void OnOutputDataReceived(object sender, DataReceivedEventArgs eventArgs) + { + if (ApplicationStartedRegex.IsMatch(eventArgs.Data)) + { + aspNetProcess.OutputDataReceived -= OnOutputDataReceived; + if (!string.IsNullOrEmpty(capturedUrl)) + { + taskCompletionSource.TrySetResult(capturedUrl); + } + else + { + taskCompletionSource.TrySetException(new InvalidOperationException( + "The application started listening without first advertising a URL")); + } + } + else + { + var match = NowListeningRegex.Match(eventArgs.Data); + if (match.Success) + { + capturedUrl = match.Groups["url"].Value; + } + } + } + } + } +} diff --git a/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj b/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj index 0ad19d5362ce..b0a60bc9c7ba 100644 --- a/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj +++ b/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj @@ -7,17 +7,43 @@ false true + + + $(NoWarn);NU5100 - + - - + + - - + + + + + + + + + + + diff --git a/src/Components/WebAssembly/Server/src/MonoDebugProxy/WebAssemblyNetDebugProxyAppBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/MonoDebugProxy/WebAssemblyNetDebugProxyAppBuilderExtensions.cs deleted file mode 100644 index 1f3ec84149fe..000000000000 --- a/src/Components/WebAssembly/Server/src/MonoDebugProxy/WebAssemblyNetDebugProxyAppBuilderExtensions.cs +++ /dev/null @@ -1,370 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Runtime.InteropServices; -using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using WebAssembly.Net.Debugging; - -namespace Microsoft.AspNetCore.Builder -{ - /// - /// Provides infrastructure for debugging Blazor WebAssembly applications. - /// - public static class WebAssemblyNetDebugProxyAppBuilderExtensions - { - private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - IgnoreNullValues = true - }; - - private static readonly string DefaultDebuggerHost = "http://localhost:9222"; - - /// - /// Adds middleware for needed for debugging Blazor WebAssembly applications - /// inside Chromium dev tools. - /// - public static void UseWebAssemblyDebugging(this IApplicationBuilder app) - { - app.UseWebSockets(); - - app.UseVisualStudioDebuggerConnectionRequestHandlers(); - - app.Use((context, next) => - { - var requestPath = context.Request.Path; - if (!requestPath.StartsWithSegments("/_framework/debug")) - { - return next(); - } - - if (requestPath.Equals("/_framework/debug/ws-proxy", StringComparison.OrdinalIgnoreCase)) - { - var loggerFactory = app.ApplicationServices.GetRequiredService(); - return DebugWebSocketProxyRequest(loggerFactory, context); - } - - if (requestPath.Equals("/_framework/debug", StringComparison.OrdinalIgnoreCase)) - { - return DebugHome(context); - } - - context.Response.StatusCode = (int)HttpStatusCode.NotFound; - return Task.CompletedTask; - }); - } - - private static string GetDebuggerHost() - { - var envVar = Environment.GetEnvironmentVariable("ASPNETCORE_WEBASSEMBLYDEBUGHOST"); - - if (string.IsNullOrEmpty(envVar)) - { - return DefaultDebuggerHost; - } - else - { - return envVar; - } - } - - private static int GetDebuggerPort() - { - var host = GetDebuggerHost(); - return new Uri(host).Port; - } - - private static void UseVisualStudioDebuggerConnectionRequestHandlers(this IApplicationBuilder app) - { - // Unfortunately VS doesn't send any deliberately distinguishing information so we know it's - // not a regular browser or API client. The closest we can do is look for the *absence* of a - // User-Agent header. In the future, we should try to get VS to send a special header to indicate - // this is a debugger metadata request. - app.Use(async (context, next) => - { - var request = context.Request; - var requestPath = request.Path; - if (requestPath.StartsWithSegments("/json") - && !request.Headers.ContainsKey("User-Agent")) - { - if (requestPath.Equals("/json", StringComparison.OrdinalIgnoreCase) || requestPath.Equals("/json/list", StringComparison.OrdinalIgnoreCase)) - { - var availableTabs = await GetOpenedBrowserTabs(); - - // Filter the list to only include tabs displaying the requested app, - // but only during the "choose application to debug" phase. We can't apply - // the same filter during the "connecting" phase (/json/list), nor do we need to. - if (requestPath.Equals("/json")) - { - availableTabs = availableTabs.Where(tab => tab.Url.StartsWith($"{request.Scheme}://{request.Host}{request.PathBase}/")); - } - - var proxiedTabInfos = availableTabs.Select(tab => - { - var underlyingV8Endpoint = tab.WebSocketDebuggerUrl; - var proxiedScheme = request.IsHttps ? "wss" : "ws"; - var proxiedV8Endpoint = $"{proxiedScheme}://{request.Host}{request.PathBase}/_framework/debug/ws-proxy?browser={WebUtility.UrlEncode(underlyingV8Endpoint)}"; - return new - { - description = "", - devtoolsFrontendUrl = "", - id = tab.Id, - title = tab.Title, - type = tab.Type, - url = tab.Url, - webSocketDebuggerUrl = proxiedV8Endpoint - }; - }); - - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync(JsonSerializer.Serialize(proxiedTabInfos)); - } - else if (requestPath.Equals("/json/version", StringComparison.OrdinalIgnoreCase)) - { - // VS Code's "js-debug" nightly extension, when configured to use the "pwa-chrome" - // debug type, uses the /json/version endpoint to find the websocket endpoint for - // debugging the browser that listens on a user-specified port. - // - // To make this flow work with the Mono debug proxy, we pass the request through - // to the underlying browser (to get its actual version info) but then overwrite - // the "webSocketDebuggerUrl" with the URL to the proxy. - // - // This whole connection flow isn't very good because it doesn't have any way - // to specify the debug port for the underlying browser. So, we end up assuming - // the default port 9222 in all cases. This is good enough for a manual "attach" - // but isn't good enough if the IDE is responsible for launching the browser, - // as it will be on a random port. So, - // - // - VS isn't going to use this. Instead it will use a configured "debugEndpoint" - // property from which it can construct the proxy URL directly (including adding - // a "browser" querystring value to specify the underlying endpoint), bypassing - // /json/version altogether - // - We will need to update the VS Code debug adapter to make it do the same as VS - // if there is a "debugEndpoint" property configured - // - // Once both VS and VS Code support the "debugEndpoint" flow, we should be able to - // remove this /json/version code altogether. We should check that in-browser - // debugging still works at that point. - - var browserVersionJsonStream = await GetBrowserVersionInfoAsync(); - var browserVersion = await JsonSerializer.DeserializeAsync>(browserVersionJsonStream); - - if (browserVersion.TryGetValue("webSocketDebuggerUrl", out var browserEndpoint)) - { - var proxyEndpoint = GetProxyEndpoint(request, ((JsonElement)browserEndpoint).GetString()); - browserVersion["webSocketDebuggerUrl"] = proxyEndpoint; - } - - context.Response.ContentType = "application/json"; - await JsonSerializer.SerializeAsync(context.Response.Body, browserVersion); - } - } - else - { - await next(); - } - }); - } - - private static async Task DebugWebSocketProxyRequest(ILoggerFactory loggerFactory, HttpContext context) - { - if (!context.WebSockets.IsWebSocketRequest) - { - context.Response.StatusCode = 400; - return; - } - - var browserUri = new Uri(context.Request.Query["browser"]); - var ideSocket = await context.WebSockets.AcceptWebSocketAsync(); - await new MonoProxy(loggerFactory).Run(browserUri, ideSocket); - } - - private static async Task DebugHome(HttpContext context) - { - context.Response.ContentType = "text/html"; - - var request = context.Request; - var appRootUrl = $"{request.Scheme}://{request.Host}{request.PathBase}/"; - var targetTabUrl = request.Query["url"]; - if (string.IsNullOrEmpty(targetTabUrl)) - { - context.Response.StatusCode = (int)HttpStatusCode.BadRequest; - await context.Response.WriteAsync("No value specified for 'url'"); - return; - } - - // TODO: Allow overriding port (but not hostname, as we're connecting to the - // local browser, not to the webserver serving the app) - var debuggerHost = GetDebuggerHost(); - var debuggerTabsListUrl = $"{debuggerHost}/json"; - IEnumerable availableTabs; - - try - { - availableTabs = await GetOpenedBrowserTabs(); - } - catch (Exception ex) - { - await context.Response.WriteAsync($@" -

Unable to find debuggable browser tab

-

- Could not get a list of browser tabs from {debuggerTabsListUrl}. - Ensure your browser is running with debugging enabled. -

-

Resolution

-

-

If you are using Google Chrome for your development, follow these instructions:

- {GetLaunchChromeInstructions(appRootUrl)} -

-

-

If you are using Microsoft Edge (Chromium) for your development, follow these instructions:

- {GetLaunchEdgeInstructions(appRootUrl)} -

-This should launch a new browser window with debugging enabled..

-

Underlying exception:

-
{ex}
- "); - - return; - } - - var matchingTabs = availableTabs - .Where(t => t.Url.Equals(targetTabUrl, StringComparison.Ordinal)) - .ToList(); - if (matchingTabs.Count == 0) - { - await context.Response.WriteAsync($@" -

Unable to find debuggable browser tab

-

- The response from {debuggerTabsListUrl} does not include - any entry for {targetTabUrl}. -

"); - return; - } - else if (matchingTabs.Count > 1) - { - // TODO: Automatically disambiguate by adding a GUID to the page title - // when you press the debugger hotkey, include it in the querystring passed - // here, then remove it once the debugger connects. - await context.Response.WriteAsync($@" -

Multiple matching tabs are open

-

- There is more than one browser tab at {targetTabUrl}. - Close the ones you do not wish to debug, then refresh this page. -

"); - return; - } - - // Now we know uniquely which tab to debug, construct the URL to the debug - // page and redirect there - var tabToDebug = matchingTabs.Single(); - var underlyingV8Endpoint = tabToDebug.WebSocketDebuggerUrl; - var proxyEndpoint = GetProxyEndpoint(request, underlyingV8Endpoint); - var devToolsUrlAbsolute = new Uri(debuggerHost + tabToDebug.DevtoolsFrontendUrl); - var devToolsUrlWithProxy = $"{devToolsUrlAbsolute.Scheme}://{devToolsUrlAbsolute.Authority}{devToolsUrlAbsolute.AbsolutePath}?{proxyEndpoint.Scheme}={proxyEndpoint.Authority}{proxyEndpoint.PathAndQuery}"; - context.Response.Redirect(devToolsUrlWithProxy); - } - - private static Uri GetProxyEndpoint(HttpRequest incomingRequest, string browserEndpoint) - { - var builder = new UriBuilder( - schemeName: incomingRequest.IsHttps ? "wss" : "ws", - hostName: incomingRequest.Host.Host) - { - Path = $"{incomingRequest.PathBase}/_framework/debug/ws-proxy", - Query = $"browser={WebUtility.UrlEncode(browserEndpoint)}" - }; - - if (incomingRequest.Host.Port.HasValue) - { - builder.Port = incomingRequest.Host.Port.Value; - } - - return builder.Uri; - } - - private static string GetLaunchChromeInstructions(string appRootUrl) - { - var profilePath = Path.Combine(Path.GetTempPath(), "blazor-chrome-debug"); - var debuggerPort = GetDebuggerPort(); - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return $@"

Press Win+R and enter the following:

-

chrome --remote-debugging-port={debuggerPort} --user-data-dir=""{profilePath}"" {appRootUrl}

"; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return $@"

In a terminal window execute the following:

-

google-chrome --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {appRootUrl}

"; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return $@"

Execute the following:

-

open /Applications/Google\ Chrome.app --args --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {appRootUrl}

"; - } - else - { - throw new InvalidOperationException("Unknown OS platform"); - } - } - - private static string GetLaunchEdgeInstructions(string appRootUrl) - { - var profilePath = Path.Combine(Path.GetTempPath(), "blazor-edge-debug"); - var debugggerPort = GetDebuggerPort(); - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return $@"

Press Win+R and enter the following:

-

msedge --remote-debugging-port={debugggerPort} --user-data-dir=""{profilePath}"" --no-first-run {appRootUrl}

"; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return $@"

In a terminal window execute the following:

-

open /Applications/Microsoft\ Edge\ Dev.app --args --remote-debugging-port={debugggerPort} --user-data-dir={profilePath} {appRootUrl}

"; - } - else - { - return $@"

Edge is not current supported on your platform

"; - } - } - - private static async Task GetBrowserVersionInfoAsync() - { - using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - var debuggerHost = GetDebuggerHost(); - var response = await httpClient.GetAsync($"{debuggerHost}/json/version"); - return await response.Content.ReadAsStreamAsync(); - } - - private static async Task> GetOpenedBrowserTabs() - { - using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - var debuggerHost = GetDebuggerHost(); - var jsonResponse = await httpClient.GetStringAsync($"{debuggerHost}/json"); - return JsonSerializer.Deserialize(jsonResponse, JsonOptions); - } - - class BrowserTab - { - public string Id { get; set; } - public string Type { get; set; } - public string Url { get; set; } - public string Title { get; set; } - public string DevtoolsFrontendUrl { get; set; } - public string WebSocketDebuggerUrl { get; set; } - } - } -} diff --git a/src/Components/WebAssembly/Server/src/WebAssemblyNetDebugProxyAppBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/WebAssemblyNetDebugProxyAppBuilderExtensions.cs new file mode 100644 index 000000000000..2ad827c336be --- /dev/null +++ b/src/Components/WebAssembly/Server/src/WebAssemblyNetDebugProxyAppBuilderExtensions.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Net; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Provides infrastructure for debugging Blazor WebAssembly applications. + /// + public static class WebAssemblyNetDebugProxyAppBuilderExtensions + { + /// + /// Adds middleware for needed for debugging Blazor WebAssembly applications + /// inside Chromium dev tools. + /// + public static void UseWebAssemblyDebugging(this IApplicationBuilder app) + { + app.Map("/_framework/debug", app => + { + app.Use(async (context, next) => + { + var debugProxyBaseUrl = await DebugProxyLauncher.EnsureLaunchedAndGetUrl(context.RequestServices); + var requestPath = context.Request.Path.ToString(); + if (requestPath == string.Empty) + { + requestPath = "/"; + } + + // Although we could redirect for every URL we see here, we filter the allowed set + // to ensure this doesn't get misused as some kind of more general redirector + switch (requestPath) + { + case "/": + case "/ws-proxy": + context.Response.Redirect($"{debugProxyBaseUrl}{requestPath}{context.Request.QueryString}"); + break; + default: + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + break; + } + }); + }); + } + } +} diff --git a/src/Components/WebAssembly/Server/src/build/Microsoft.AspNetCore.Components.WebAssembly.Server.targets b/src/Components/WebAssembly/Server/src/build/Microsoft.AspNetCore.Components.WebAssembly.Server.targets new file mode 100644 index 000000000000..ffa102691e2b --- /dev/null +++ b/src/Components/WebAssembly/Server/src/build/Microsoft.AspNetCore.Components.WebAssembly.Server.targets @@ -0,0 +1,16 @@ + + + + + + <_DebugProxyBinaries Include="$(MSBuildThisFileDirectory)..\tools\BlazorDebugProxy\**" /> + + + + + + diff --git a/src/Components/WebAssembly/testassets/HostedInAspNet.Server/Properties/launchSettings.json b/src/Components/WebAssembly/testassets/HostedInAspNet.Server/Properties/launchSettings.json new file mode 100644 index 000000000000..959797907279 --- /dev/null +++ b/src/Components/WebAssembly/testassets/HostedInAspNet.Server/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:56500/", + "sslPort": 44347 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "HostedInAspNet.Server": { + "commandName": "Project", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + } +} diff --git a/src/Components/WebAssembly/testassets/StandaloneApp/Properties/launchSettings.json b/src/Components/WebAssembly/testassets/StandaloneApp/Properties/launchSettings.json new file mode 100644 index 000000000000..541c4c44680a --- /dev/null +++ b/src/Components/WebAssembly/testassets/StandaloneApp/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:56502/", + "sslPort": 44332 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "StandaloneApp": { + "commandName": "Project", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + } +} diff --git a/src/Shared/CommandLineUtils/Utilities/DotNetMuxer.cs b/src/Shared/CommandLineUtils/Utilities/DotNetMuxer.cs new file mode 100644 index 000000000000..52c98b5eb251 --- /dev/null +++ b/src/Shared/CommandLineUtils/Utilities/DotNetMuxer.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +// System.AppContext.GetData is not available in these frameworks +#if !NET451 && !NET452 && !NET46 && !NET461 + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +namespace Microsoft.Extensions.CommandLineUtils +{ + /// + /// Utilities for finding the "dotnet.exe" file from the currently running .NET Core application + /// + internal static class DotNetMuxer + { + private const string MuxerName = "dotnet"; + + static DotNetMuxer() + { + MuxerPath = TryFindMuxerPath(); + } + + /// + /// The full filepath to the .NET Core muxer. + /// + public static string MuxerPath { get; } + + /// + /// Finds the full filepath to the .NET Core muxer, + /// or returns a string containing the default name of the .NET Core muxer ('dotnet'). + /// + /// The path or a string named 'dotnet'. + public static string MuxerPathOrDefault() + => MuxerPath ?? MuxerName; + + private static string TryFindMuxerPath() + { + var fileName = MuxerName; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + fileName += ".exe"; + } + + var mainModule = Process.GetCurrentProcess().MainModule; + if (!string.IsNullOrEmpty(mainModule?.FileName) + && Path.GetFileName(mainModule.FileName).Equals(fileName, StringComparison.OrdinalIgnoreCase)) + { + return mainModule.FileName; + } + + return null; + } + } +} +#endif