Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
39 changes: 23 additions & 16 deletions src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1073,29 +1073,36 @@ async ValueTask<bool> BuildWithFrameworkAndDeviceSelectionAsync()
}
}

// Select device if needed:
if (needsDeviceSelection
&& rootProject.Targets.ContainsKey(TargetNames.ComputeAvailableDevices))
// Select device if needed.
// Check the TFM-specific inner project node for ComputeAvailableDevices, since workload
// targets (e.g., MAUI) may only be imported when TargetFramework is set.
if (needsDeviceSelection)
{
Debug.Assert(deviceSelector != null);
Debug.Assert(projectGraph != null);

var deviceInfo = await TrySelectDeviceAsync(projectGraph, rootProject, targetFramework, deviceSelector, cancellationToken);
if (deviceInfo == null)
var projectNodeForDeviceCheck = projectGraph.TryGetProjectNode(rootProject.FullPath, targetFramework);
if (projectNodeForDeviceCheck != null
&& projectNodeForDeviceCheck.ProjectInstance.Targets.ContainsKey(TargetNames.ComputeAvailableDevices))
{
return false;
}
var deviceInfo = await TrySelectDeviceAsync(projectGraph, rootProject, targetFramework, deviceSelector, cancellationToken);
if (deviceInfo == null)
{
return false;
}

selectedDevice = deviceInfo.Id;
selectedDeviceRuntimeIdentifier = deviceInfo.RuntimeIdentifier;
_context.Logger.LogDebug("Selected device: {DeviceId}", selectedDevice);
selectedDevice = deviceInfo.Id;
selectedDeviceRuntimeIdentifier = deviceInfo.RuntimeIdentifier;
_context.Logger.LogDebug("Selected device: {DeviceId}", selectedDevice);

// 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(selectedDeviceRuntimeIdentifier))
{
if (!await BuildAsync(BuildAction.RestoreOnly, targetFramework, deviceInfo))
// 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(selectedDeviceRuntimeIdentifier))
{
return false;
if (!await BuildAsync(BuildAction.RestoreOnly, targetFramework, deviceInfo))
{
return false;
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project>

<Target Name="ComputeAvailableDevices" Returns="@(Devices)" DependsOnTargets="ResolveFrameworkReferences">
<ItemGroup Condition="'$(SingleDevice)' != 'true' and '$(TargetFramework)' != 'net9.0'">
<Devices Include="test-device-1" Description="Test Device 1" Type="Emulator" Status="Online" RuntimeIdentifier="$(NETCoreSdkRuntimeIdentifier)" />
<Devices Include="test-device-2" Description="Test Device 2" Type="Device" Status="Online" />
</ItemGroup>
<ItemGroup Condition="'$(SingleDevice)' != 'true' and '$(TargetFramework)' == 'net9.0'">
<Devices Include="test-device-downlevel-1" Description="Test Device Downlevel 1" Type="Emulator" Status="Online" RuntimeIdentifier="$(NETCoreSdkRuntimeIdentifier)" />
<Devices Include="test-device-downlevel-2" Description="Test Device Downlevel 2" Type="Simulator" Status="Booted" />
</ItemGroup>
</Target>

<!-- DeployToDevice target for testing, ResolveFrameworkReferences mimics Android -->
<Target Name="DeployToDevice" DependsOnTargets="ResolveFrameworkReferences">
<Message Text="DeployToDevice: Deployed to device $(Device) with RuntimeIdentifier $(RuntimeIdentifier)" />
</Target>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net9.0;$(CurrentTargetFramework)</TargetFrameworks>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>

<ItemGroup>
<Compile Include="Program.cs" />
</ItemGroup>

<!-- Generate a file with Device and RuntimeIdentifier properties if they are set -->
<Target Name="GenerateDeviceInfo"
BeforeTargets="CoreCompile"
Condition="'$(Device)' != ''">

<PropertyGroup>
<DeviceInfoFile>$(IntermediateOutputPath)DeviceInfo.cs</DeviceInfoFile>
</PropertyGroup>

<ItemGroup>
<DeviceInfoLines Include="// &lt;auto-generated/&gt;" />
<DeviceInfoLines Include="namespace DotNetRunDevicesWorkload%3B" />
<DeviceInfoLines Include=" " />
<DeviceInfoLines Include="internal static class DeviceInfo" />
<DeviceInfoLines Include="{" />
<DeviceInfoLines Include=" public const string Device = &quot;$(Device)&quot;%3B" />
<DeviceInfoLines Include=" public const string RuntimeIdentifier = &quot;$(RuntimeIdentifier)&quot;%3B" />
<DeviceInfoLines Include="}" />
</ItemGroup>

<WriteLinesToFile File="$(DeviceInfoFile)" Lines="@(DeviceInfoLines)" Overwrite="true" />

<ItemGroup>
<Compile Include="$(DeviceInfoFile)" />
<FileWrites Include="$(DeviceInfoFile)" />
</ItemGroup>
</Target>

<!-- Simulate MAUI workload: device targets only available for TFM-specific inner builds -->
<Import Project="Devices.targets" Condition="'$(TargetFramework)' != ''" />

</Project>
21 changes: 21 additions & 0 deletions test/TestAssets/TestProjects/DotnetRunDevicesWorkload/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;

namespace DotNetRunDevicesWorkload
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello from multi-targeted app!");
Console.WriteLine($"Target Framework: {AppContext.TargetFrameworkName}");
Console.WriteLine($"Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}");

// DeviceInfo class is generated at build time when Device property is set
Console.WriteLine($"Device: {DeviceInfo.Device}");
Console.WriteLine($"RuntimeIdentifier: {DeviceInfo.RuntimeIdentifier}");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,29 @@ public void FormatDevice_IdOnly()
var formatted = SpectreBuildParametersSelectionPrompt.FormatDevice(device);
Assert.Equal("device-1", formatted);
}

[Fact]
public async Task SelectsFrameworkThenDevice()
{
var console = new SpectreTestConsole();
console.Profile.Capabilities.Interactive = true;

// Push keys for TFM selection (search + enter)
console.Input.PushText("net9.0");
console.Input.PushKey(ConsoleKey.Enter);

// Push keys for device selection (search + enter)
console.Input.PushText("Pixel 7 Pro");
console.Input.PushKey(ConsoleKey.Enter);

var frameworks = new[] { "net7.0", "net8.0", "net9.0" };
var devices = CreateTestDevices();
var prompt = new SpectreBuildParametersSelectionPrompt(console);

var selectedFramework = await prompt.SelectTargetFrameworkAsync(frameworks, CancellationToken.None);
Assert.Equal("net9.0", selectedFramework);

var selectedDevice = await prompt.SelectDeviceAsync(devices, CancellationToken.None);
Assert.Equal("0A041FDD400327", selectedDevice.Id);
}
}
84 changes: 84 additions & 0 deletions test/dotnet-watch.Tests/HotReload/MauiHotReloadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,90 @@ public async Task SelectsDevice()
await App.WaitUntilOutputContains("Device: test-device-1");
}

/// <summary>
/// Tests that the device selection prompt works after a TFM selection prompt.
/// This is the scenario reported as hanging on macOS: when PhysicalConsole's
/// KeyPressed channel is used for the first Spectre prompt, the second prompt
/// must also receive key events from the same channel.
/// </summary>
[Fact]
public async Task SelectsFrameworkThenDevice()
{
var testAsset = TestAssets.CopyTestAsset("DotnetRunDevices")
.WithSource();

var tfm = ToolsetInfo.CurrentTargetFramework;

// Start watch WITHOUT --framework so both TFM and device prompts appear.
App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin);

// First prompt: select target framework
await App.WaitUntilOutputContains(Resources.SelectTargetFrameworkPrompt);

foreach (var c in tfm)
{
App.SendKey(c);
}
App.SendKey('\r');

// Second prompt: select device (this is the one that hangs without the fix)
await App.WaitUntilOutputContains(Resources.SelectDevicePrompt);

foreach (var c in "test-device-1")
{
App.SendKey(c);
}
App.SendKey('\r');

// The app should launch and print the selected device
await App.WaitUntilOutputContains("Device: test-device-1");
}

