Skip to content

Commit 11835cf

Browse files
authored
Add support for launching a browser on file change (#24220)
1 parent b33f8dd commit 11835cf

19 files changed

+604
-29
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Linq;
6+
using System.Net.WebSockets;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Microsoft.AspNetCore.Builder;
10+
using Microsoft.AspNetCore.Hosting;
11+
using Microsoft.AspNetCore.Hosting.Server;
12+
using Microsoft.AspNetCore.Hosting.Server.Features;
13+
using Microsoft.AspNetCore.Http;
14+
using Microsoft.Extensions.DependencyInjection;
15+
using Microsoft.Extensions.Hosting;
16+
using Microsoft.Extensions.Logging;
17+
using Microsoft.Extensions.Tools.Internal;
18+
19+
namespace Microsoft.DotNet.Watcher.Tools
20+
{
21+
public class BrowserRefreshServer : IAsyncDisposable
22+
{
23+
private readonly IReporter _reporter;
24+
private readonly TaskCompletionSource _taskCompletionSource;
25+
private IHost _refreshServer;
26+
private WebSocket _webSocket;
27+
28+
public BrowserRefreshServer(IReporter reporter)
29+
{
30+
_reporter = reporter;
31+
_taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
32+
}
33+
34+
public async ValueTask<string> StartAsync(CancellationToken cancellationToken)
35+
{
36+
_refreshServer = new HostBuilder()
37+
.ConfigureWebHost(builder =>
38+
{
39+
builder.UseKestrel();
40+
builder.UseUrls("http://127.0.0.1:0");
41+
42+
builder.Configure(app =>
43+
{
44+
app.UseWebSockets();
45+
app.Run(WebSocketRequest);
46+
});
47+
})
48+
.Build();
49+
50+
await _refreshServer.StartAsync(cancellationToken);
51+
52+
var serverUrl = _refreshServer.Services
53+
.GetRequiredService<IServer>()
54+
.Features
55+
.Get<IServerAddressesFeature>()
56+
.Addresses
57+
.First();
58+
59+
return serverUrl.Replace("http://", "ws://");
60+
}
61+
62+
private async Task WebSocketRequest(HttpContext context)
63+
{
64+
if (!context.WebSockets.IsWebSocketRequest)
65+
{
66+
context.Response.StatusCode = 400;
67+
return;
68+
}
69+
70+
_webSocket = await context.WebSockets.AcceptWebSocketAsync();
71+
await _taskCompletionSource.Task;
72+
}
73+
74+
public async Task SendMessage(byte[] messageBytes, CancellationToken cancellationToken = default)
75+
{
76+
if (_webSocket == null || _webSocket.CloseStatus.HasValue)
77+
{
78+
return;
79+
}
80+
81+
try
82+
{
83+
await _webSocket.SendAsync(messageBytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken);
84+
}
85+
catch (Exception ex)
86+
{
87+
_reporter.Output($"Refresh server error: {ex}");
88+
}
89+
}
90+
91+
public async ValueTask DisposeAsync()
92+
{
93+
if (_webSocket != null)
94+
{
95+
await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, default);
96+
_webSocket.Dispose();
97+
}
98+
99+
if (_refreshServer != null)
100+
{
101+
await _refreshServer.StopAsync();
102+
_refreshServer.Dispose();
103+
}
104+
105+
_taskCompletionSource.TrySetResult();
106+
}
107+
}
108+
}

src/Tools/dotnet-watch/src/DotNetWatcher.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
namespace Microsoft.DotNet.Watcher
1616
{
17-
public class DotNetWatcher
17+
public class DotNetWatcher : IAsyncDisposable
1818
{
1919
private readonly IReporter _reporter;
2020
private readonly ProcessRunner _processRunner;
@@ -31,6 +31,7 @@ public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory)
3131
{
3232
new MSBuildEvaluationFilter(fileSetFactory),
3333
new NoRestoreFilter(),
34+
new LaunchBrowserFilter(),
3435
};
3536
}
3637

@@ -140,5 +141,21 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
140141
}
141142
}
142143
}
144+
145+
public async ValueTask DisposeAsync()
146+
{
147+
foreach (var filter in _filters)
148+
{
149+
if (filter is IAsyncDisposable asyncDisposable)
150+
{
151+
await asyncDisposable.DisposeAsync();
152+
}
153+
else if (filter is IDisposable diposable)
154+
{
155+
diposable.Dispose();
156+
}
157+
158+
}
159+
}
143160
}
144161
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System.Collections.Generic;
@@ -7,6 +7,8 @@ namespace Microsoft.DotNet.Watcher
77
{
88
public interface IFileSet : IEnumerable<string>
99
{
10+
bool IsNetCoreApp31OrNewer { get; }
11+
1012
bool Contains(string filePath);
1113
}
1214
}

