diff --git a/src/Microsoft.AspNetCore.Blazor.Build/targets/All.props b/src/Microsoft.AspNetCore.Blazor.Build/targets/All.props index 919d5cba0b..223d79d523 100644 --- a/src/Microsoft.AspNetCore.Blazor.Build/targets/All.props +++ b/src/Microsoft.AspNetCore.Blazor.Build/targets/All.props @@ -4,6 +4,9 @@ $(DefaultWebContentItemExcludes);wwwroot\** + + true + true diff --git a/src/Microsoft.AspNetCore.Blazor.Build/targets/All.targets b/src/Microsoft.AspNetCore.Blazor.Build/targets/All.targets index 07aff3ddaa..2afe433dd0 100644 --- a/src/Microsoft.AspNetCore.Blazor.Build/targets/All.targets +++ b/src/Microsoft.AspNetCore.Blazor.Build/targets/All.targets @@ -23,6 +23,7 @@ + diff --git a/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/AutoRebuildExtensions.cs b/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/AutoRebuildExtensions.cs new file mode 100644 index 0000000000..a54e023966 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/AutoRebuildExtensions.cs @@ -0,0 +1,135 @@ +// 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 Microsoft.AspNetCore.Blazor.Server; +using Microsoft.AspNetCore.Blazor.Server.AutoRebuild; +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Builder +{ + internal static class AutoRebuildExtensions + { + // Note that we don't need to watch typical static-file extensions (.css, .js, etc.) + // because anything in wwwroot is just served directly from disk on each reload. But + // as a special case, we do watch index.html because it needs compilation. + // TODO: Make the set of extensions and exclusions configurable in csproj + private static string[] _includedSuffixes = new[] { ".cs", ".cshtml", "index.html" }; + private static string[] _excludedDirectories = new[] { "obj", "bin" }; + + public static void UseAutoRebuild(this IApplicationBuilder appBuilder, BlazorConfig config) + { + // Currently this only supports VS for Windows. Later on we can add + // an IRebuildService implementation for VS for Mac, etc. + if (!VSForWindowsRebuildService.TryCreate(out var rebuildService)) + { + return; // You're not on Windows, or you didn't launch this process from VS + } + + // Assume we're up to date when the app starts. + var buildToken = new RebuildToken(new DateTime(1970, 1, 1)) { BuildTask = Task.CompletedTask, }; + + WatchFileSystem(config, () => + { + // Don't start the recompilation immediately. We only start it when the next + // HTTP request arrives, because it's annoying if the IDE is constantly rebuilding + // when you're making changes to multiple files and aren't ready to reload + // in the browser yet. + // + // Replacing the token means that new requests that come in will trigger a rebuild, + // and will all 'join' that build until a new file change occurs. + buildToken = new RebuildToken(DateTime.Now); + }); + + appBuilder.Use(async (context, next) => + { + try + { + var token = buildToken; + if (token.BuildTask == null) + { + // The build is out of date, but a new build is not yet started. + // + // We can count on VS to only allow one build at a time, this is a safe race + // because if we request a second concurrent build, it will 'join' the current one. + var task = rebuildService.PerformRebuildAsync( + config.SourceMSBuildPath, + token.LastChange); + token.BuildTask = task; + } + + // In the general case it's safe to await this task, it will be a completed task + // if everything is up to date. + await token.BuildTask; + } + catch (Exception) + { + // If there's no listener on the other end of the pipe, or if anything + // else goes wrong, we just let the incoming request continue. + // There's nowhere useful to log this information so if people report + // problems we'll just have to get a repro and debug it. + // If it was an error on the VS side, it logs to the output window. + } + + await next(); + }); + } + + private static void WatchFileSystem(BlazorConfig config, Action onWrite) + { + var clientAppRootDir = Path.GetDirectoryName(config.SourceMSBuildPath); + var excludePathPrefixes = _excludedDirectories.Select(subdir + => Path.Combine(clientAppRootDir, subdir) + Path.DirectorySeparatorChar); + + var fsw = new FileSystemWatcher(clientAppRootDir); + fsw.Created += OnEvent; + fsw.Changed += OnEvent; + fsw.Deleted += OnEvent; + fsw.Renamed += OnEvent; + fsw.IncludeSubdirectories = true; + fsw.EnableRaisingEvents = true; + + void OnEvent(object sender, FileSystemEventArgs eventArgs) + { + if (!File.Exists(eventArgs.FullPath)) + { + // It's probably a directory rather than a file + return; + } + + if (!_includedSuffixes.Any(ext => eventArgs.Name.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) + { + // Not a candiate file type + return; + } + + if (excludePathPrefixes.Any(prefix => eventArgs.FullPath.StartsWith(prefix, StringComparison.Ordinal))) + { + // In an excluded subdirectory + return; + } + + onWrite(); + } + } + + // Represents a three-state value for the state of the build + // + // BuildTask == null means the build is out of date, but no build has started + // BuildTask.IsCompleted == false means the build has been started, but has not completed + // BuildTask.IsCompleted == true means the build has completed + private class RebuildToken + { + public RebuildToken(DateTime lastChange) + { + LastChange = lastChange; + } + + public DateTime LastChange { get; } + + public Task BuildTask; + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/IRebuildService.cs b/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/IRebuildService.cs new file mode 100644 index 0000000000..ee48b02548 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/IRebuildService.cs @@ -0,0 +1,17 @@ +// 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.Threading.Tasks; + +namespace Microsoft.AspNetCore.Blazor.Server.AutoRebuild +{ + /// + /// Represents a mechanism for rebuilding a .NET project. For example, it + /// could be a way of signalling to a VS process to perform a build. + /// + internal interface IRebuildService + { + Task PerformRebuildAsync(string projectFullPath, DateTime ifNotBuiltSince); + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/ProcessUtils.cs b/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/ProcessUtils.cs new file mode 100644 index 0000000000..4ede95e267 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/ProcessUtils.cs @@ -0,0 +1,51 @@ +// 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.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Blazor.Server.AutoRebuild +{ + internal static class ProcessUtils + { + // Based on https://stackoverflow.com/a/3346055 + + public static Process GetParent(Process process) + { + var result = new ProcessBasicInformation(); + var handle = process.Handle; + var status = NtQueryInformationProcess(handle, 0, ref result, Marshal.SizeOf(result), out var returnLength); + if (status != 0) + { + throw new Win32Exception(status); + } + + try + { + var parentProcessId = result.InheritedFromUniqueProcessId.ToInt32(); + return parentProcessId > 0 ? Process.GetProcessById(parentProcessId) : null; + } + catch (ArgumentException) + { + return null; // Process not found + } + } + + [DllImport("ntdll.dll")] + private static extern int NtQueryInformationProcess(IntPtr processHandle, int processInformationClass, ref ProcessBasicInformation processInformation, int processInformationLength, out int returnLength); + + [StructLayout(LayoutKind.Sequential)] + struct ProcessBasicInformation + { + // These members must match PROCESS_BASIC_INFORMATION + public IntPtr Reserved1; + public IntPtr PebBaseAddress; + public IntPtr Reserved2_0; + public IntPtr Reserved2_1; + public IntPtr UniqueProcessId; + public IntPtr InheritedFromUniqueProcessId; + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/StreamProtocolExtensions.cs b/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/StreamProtocolExtensions.cs new file mode 100644 index 0000000000..ae7aa96462 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/StreamProtocolExtensions.cs @@ -0,0 +1,40 @@ +// 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.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Blazor.Server.AutoRebuild +{ + internal static class StreamProtocolExtensions + { + public static async Task WriteStringAsync(this Stream stream, string str) + { + var utf8Bytes = Encoding.UTF8.GetBytes(str); + await stream.WriteAsync(BitConverter.GetBytes(utf8Bytes.Length), 0, 4); + await stream.WriteAsync(utf8Bytes, 0, utf8Bytes.Length); + } + + public static async Task WriteDateTimeAsync(this Stream stream, DateTime value) + { + var ticksBytes = BitConverter.GetBytes(value.Ticks); + await stream.WriteAsync(ticksBytes, 0, 8); + } + + public static async Task ReadBoolAsync(this Stream stream) + { + var responseBuf = new byte[1]; + await stream.ReadAsync(responseBuf, 0, 1); + return responseBuf[0] == 1; + } + + public static async Task ReadIntAsync(this Stream stream) + { + var responseBuf = new byte[4]; + await stream.ReadAsync(responseBuf, 0, 4); + return BitConverter.ToInt32(responseBuf, 0); + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/VSForWindowsRebuildService.cs b/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/VSForWindowsRebuildService.cs new file mode 100644 index 0000000000..6ba10ab992 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Server/AutoRebuild/VSForWindowsRebuildService.cs @@ -0,0 +1,107 @@ +// 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.Pipes; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Blazor.Server.AutoRebuild +{ + /// + /// Finds the VS process that launched this app process (if any), and uses + /// named pipes to communicate with its AutoRebuild listener (if any). + /// + internal class VSForWindowsRebuildService : IRebuildService + { + private const int _connectionTimeoutMilliseconds = 3000; + private readonly Process _vsProcess; + + public static bool TryCreate(out VSForWindowsRebuildService result) + { + var vsProcess = FindAncestorVSProcess(); + if (vsProcess != null) + { + result = new VSForWindowsRebuildService(vsProcess); + return true; + } + else + { + result = null; + return false; + } + } + + public async Task PerformRebuildAsync(string projectFullPath, DateTime ifNotBuiltSince) + { + var pipeName = $"BlazorAutoRebuild\\{_vsProcess.Id}"; + using (var pipeClient = new NamedPipeClientStream(pipeName)) + { + await pipeClient.ConnectAsync(_connectionTimeoutMilliseconds); + + // Protocol: + // 1. Receive protocol version number from the VS listener + // If we're incompatible with it, send back special string "abort" and end + // 2. Send the project path to the VS listener + // 3. Send the 'if not rebuilt since' timestamp to the VS listener + // 4. Wait for it to send back a bool representing the result + // Keep in sync with AutoRebuildService.cs in the BlazorExtension project + // In the future we may extend this to getting back build error details + var remoteProtocolVersion = await pipeClient.ReadIntAsync(); + if (remoteProtocolVersion == 1) + { + await pipeClient.WriteStringAsync(projectFullPath); + await pipeClient.WriteDateTimeAsync(ifNotBuiltSince); + return await pipeClient.ReadBoolAsync(); + } + else + { + await pipeClient.WriteStringAsync("abort"); + return false; + } + } + } + + private VSForWindowsRebuildService(Process vsProcess) + { + _vsProcess = vsProcess ?? throw new ArgumentNullException(nameof(vsProcess)); + } + + private static Process FindAncestorVSProcess() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return null; + } + + var candidateProcess = Process.GetCurrentProcess(); + while (candidateProcess != null && !candidateProcess.HasExited) + { + // It's unlikely that anyone's going to have a non-VS process in the process + // hierarchy called 'devenv', but if that turns out to be a scenario, we could + // (for example) write the VS PID to the obj directory during build, and then + // only consider processes with that ID. We still want to be sure there really + // is such a process in our ancestor chain, otherwise if you did "dotnet run" + // in a command prompt, we'd be confused and think it was launched from VS. + if (candidateProcess.ProcessName.Equals("devenv", StringComparison.OrdinalIgnoreCase)) + { + return candidateProcess; + } + + try + { + candidateProcess = ProcessUtils.GetParent(candidateProcess); + } + catch (Exception) + { + // There's probably some permissions issue that prevents us from seeing + // further up the ancestor list, so we have to stop looking here. + break; + } + } + + return null; + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Server/BlazorAppBuilderExtensions.cs b/src/Microsoft.AspNetCore.Blazor.Server/BlazorAppBuilderExtensions.cs index 8142ab2560..3b75e22d7c 100644 --- a/src/Microsoft.AspNetCore.Blazor.Server/BlazorAppBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.Blazor.Server/BlazorAppBuilderExtensions.cs @@ -49,6 +49,11 @@ namespace Microsoft.AspNetCore.Builder OnPrepareResponse = SetCacheHeaders }; + if (env.IsDevelopment() && config.EnableAutoRebuilding) + { + applicationBuilder.UseAutoRebuild(config); + } + // First, match the request against files in the client app dist directory applicationBuilder.UseStaticFiles(distDirStaticFiles); diff --git a/src/Microsoft.AspNetCore.Blazor.Server/BlazorConfig.cs b/src/Microsoft.AspNetCore.Blazor.Server/BlazorConfig.cs index 0884d93eda..765a46cbcc 100644 --- a/src/Microsoft.AspNetCore.Blazor.Server/BlazorConfig.cs +++ b/src/Microsoft.AspNetCore.Blazor.Server/BlazorConfig.cs @@ -14,6 +14,7 @@ namespace Microsoft.AspNetCore.Blazor.Server public string WebRootPath { get; } public string DistPath => Path.Combine(Path.GetDirectoryName(SourceOutputAssemblyPath), "dist"); + public bool EnableAutoRebuilding { get; } public static BlazorConfig Read(string assemblyPath) => new BlazorConfig(assemblyPath); @@ -41,6 +42,8 @@ namespace Microsoft.AspNetCore.Blazor.Server { WebRootPath = webRootPath; } + + EnableAutoRebuilding = configLines.Contains("autorebuild:true", StringComparer.Ordinal); } } } diff --git a/tooling/Microsoft.VisualStudio.BlazorExtension/AutoRebuild/AutoRebuildService.cs b/tooling/Microsoft.VisualStudio.BlazorExtension/AutoRebuild/AutoRebuildService.cs new file mode 100644 index 0000000000..49977fccfa --- /dev/null +++ b/tooling/Microsoft.VisualStudio.BlazorExtension/AutoRebuild/AutoRebuildService.cs @@ -0,0 +1,147 @@ +// 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 Microsoft.VisualStudio.Shell.Interop; +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; +using Package = Microsoft.VisualStudio.Shell.Package; +using ThreadHelper = Microsoft.VisualStudio.Shell.ThreadHelper; + +namespace Microsoft.VisualStudio.BlazorExtension +{ + /// + /// The counterpart to VSForWindowsRebuildService.cs in the Blazor.Server project. + /// Listens for named pipe connections and rebuilds projects on request. + /// + internal class AutoRebuildService + { + private const int _protocolVersion = 1; + private readonly BuildEventsWatcher _buildEventsWatcher; + private readonly string _pipeName; + + public AutoRebuildService(BuildEventsWatcher buildEventsWatcher) + { + _buildEventsWatcher = buildEventsWatcher ?? throw new ArgumentNullException(nameof(buildEventsWatcher)); + _pipeName = $"BlazorAutoRebuild\\{Process.GetCurrentProcess().Id}"; + } + + public void Listen() + { + AddBuildServiceNamedPipeServer(); + } + + private void AddBuildServiceNamedPipeServer() + { + Task.Factory.StartNew(async () => + { + try + { + var identity = WindowsIdentity.GetCurrent(); + var identifier = identity.Owner; + var security = new PipeSecurity(); + + // Restrict access to just this account. + var rule = new PipeAccessRule(identifier, PipeAccessRights.ReadWrite | PipeAccessRights.CreateNewInstance, AccessControlType.Allow); + security.AddAccessRule(rule); + security.SetOwner(identifier); + + // And our current elevation level + var principal = new WindowsPrincipal(identity); + var isServerElevated = principal.IsInRole(WindowsBuiltInRole.Administrator); + + using (var serverPipe = new NamedPipeServerStream( + _pipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.WriteThrough, + 0x10000, // 64k input buffer + 0x10000, // 64k output buffer + security, + HandleInheritability.None)) + { + // As soon as we receive a connection, spin up another background + // listener to wait for the next connection + await serverPipe.WaitForConnectionAsync(); + AddBuildServiceNamedPipeServer(); + + await HandleRequestAsync(serverPipe, isServerElevated); + } + } + catch (Exception ex) + { + await AttemptLogErrorAsync( + $"Error in Blazor AutoRebuildService:\n{ex.Message}\n{ex.StackTrace}"); + } + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + private async Task HandleRequestAsync(NamedPipeServerStream stream, bool isServerElevated) + { + // Protocol: + // 1. Send a "protocol version" number to the client + // 2. Receive the project path from the client + // If it is the special string "abort", gracefully disconnect and end + // This is to allow for mismatches between server and client protocol version + // 3. Receive the "if not built since" timestamp from the client + // 4. Perform the build, then send back the success/failure result flag + // Keep in sync with VSForWindowsRebuildService.cs in the Blazor.Server project + // In the future we may extend this to getting back build error details + await stream.WriteIntAsync(_protocolVersion); + var projectPath = await stream.ReadStringAsync(); + + // We can't do the security check for elevation until we read from the stream. + if (isServerElevated != IsClientElevated(stream)) + { + return; + } + + if (projectPath.Equals("abort", StringComparison.Ordinal)) + { + return; + } + + var allowExistingBuildsSince = await stream.ReadDateTimeAsync(); + var buildResult = await _buildEventsWatcher.PerformBuildAsync(projectPath, allowExistingBuildsSince); + await stream.WriteBoolAsync(buildResult); + } + + private async Task AttemptLogErrorAsync(string message) + { + if (!ThreadHelper.CheckAccess()) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + } + + var outputWindow = (IVsOutputWindow)Package.GetGlobalService(typeof(SVsOutputWindow)); + if (outputWindow != null) + { + outputWindow.GetPane(VSConstants.OutputWindowPaneGuid.BuildOutputPane_guid, out var pane); + if (pane != null) + { + pane.OutputString(message); + pane.Activate(); + } + } + } + + private bool? IsClientElevated(NamedPipeServerStream stream) + { + bool? isClientElevated = null; + stream.RunAsClient(() => + { + var identity = WindowsIdentity.GetCurrent(ifImpersonating: true); + var principal = new WindowsPrincipal(identity); + isClientElevated = principal.IsInRole(WindowsBuiltInRole.Administrator); + }); + + return isClientElevated; + } + } +} diff --git a/tooling/Microsoft.VisualStudio.BlazorExtension/AutoRebuild/BuildEventsWatcher.cs b/tooling/Microsoft.VisualStudio.BlazorExtension/AutoRebuild/BuildEventsWatcher.cs new file mode 100644 index 0000000000..1c56be2254 --- /dev/null +++ b/tooling/Microsoft.VisualStudio.BlazorExtension/AutoRebuild/BuildEventsWatcher.cs @@ -0,0 +1,175 @@ +// 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.Threading.Tasks; +using Microsoft.VisualStudio.ProjectSystem.Properties; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; + +namespace Microsoft.VisualStudio.BlazorExtension +{ + /// + /// Watches for Blazor project build events, starts new builds, and tracks builds in progress. + /// + internal class BuildEventsWatcher : IVsUpdateSolutionEvents2 + { + private const string BlazorProjectCapability = "Blazor"; + private readonly IVsSolution _vsSolution; + private readonly IVsSolutionBuildManager _vsBuildManager; + private readonly object mostRecentBuildInfosLock = new object(); + private readonly Dictionary mostRecentBuildInfos + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public BuildEventsWatcher(IVsSolution vsSolution, IVsSolutionBuildManager vsBuildManager) + { + _vsSolution = vsSolution ?? throw new ArgumentNullException(nameof(vsSolution)); + _vsBuildManager = vsBuildManager ?? throw new ArgumentNullException(nameof(vsBuildManager)); + } + + public Task PerformBuildAsync(string projectPath, DateTime allowExistingBuildsSince) + { + BuildInfo newBuildInfo; + + lock (mostRecentBuildInfosLock) + { + if (mostRecentBuildInfos.TryGetValue(projectPath, out var existingInfo)) + { + // If there's a build in progress, we'll join that even if it was started + // before allowExistingBuildsSince, because it's too messy to cancel + // in-progress builds. On rare occasions if the user is editing files while + // a build is in progress they *might* see a not-latest build when they + // reload, but then they just have to reload again. + var acceptBuild = !existingInfo.TaskCompletionSource.Task.IsCompleted + || existingInfo.StartTime > allowExistingBuildsSince; + if (acceptBuild) + { + return existingInfo.TaskCompletionSource.Task; + } + } + + // We're going to start a new build now. Track the BuildInfo for it even + // before it starts so other incoming build requests can join it. + mostRecentBuildInfos[projectPath] = newBuildInfo = new BuildInfo(); + } + + return PerformNewBuildAsync(projectPath, newBuildInfo); + } + + public int UpdateSolution_Begin(ref int pfCancelUpdate) + => VSConstants.S_OK; + + public int UpdateSolution_Done(int fSucceeded, int fModified, int fCancelCommand) + => VSConstants.S_OK; + + public int UpdateSolution_StartUpdate(ref int pfCancelUpdate) + => VSConstants.S_OK; + + public int UpdateSolution_Cancel() + => VSConstants.S_OK; + + public int OnActiveProjectCfgChange(IVsHierarchy pIVsHierarchy) + => VSConstants.S_OK; + + public int UpdateProjectCfg_Begin(IVsHierarchy pHierProj, IVsCfg pCfgProj, IVsCfg pCfgSln, uint dwAction, ref int pfCancel) + { + if (IsBlazorProject(pHierProj)) + { + // This method runs both for manually-invoked builds and for builds triggered automatically + // by PerformNewBuildAsync(). In the case where it's a manually-invoked build, make sure + // there's an in-progress BuildInfo so that if there are further builds requests while the + // build is still in progress we can join them onto this existing build. + var ctx = (IVsBrowseObjectContext)pCfgProj; + var projectPath = ctx.UnconfiguredProject.FullPath; + lock (mostRecentBuildInfosLock) + { + var hasBuildInProgress = + mostRecentBuildInfos.TryGetValue(projectPath, out var existingInfo) + && !existingInfo.TaskCompletionSource.Task.IsCompleted; + if (!hasBuildInProgress) + { + mostRecentBuildInfos[projectPath] = new BuildInfo(); + } + } + } + + return VSConstants.S_OK; + } + + public int UpdateProjectCfg_Done(IVsHierarchy pHierProj, IVsCfg pCfgProj, IVsCfg pCfgSln, uint dwAction, int fSuccess, int fCancel) + { + if (IsBlazorProject(pHierProj)) + { + var buildResult = fSuccess == 1; + var ctx = (IVsBrowseObjectContext)pCfgProj; + var projectPath = ctx.UnconfiguredProject.FullPath; + + // Mark pending build info as completed + BuildInfo foundInfo = null; + lock (mostRecentBuildInfosLock) + { + mostRecentBuildInfos.TryGetValue(projectPath, out foundInfo); + } + if (foundInfo != null) + { + foundInfo.TaskCompletionSource.TrySetResult(buildResult); + } + } + + return VSConstants.S_OK; + } + + private async Task PerformNewBuildAsync(string projectPath, BuildInfo buildInfo) + { + // Switch to the UI thread and request the build + var didStartBuild = await ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + var hr = _vsSolution.GetProjectOfUniqueName(projectPath, out var hierarchy); + if (hr != VSConstants.S_OK) + { + return false; + } + + hr = _vsBuildManager.StartSimpleUpdateProjectConfiguration( + hierarchy, + /* not used */ null, + /* not used */ null, + (uint)VSSOLNBUILDUPDATEFLAGS.SBF_OPERATION_BUILD, + /* other flags */ 0, + /* suppress dialogs */ 1); + if (hr != VSConstants.S_OK) + { + return false; + } + + return true; + }); + + if (!didStartBuild) + { + // Since the build didn't start, make sure nobody's waiting for it + buildInfo.TaskCompletionSource.TrySetResult(false); + } + + return await buildInfo.TaskCompletionSource.Task; + } + + private static bool IsBlazorProject(IVsHierarchy pHierProj) + => pHierProj.IsCapabilityMatch(BlazorProjectCapability); + + class BuildInfo + { + public DateTime StartTime { get; } + public TaskCompletionSource TaskCompletionSource { get; } + + public BuildInfo() + { + StartTime = DateTime.Now; + TaskCompletionSource = new TaskCompletionSource(); + } + } + } +} diff --git a/tooling/Microsoft.VisualStudio.BlazorExtension/AutoRebuild/StreamProtocolExtensions.cs b/tooling/Microsoft.VisualStudio.BlazorExtension/AutoRebuild/StreamProtocolExtensions.cs new file mode 100644 index 0000000000..4c13dc9588 --- /dev/null +++ b/tooling/Microsoft.VisualStudio.BlazorExtension/AutoRebuild/StreamProtocolExtensions.cs @@ -0,0 +1,49 @@ +// 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.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.BlazorExtension +{ + internal static class StreamProtocolExtensions + { + public static async Task ReadStringAsync(this Stream stream) + { + var length = BitConverter.ToInt32(await ReadBytesAsync(stream, 4), 0); + var utf8Bytes = await ReadBytesAsync(stream, length); + return Encoding.UTF8.GetString(utf8Bytes); + } + + public static async Task ReadDateTimeAsync(this Stream stream) + { + var ticksBytes = await ReadBytesAsync(stream, 8); + var ticks = BitConverter.ToInt64(ticksBytes, 0); + return new DateTime(ticks); + } + + public static async Task WriteBoolAsync(this Stream stream, bool value) + { + var byteVal = value ? (byte)1 : (byte)0; + await stream.WriteAsync(new[] { byteVal }, 0, 1); + } + + public static async Task WriteIntAsync(this Stream stream, int value) + { + await stream.WriteAsync(BitConverter.GetBytes(value), 0, 4); + } + + private static async Task ReadBytesAsync(Stream stream, int exactLength) + { + var buf = new byte[exactLength]; + var bytesRead = 0; + while (bytesRead < exactLength) + { + bytesRead += await stream.ReadAsync(buf, bytesRead, exactLength - bytesRead); + } + return buf; + } + } +} diff --git a/tooling/Microsoft.VisualStudio.BlazorExtension/BlazorPackage.cs b/tooling/Microsoft.VisualStudio.BlazorExtension/BlazorPackage.cs index 1d93fb5164..f5ea8de8a4 100644 --- a/tooling/Microsoft.VisualStudio.BlazorExtension/BlazorPackage.cs +++ b/tooling/Microsoft.VisualStudio.BlazorExtension/BlazorPackage.cs @@ -4,6 +4,7 @@ using System; using System.Runtime.InteropServices; using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; namespace Microsoft.VisualStudio.BlazorExtension { @@ -11,8 +12,29 @@ namespace Microsoft.VisualStudio.BlazorExtension [PackageRegistration(UseManagedResourcesOnly = true)] [AboutDialogInfo(PackageGuidString, "ASP.NET Core Blazor Language Services", "#110", "112")] [Guid(BlazorPackage.PackageGuidString)] + [ProvideAutoLoad(UIContextGuids80.SolutionExists)] public sealed class BlazorPackage : Package { public const string PackageGuidString = "d9fe04bc-57a7-4107-915e-3a5c2f9e19fb"; + + protected override void Initialize() + { + base.Initialize(); + RegisterAutoRebuildService(); + } + + private void RegisterAutoRebuildService() + { + ThreadHelper.ThrowIfNotOnUIThread(); + + // Create build watcher. No need to unadvise, as this only happens once anyway. + var solution = (IVsSolution)GetGlobalService(typeof(IVsSolution)); + var buildManager = (IVsSolutionBuildManager)GetService(typeof(SVsSolutionBuildManager)); + var buildWatcher = new BuildEventsWatcher(solution, buildManager); + var hr = buildManager.AdviseUpdateSolutionEvents(buildWatcher, out var cookie); + Marshal.ThrowExceptionForHR(hr); + + new AutoRebuildService(buildWatcher).Listen(); + } } } diff --git a/tooling/Microsoft.VisualStudio.BlazorExtension/Microsoft.VisualStudio.BlazorExtension.csproj b/tooling/Microsoft.VisualStudio.BlazorExtension/Microsoft.VisualStudio.BlazorExtension.csproj index 5f9967ee0f..fe0fd5830c 100644 --- a/tooling/Microsoft.VisualStudio.BlazorExtension/Microsoft.VisualStudio.BlazorExtension.csproj +++ b/tooling/Microsoft.VisualStudio.BlazorExtension/Microsoft.VisualStudio.BlazorExtension.csproj @@ -250,6 +250,9 @@ + + +