/// <summary>
/// Simulates the MAUI scenario where ComputeAvailableDevices is only available
/// for TFM-specific inner builds (imported conditionally by workload targets).
/// When dotnet-watch loads the project graph with null TFM, the outer project
/// doesn't have the target, so device selection falls through to the child
/// dotnet-run process. This causes a stdin race: dotnet-watch's Console.ReadKey()
/// loop steals all key presses from the child process's device prompt.
/// </summary>
[Fact]
public async Task SelectsFrameworkThenDevice_WorkloadConditionalTarget()
{
var testAsset = TestAssets.CopyTestAsset("DotnetRunDevicesWorkload")
.WithSource();

var tfm = ToolsetInfo.CurrentTargetFramework;

// Start watch WITHOUT --framework so both TFM and device prompts appear.
App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin);

// First prompt: select target framework
await App.WaitUntilOutputContains(Resources.SelectTargetFrameworkPrompt);

foreach (var c in tfm)
{
App.SendKey(c);
}
App.SendKey('\r');

// Second prompt: select device.
// With the bug, dotnet-watch doesn't see ComputeAvailableDevices in the outer
// project, skips device selection, and launches the child dotnet-run which shows
// its own device prompt. The child's stdin competes with dotnet-watch's
// Console.ReadKey() loop, causing the prompt to hang.
await App.WaitUntilOutputContains(Resources.SelectDevicePrompt);

foreach (var c in "test-device-1")
{
App.SendKey(c);
}
App.SendKey('\r');

// The app should launch and print the selected device
await App.WaitUntilOutputContains("Device: test-device-1");
}

[Fact]
public async Task AutoSelectsSingleDevice()
{
Expand Down
Loading