Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/Dotnet.Watch/Watch/Context/ProjectOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ internal sealed record ProjectOptions
/// </summary>
public string? TargetFramework { get; init; }

/// <summary>
/// Device identifier to use when launching the project.
/// If the project supports device selection and <see cref="Device"/> is null
/// the user will be prompted for a device in interactive mode.
/// </summary>
public string? Device { get; init; }

/// <summary>
/// RuntimeIdentifier provided by the selected device, if any.
/// </summary>
public string? DeviceRuntimeIdentifier { get; init; }

/// <summary>
/// No value indicates that no launch profile should be used.
/// Null value indicates that the default launch profile should be used.
Expand Down
172 changes: 145 additions & 27 deletions src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Encodings.Web;
using Microsoft.Build.Execution;
using Microsoft.CodeAnalysis;
using Microsoft.DotNet.HotReload;
using Microsoft.Extensions.Logging;
Expand All @@ -18,18 +19,23 @@ internal sealed class HotReloadDotNetWatcher
private readonly IConsole _console;
private readonly IRuntimeProcessLauncherFactory? _runtimeProcessLauncherFactory;
private readonly RestartPrompt? _rudeEditRestartPrompt;
private readonly TargetFrameworkSelectionPrompt? _targetFrameworkSelectionPrompt;
private readonly WatchSelectionPrompt _selectionPrompt;

private readonly DotNetWatchContext _context;
private readonly ProjectGraphFactory _designTimeBuildGraphFactory;

internal Task? Test_FileChangesCompletedTask { get; set; }

public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRuntimeProcessLauncherFactory? runtimeProcessLauncherFactory, TargetFrameworkSelectionPrompt? targetFrameworkSelectionPrompt)
public HotReloadDotNetWatcher(
DotNetWatchContext context,
IConsole console,
IRuntimeProcessLauncherFactory? runtimeProcessLauncherFactory,
WatchSelectionPrompt selectionPrompt)
{
_context = context;
_console = console;
_runtimeProcessLauncherFactory = runtimeProcessLauncherFactory;
_selectionPrompt = selectionPrompt;
if (!context.Options.NonInteractive)
{
var consoleInput = new ConsoleInputReader(_console, context.Options.LogLevel, context.EnvironmentOptions.SuppressEmojis);
Expand All @@ -41,7 +47,6 @@ public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRun
}

_rudeEditRestartPrompt = new RestartPrompt(context.Logger, consoleInput, noPrompt ? true : null);
_targetFrameworkSelectionPrompt = targetFrameworkSelectionPrompt;
}

_designTimeBuildGraphFactory = new ProjectGraphFactory(
Expand Down Expand Up @@ -95,7 +100,8 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken)
_context.RootProjects,
fileWatcher,
_context.MainProjectOptions,
frameworkSelector: _targetFrameworkSelectionPrompt != null ? _targetFrameworkSelectionPrompt.SelectAsync : null,
frameworkSelector: _context.Options.NonInteractive ? null : _selectionPrompt.SelectTargetFrameworkAsync,
deviceSelector: _context.Options.NonInteractive ? null : _selectionPrompt.SelectDeviceAsync,
iterationCancellationToken);

