Skip to content

Commit 5048a76

Browse files
[dotnet-run] implement "device" selection logic
Context: https://github.com/dotnet/sdk/blob/5398e10de90dc9a27e0290ad55c2ae67360ea8be/documentation/specs/dotnet-run-for-maui.md ~~ Spec Changes ~~ **Added RuntimeIdentifier Support** - Examples to include `%(RuntimeIdentifier)` metadata (e.g., `android-arm64`, `ios-arm64`, `iossimulator-arm64`) - When a device provides a `%(RuntimeIdentifier)`, it will be passed as `-p:RuntimeIdentifier` to subsequent MSBuild steps (build, deploy, ComputeRunArguments, run) - `%(RuntimeIdentifier)` is optional but recommended **Added Binary Logs Documentation** - Added new section "Binary Logs for Device Selection" explaining: - When binlog files are created (when using `-bl:` with `dotnet run`) - The naming pattern: `<base-name>-dotnet-run-devices.binlog` ~~ Implementation ~~ **Renamed `TargetFrameworkSelector` to `RunCommandSelector`** - Expanded scope from just framework selection to handle both target framework and device selection - Made it a non-static class implementing `IDisposable` to: - Cache the MSBuild project instance across operations - Avoid loading/evaluating the project multiple times, except when global properties change - Properly manage MSBuild resources (ProjectCollection, Project, ProjectInstance) with `IDisposable` - Added `InvalidateGlobalProperties()` method to re-evaluate the project when needed with a `$(TargetFramework)` global property change. - Binary logger is owned by the `selector` instance and properly disposed. **Added Tests** - Mock test project (`DotnetRunDevices.csproj`) implements `ComputeAvailableDevices` target that returns hardcoded device items based on the target framework - Test project includes `GenerateDeviceInfo` target that runs during build when a device is selected: - Generates `DeviceInfo.cs` with constants for `$(Device)` and `$(RuntimeIdentifier)` properties - Writes to intermediate output directory before compilation - Test application prints these generated constants, allowing tests to verify that: - The correct device ID was passed to MSBuild - `$(RuntimeIdentifier)` was propagated correctly (when provided by device) - Multi-targeted apps can have different devices per framework
1 parent 6756697 commit 5048a76

24 files changed

+1488
-174
lines changed

documentation/specs/dotnet-run-for-maui.md

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,21 +56,24 @@ to make extensible for .NET MAUI (and future) scenarios.
5656
```xml
5757
<ItemGroup>
5858
<!-- Android examples -->
59-
<Devices Include="emulator-5554" Description="Pixel 7 - API 35" Type="Emulator" Status="Offline" />
60-
<Devices Include="emulator-5555" Description="Pixel 7 - API 36" Type="Emulator" Status="Online" />
61-
<Devices Include="0A041FDD400327" Description="Pixel 7 Pro" Type="Device" Status="Online" />
59+
<Devices Include="emulator-5554" Description="Pixel 7 - API 35" Type="Emulator" Status="Offline" RuntimeIdentifier="android-x64" />
60+
<Devices Include="emulator-5555" Description="Pixel 7 - API 36" Type="Emulator" Status="Online" RuntimeIdentifier="android-x64" />
61+
<Devices Include="0A041FDD400327" Description="Pixel 7 Pro" Type="Device" Status="Online" RuntimeIdentifier="android-arm64" />
6262
<!-- iOS examples -->
63-
<Devices Include="94E71AE5-8040-4DB2-8A9C-6CD24EF4E7DE" Description="iPhone 11 - iOS 18.6" Type="Simulator" Status="Shutdown" />
64-
<Devices Include="FBF5DCE8-EE2B-4215-8118-3A2190DE1AD7" Description="iPhone 14 - iOS 26.0" Type="Simulator" Status="Booted" />
65-
<Devices Include="23261B78-1E31-469C-A46E-1776D386EFD8" Description="My iPhone 13" Type="Device" Status="Unavailable" />
66-
<Devices Include="AF40CC64-2CDB-5F16-9651-86BCDF380881" Description="My iPhone 15" Type="Device" Status="Paired" />
63+
<Devices Include="94E71AE5-8040-4DB2-8A9C-6CD24EF4E7DE" Description="iPhone 11 - iOS 18.6" Type="Simulator" Status="Shutdown" RuntimeIdentifier="iossimulator-arm64" />
64+
<Devices Include="FBF5DCE8-EE2B-4215-8118-3A2190DE1AD7" Description="iPhone 14 - iOS 26.0" Type="Simulator" Status="Booted" RuntimeIdentifier="iossimulator-arm64" />
65+
<Devices Include="23261B78-1E31-469C-A46E-1776D386EFD8" Description="My iPhone 13" Type="Device" Status="Unavailable" RuntimeIdentifier="ios-arm64" />
66+
<Devices Include="AF40CC64-2CDB-5F16-9651-86BCDF380881" Description="My iPhone 15" Type="Device" Status="Paired" RuntimeIdentifier="ios-arm64" />
6767
</ItemGroup>
6868
```
6969

