From 11835cf768de14fa9806241e498ff4d0cba362d6 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Thu, 23 Jul 2020 17:04:31 -0700 Subject: [PATCH] Add support for launching a browser on file change (#24220) --- .../dotnet-watch/src/BrowserRefreshServer.cs | 108 +++++++++ src/Tools/dotnet-watch/src/DotNetWatcher.cs | 19 +- src/Tools/dotnet-watch/src/IFileSet.cs | 4 +- .../dotnet-watch/src/Internal/FileSet.cs | 7 +- .../src/Internal/MsBuildFileSetFactory.cs | 9 +- .../src/Internal/ProcessRunner.cs | 50 ++-- .../dotnet-watch/src/LaunchBrowserFilter.cs | 219 ++++++++++++++++++ .../dotnet-watch/src/LaunchSettingsJson.cs | 21 ++ src/Tools/dotnet-watch/src/ProcessSpec.cs | 8 +- src/Tools/dotnet-watch/src/Program.cs | 4 +- .../src/assets/DotNetWatch.targets | 15 +- .../dotnet-watch/src/dotnet-watch.csproj | 20 ++ .../src/runtimeconfig.template.json | 3 + .../dotnet-watch/test/BrowserLaunchTests.cs | 68 ++++++ .../test/MSBuildEvaluationFilterTest.cs | 4 +- .../test/Scenario/WatchableApp.cs | 8 + .../AppWithLaunchSettings.csproj | 9 + .../AppWithLaunchSettings/Program.cs | 32 +++ .../Properties/launchSettings.json | 29 +++ 19 files changed, 606 insertions(+), 31 deletions(-) create mode 100644 src/Tools/dotnet-watch/src/BrowserRefreshServer.cs create mode 100644 src/Tools/dotnet-watch/src/LaunchBrowserFilter.cs create mode 100644 src/Tools/dotnet-watch/src/LaunchSettingsJson.cs create mode 100644 src/Tools/dotnet-watch/src/runtimeconfig.template.json create mode 100644 src/Tools/dotnet-watch/test/BrowserLaunchTests.cs create mode 100644 src/Tools/dotnet-watch/test/TestProjects/AppWithLaunchSettings/AppWithLaunchSettings.csproj create mode 100644 src/Tools/dotnet-watch/test/TestProjects/AppWithLaunchSettings/Program.cs create mode 100644 src/Tools/dotnet-watch/test/TestProjects/AppWithLaunchSettings/Properties/launchSettings.json diff --git a/src/Tools/dotnet-watch/src/BrowserRefreshServer.cs b/src/Tools/dotnet-watch/src/BrowserRefreshServer.cs new file mode 100644 index 0000000000..f173980dba --- /dev/null +++ b/src/Tools/dotnet-watch/src/BrowserRefreshServer.cs @@ -0,0 +1,108 @@ +// 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.Linq; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public class BrowserRefreshServer : IAsyncDisposable + { + private readonly IReporter _reporter; + private readonly TaskCompletionSource _taskCompletionSource; + private IHost _refreshServer; + private WebSocket _webSocket; + + public BrowserRefreshServer(IReporter reporter) + { + _reporter = reporter; + _taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public async ValueTask StartAsync(CancellationToken cancellationToken) + { + _refreshServer = new HostBuilder() + .ConfigureWebHost(builder => + { + builder.UseKestrel(); + builder.UseUrls("http://127.0.0.1:0"); + + builder.Configure(app => + { + app.UseWebSockets(); + app.Run(WebSocketRequest); + }); + }) + .Build(); + + await _refreshServer.StartAsync(cancellationToken); + + var serverUrl = _refreshServer.Services + .GetRequiredService() + .Features + .Get() + .Addresses + .First(); + + return serverUrl.Replace("http://", "ws://"); + } + + private async Task WebSocketRequest(HttpContext context) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + _webSocket = await context.WebSockets.AcceptWebSocketAsync(); + await _taskCompletionSource.Task; + } + + public async Task SendMessage(byte[] messageBytes, CancellationToken cancellationToken = default) + { + if (_webSocket == null || _webSocket.CloseStatus.HasValue) + { + return; + } + + try + { + await _webSocket.SendAsync(messageBytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + } + catch (Exception ex) + { + _reporter.Output($"Refresh server error: {ex}"); + } + } + + public async ValueTask DisposeAsync() + { + if (_webSocket != null) + { + await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, default); + _webSocket.Dispose(); + } + + if (_refreshServer != null) + { + await _refreshServer.StopAsync(); + _refreshServer.Dispose(); + } + + _taskCompletionSource.TrySetResult(); + } + } +} diff --git a/src/Tools/dotnet-watch/src/DotNetWatcher.cs b/src/Tools/dotnet-watch/src/DotNetWatcher.cs index 76b5a3adc0..5e2584fc59 100644 --- a/src/Tools/dotnet-watch/src/DotNetWatcher.cs +++ b/src/Tools/dotnet-watch/src/DotNetWatcher.cs @@ -14,7 +14,7 @@ using Microsoft.Extensions.Tools.Internal; namespace Microsoft.DotNet.Watcher { - public class DotNetWatcher + public class DotNetWatcher : IAsyncDisposable { private readonly IReporter _reporter; private readonly ProcessRunner _processRunner; @@ -31,6 +31,7 @@ namespace Microsoft.DotNet.Watcher { new MSBuildEvaluationFilter(fileSetFactory), new NoRestoreFilter(), + new LaunchBrowserFilter(), }; } @@ -140,5 +141,21 @@ namespace Microsoft.DotNet.Watcher } } } + + public async ValueTask DisposeAsync() + { + foreach (var filter in _filters) + { + if (filter is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (filter is IDisposable diposable) + { + diposable.Dispose(); + } + + } + } } } diff --git a/src/Tools/dotnet-watch/src/IFileSet.cs b/src/Tools/dotnet-watch/src/IFileSet.cs index 7554d3f542..177dc6d00e 100644 --- a/src/Tools/dotnet-watch/src/IFileSet.cs +++ b/src/Tools/dotnet-watch/src/IFileSet.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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.Collections.Generic; @@ -7,6 +7,8 @@ namespace Microsoft.DotNet.Watcher { public interface IFileSet : IEnumerable { + bool IsNetCoreApp31OrNewer { get; } + bool Contains(string filePath); } } diff --git a/src/Tools/dotnet-watch/src/Internal/FileSet.cs b/src/Tools/dotnet-watch/src/Internal/FileSet.cs index 7fdf8748e9..3ca17027db 100644 --- a/src/Tools/dotnet-watch/src/Internal/FileSet.cs +++ b/src/Tools/dotnet-watch/src/Internal/FileSet.cs @@ -11,8 +11,9 @@ namespace Microsoft.DotNet.Watcher.Internal { private readonly HashSet _files; - public FileSet(IEnumerable files) + public FileSet(bool isNetCoreApp31OrNewer, IEnumerable files) { + IsNetCoreApp31OrNewer = isNetCoreApp31OrNewer; _files = new HashSet(files, StringComparer.OrdinalIgnoreCase); } @@ -20,7 +21,9 @@ namespace Microsoft.DotNet.Watcher.Internal public int Count => _files.Count; - public static IFileSet Empty = new FileSet(Array.Empty()); + public bool IsNetCoreApp31OrNewer { get; } + + public static IFileSet Empty = new FileSet(false, Array.Empty()); public IEnumerator GetEnumerator() => _files.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator(); diff --git a/src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs b/src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs index 3cdf453067..dc6d7b0829 100644 --- a/src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs +++ b/src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs @@ -72,6 +72,7 @@ namespace Microsoft.DotNet.Watcher.Internal Arguments = new[] { "msbuild", + "/nologo", _projectFile, $"/p:_DotNetWatchListFile={watchList}" }.Concat(_buildFlags), @@ -84,8 +85,12 @@ namespace Microsoft.DotNet.Watcher.Internal if (exitCode == 0 && File.Exists(watchList)) { + var lines = File.ReadAllLines(watchList); + var isNetCoreApp31OrNewer = lines.FirstOrDefault() == "true"; + var fileset = new FileSet( - File.ReadAllLines(watchList) + isNetCoreApp31OrNewer, + lines.Skip(1) .Select(l => l?.Trim()) .Where(l => !string.IsNullOrEmpty(l))); @@ -123,7 +128,7 @@ namespace Microsoft.DotNet.Watcher.Internal { _reporter.Warn("Fix the error to continue or press Ctrl+C to exit."); - var fileSet = new FileSet(new[] { _projectFile }); + var fileSet = new FileSet(false, new[] { _projectFile }); using (var watcher = new FileSetWatcher(fileSet, _reporter)) { diff --git a/src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs b/src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs index 7874d592b6..050828467a 100644 --- a/src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs +++ b/src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs @@ -37,37 +37,49 @@ namespace Microsoft.DotNet.Watcher.Internal { cancellationToken.Register(() => processState.TryKill()); - process.OutputDataReceived += (_, a) => + var readOutput = false; + var readError = false; + if (processSpec.IsOutputCaptured) { - if (!string.IsNullOrEmpty(a.Data)) + readOutput = true; + readError = true; + process.OutputDataReceived += (_, a) => { - processSpec.OutputCapture.AddLine(a.Data); - } - }; - process.ErrorDataReceived += (_, a) => + if (!string.IsNullOrEmpty(a.Data)) + { + processSpec.OutputCapture.AddLine(a.Data); + } + }; + process.ErrorDataReceived += (_, a) => + { + if (!string.IsNullOrEmpty(a.Data)) + { + processSpec.OutputCapture.AddLine(a.Data); + } + }; + } + else if (processSpec.OnOutput != null) { - if (!string.IsNullOrEmpty(a.Data)) - { - processSpec.OutputCapture.AddLine(a.Data); - } - }; + readOutput = true; + process.OutputDataReceived += processSpec.OnOutput; + } stopwatch.Start(); process.Start(); _reporter.Verbose($"Started '{processSpec.Executable}' with process id {process.Id}"); - if (processSpec.IsOutputCaptured) + if (readOutput) + { + process.BeginOutputReadLine(); + } + if (readError) { process.BeginErrorReadLine(); - process.BeginOutputReadLine(); - await processState.Task; - } - else - { - await processState.Task; } + await processState.Task; + exitCode = process.ExitCode; stopwatch.Stop(); _reporter.Verbose($"Process id {process.Id} ran for {stopwatch.ElapsedMilliseconds}ms"); @@ -87,7 +99,7 @@ namespace Microsoft.DotNet.Watcher.Internal Arguments = ArgumentEscaper.EscapeAndConcatenate(processSpec.Arguments), UseShellExecute = false, WorkingDirectory = processSpec.WorkingDirectory, - RedirectStandardOutput = processSpec.IsOutputCaptured, + RedirectStandardOutput = processSpec.IsOutputCaptured || (processSpec.OnOutput != null), RedirectStandardError = processSpec.IsOutputCaptured, } }; diff --git a/src/Tools/dotnet-watch/src/LaunchBrowserFilter.cs b/src/Tools/dotnet-watch/src/LaunchBrowserFilter.cs new file mode 100644 index 0000000000..de8b189f3f --- /dev/null +++ b/src/Tools/dotnet-watch/src/LaunchBrowserFilter.cs @@ -0,0 +1,219 @@ +// 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 System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public sealed class LaunchBrowserFilter : IWatchFilter, IAsyncDisposable + { + private readonly byte[] ReloadMessage = Encoding.UTF8.GetBytes("Reload"); + private readonly byte[] WaitMessage = Encoding.UTF8.GetBytes("Wait"); + private static readonly Regex NowListeningRegex = new Regex(@"^\s*Now listening on: (?.*)$", RegexOptions.None | RegexOptions.Compiled, TimeSpan.FromSeconds(10)); + + private readonly bool _runningInTest; + private readonly bool _suppressLaunchBrowser; + private readonly string _browserPath; + private bool _canLaunchBrowser; + private Process _browserProcess; + private bool _browserLaunched; + private BrowserRefreshServer _refreshServer; + private IReporter _reporter; + private string _launchPath; + private CancellationToken _cancellationToken; + + public LaunchBrowserFilter() + { + var suppressLaunchBrowser = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"); + _suppressLaunchBrowser = (suppressLaunchBrowser == "1" || suppressLaunchBrowser == "true"); + _runningInTest = Environment.GetEnvironmentVariable("__DOTNET_WATCH_RUNNING_AS_TEST") == "true"; + _browserPath = Environment.GetEnvironmentVariable("DOTNET_WATCH_BROWSER_PATH"); + } + + public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToken cancellationToken) + { + if (_suppressLaunchBrowser) + { + return; + } + + if (context.Iteration == 0) + { + _reporter = context.Reporter; + + if (CanLaunchBrowser(context, out var launchPath)) + { + context.Reporter.Verbose("dotnet-watch is configured to launch a browser on ASP.NET Core application startup."); + _canLaunchBrowser = true; + _launchPath = launchPath; + _cancellationToken = cancellationToken; + + _refreshServer = new BrowserRefreshServer(context.Reporter); + var serverUrl = await _refreshServer.StartAsync(cancellationToken); + + context.Reporter.Verbose($"Refresh server running at {serverUrl}."); + context.ProcessSpec.EnvironmentVariables["DOTNET_WATCH_REFRESH_URL"] = serverUrl; + + context.ProcessSpec.OnOutput += OnOutput; + } + } + + if (_canLaunchBrowser) + { + if (context.Iteration > 0) + { + // We've detected a change. Notify the browser. + await _refreshServer.SendMessage(WaitMessage, cancellationToken); + } + } + } + + private void OnOutput(object sender, DataReceivedEventArgs eventArgs) + { + // We've redirected the output, but want to ensure that it continues to appear in the user's console. + Console.WriteLine(eventArgs.Data); + + if (string.IsNullOrEmpty(eventArgs.Data)) + { + return; + } + + var match = NowListeningRegex.Match(eventArgs.Data); + if (match.Success) + { + var launchUrl = match.Groups["url"].Value; + + var process = (Process)sender; + process.OutputDataReceived -= OnOutput; + process.CancelOutputRead(); + + if (!_browserLaunched) + { + _reporter.Verbose("Launching browser."); + try + { + LaunchBrowser(launchUrl); + _browserLaunched = true; + } + catch (Exception ex) + { + _reporter.Output($"Unable to launch browser: {ex}"); + _canLaunchBrowser = false; + } + } + else + { + _reporter.Verbose("Reloading browser."); + _ = _refreshServer.SendMessage(ReloadMessage, _cancellationToken); + } + } + } + + private void LaunchBrowser(string launchUrl) + { + var fileName = launchUrl + "/" + _launchPath; + var args = string.Empty; + if (!string.IsNullOrEmpty(_browserPath)) + { + args = fileName; + fileName = _browserPath; + } + + if (_runningInTest) + { + _reporter.Output($"Launching browser: {fileName} {args}"); + return; + } + + _browserProcess = Process.Start(new ProcessStartInfo + { + FileName = fileName, + Arguments = args, + UseShellExecute = true, + }); + } + + private static bool CanLaunchBrowser(DotNetWatchContext context, out string launchUrl) + { + launchUrl = null; + var reporter = context.Reporter; + + if (!context.FileSet.IsNetCoreApp31OrNewer) + { + // Browser refresh middleware supports 3.1 or newer + reporter.Verbose("Browser refresh is only supported in .NET Core 3.1 or newer projects."); + return false; + } + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // Launching a browser requires file associations that are not available in all operating systems. + reporter.Verbose("Browser refresh is only supported in Windows and MacOS."); + return false; + } + + var dotnetCommand = context.ProcessSpec.Arguments.FirstOrDefault(); + if (!string.Equals(dotnetCommand, "run", StringComparison.Ordinal)) + { + reporter.Verbose("Browser refresh is only supported for run commands."); + return false; + } + + // We're executing the run-command. Determine if the launchSettings allows it + var launchSettingsPath = Path.Combine(context.ProcessSpec.WorkingDirectory, "Properties", "launchSettings.json"); + if (!File.Exists(launchSettingsPath)) + { + reporter.Verbose($"No launchSettings.json file found at {launchSettingsPath}. Unable to determine if browser refresh is allowed."); + return false; + } + + LaunchSettingsJson launchSettings; + try + { + launchSettings = JsonSerializer.Deserialize( + File.ReadAllText(launchSettingsPath), + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + } + catch (Exception ex) + { + reporter.Verbose($"Error reading launchSettings.json: {ex}."); + return false; + } + + var defaultProfile = launchSettings.Profiles.FirstOrDefault(f => f.Value.CommandName == "Project").Value; + if (defaultProfile is null) + { + reporter.Verbose("Unable to find default launchSettings profile."); + return false; + } + + if (!defaultProfile.LaunchBrowser) + { + reporter.Verbose("launchSettings does not allow launching browsers."); + return false; + } + + launchUrl = defaultProfile.LaunchUrl; + return true; + } + + public async ValueTask DisposeAsync() + { + _browserProcess?.Dispose(); + if (_refreshServer != null) + { + await _refreshServer.DisposeAsync(); + } + } + } +} diff --git a/src/Tools/dotnet-watch/src/LaunchSettingsJson.cs b/src/Tools/dotnet-watch/src/LaunchSettingsJson.cs new file mode 100644 index 0000000000..dc739b6c45 --- /dev/null +++ b/src/Tools/dotnet-watch/src/LaunchSettingsJson.cs @@ -0,0 +1,21 @@ +// 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.Collections.Generic; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public class LaunchSettingsJson + { + public Dictionary Profiles { get; set; } + } + + public class LaunchSettingsProfile + { + public string CommandName { get; set; } + + public bool LaunchBrowser { get; set; } + + public string LaunchUrl { get; set; } + } +} diff --git a/src/Tools/dotnet-watch/src/ProcessSpec.cs b/src/Tools/dotnet-watch/src/ProcessSpec.cs index ad5eb262b3..5e80a3f54b 100644 --- a/src/Tools/dotnet-watch/src/ProcessSpec.cs +++ b/src/Tools/dotnet-watch/src/ProcessSpec.cs @@ -1,8 +1,10 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Threading; using Microsoft.DotNet.Watcher.Internal; namespace Microsoft.DotNet.Watcher @@ -19,5 +21,9 @@ namespace Microsoft.DotNet.Watcher => Path.GetFileNameWithoutExtension(Executable); public bool IsOutputCaptured => OutputCapture != null; + + public DataReceivedEventHandler OnOutput { get; set; } + + public CancellationToken CancelOutputCapture { get; set; } } } diff --git a/src/Tools/dotnet-watch/src/Program.cs b/src/Tools/dotnet-watch/src/Program.cs index 8d0903bfc9..158f4adcf4 100644 --- a/src/Tools/dotnet-watch/src/Program.cs +++ b/src/Tools/dotnet-watch/src/Program.cs @@ -162,8 +162,8 @@ namespace Microsoft.DotNet.Watcher _reporter.Output("Polling file watcher is enabled"); } - await new DotNetWatcher(reporter, fileSetFactory) - .WatchAsync(processInfo, cancellationToken); + await using var watcher = new DotNetWatcher(reporter, fileSetFactory); + await watcher.WatchAsync(processInfo, cancellationToken); return 0; } diff --git a/src/Tools/dotnet-watch/src/assets/DotNetWatch.targets b/src/Tools/dotnet-watch/src/assets/DotNetWatch.targets index 5ce4f08672..4126ed6ec4 100644 --- a/src/Tools/dotnet-watch/src/assets/DotNetWatch.targets +++ b/src/Tools/dotnet-watch/src/assets/DotNetWatch.targets @@ -9,9 +9,22 @@ them to a file. --> + + + <_IsMicrosoftNETCoreApp31OrNewer + Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp' And $([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '3.1'))">true + + <_IsMicrosoftNETCoreApp31OrNewer Condition="'$(_IsMicrosoftNETCoreApp31OrNewer)' == ''">false + + + + <_WatchListLine Include="$(_IsMicrosoftNETCoreApp31OrNewer)" /> + <_WatchListLine Include="%(Watch.FullPath)" /> + + + Lines="@(_WatchListLine)" />