// Try load project graph and perform design-time build even if the build failed.
Expand Down Expand Up @@ -142,7 +148,12 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken)
var mainProjectOptions = _context.MainProjectOptions;
if (mainProjectOptions != null)
{
mainProjectOptions = mainProjectOptions with { TargetFramework = rootProjectsBuildResult.MainProjectTargetFramework };
mainProjectOptions = mainProjectOptions with
{
TargetFramework = rootProjectsBuildResult.MainProjectTargetFramework,
Device = rootProjectsBuildResult.SelectedDevice?.Id ?? mainProjectOptions.Device,
DeviceRuntimeIdentifier = rootProjectsBuildResult.SelectedDevice?.RuntimeIdentifier ?? mainProjectOptions.DeviceRuntimeIdentifier,
};

if (projectGraph.Graph.GraphRoots.Single()?.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability) == true)
{
Expand Down Expand Up @@ -272,6 +283,7 @@ [.. updates.ProjectsToRebuild.Select(ProjectRepresentation.FromProjectOrEntryPoi
fileWatcher,
mainProjectOptions,
frameworkSelector: null,
deviceSelector: null,
iterationCancellationToken);

if (result.Success)
Expand Down Expand Up @@ -939,9 +951,10 @@ private enum BuildAction
}

// internal for testing
internal sealed class BuildProjectsResult(string? mainProjectTargetFramework, LoadedProjectGraph? projectGraph, bool success)
internal sealed class BuildProjectsResult(string? mainProjectTargetFramework, DeviceInfo? selectedDevice, LoadedProjectGraph? projectGraph, bool success)
{
public string? MainProjectTargetFramework { get; } = mainProjectTargetFramework;
public DeviceInfo? SelectedDevice { get; } = selectedDevice;
public LoadedProjectGraph? ProjectGraph { get; } = projectGraph;
public bool Success { get; } = success;
}
Expand All @@ -952,33 +965,37 @@ internal async Task<BuildProjectsResult> BuildProjectsAsync(
FileWatcher fileWatcher,
ProjectOptions? mainProjectOptions,
Func<IReadOnlyList<string>, CancellationToken, ValueTask<string>>? frameworkSelector,
Func<IReadOnlyList<DeviceInfo>, CancellationToken, ValueTask<DeviceInfo>>? deviceSelector,
CancellationToken cancellationToken)
{
Debug.Assert(projects.Any());

LoadedProjectGraph? projectGraph = null;
var targetFramework = mainProjectOptions?.TargetFramework;
DeviceInfo? selectedDevice = null;

_context.Logger.Log(MessageDescriptor.BuildStartedNotification, projects);

// pause accumulating file changes during build:
fileWatcher.SuppressEvents = true;
try
{
var success = await BuildWithFrameworkSelectionAsync();
var success = await BuildWithFrameworkAndDeviceSelectionAsync();
_context.Logger.Log(MessageDescriptor.BuildCompletedNotification, (projects, success));
return new BuildProjectsResult(targetFramework, projectGraph, success);
return new BuildProjectsResult(targetFramework, selectedDevice, projectGraph, success);
}
finally
{
fileWatcher.SuppressEvents = false;
}

async ValueTask<bool> BuildWithFrameworkSelectionAsync()
async ValueTask<bool> BuildWithFrameworkAndDeviceSelectionAsync()
{
var needsFrameworkSelection = targetFramework == null && frameworkSelector != null;
var needsDeviceSelection = mainProjectOptions?.Device == null && deviceSelector != null;

if (mainProjectOptions == null ||
frameworkSelector == null ||
targetFramework != null ||
(!needsFrameworkSelection && !needsDeviceSelection) ||
!mainProjectOptions.Representation.IsProjectFile)
Comment on lines +998 to 1003
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When --device is specified on the command line, mainProjectOptions.Device is non-null so device computation/selection is skipped and selectedDevice remains null. As a result, the subsequent build/restore invocations won’t get -p:Device=... (and no RID discovery/re-restore can happen), which breaks the intended “pre-specified device” flow. Consider treating an explicitly provided device as a pre-selection: run ComputeAvailableDevices to validate/resolve the matching device (including RuntimeIdentifier), set selectedDevice, and pass it into the build/restore path (including the re-restore when the RID is new).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is one thing we don't do for dotnet run either. If someone passes --device, we just use it and don't compute the RuntimeIdentifier.

I think it is OK for now; we haven't found a place you would need this -- you can pass -r.

{
return await BuildAsync(BuildAction.RestoreAndBuild, targetFramework);
Expand All @@ -997,28 +1014,53 @@ async ValueTask<bool> BuildWithFrameworkSelectionAsync()
}

var rootProject = projectGraph.Graph.GraphRoots.Single().ProjectInstance;
if (rootProject.GetTargetFramework() is var framework and not "")
{
targetFramework = framework;
}
else if (rootProject.GetTargetFrameworks() is var frameworks and not [])

// Select target framework if needed:
if (targetFramework == null && frameworkSelector != null)
{
targetFramework = await frameworkSelector(frameworks, cancellationToken);
if (rootProject.GetTargetFramework() is var framework and not "")
{
targetFramework = framework;
}
else if (rootProject.GetTargetFrameworks() is var frameworks and not [])
{
targetFramework = await frameworkSelector(frameworks, cancellationToken);
}
else
{
_context.BuildLogger.LogError("Project '{Path}' does not specify a target framework.", rootProject.FullPath);
return false;
}
}
else

// Select device if needed:
if (mainProjectOptions.Device == null && deviceSelector != null)
{
_context.BuildLogger.LogError("Project '{Path}' does not specify a target framework.", rootProject.FullPath);
return false;
selectedDevice = await TrySelectDeviceAsync(rootProject, targetFramework, deviceSelector, cancellationToken);
if (selectedDevice != null)
{
_context.Logger.LogDebug("Selected device: {DeviceId}", selectedDevice.Id);

// If the device provides a RuntimeIdentifier, re-restore so the assets file
// includes the RID target. This mirrors the dotnet-run behavior.
if (!string.IsNullOrEmpty(selectedDevice.RuntimeIdentifier))
{
if (!await BuildAsync(BuildAction.RestoreOnly, targetFramework, selectedDevice))
{
return false;
}
}
}
}

return await BuildAsync(BuildAction.BuildOnly, targetFramework);
return await BuildAsync(BuildAction.BuildOnly, targetFramework, selectedDevice);
}

async Task<bool> BuildAsync(BuildAction action, string? targetFramework)
async Task<bool> BuildAsync(BuildAction action, string? targetFramework, DeviceInfo? device = null)
{
if (projects is [var singleProject])
{
return await BuildFileOrProjectOrSolutionAsync(singleProject.ProjectOrEntryPointFilePath, targetFramework, action, cancellationToken);
return await BuildFileOrProjectOrSolutionAsync(singleProject.ProjectOrEntryPointFilePath, targetFramework, device, action, cancellationToken);
}

// TODO: workaround for https://github.com/dotnet/sdk/issues/51311
Expand All @@ -1027,7 +1069,7 @@ async Task<bool> BuildAsync(BuildAction action, string? targetFramework)

if (projectPaths is [var singleProjectPath])
{
if (!await BuildFileOrProjectOrSolutionAsync(singleProjectPath, targetFramework, action, cancellationToken))
if (!await BuildFileOrProjectOrSolutionAsync(singleProjectPath, targetFramework, device, action, cancellationToken))
{
return false;
}
Expand All @@ -1047,7 +1089,7 @@ async Task<bool> BuildAsync(BuildAction action, string? targetFramework)

try
{
if (!await BuildFileOrProjectOrSolutionAsync(solutionFile, targetFramework, action, cancellationToken))
if (!await BuildFileOrProjectOrSolutionAsync(solutionFile, targetFramework, device, action, cancellationToken))
{
return false;
}
Expand All @@ -1068,7 +1110,7 @@ async Task<bool> BuildAsync(BuildAction action, string? targetFramework)
// To maximize parallelism of building dependencies, build file-based projects after all physical projects:
foreach (var file in projects.Where(p => p.EntryPointFilePath != null).Select(p => p.EntryPointFilePath!))
{
if (!await BuildFileOrProjectOrSolutionAsync(file, targetFramework, action, cancellationToken))
if (!await BuildFileOrProjectOrSolutionAsync(file, targetFramework, device, action, cancellationToken))
{
return false;
}
Expand All @@ -1078,7 +1120,73 @@ async Task<bool> BuildAsync(BuildAction action, string? targetFramework)
}
}

private async Task<bool> BuildFileOrProjectOrSolutionAsync(string path, string? targetFramework, BuildAction action, CancellationToken cancellationToken)
private const string ComputeAvailableDevicesTarget = "ComputeAvailableDevices";

/// <summary>
/// Attempts to compute available devices and select one.
/// Auto-selects a single device. For multiple devices, uses the device selector (interactive)
/// or logs an error listing available devices (non-interactive).
/// </summary>
private async Task<DeviceInfo?> TrySelectDeviceAsync(
ProjectInstance rootProject,
string? targetFramework,
Func<IReadOnlyList<DeviceInfo>, CancellationToken, ValueTask<DeviceInfo>> deviceSelector,
CancellationToken cancellationToken)
{
// Check if the ComputeAvailableDevices target exists in the project.
if (!rootProject.Targets.ContainsKey(ComputeAvailableDevicesTarget))
{
return null;
}

// Create a new ProjectInstance with the selected TFM so device computation is correct.
var globalProps = new Dictionary<string, string>(rootProject.GlobalProperties, StringComparer.OrdinalIgnoreCase);
if (targetFramework != null)
{
globalProps["TargetFramework"] = targetFramework;
}

var projectInstance = new ProjectInstance(rootProject.FullPath, globalProps, rootProject.ToolsVersion);

var buildResult = projectInstance.Build(
targets: [ComputeAvailableDevicesTarget],
loggers: null,
remoteLoggers: null,
out var targetOutputs);

if (!buildResult || !targetOutputs.TryGetValue(ComputeAvailableDevicesTarget, out var targetResult))
{
_context.Logger.LogDebug("ComputeAvailableDevices target failed or returned no output.");
return null;
}

var devices = new List<DeviceInfo>(targetResult.Items.Length);
foreach (var item in targetResult.Items)
{
devices.Add(new DeviceInfo(
item.ItemSpec,
item.GetMetadata("Description"),
item.GetMetadata("Type"),
item.GetMetadata("Status"),
item.GetMetadata("RuntimeIdentifier")));
}

if (devices.Count == 0)
{
_context.Logger.Log(MessageDescriptor.NoDevicesAvailable);
return null;
}

// Auto-select if only one device is available.
if (devices.Count == 1)
{
return devices[0];
}

return await deviceSelector(devices, cancellationToken);
}

private async Task<bool> BuildFileOrProjectOrSolutionAsync(string path, string? targetFramework, DeviceInfo? device, BuildAction action, CancellationToken cancellationToken)
{
var arguments = new List<string>
{
Expand All @@ -1094,6 +1202,16 @@ private async Task<bool> BuildFileOrProjectOrSolutionAsync(string path, string?
arguments.Add(targetFramework);
}

if (device != null)
{
arguments.Add($"-p:Device={device.Id}");

if (!string.IsNullOrEmpty(device.RuntimeIdentifier))
{
arguments.Add($"-p:RuntimeIdentifier={device.RuntimeIdentifier}");
}
}

if (action == BuildAction.BuildOnly)
{
arguments.Add("--no-restore");
Expand Down
11 changes: 11 additions & 0 deletions src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ private static IReadOnlyList<string> GetProcessArguments(ProjectOptions projectO
arguments.Add(projectOptions.TargetFramework);
}

if (projectOptions.Device != null)
{
arguments.Add("--device");
arguments.Add(projectOptions.Device);

if (projectOptions.DeviceRuntimeIdentifier != null)
{
arguments.Add($"-p:RuntimeIdentifier={projectOptions.DeviceRuntimeIdentifier}");
}
}

foreach (var (name, value) in environmentBuilder)
{
arguments.Add("-e");
Expand Down
9 changes: 9 additions & 0 deletions src/Dotnet.Watch/Watch/UI/DeviceInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.DotNet.Watch;

/// <summary>
/// Represents a device item returned from the ComputeAvailableDevices MSBuild target.
/// </summary>
internal sealed record DeviceInfo(string Id, string? Description, string? Type, string? Status, string? RuntimeIdentifier);
1 change: 1 addition & 0 deletions src/Dotnet.Watch/Watch/UI/IReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ public static MessageDescriptor GetDescriptor(EventId id)
public static readonly MessageDescriptor<IEnumerable<ProjectRepresentation>> BuildStartedNotification = CreateNotification<IEnumerable<ProjectRepresentation>>();
public static readonly MessageDescriptor<(IEnumerable<ProjectRepresentation> projects, bool success)> BuildCompletedNotification = CreateNotification<(IEnumerable<ProjectRepresentation> projects, bool success)>();
public static readonly MessageDescriptor<string> ManifestFileNotFound = Create(LogEvents.ManifestFileNotFound, Emoji.Default);
public static readonly MessageDescriptor<None> NoDevicesAvailable = Create("No devices are available for this project.", Emoji.Error, LogLevel.Error);
}

internal sealed class MessageDescriptor<TArgs>(string? format, Emoji emoji, LogLevel level, EventId id)
Expand Down
28 changes: 0 additions & 28 deletions src/Dotnet.Watch/Watch/UI/TargetFrameworkSelectionPrompt.cs

This file was deleted.

Loading
Loading