70-
_NOTE: each workload can decide which metadata values for `%(Type)`
71-
and `%(Status)` are useful, filtering offline devices, etc. The output
72-
above would be analogous to running `adb devices`, `xcrun simctl list
73-
devices`, or `xcrun devicectl list devices`._
70+
_NOTE: each workload can decide which metadata values for `%(Type)`,
71+
`%(Status)`, and `%(RuntimeIdentifier)` are useful, filtering offline
72+
devices, etc. The output above would be analogous to running `adb
73+
devices`, `xcrun simctl list devices`, or `xcrun devicectl list
74+
devices`. The `%(RuntimeIdentifier)` metadata is optional but
75+
recommended, as it allows the build system to pass the appropriate RID
76+
to subsequent build, deploy, and run steps._
7477

7578
* Continuing on...
7679

@@ -81,24 +84,28 @@ devices`, or `xcrun devicectl list devices`._
8184
`--device` switch. Listing the options returned by the
8285
`ComputeAvailableDevices` MSBuild target.
8386

84-
* `build`: unchanged, but is passed `-p:Device`.
87+
* `build`: unchanged, but is passed `-p:Device` and optionally `-p:RuntimeIdentifier`
88+
if the selected device provided a `%(RuntimeIdentifier)` metadata value.
8589

8690
* `deploy`
8791

8892
* If a `DeployToDevice` MSBuild target is available, provided by the
8993
iOS or Android workload, etc.
9094

9195
* Call the MSBuild target, passing in the identifier for the selected
92-
`-p:Device` global MSBuild property.
96+
`-p:Device` global MSBuild property, and optionally `-p:RuntimeIdentifier`
97+
if the selected device provided a `%(RuntimeIdentifier)` metadata value.
9398

9499
* This step needs to run, even with `--no-build`, as you may have
95100
selected a different device.
96101

97-
* `ComputeRunArguments`: unchanged, but is passed `-p:Device`.
102+
* `ComputeRunArguments`: unchanged, but is passed `-p:Device` and optionally
103+
`-p:RuntimeIdentifier` if the selected device provided a `%(RuntimeIdentifier)`
104+
metadata value.
98105

99106
* `run`: unchanged. `ComputeRunArguments` should have set a valid
100107
`$(RunCommand)` and `$(RunArguments)` using the value supplied by
101-
`-p:Device`.
108+
`-p:Device` and optionally `-p:RuntimeIdentifier`.
102109

103110
## New `dotnet run` Command-line Switches
104111

@@ -139,6 +146,19 @@ A new `--device` switch will:
139146
* The iOS and Android workloads will know how to interpret `$(Device)`
140147
to select an appropriate device, emulator, or simulator.
141148

149+
## Binary Logs for Device Selection
150+
151+
When using the `-bl:` argument with `dotnet run`, binary logs (`.binlog` files)
152+
are created to help diagnose issues with device selection and the build process.
153+
154+
For device selection operations (when calling the `ComputeAvailableDevices` target),
155+
the binlog files follow this naming pattern:
156+
157+
* If you specify `-bl:filename.binlog`, the actual file created will be
158+
`filename-dotnet-run-devices.binlog`
159+
* If you specify `-bl` without a filename, the file created will be
160+
`msbuild-dotnet-run-devices.binlog`
161+
142162
## What about Launch Profiles?
143163

144164
The iOS and Android workloads ignore all

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public static class Constants
2929
// MSBuild targets
3030
public const string Build = nameof(Build);
3131
public const string ComputeRunArguments = nameof(ComputeRunArguments);
32+
public const string ComputeAvailableDevices = nameof(ComputeAvailableDevices);
3233
public const string CoreCompile = nameof(CoreCompile);
3334

3435
// MSBuild item metadata

src/Cli/dotnet/Commands/CliCommandStrings.resx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1786,6 +1786,30 @@ Your project targets multiple frameworks. Specify which framework to run using '
17861786
<data name="RunRuntimeOptionDescription" xml:space="preserve">
17871787
<value>The target runtime to run for.</value>
17881788
</data>
1789+
<data name="CommandOptionDeviceDescription" xml:space="preserve">
1790+
<value>The device identifier to use for running the application.</value>
1791+
</data>
1792+
<data name="CommandOptionDeviceHelpName" xml:space="preserve">
1793+
<value>DEVICE</value>
1794+
</data>
1795+
<data name="CommandOptionListDevicesDescription" xml:space="preserve">
1796+
<value>List available devices for running the application.</value>
1797+
</data>
1798+
<data name="RunCommandAvailableDevices" xml:space="preserve">
1799+
<value>Available devices:</value>
1800+
</data>
1801+
<data name="RunCommandNoDevicesAvailable" xml:space="preserve">
1802+
<value>No devices are available for this project.</value>
1803+
</data>
1804+
<data name="RunCommandSelectDevicePrompt" xml:space="preserve">
1805+
<value>Select a device to run on:</value>
1806+
</data>
1807+
<data name="RunCommandMoreDevicesText" xml:space="preserve">
1808+
<value>Move up and down to reveal more devices</value>
1809+
</data>
1810+
<data name="RunCommandExceptionUnableToRunSpecifyDevice" xml:space="preserve">
1811+
<value>Unable to run this project because multiple devices are available. Please specify which device to use by passing the {0} argument with one of the following values:</value>
1812+
</data>
17891813
<data name="RuntimeConfigDefinition" xml:space="preserve">
17901814
<value>Path to &lt;application&gt;.runtimeconfig.json file.</value>
17911815
</data>

src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ public override RunApiOutput Execute()
102102
launchProfile: null,
103103
noLaunchProfile: false,
104104
noLaunchProfileArguments: false,
105+
device: null,
106+
listDevices: false,
105107
noRestore: false,
106108
noCache: false,
107109
interactive: false,

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

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
using Microsoft.Build.Framework;
1414
using Microsoft.Build.Logging;
1515
using Microsoft.DotNet.Cli.CommandFactory;
16+
using Microsoft.DotNet.Cli.CommandLine;
1617
using Microsoft.DotNet.Cli.Commands.Restore;
1718
using Microsoft.DotNet.Cli.Commands.Run.LaunchSettings;
18-
using Microsoft.DotNet.Cli.CommandLine;
1919
using Microsoft.DotNet.Cli.Extensions;
2020
using Microsoft.DotNet.Cli.Utils;
2121
using Microsoft.DotNet.Cli.Utils.Extensions;
@@ -85,6 +85,16 @@ public class RunCommand
8585
/// </summary>
8686
public bool NoLaunchProfileArguments { get; }
8787

88+
/// <summary>
89+
/// Device identifier to use for running the application.
90+
/// </summary>
91+
public string? Device { get; }
92+
93+
/// <summary>
94+
/// Whether to list available devices and exit.
95+
/// </summary>
96+
public bool ListDevices { get; }
97+
8898
/// <param name="applicationArgs">unparsed/arbitrary CLI tokens to be passed to the running application</param>
8999
public RunCommand(
90100
bool noBuild,
@@ -93,6 +103,8 @@ public RunCommand(
93103
string? launchProfile,
94104
bool noLaunchProfile,
95105
bool noLaunchProfileArguments,
106+
string? device,
107+
bool listDevices,
96108
bool noRestore,
97109
bool noCache,
98110
bool interactive,
@@ -112,6 +124,8 @@ public RunCommand(
112124
LaunchProfile = launchProfile;
113125
NoLaunchProfile = noLaunchProfile;
114126
NoLaunchProfileArguments = noLaunchProfileArguments;
127+
Device = device;
128+
ListDevices = listDevices;
115129
ApplicationArgs = applicationArgs;
116130
Interactive = interactive;
117131
NoRestore = noRestore;
@@ -128,10 +142,11 @@ public int Execute()
128142
return 1;
129143
}
130144

131-
// Pre-run evaluation: Handle target framework selection for multi-targeted projects
132-
if (ProjectFileFullPath is not null && !TrySelectTargetFrameworkIfNeeded())
145+
// Pre-run evaluation: Handle target framework and device selection for project-based scenarios
146+
if (ProjectFileFullPath is not null && !TrySelectTargetFrameworkAndDeviceIfNeeded())
133147
{
134-
return 1;
148+
// If --list-devices was specified, this is a successful exit
149+
return ListDevices ? 0 : 1;
135150
}
136151

137152
// For file-based projects, check for multi-targeting before building
@@ -199,26 +214,88 @@ public int Execute()
199214
}
200215

201216
/// <summary>
202-
/// Checks if target framework selection is needed for multi-targeted projects.
203-
/// If needed and we're in interactive mode, prompts the user to select a framework.
204-
/// If needed and we're in non-interactive mode, shows an error.
217+
/// Checks if target framework selection and device selection are needed.
218+
/// Uses a single RunCommandSelector instance for both operations, re-evaluating
219+
/// the project after framework selection to get the correct device list.
205220
/// </summary>
206221
/// <returns>True if we can continue, false if we should exit</returns>
207-
private bool TrySelectTargetFrameworkIfNeeded()
222+
private bool TrySelectTargetFrameworkAndDeviceIfNeeded()
208223
{
209224
Debug.Assert(ProjectFileFullPath is not null);
210225

211226
var globalProperties = CommonRunHelpers.GetGlobalPropertiesFromArgs(MSBuildArgs);
212-
if (TargetFrameworkSelector.TrySelectTargetFramework(
213-
ProjectFileFullPath,
214-
globalProperties,
215-
Interactive,
216-
out string? selectedFramework))
227+
228+
// If user specified --device on command line, add it to global properties and MSBuildArgs
229+
if (!string.IsNullOrWhiteSpace(Device))
230+
{
231+
globalProperties["Device"] = Device;
232+
var properties = new Dictionary<string, string> { { "Device", Device } };
233+
var additionalProperties = new ReadOnlyDictionary<string, string>(properties);
234+
MSBuildArgs = MSBuildArgs.CloneWithAdditionalProperties(additionalProperties);
235+
}
236+
237+
// Optimization: If BOTH framework AND device are already specified (and we're not listing devices),
238+
// we can skip both framework selection and device selection entirely
239+
bool hasFramework = globalProperties.TryGetValue("TargetFramework", out var existingFramework) && !string.IsNullOrWhiteSpace(existingFramework);
240+
bool hasDevice = globalProperties.TryGetValue("Device", out var preSpecifiedDevice) && !string.IsNullOrWhiteSpace(preSpecifiedDevice);
241+
242+
if (!ListDevices && hasFramework && hasDevice)
243+
{
244+
// Both framework and device are pre-specified, no need to create selector or logger
245+
return true;
246+
}
247+
248+
// Create a single selector for both framework and device selection
249+
FacadeLogger? logger = LoggerUtility.DetermineBinlogger([.. MSBuildArgs.OtherMSBuildArgs], "dotnet-run-devices");
250+
using var selector = new RunCommandSelector(ProjectFileFullPath, globalProperties, Interactive, logger);
251+
252+
// Step 1: Select target framework if needed
253+
if (!selector.TrySelectTargetFramework(out string? selectedFramework))
254+
{
255+
return false;
256+
}
257+
258+
if (selectedFramework is not null)
217259
{
218260
ApplySelectedFramework(selectedFramework);
261+
262+
// Re-evaluate project with the selected framework so device selection sees the right devices
263+
var properties = CommonRunHelpers.GetGlobalPropertiesFromArgs(MSBuildArgs);
264+
selector.InvalidateGlobalProperties(properties);
265+
}
266+
267+
// Step 2: Check if device is now pre-specified after framework selection
268+
if (!ListDevices && hasDevice)
269+
{
270+
// Device was pre-specified, we can skip device selection
219271
return true;
220272
}
221273

274+
// Step 3: Select device if needed
275+
if (selector.TrySelectDevice(
276+
ListDevices,
277+
out string? selectedDevice,
278+
out string? runtimeIdentifier))
279+
{
280+
// If a device was selected (either by user or by prompt), apply it to MSBuildArgs
281+
if (selectedDevice is not null)
282+
{
283+
var properties = new Dictionary<string, string> { { "Device", selectedDevice } };
284+
285+
// If the device provided a RuntimeIdentifier, add it too
286+
if (!string.IsNullOrEmpty (runtimeIdentifier))
287+
{
288+
properties["RuntimeIdentifier"] = runtimeIdentifier;
289+
}
290+
291+
var additionalProperties = new ReadOnlyDictionary<string, string>(properties);
292+
MSBuildArgs = MSBuildArgs.CloneWithAdditionalProperties(additionalProperties);
293+
}
294+
295+
// If ListDevices was set, we return true but the caller will exit after listing
296+
return !ListDevices;
297+
}
298+
222299
return false;
223300
}
224301

@@ -246,8 +323,8 @@ private bool TrySelectTargetFrameworkForFileBasedProject()
246323
return true; // Not multi-targeted
247324
}
248325

249-
// Use TargetFrameworkSelector to handle multi-target selection (or single framework selection)
250-
if (TargetFrameworkSelector.TrySelectTargetFramework(frameworks, Interactive, out string? selectedFramework))
326+
// Use RunCommandSelector to handle multi-target selection (or single framework selection)
327+
if (RunCommandSelector.TrySelectTargetFramework(frameworks, Interactive, out string? selectedFramework))
251328
{
252329
ApplySelectedFramework(selectedFramework);
253330
return true;
@@ -805,6 +882,8 @@ public static RunCommand FromParseResult(ParseResult parseResult)
805882
launchProfile: launchProfile,
806883
noLaunchProfile: parseResult.HasOption(RunCommandParser.NoLaunchProfileOption),
807884
noLaunchProfileArguments: parseResult.HasOption(RunCommandParser.NoLaunchProfileArgumentsOption),
885+
device: parseResult.GetValue(RunCommandParser.DeviceOption),
886+
listDevices: parseResult.HasOption(RunCommandParser.ListDevicesOption),
808887
noRestore: parseResult.HasOption(RunCommandParser.NoRestoreOption) || parseResult.HasOption(RunCommandParser.NoBuildOption),
809888
noCache: parseResult.HasOption(RunCommandParser.NoCacheOption),
810889
interactive: parseResult.GetValue(RunCommandParser.InteractiveOption),

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ internal static class RunCommandParser
4848
Description = CliCommandStrings.CommandOptionNoLaunchProfileArgumentsDescription
4949
};
5050

51+
public static readonly Option<string> DeviceOption = new("--device")
52+
{
53+
Description = CliCommandStrings.CommandOptionDeviceDescription,
54+
HelpName = CliCommandStrings.CommandOptionDeviceHelpName
55+
};
56+
57+
public static readonly Option<bool> ListDevicesOption = new("--list-devices")
58+
{
59+
Description = CliCommandStrings.CommandOptionListDevicesDescription,
60+
Arity = ArgumentArity.Zero
61+
};
62+
5163
public static readonly Option<bool> NoBuildOption = new("--no-build")
5264
{
5365
Description = CliCommandStrings.CommandOptionNoBuildDescription,
@@ -98,6 +110,8 @@ private static Command ConstructCommand()
98110
command.Options.Add(PropertyOption);
99111
command.Options.Add(LaunchProfileOption);
100112
command.Options.Add(NoLaunchProfileOption);
113+
command.Options.Add(DeviceOption);
114+
command.Options.Add(ListDevicesOption);
101115
command.Options.Add(NoBuildOption);
102116
command.Options.Add(InteractiveOption);
103117
command.Options.Add(NoRestoreOption);

0 commit comments

Comments
 (0)