diff --git a/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs b/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs index de999b11e0a5..a1fa2ff77a51 100644 --- a/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs +++ b/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs @@ -3,10 +3,9 @@ using System.Collections.Immutable; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; +using Microsoft.DotNet.ProjectTools; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; @@ -26,7 +25,7 @@ public void InstallBrowserLaunchTrigger( AbstractBrowserRefreshServer? server, CancellationToken cancellationToken) { - if (!CanLaunchBrowser(projectOptions, out var launchProfile)) + if (!CanLaunchBrowser(projectOptions, out var profileLaunchUrl)) { if (environmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) { @@ -42,7 +41,7 @@ public void InstallBrowserLaunchTrigger( ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, key) => set.Add(key), projectNode.GetProjectInstanceId())) { // first build iteration of a root project: - var launchUrl = GetLaunchUrl(launchProfile.LaunchUrl, url); + var launchUrl = GetLaunchUrl(profileLaunchUrl, url); LaunchBrowser(launchUrl, server); } else if (server != null) @@ -99,9 +98,9 @@ private void LaunchBrowser(string launchUrl, AbstractBrowserRefreshServer? serve } } - private bool CanLaunchBrowser(ProjectOptions projectOptions, [NotNullWhen(true)] out LaunchSettingsProfile? launchProfile) + private bool CanLaunchBrowser(ProjectOptions projectOptions, out string? launchUrl) { - launchProfile = null; + launchUrl = null; if (environmentOptions.SuppressLaunchBrowser) { @@ -114,20 +113,16 @@ private bool CanLaunchBrowser(ProjectOptions projectOptions, [NotNullWhen(true)] return false; } - launchProfile = GetLaunchProfile(projectOptions); - if (launchProfile is not { LaunchBrowser: true }) + var launchProfile = projectOptions.GetLaunchProfile(logger); + if (launchProfile is not ProjectLaunchProfile { LaunchBrowser: true, LaunchUrl: var url}) { logger.LogDebug("launchSettings does not allow launching browsers."); return false; } logger.Log(MessageDescriptor.ConfiguredToLaunchBrowser); - return true; - } - private LaunchSettingsProfile GetLaunchProfile(ProjectOptions projectOptions) - { - return (projectOptions.NoLaunchProfile == true - ? null : LaunchSettingsProfile.ReadLaunchProfile(projectOptions.ProjectPath, projectOptions.LaunchProfileName, logger)) ?? new(); + launchUrl = url; + return true; } } diff --git a/src/BuiltInTools/Watch/Build/BuildNames.cs b/src/BuiltInTools/Watch/Build/BuildNames.cs index f5f8c3463a3f..1cb5ab0ddae4 100644 --- a/src/BuiltInTools/Watch/Build/BuildNames.cs +++ b/src/BuiltInTools/Watch/Build/BuildNames.cs @@ -45,6 +45,7 @@ internal static class TargetNames { public const string Compile = nameof(Compile); public const string Restore = nameof(Restore); + public const string CompileDesignTime = nameof(CompileDesignTime); public const string GenerateComputedBuildStaticWebAssets = nameof(GenerateComputedBuildStaticWebAssets); public const string ReferenceCopyLocalPathsOutputGroup = nameof(ReferenceCopyLocalPathsOutputGroup); } @@ -54,4 +55,5 @@ internal static class ProjectCapability public const string Aspire = nameof(Aspire); public const string AspNetCore = nameof(AspNetCore); public const string WebAssembly = nameof(WebAssembly); + public const string SupportsHotReload = nameof(SupportsHotReload); } diff --git a/src/BuiltInTools/Watch/Build/EvaluationResult.cs b/src/BuiltInTools/Watch/Build/EvaluationResult.cs index bb04a3683a7e..102d246b220b 100644 --- a/src/BuiltInTools/Watch/Build/EvaluationResult.cs +++ b/src/BuiltInTools/Watch/Build/EvaluationResult.cs @@ -95,8 +95,13 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera // populated by design-time build. var projectInstance = project.ProjectInstance.DeepCopy(); - // skip outer build project nodes: - if (projectInstance.GetPropertyValue(PropertyNames.TargetFramework) == "") + var compileTarget = projectInstance.Targets.ContainsKey(TargetNames.CompileDesignTime) + ? TargetNames.CompileDesignTime + : projectInstance.Targets.ContainsKey(TargetNames.Compile) + ? TargetNames.Compile + : null; + + if (compileTarget == null) { continue; } @@ -105,7 +110,7 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera using (var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "DesignTimeBuild")) { - if (!projectInstance.Build([TargetNames.Compile, .. customCollectWatchItems], loggers)) + if (!projectInstance.Build([compileTarget, .. customCollectWatchItems], loggers)) { logger.LogError("Failed to build project '{Path}'.", projectInstance.FullPath); loggers.ReportOutput(); @@ -116,7 +121,6 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera var projectPath = projectInstance.FullPath; var projectDirectory = Path.GetDirectoryName(projectPath)!; - // TODO: Compile and AdditionalItems should be provided by Roslyn var items = projectInstance.GetItems(ItemNames.Compile) .Concat(projectInstance.GetItems(ItemNames.AdditionalFiles)) .Concat(projectInstance.GetItems(ItemNames.Watch)); diff --git a/src/BuiltInTools/Watch/Context/ProjectOptions.cs b/src/BuiltInTools/Watch/Context/ProjectOptions.cs index 20eb2eed69eb..46adc9d49cd3 100644 --- a/src/BuiltInTools/Watch/Context/ProjectOptions.cs +++ b/src/BuiltInTools/Watch/Context/ProjectOptions.cs @@ -1,10 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.CompilerServices; +using Microsoft.DotNet.ProjectTools; +using Microsoft.Extensions.Logging; + namespace Microsoft.DotNet.Watch; internal sealed record ProjectOptions { + private StrongBox? _lazyLaunchProfile; + public required bool IsRootProject { get; init; } public required string ProjectPath { get; init; } public required string WorkingDirectory { get; init; } @@ -33,4 +39,42 @@ internal sealed record ProjectOptions /// public bool IsCodeExecutionCommand => Command is "run" or "test"; + + public LaunchProfile? GetLaunchProfile(ILogger logger) + { + return (_lazyLaunchProfile ??= new StrongBox(ReadProfile())).Value; + + LaunchProfile? ReadProfile() + { + if (NoLaunchProfile) + { + return null; + } + + var launchSettingsPath = LaunchSettings.TryFindLaunchSettingsFile(ProjectPath, LaunchProfileName, (message, isError) => + { + if (isError) + { + logger.LogError(message); + } + else + { + logger.LogWarning(message); + } + }); + + if (launchSettingsPath == null) + { + return null; + } + + var result = LaunchSettings.ReadProfileSettingsFromFile(launchSettingsPath, LaunchProfileName); + if (result.FailureReason != null) + { + logger.LogError("Failed to read launch settings from '{LaunchSettingsPath}': {FailureReason}", launchSettingsPath, result.FailureReason); + } + + return result.Profile; + } + } } diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs index 52df0fa62829..3b15707c12d1 100644 --- a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -6,6 +6,7 @@ using Microsoft.Build.Graph; using Microsoft.CodeAnalysis; using Microsoft.DotNet.HotReload; +using Microsoft.DotNet.ProjectTools; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch @@ -246,7 +247,8 @@ void FileChangedCallback(ChangedPath change) continue; } - if (!rootProjectCapabilities.Contains("SupportsHotReload")) + if (rootProjectOptions.GetLaunchProfile(_context.Logger) is not ExecutableLaunchProfile && + !rootProjectCapabilities.Contains(ProjectCapability.SupportsHotReload)) { _context.Logger.LogWarning("Project '{Name}' does not support Hot Reload and must be rebuilt.", rootProject.GetDisplayName()); diff --git a/src/BuiltInTools/Watch/Process/LaunchSettingsProfile.cs b/src/BuiltInTools/Watch/Process/LaunchSettingsProfile.cs deleted file mode 100644 index 1e66687c5690..000000000000 --- a/src/BuiltInTools/Watch/Process/LaunchSettingsProfile.cs +++ /dev/null @@ -1,120 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.DotNet.ProjectTools; -using Microsoft.Extensions.Logging; - -namespace Microsoft.DotNet.Watch -{ - internal sealed class LaunchSettingsProfile - { - private static readonly JsonSerializerOptions s_serializerOptions = new(JsonSerializerDefaults.Web) - { - AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip, - }; - - [JsonIgnore] - public string? LaunchProfileName { get; set; } - public string? ApplicationUrl { get; init; } - public string? CommandName { get; init; } - public bool LaunchBrowser { get; init; } - public string? LaunchUrl { get; init; } - - internal static LaunchSettingsProfile? ReadLaunchProfile(string projectPath, string? launchProfileName, ILogger logger) - { - var launchSettingsPath = LaunchSettings.TryFindLaunchSettingsFile(projectPath, launchProfileName, (message, isError) => - { - if (isError) - { - logger.LogError(message); - } - else - { - logger.LogWarning(message); - } - }); - - if (launchSettingsPath == null) - { - return null; - } - - LaunchSettingsJson? launchSettings; - try - { - launchSettings = JsonSerializer.Deserialize( - File.ReadAllText(launchSettingsPath), - s_serializerOptions); - } - catch (Exception e) - { - logger.LogDebug("Error reading '{Path}': {Message}.", launchSettingsPath, e.Message); - return null; - } - - if (string.IsNullOrEmpty(launchProfileName)) - { - // Load the default (first) launch profile - return ReadDefaultLaunchProfile(launchSettings, logger); - } - - // Load the specified launch profile - var namedProfile = launchSettings?.Profiles?.FirstOrDefault(kvp => - string.Equals(kvp.Key, launchProfileName, StringComparison.Ordinal)).Value; - - if (namedProfile is null) - { - logger.LogWarning("Unable to find launch profile with name '{ProfileName}'. Falling back to default profile.", launchProfileName); - - // Check if a case-insensitive match exists - var caseInsensitiveNamedProfile = launchSettings?.Profiles?.FirstOrDefault(kvp => - string.Equals(kvp.Key, launchProfileName, StringComparison.OrdinalIgnoreCase)).Key; - - if (caseInsensitiveNamedProfile is not null) - { - logger.LogWarning("Note: Launch profile names are case-sensitive. Did you mean '{ProfileName}'?", caseInsensitiveNamedProfile); - } - - return ReadDefaultLaunchProfile(launchSettings, logger); - } - - logger.LogDebug("Found named launch profile '{ProfileName}'.", launchProfileName); - namedProfile.LaunchProfileName = launchProfileName; - return namedProfile; - } - - private static LaunchSettingsProfile? ReadDefaultLaunchProfile(LaunchSettingsJson? launchSettings, ILogger logger) - { - if (launchSettings is null || launchSettings.Profiles is null) - { - logger.LogDebug("Unable to find default launch profile."); - return null; - } - - // Look for the first profile with a supported command name - // Note: These must match the command names supported by LaunchSettingsManager in src/Cli/dotnet/Commands/Run/LaunchSettings/ - var supportedCommandNames = new[] { "Project", "Executable" }; - var defaultProfileKey = launchSettings.Profiles.FirstOrDefault(entry => - entry.Value.CommandName != null && supportedCommandNames.Contains(entry.Value.CommandName, StringComparer.Ordinal)).Key; - - if (defaultProfileKey is null) - { - logger.LogDebug("Unable to find a supported command name in the default launch profile. Supported types: {SupportedTypes}", - string.Join(", ", supportedCommandNames)); - return null; - } - - var defaultProfile = launchSettings.Profiles[defaultProfileKey]; - defaultProfile.LaunchProfileName = defaultProfileKey; - return defaultProfile; - } - - internal class LaunchSettingsJson - { - public OrderedDictionary? Profiles { get; set; } - } - } -} diff --git a/src/BuiltInTools/Watch/Process/ProjectLauncher.cs b/src/BuiltInTools/Watch/Process/ProjectLauncher.cs index fb74333ebdd3..1c5c6192e731 100644 --- a/src/BuiltInTools/Watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/Watch/Process/ProjectLauncher.cs @@ -3,6 +3,7 @@ using System.Globalization; using Microsoft.DotNet.HotReload; +using Microsoft.DotNet.ProjectTools; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; @@ -41,7 +42,12 @@ public EnvironmentOptions EnvironmentOptions return null; } - if (!projectNode.IsNetCoreApp(Versions.Version6_0)) + var launchProfile = projectOptions.GetLaunchProfile(context.Logger); + + // TODO: extract TFM from Exe + // If an executable profile is being used to launch the application the TFM of the project is irrelevant. + if (launchProfile is not ExecutableLaunchProfile && + !projectNode.IsNetCoreApp(Versions.Version6_0)) { Logger.LogError($"Hot Reload based watching is only supported in .NET 6.0 or newer apps. Use --no-hot-reload switch or update the project's launchSettings.json to disable this feature."); return null; diff --git a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json index 9e28729eb807..fe99c8c67ad7 100644 --- a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json +++ b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json @@ -3,7 +3,7 @@ "dotnet-watch": { "commandName": "Project", "commandLineArgs": "--verbose -bl", - "workingDirectory": "C:\\bugs\\9756\\aspire-watch-start-issue\\Aspire.AppHost", + "workingDirectory": "C:\\Temp\\Traversal", "environmentVariables": { "DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)", "DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000", @@ -13,4 +13,4 @@ } } } -} +} \ No newline at end of file diff --git a/test/dotnet-watch.Tests/Process/LaunchSettingsProfileTest.cs b/test/dotnet-watch.Tests/Process/LaunchSettingsProfileTest.cs deleted file mode 100644 index f05b05bb47bd..000000000000 --- a/test/dotnet-watch.Tests/Process/LaunchSettingsProfileTest.cs +++ /dev/null @@ -1,101 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable disable - -using Microsoft.Extensions.Logging; - -namespace Microsoft.DotNet.Watch.UnitTests; - -public class LaunchSettingsProfileTest -{ - private readonly ILogger _logger; - private readonly TestAssetsManager _testAssets; - - public LaunchSettingsProfileTest(ITestOutputHelper output) - { - _logger = new TestLogger(output); - _testAssets = new TestAssetsManager(output); - } - - [Fact] - public void LoadsLaunchProfiles() - { - var project = _testAssets.CreateTestProject(new TestProject("Project1") - { - TargetFrameworks = ToolsetInfo.CurrentTargetFramework, - }); - - WriteFile(project, Path.Combine("Properties", "launchSettings.json"), - """ - { - "profiles": { - "http": { - "applicationUrl": "http://localhost:5000", - "commandName": "Project", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "applicationUrl": "https://localhost:5001", - "commandName": "Project", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, // This comment and trailing comma shouldn't cause any issues - } - } - """); - - var projectPath = Path.Combine(project.TestRoot, "Project1", "Project1.csproj"); - - var expected = LaunchSettingsProfile.ReadLaunchProfile(projectPath, launchProfileName: "http", _logger); - Assert.NotNull(expected); - Assert.Equal("http://localhost:5000", expected.ApplicationUrl); - - expected = LaunchSettingsProfile.ReadLaunchProfile(projectPath, "https", _logger); - Assert.NotNull(expected); - Assert.Equal("https://localhost:5001", expected.ApplicationUrl); - - expected = LaunchSettingsProfile.ReadLaunchProfile(projectPath, "notfound", _logger); - Assert.NotNull(expected); - } - - [Fact] - public void DefaultLaunchProfileWithoutProjectCommand() - { - var project = _testAssets.CreateTestProject(new TestProject("Project1") - { - TargetFrameworks = ToolsetInfo.CurrentTargetFramework, - }); - - WriteFile(project, Path.Combine("Properties", "launchSettings.json"), - """ - { - "profiles": { - "profile": { - "applicationUrl": "http://localhost:5000" - } - } - } - """); - - var projectPath = Path.Combine(project.Path, "Project1", "Project1.csproj"); - - var expected = LaunchSettingsProfile.ReadLaunchProfile(projectPath, launchProfileName: null, _logger); - Assert.Null(expected); - } - - private static string WriteFile(TestAsset testAsset, string name, string contents = "") - { - var path = Path.Combine(GetTestProjectDirectory(testAsset), name); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - File.WriteAllText(path, contents); - - return path; - } - - private static string GetTestProjectDirectory(TestAsset testAsset) - => Path.Combine(testAsset.Path, testAsset.TestProject.Name); -} diff --git a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj index 9b1afe3702b4..8d2d57b001ce 100644 --- a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj +++ b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj @@ -33,7 +33,7 @@ <_FileItem Include="$(_Files)" /> - +