src/Tools/dotnet-watch/src/Internal/FileSet.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,19 @@ public class FileSet : IFileSet
1111
{
1212
private readonly HashSet<string> _files;
1313

14-
public FileSet(IEnumerable<string> files)
14+
public FileSet(bool isNetCoreApp31OrNewer, IEnumerable<string> files)
1515
{
16+
IsNetCoreApp31OrNewer = isNetCoreApp31OrNewer;
1617
_files = new HashSet<string>(files, StringComparer.OrdinalIgnoreCase);
1718
}
1819

1920
public bool Contains(string filePath) => _files.Contains(filePath);
2021

2122
public int Count => _files.Count;
2223

23-
public static IFileSet Empty = new FileSet(Array.Empty<string>());
24+
public bool IsNetCoreApp31OrNewer { get; }
25+
26+
public static IFileSet Empty = new FileSet(false, Array.Empty<string>());
2427

2528
public IEnumerator<string> GetEnumerator() => _files.GetEnumerator();
2629
IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator();

src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public async Task<IFileSet> CreateAsync(CancellationToken cancellationToken)
7272
Arguments = new[]
7373
{
7474
"msbuild",
75+
"/nologo",
7576
_projectFile,
7677
$"/p:_DotNetWatchListFile={watchList}"
7778
}.Concat(_buildFlags),
@@ -84,8 +85,12 @@ public async Task<IFileSet> CreateAsync(CancellationToken cancellationToken)
8485

8586
if (exitCode == 0 && File.Exists(watchList))
8687
{
88+
var lines = File.ReadAllLines(watchList);
89+
var isNetCoreApp31OrNewer = lines.FirstOrDefault() == "true";
90+
8791
var fileset = new FileSet(
88-
File.ReadAllLines(watchList)
92+
isNetCoreApp31OrNewer,
93+
lines.Skip(1)
8994
.Select(l => l?.Trim())
9095
.Where(l => !string.IsNullOrEmpty(l)));
9196

@@ -123,7 +128,7 @@ public async Task<IFileSet> CreateAsync(CancellationToken cancellationToken)
123128
{
124129
_reporter.Warn("Fix the error to continue or press Ctrl+C to exit.");
125130

126-
var fileSet = new FileSet(new[] { _projectFile });
131+
var fileSet = new FileSet(false, new[] { _projectFile });
127132

128133
using (var watcher = new FileSetWatcher(fileSet, _reporter))
129134
{

src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,37 +37,49 @@ public async Task<int> RunAsync(ProcessSpec processSpec, CancellationToken cance
3737
{
3838
cancellationToken.Register(() => processState.TryKill());
3939

40-
process.OutputDataReceived += (_, a) =>
40+
var readOutput = false;
41+
var readError = false;
42+
if (processSpec.IsOutputCaptured)
4143
{
42-
if (!string.IsNullOrEmpty(a.Data))
44+
readOutput = true;
45+
readError = true;
46+
process.OutputDataReceived += (_, a) =>
4347
{
44-
processSpec.OutputCapture.AddLine(a.Data);
45-
}
46-
};
47-
process.ErrorDataReceived += (_, a) =>
48-
{
49-
if (!string.IsNullOrEmpty(a.Data))
48+
if (!string.IsNullOrEmpty(a.Data))
49+
{
50+
processSpec.OutputCapture.AddLine(a.Data);
51+
}
52+
};
53+
process.ErrorDataReceived += (_, a) =>
5054
{
51-
processSpec.OutputCapture.AddLine(a.Data);
52-
}
53-
};
55+
if (!string.IsNullOrEmpty(a.Data))
56+
{
57+
processSpec.OutputCapture.AddLine(a.Data);
58+
}
59+
};
60+
}
61+
else if (processSpec.OnOutput != null)
62+
{
63+
readOutput = true;
64+
process.OutputDataReceived += processSpec.OnOutput;
65+
}
5466

5567
stopwatch.Start();
5668
process.Start();
5769

5870
_reporter.Verbose($"Started '{processSpec.Executable}' with process id {process.Id}");
5971

60-
if (processSpec.IsOutputCaptured)
72+
if (readOutput)
6173
{
62-
process.BeginErrorReadLine();
6374
process.BeginOutputReadLine();
64-
await processState.Task;
6575
}
66-
else
76+
if (readError)
6777
{
68-
await processState.Task;
78+
process.BeginErrorReadLine();
6979
}
7080

81+
await processState.Task;
82+
7183
exitCode = process.ExitCode;
7284
stopwatch.Stop();
7385
_reporter.Verbose($"Process id {process.Id} ran for {stopwatch.ElapsedMilliseconds}ms");
@@ -87,7 +99,7 @@ private Process CreateProcess(ProcessSpec processSpec)
8799
Arguments = ArgumentEscaper.EscapeAndConcatenate(processSpec.Arguments),
88100
UseShellExecute = false,
89101
WorkingDirectory = processSpec.WorkingDirectory,
90-
RedirectStandardOutput = processSpec.IsOutputCaptured,
102+
RedirectStandardOutput = processSpec.IsOutputCaptured || (processSpec.OnOutput != null),
91103
RedirectStandardError = processSpec.IsOutputCaptured,
92104
}
93105
};

0 commit comments

Comments
 (0)