Skip to content

Commit 2b7468d

Browse files
[dotnet-run] Implement DeployToDevice target invocation (#52046)
Context: https://github.com/dotnet/sdk/blob/49c0d73c0bfa9eb2870827828354d5c96ed786f0/documentation/specs/dotnet-run-for-maui.md Context: dotnet/android#10631 Context: dotnet/android#10640 Add support for calling the `DeployToDevice` MSBuild target during `dotnet run`. The target is invoked after the build step (or with --no-build) to enable deployment to physical devices or emulators. The main change here is to create the `RunCommandSelector` earlier in the `RunCommand` execution, so that it can be used both for selecting the target framework and device before build, and for invoking the `DeployToDevice` target after build. I tested this by making `DotnetRunDevices.csproj` include a target: <Target Name="DeployToDevice" DependsOnTargets="ResolveFrameworkReferences"> <Message Text="DeployToDevice: Deployed to device $(Device) with RuntimeIdentifier $(RuntimeIdentifier)" Importance="high" /> </Target> Where `DependsOnTargets="ResolveFrameworkReferences"` mimics what can happen in the Android workload. I had to stop caching each `ProjectInstance` in `RunCommandSelector`, as doing so can cause: 》"artifacts\tmp\Debug\testing\ItDoesNotCall---A078BA19\DotnetRunDevices.csproj" (DeployToDevice target) (1) -> 》(ResolveFrameworkReferences target) -> 》 artifacts\bin\redist\Debug\dotnet\sdk\11.0.100-dev\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(410,5): error MSB4018: The "ResolveFrameworkReferences" task failed unexpectedly. 》artifacts\bin\redist\Debug\dotnet\sdk\11.0.100-dev\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(410,5): error MSB4018: System.ArgumentException: An item with the same key has already been added. Key: Microsoft.NETCore.App 》artifacts\bin\redist\Debug\dotnet\sdk\11.0.100-dev\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(410,5): error MSB4018: at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior) 》artifacts\bin\redist\Debug\dotnet\sdk\11.0.100-dev\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(410,5): error MSB4018: at System.Collections.Generic.Dictionary`2.Add(TKey key, TValue value) 》artifacts\bin\redist\Debug\dotnet\sdk\11.0.100-dev\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(410,5): error MSB4018: at System.Linq.Enumerable.SpanToDictionary[TSource,TKey](ReadOnlySpan`1 source, Func`2 keySelector, IEqualityComparer`1 comparer) 》artifacts\bin\redist\Debug\dotnet\sdk\11.0.100-dev\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(410,5): error MSB4018: at System.Linq.Enumerable.ToDictionary[TSource,TKey](IEnumerable`1 source, Func`2 keySelector, IEqualityComparer`1 comparer) 》artifacts\bin\redist\Debug\dotnet\sdk\11.0.100-dev\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(410,5): error MSB4018: at Microsoft.NET.Build.Tasks.ResolveFrameworkReferences.ExecuteCore() in src\Tasks\Microsoft.NET.Build.Tasks\ResolveFrameworkReferences.cs:line 29 》artifacts\bin\redist\Debug\dotnet\sdk\11.0.100-dev\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(410,5): error MSB4018: at Microsoft.NET.Build.Tasks.TaskBase.Execute() in src\Tasks\Common\TaskBase.cs:line 36 》artifacts\bin\redist\Debug\dotnet\sdk\11.0.100-dev\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(410,5): error MSB4018: at Microsoft.Build.BackEnd.TaskExecutionHost.Execute() 》artifacts\bin\redist\Debug\dotnet\sdk\11.0.100-dev\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(410,5): error MSB4018: at Microsoft.Build.BackEnd.TaskBuilder.ExecuteInstantiatedTask(TaskExecutionHost taskExecutionHost, TaskLoggingContext taskLoggingContext, TaskHost taskHost, ItemBucket bucket, TaskExecutionMode howToExecuteTask) The `ResolveFrameworkReferences` target will add duplicate `Microsoft.NETCore.App` items within a `ProjectInstance` causing the exception above.
1 parent fdf6886 commit 2b7468d

19 files changed

+237
-21
lines changed

src/Cli/Microsoft.DotNet.Cli.Utils/Constants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public static class Constants
3030
public const string Build = nameof(Build);
3131
public const string ComputeRunArguments = nameof(ComputeRunArguments);
3232
public const string ComputeAvailableDevices = nameof(ComputeAvailableDevices);
33+
public const string DeployToDevice = nameof(DeployToDevice);
3334
public const string CoreCompile = nameof(CoreCompile);
3435

3536
// MSBuild item metadata

src/Cli/dotnet/Commands/CliCommandStrings.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,6 +1693,9 @@ The default is to publish a framework-dependent application.</value>
16931693
<data name="RunCommandException" xml:space="preserve">
16941694
<value>The build failed. Fix the build errors and run again.</value>
16951695
</data>
1696+
<data name="RunCommandDeployFailed" xml:space="preserve">
1697+
<value>Deployment to device failed. Fix any deployment errors and run again.</value>
1698+
</data>
16961699
<data name="RunCommandExceptionCouldNotApplyLaunchSettings" xml:space="preserve">
16971700
<value>The launch profile "{0}" could not be applied.
16981701
{1}</value>

src/Cli/dotnet/Commands/Run/RunCommand.cs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,10 @@ public int Execute()
159159
try
160160
{
161161
// Pre-run evaluation: Handle target framework and device selection for project-based scenarios
162-
if (ProjectFileFullPath is not null && !TrySelectTargetFrameworkAndDeviceIfNeeded(logger))
162+
using var selector = ProjectFileFullPath is not null
163+
? new RunCommandSelector(ProjectFileFullPath, Interactive, MSBuildArgs, logger)
164+
: null;
165+
if (selector is not null && !TrySelectTargetFrameworkAndDeviceIfNeeded(selector))
163166
{
164167
// If --list-devices was specified, this is a successful exit
165168
return ListDevices ? 0 : 1;
@@ -202,6 +205,17 @@ public int Execute()
202205
cachedRunProperties = cacheEntry?.Run;
203206
}
204207

208+
// Deploy step: Call DeployToDevice target if available
209+
// This must run even with --no-build, as the user may have selected a different device
210+
if (selector is not null && !selector.TryDeployToDevice())
211+
{
212+
// Only error if we have a valid project (not a .sln file, etc.)
213+
if (selector.HasValidProject)
214+
{
215+
throw new GracefulException(CliCommandStrings.RunCommandDeployFailed);
216+
}
217+
}
218+
205219
var targetCommand = GetTargetCommand(launchProfileParseResult.Profile, projectFactory, cachedRunProperties, logger);
206220

207221
// Send telemetry about the run operation
@@ -238,12 +252,10 @@ internal ICommand GetTargetCommand(LaunchProfile? launchSettings, Func<ProjectCo
238252
/// Uses a single RunCommandSelector instance for both operations, re-evaluating
239253
/// the project after framework selection to get the correct device list.
240254
/// </summary>
241-
/// <param name="logger">Optional logger for MSBuild operations (device selection)</param>
255+
/// <param name="selector">The RunCommandSelector instance to use for selection</param>
242256
/// <returns>True if we can continue, false if we should exit</returns>
243-
private bool TrySelectTargetFrameworkAndDeviceIfNeeded(FacadeLogger? logger)
257+
private bool TrySelectTargetFrameworkAndDeviceIfNeeded(RunCommandSelector selector)
244258
{
245-
Debug.Assert(ProjectFileFullPath is not null);
246-
247259
var globalProperties = CommonRunHelpers.GetGlobalPropertiesFromArgs(MSBuildArgs);
248260

249261
// If user specified --device on command line, add it to global properties and MSBuildArgs
@@ -262,13 +274,10 @@ private bool TrySelectTargetFrameworkAndDeviceIfNeeded(FacadeLogger? logger)
262274

263275
if (!ListDevices && hasFramework && hasDevice)
264276
{
265-
// Both framework and device are pre-specified, no need to create selector or logger
277+
// Both framework and device are pre-specified
266278
return true;
267279
}
268280

269-
// Create a single selector for both framework and device selection
270-
using var selector = new RunCommandSelector(ProjectFileFullPath, globalProperties, Interactive, MSBuildArgs, logger);
271-
272281
// Step 1: Select target framework if needed
273282
if (!selector.TrySelectTargetFramework(out string? selectedFramework))
274283
{

src/Cli/dotnet/Commands/Run/RunCommandSelector.cs

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,25 @@ internal sealed class RunCommandSelector : IDisposable
3131

3232
private ProjectCollection? _collection;
3333
private Microsoft.Build.Evaluation.Project? _project;
34-
private ProjectInstance? _projectInstance;
34+
35+
/// <summary>
36+
/// Gets whether the selector has a valid project that can be evaluated.
37+
/// This is false for .sln files or other invalid project files.
38+
/// </summary>
39+
public bool HasValidProject { get; private set; }
3540

3641
/// <param name="projectFilePath">Path to the project file to evaluate</param>
37-
/// <param name="globalProperties">Global MSBuild properties to use during evaluation</param>
3842
/// <param name="isInteractive">Whether to prompt the user for selections</param>
43+
/// <param name="msbuildArgs">MSBuild arguments containing properties and verbosity settings</param>
3944
/// <param name="binaryLogger">Optional binary logger for MSBuild operations. The logger will not be disposed by this class.</param>
4045
public RunCommandSelector(
4146
string projectFilePath,
42-
Dictionary<string, string> globalProperties,
4347
bool isInteractive,
4448
MSBuildArgs msbuildArgs,
4549
FacadeLogger? binaryLogger = null)
4650
{
4751
_projectFilePath = projectFilePath;
48-
_globalProperties = globalProperties;
52+
_globalProperties = CommonRunHelpers.GetGlobalPropertiesFromArgs(msbuildArgs);
4953
_isInteractive = isInteractive;
5054
_msbuildArgs = msbuildArgs;
5155
_binaryLogger = binaryLogger;
@@ -102,9 +106,9 @@ public void InvalidateGlobalProperties(Dictionary<string, string> updatedPropert
102106

103107
// Dispose existing project to force re-evaluation
104108
_project = null;
105-
_projectInstance = null;
106109
_collection?.Dispose();
107110
_collection = null;
111+
HasValidProject = false;
108112
}
109113

110114
/// <summary>
@@ -114,8 +118,10 @@ private bool OpenProjectIfNeeded([NotNullWhen(true)] out ProjectInstance? projec
114118
{
115119
if (_project is not null)
116120
{
117-
Debug.Assert(_projectInstance is not null);
118-
projectInstance = _projectInstance;
121+
// Create a fresh ProjectInstance for each build operation
122+
// to avoid accumulating state (existing item groups) from previous builds
123+
projectInstance = _project.CreateProjectInstance();
124+
HasValidProject = true;
119125
return true;
120126
}
121127

@@ -126,14 +132,15 @@ private bool OpenProjectIfNeeded([NotNullWhen(true)] out ProjectInstance? projec
126132
loggers: GetLoggers(),
127133
toolsetDefinitionLocations: ToolsetDefinitionLocations.Default);
128134
_project = _collection.LoadProject(_projectFilePath);
129-
_projectInstance = _project.CreateProjectInstance();
130-
projectInstance = _projectInstance;
135+
projectInstance = _project.CreateProjectInstance();
136+
HasValidProject = true;
131137
return true;
132138
}
133139
catch (InvalidProjectFileException)
134140
{
135141
// Invalid project file, return false
136142
projectInstance = null;
143+
HasValidProject = false;
137144
return false;
138145
}
139146
}
@@ -461,6 +468,36 @@ public bool TrySelectDevice(
461468
}
462469
}
463470

471+
/// <summary>
472+
/// Attempts to deploy to a device by calling the DeployToDevice MSBuild target if it exists.
473+
/// This reuses the already-loaded project instance for performance.
474+
/// </summary>
475+
/// <returns>True if deployment succeeded or was skipped (no target), false if deployment failed</returns>
476+
public bool TryDeployToDevice()
477+
{
478+
if (!OpenProjectIfNeeded(out var projectInstance))
479+
{
480+
// Invalid project file
481+
return false;
482+
}
483+
484+
// Check if the DeployToDevice target exists in the project
485+
if (!projectInstance.Targets.ContainsKey(Constants.DeployToDevice))
486+
{
487+
// Target doesn't exist, skip deploy step
488+
return true;
489+
}
490+
491+
// Build the DeployToDevice target
492+
var buildResult = projectInstance.Build(
493+
targets: [Constants.DeployToDevice],
494+
loggers: GetLoggers(),
495+
remoteLoggers: null,
496+
out _);
497+
498+
return buildResult;
499+
}
500+
464501
/// <summary>
465502
/// Gets the list of loggers to use for MSBuild operations.
466503
/// Creates a fresh console logger each time to avoid disposal issues when calling Build() multiple times.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)