diff --git a/playground/AspireWithMaui/AspireWithMaui.AppHost/AppHost.cs b/playground/AspireWithMaui/AspireWithMaui.AppHost/AppHost.cs index c833af12b55..a5fb64bf1b9 100644 --- a/playground/AspireWithMaui/AspireWithMaui.AppHost/AppHost.cs +++ b/playground/AspireWithMaui/AspireWithMaui.AppHost/AppHost.cs @@ -7,4 +7,7 @@ mauiapp.AddWindowsDevice() .WithReference(weatherApi); +mauiapp.AddMacCatalystDevice() + .WithReference(weatherApi); + builder.Build().Run(); diff --git a/playground/AspireWithMaui/README.md b/playground/AspireWithMaui/README.md index 1fc3811d453..d694cf362ac 100644 --- a/playground/AspireWithMaui/README.md +++ b/playground/AspireWithMaui/README.md @@ -56,17 +56,19 @@ After running the restore script with `-restore-maui`, you can build and run the ## What's Included - **AspireWithMaui.AppHost** - The Aspire app host that orchestrates all services -- **AspireWithMaui.MauiClient** - A .NET MAUI application that connects to the backend (Windows platform only in this playground) +- **AspireWithMaui.MauiClient** - A .NET MAUI application that connects to the backend (Windows and Mac Catalyst platforms) - **AspireWithMaui.WeatherApi** - An ASP.NET Core Web API providing weather data - **AspireWithMaui.ServiceDefaults** - Shared service defaults for non-MAUI projects - **AspireWithMaui.MauiServiceDefaults** - Shared service defaults specific to MAUI projects ## Features Demonstrated -### MAUI Windows Platform Support -The playground demonstrates Aspire's ability to manage MAUI apps on Windows: -- Configures the MAUI app with `.AddMauiWindows()` -- Automatically detects the Windows target framework from the project file +### MAUI Multi-Platform Support +The playground demonstrates Aspire's ability to manage MAUI apps on multiple platforms: +- **Windows**: Configures the MAUI app with `.AddWindowsDevice()` +- **Mac Catalyst**: Configures the MAUI app with `.AddMacCatalystDevice()` +- Automatically detects platform-specific target frameworks from the project file +- Shows "Unsupported" state in dashboard when running on incompatible host OS - Sets up dev tunnels for MAUI app communication with backend services ### OpenTelemetry Integration @@ -76,8 +78,8 @@ The MAUI client uses OpenTelemetry to send traces and metrics to the Aspire dash The MAUI app discovers and connects to backend services (WeatherApi) using Aspire's service discovery. ### Future Platform Support -The architecture is designed to support additional platforms (Android, iOS, macCatalyst) through: -- `.AddMauiAndroid()`, `.AddMauiIos()`, `.AddMauiMacCatalyst()` extension methods (coming in future updates) +The architecture is designed to support additional platforms (Android, iOS) through: +- `.AddAndroidDevice()`, `.AddIosDevice()` extension methods (coming in future updates) - Parallel extension patterns for each platform ## Troubleshooting @@ -95,23 +97,25 @@ If you encounter build errors: 3. Try running `dotnet build` from the repository root first ### Platform-Specific Issues -- **Windows**: Requires Windows 10 build 19041 or higher for WinUI support +- **Windows**: Requires Windows 10 build 19041 or higher for WinUI support. Mac Catalyst devices will show as "Unsupported" when running on Windows. +- **Mac Catalyst**: Requires macOS to run. Windows devices will show as "Unsupported" when running on macOS. - **Android**: Not yet implemented in this playground (coming soon) -- **iOS/macCatalyst**: Not yet implemented in this playground (coming soon) +- **iOS**: Not yet implemented in this playground (coming soon) ## Current Status ✅ **Implemented:** -- Windows platform support via `AddMauiWindows()` -- Automatic Windows TFM detection from project file +- Windows platform support via `AddWindowsDevice()` +- Mac Catalyst platform support via `AddMacCatalystDevice()` +- Automatic platform-specific TFM detection from project file +- Platform validation with "Unsupported" state for incompatible hosts - Dev tunnel configuration for MAUI-to-backend communication - Service discovery integration - OpenTelemetry integration 🚧 **Coming Soon:** -- Android platform support -- iOS platform support -- macCatalyst platform support +- Android platform support via `AddAndroidDevice()` +- iOS platform support via `AddIosDevice()` - Multi-platform simultaneous debugging ## Learn More diff --git a/src/Aspire.Hosting.Maui/IMauiPlatformResource.cs b/src/Aspire.Hosting.Maui/IMauiPlatformResource.cs new file mode 100644 index 00000000000..9fb3efab808 --- /dev/null +++ b/src/Aspire.Hosting.Maui/IMauiPlatformResource.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Maui; + +/// +/// Marker interface for MAUI platform-specific resources (Windows, Android, iOS, Mac Catalyst). +/// +/// +/// This interface is used to identify resources that represent a specific platform instance +/// of a MAUI application, allowing for common handling across all MAUI platforms. +/// +internal interface IMauiPlatformResource +{ +} diff --git a/src/Aspire.Hosting.Maui/Lifecycle/UnsupportedPlatformEventSubscriber.cs b/src/Aspire.Hosting.Maui/Lifecycle/UnsupportedPlatformEventSubscriber.cs index 1a3ee3e711b..aa83fc8cd75 100644 --- a/src/Aspire.Hosting.Maui/Lifecycle/UnsupportedPlatformEventSubscriber.cs +++ b/src/Aspire.Hosting.Maui/Lifecycle/UnsupportedPlatformEventSubscriber.cs @@ -12,6 +12,10 @@ namespace Aspire.Hosting.Maui.Lifecycle; /// Event subscriber that sets the "Unsupported" state for MAUI platform resources /// marked with . /// +/// +/// This subscriber handles all MAUI platform resources (Windows, Android, iOS, Mac Catalyst) +/// by checking for the marker interface. +/// /// The notification service for publishing resource state updates. internal sealed class UnsupportedPlatformEventSubscriber(ResourceNotificationService notificationService) : IDistributedApplicationEventingSubscriber { @@ -23,7 +27,7 @@ public Task SubscribeAsync(IDistributedApplicationEventing eventing, Distributed // Find all MAUI platform resources with the UnsupportedPlatformAnnotation foreach (var resource in @event.Model.Resources) { - if (resource is MauiWindowsPlatformResource && + if (resource is IMauiPlatformResource && resource.TryGetLastAnnotation(out var annotation)) { // Set the state to "Unsupported" with a warning style and the reason diff --git a/src/Aspire.Hosting.Maui/MauiMacCatalystExtensions.cs b/src/Aspire.Hosting.Maui/MauiMacCatalystExtensions.cs new file mode 100644 index 00000000000..2b121d5e2f2 --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiMacCatalystExtensions.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Maui; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Mac Catalyst platform resources to MAUI projects. +/// +public static class MauiMacCatalystExtensions +{ + /// + /// Adds a Mac Catalyst device resource to run the MAUI application on the macOS platform. + /// + /// The MAUI project resource builder. + /// A reference to the . + /// + /// This method creates a new Mac Catalyst platform resource that will run the MAUI application + /// targeting the Mac Catalyst platform using dotnet run. The resource does not auto-start + /// and must be explicitly started from the dashboard by clicking the start button. + /// + /// The resource name will default to "{projectName}-maccatalyst". + /// + /// + /// + /// Add a Mac Catalyst device to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// var macCatalystDevice = maui.AddMacCatalystDevice(); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddMacCatalystDevice( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var name = $"{builder.Resource.Name}-maccatalyst"; + return builder.AddMacCatalystDevice(name); + } + + /// + /// Adds a Mac Catalyst device resource to run the MAUI application on the macOS platform with a specific name. + /// + /// The MAUI project resource builder. + /// The name of the Mac Catalyst device resource. + /// A reference to the . + /// + /// This method creates a new Mac Catalyst platform resource that will run the MAUI application + /// targeting the Mac Catalyst platform using dotnet run. The resource does not auto-start + /// and must be explicitly started from the dashboard by clicking the start button. + /// + /// Multiple Mac Catalyst device resources can be added to the same MAUI project if needed, each with + /// a unique name. + /// + /// + /// + /// Add multiple Mac Catalyst devices to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// var macCatalystDevice1 = maui.AddMacCatalystDevice("maccatalyst-device-1"); + /// var macCatalystDevice2 = maui.AddMacCatalystDevice("maccatalyst-device-2"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddMacCatalystDevice( + this IResourceBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + // Check if a Mac Catalyst device with this name already exists in the application model + var existingMacCatalystDevices = builder.ApplicationBuilder.Resources + .OfType() + .FirstOrDefault(r => r.Parent == builder.Resource && + string.Equals(r.Name, name, StringComparisons.ResourceName)); + + if (existingMacCatalystDevices is not null) + { + throw new DistributedApplicationException( + $"Mac Catalyst device with name '{name}' already exists on MAUI project '{builder.Resource.Name}'. " + + $"Provide a unique name parameter when calling AddMacCatalystDevice() to add multiple Mac Catalyst devices."); + } + + // Get the absolute project path and working directory + var (projectPath, workingDirectory) = MauiPlatformHelper.GetProjectPaths(builder); + + var macCatalystResource = new MauiMacCatalystPlatformResource(name, builder.Resource); + + var resourceBuilder = builder.ApplicationBuilder.AddResource(macCatalystResource) + .WithAnnotation(new MauiProjectMetadata(projectPath)) + .WithAnnotation(new ExecutableAnnotation + { + Command = "dotnet", + WorkingDirectory = workingDirectory + }); + + // Configure the platform resource with common settings + MauiPlatformHelper.ConfigurePlatformResource( + resourceBuilder, + projectPath, + "maccatalyst", + "Mac Catalyst", + "net10.0-maccatalyst", + OperatingSystem.IsMacOS, + "Desktop", + "-p:OpenArguments=-W"); + + return resourceBuilder; + } +} diff --git a/src/Aspire.Hosting.Maui/MauiMacCatalystPlatformResource.cs b/src/Aspire.Hosting.Maui/MauiMacCatalystPlatformResource.cs new file mode 100644 index 00000000000..7442be410bf --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiMacCatalystPlatformResource.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Maui; + +/// +/// Represents a Mac Catalyst platform instance of a .NET MAUI project. +/// +/// The name of the resource. +/// The parent MAUI project resource. +/// +/// This resource represents a MAUI application running on the Mac Catalyst platform. +/// The actual build and deployment happens when the resource is started, allowing for +/// incremental builds during development without blocking AppHost startup. +/// +/// Use +/// to add this resource to a MAUI project. +/// +/// +public class MauiMacCatalystPlatformResource(string name, MauiProjectResource parent) + : ProjectResource(name), IResourceWithParent, IMauiPlatformResource +{ + /// + /// Gets the parent MAUI project resource. + /// + public MauiProjectResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent)); +} diff --git a/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs b/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs new file mode 100644 index 00000000000..65ccbc0b6d6 --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Lifecycle; +using Aspire.Hosting.Maui.Annotations; +using Aspire.Hosting.Maui.Lifecycle; +using Aspire.Hosting.Maui.Utilities; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Maui; + +/// +/// Helper methods for adding platform-specific MAUI device resources. +/// +internal static class MauiPlatformHelper +{ + /// + /// Gets the absolute project path and working directory from a MAUI project resource. + /// + /// The MAUI project resource builder. + /// A tuple containing the absolute project path and working directory. + internal static (string ProjectPath, string WorkingDirectory) GetProjectPaths(IResourceBuilder builder) + { + var projectPath = builder.Resource.ProjectPath; + if (!Path.IsPathRooted(projectPath)) + { + projectPath = PathNormalizer.NormalizePathForCurrentPlatform( + Path.Combine(builder.ApplicationBuilder.AppHostDirectory, projectPath)); + } + + var workingDirectory = Path.GetDirectoryName(projectPath) + ?? throw new InvalidOperationException($"Unable to determine directory from project path: {projectPath}"); + + return (projectPath, workingDirectory); + } + + /// + /// Configures a platform resource with common settings and TFM validation. + /// + /// The type of platform resource. + /// The resource builder. + /// The absolute path to the project file. + /// The platform name (e.g., "windows", "maccatalyst"). + /// The display name for the platform (e.g., "Windows", "Mac Catalyst"). + /// Example TFM for error messages (e.g., "net10.0-windows10.0.19041.0"). + /// Function to check if the platform is supported on the current host. + /// The icon name for the resource. + /// Optional additional command-line arguments to pass to dotnet run. + internal static void ConfigurePlatformResource( + IResourceBuilder resourceBuilder, + string projectPath, + string platformName, + string platformDisplayName, + string tfmExample, + Func isSupported, + string iconName = "Desktop", + params string[] additionalArgs) where T : ProjectResource + { + // Check if the project has the platform TFM and get the actual TFM value + var platformTfm = ProjectFileReader.GetPlatformTargetFramework(projectPath, platformName); + + // Set the command line arguments with the detected TFM if available + resourceBuilder.WithArgs(context => + { + context.Args.Add("run"); + if (!string.IsNullOrEmpty(platformTfm)) + { + context.Args.Add("-f"); + context.Args.Add(platformTfm); + } + // Add any additional platform-specific arguments + foreach (var arg in additionalArgs) + { + context.Args.Add(arg); + } + }); + + resourceBuilder + .WithOtlpExporter() + .WithIconName(iconName) + .WithExplicitStart(); + + // Validate the platform TFM when the resource is about to start + resourceBuilder.OnBeforeResourceStarted((resource, eventing, ct) => + { + // If we couldn't detect the TFM earlier, fail the resource start + if (string.IsNullOrEmpty(platformTfm)) + { + throw new DistributedApplicationException( + $"Unable to detect {platformDisplayName} target framework in project '{projectPath}'. " + + $"Ensure the project file contains a TargetFramework or TargetFrameworks element with a {platformDisplayName} target framework (e.g., {tfmExample}) " + + $"or remove the Add{platformDisplayName.Replace(" ", "")}Device() call from your AppHost."); + } + + return Task.CompletedTask; + }); + + // Check if platform is supported on the current host + if (!isSupported()) + { + var reason = $"{platformDisplayName} platform not available on this host"; + + // Mark as unsupported + resourceBuilder.WithAnnotation(new UnsupportedPlatformAnnotation(reason), ResourceAnnotationMutationBehavior.Append); + + // Add an event subscriber to set the "Unsupported" state after orchestrator initialization + var appBuilder = resourceBuilder.ApplicationBuilder; + appBuilder.Services.TryAddEventingSubscriber(); + } + } +} diff --git a/src/Aspire.Hosting.Maui/MauiProjectResource.cs b/src/Aspire.Hosting.Maui/MauiProjectResource.cs index f844dad0e5f..f500db18a06 100644 --- a/src/Aspire.Hosting.Maui/MauiProjectResource.cs +++ b/src/Aspire.Hosting.Maui/MauiProjectResource.cs @@ -12,7 +12,7 @@ namespace Aspire.Hosting.Maui; /// The path to the .NET MAUI project file. /// /// This resource serves as a parent for platform-specific MAUI resources (Windows, Android, iOS, macOS). -/// Use extension methods like AddWindowsDevice to add platform-specific instances. +/// Use extension methods like AddWindowsDevice or AddMacCatalystDevice to add platform-specific instances. /// /// MAUI projects are built on-demand when the platform-specific resource is started, avoiding long /// AppHost startup times while still allowing incremental builds during development. diff --git a/src/Aspire.Hosting.Maui/MauiWindowsExtensions.cs b/src/Aspire.Hosting.Maui/MauiWindowsExtensions.cs index 1729a3549be..5bf98fbc8eb 100644 --- a/src/Aspire.Hosting.Maui/MauiWindowsExtensions.cs +++ b/src/Aspire.Hosting.Maui/MauiWindowsExtensions.cs @@ -2,12 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Maui; -using Aspire.Hosting.Maui.Annotations; -using Aspire.Hosting.Maui.Lifecycle; -using Aspire.Hosting.Maui.Utilities; -using Aspire.Hosting.Utils; namespace Aspire.Hosting; @@ -97,18 +92,7 @@ public static IResourceBuilder AddWindowsDevice( } // Get the absolute project path and working directory - var projectPath = builder.Resource.ProjectPath; - if (!Path.IsPathRooted(projectPath)) - { - projectPath = PathNormalizer.NormalizePathForCurrentPlatform( - Path.Combine(builder.ApplicationBuilder.AppHostDirectory, projectPath)); - } - - var workingDirectory = Path.GetDirectoryName(projectPath) - ?? throw new InvalidOperationException($"Unable to determine directory from project path: {projectPath}"); - - // Check if the project has the Windows TFM and get the actual TFM value - var windowsTfm = ProjectFileReader.GetPlatformTargetFramework(projectPath, "windows"); + var (projectPath, workingDirectory) = MauiPlatformHelper.GetProjectPaths(builder); var windowsResource = new MauiWindowsPlatformResource(name, builder.Resource); @@ -118,46 +102,17 @@ public static IResourceBuilder AddWindowsDevice( { Command = "dotnet", WorkingDirectory = workingDirectory - }) - .WithArgs(context => - { - context.Args.Add("run"); - if (!string.IsNullOrEmpty(windowsTfm)) - { - context.Args.Add("-f"); - context.Args.Add(windowsTfm); - } - }) - .WithOtlpExporter() - .WithIconName("Desktop") - .WithExplicitStart(); - - // Validate the Windows TFM when the resource is about to start - resourceBuilder.OnBeforeResourceStarted((resource, eventing, ct) => - { - // If we couldn't detect the TFM earlier, fail the resource start - if (string.IsNullOrEmpty(windowsTfm)) - { - throw new DistributedApplicationException( - $"Unable to detect Windows target framework in project '{projectPath}'. " + - "Ensure the project file contains a TargetFramework or TargetFrameworks element with a Windows target framework (e.g., net10.0-windows10.0.19041.0) " + - "or remove the AddWindowsDevice() call from your AppHost."); - } - - return Task.CompletedTask; - }); - - // Check if Windows platform is supported on the current host - if (!OperatingSystem.IsWindows()) - { - var reason = "Windows platform not available on this host"; - - // Mark as unsupported - resourceBuilder.WithAnnotation(new UnsupportedPlatformAnnotation(reason), ResourceAnnotationMutationBehavior.Append); - - // Add an event subscriber to set the "Unsupported" state after orchestrator initialization - builder.ApplicationBuilder.Services.TryAddEventingSubscriber(); - } + }); + + // Configure the platform resource with common settings + MauiPlatformHelper.ConfigurePlatformResource( + resourceBuilder, + projectPath, + "windows", + "Windows", + "net10.0-windows10.0.19041.0", + OperatingSystem.IsWindows, + "Desktop"); return resourceBuilder; } diff --git a/src/Aspire.Hosting.Maui/MauiWindowsPlatformResource.cs b/src/Aspire.Hosting.Maui/MauiWindowsPlatformResource.cs index 7d31af624db..f5ab9e86ed0 100644 --- a/src/Aspire.Hosting.Maui/MauiWindowsPlatformResource.cs +++ b/src/Aspire.Hosting.Maui/MauiWindowsPlatformResource.cs @@ -20,7 +20,7 @@ namespace Aspire.Hosting.Maui; /// /// public class MauiWindowsPlatformResource(string name, MauiProjectResource parent) - : ProjectResource(name), IResourceWithParent + : ProjectResource(name), IResourceWithParent, IMauiPlatformResource { /// /// Gets the parent MAUI project resource. diff --git a/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs b/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs index 294ac15343e..1a2557f9ce5 100644 --- a/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs +++ b/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs @@ -8,6 +8,13 @@ //------------------------------------------------------------------------------ namespace Aspire.Hosting { + public static partial class MauiMacCatalystExtensions + { + public static ApplicationModel.IResourceBuilder AddMacCatalystDevice(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } + + public static ApplicationModel.IResourceBuilder AddMacCatalystDevice(this ApplicationModel.IResourceBuilder builder) { throw null; } + } + public static partial class MauiProjectExtensions { public static ApplicationModel.IResourceBuilder AddMauiProject(this IDistributedApplicationBuilder builder, string name, string projectPath) { throw null; } @@ -23,6 +30,13 @@ public static partial class MauiWindowsExtensions namespace Aspire.Hosting.Maui { + public partial class MauiMacCatalystPlatformResource : ApplicationModel.ProjectResource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource + { + public MauiMacCatalystPlatformResource(string name, MauiProjectResource parent) : base(default!) { } + + public MauiProjectResource Parent { get { throw null; } } + } + public partial class MauiProjectResource : ApplicationModel.Resource { public MauiProjectResource(string name, string projectPath) : base(default!) { } diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiMacCatalystExtensionsTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiMacCatalystExtensionsTests.cs new file mode 100644 index 00000000000..bcdc661fc35 --- /dev/null +++ b/tests/Aspire.Hosting.Maui.Tests/MauiMacCatalystExtensionsTests.cs @@ -0,0 +1,326 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Eventing; +using Aspire.Hosting.Maui.Utilities; +using Aspire.Hosting.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Tests; + +public class MauiMacCatalystExtensionsTests +{ + [Fact] + public void AddMacCatalystDevice_CreatesResource() + { + // Arrange - Create a temporary project file with macOS Catalyst TFM + var projectContent = """ + + + net10.0-android;net10.0-ios;net10.0-maccatalyst + + + """; + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var macCatalyst = maui.AddMacCatalystDevice(); + + // Assert + Assert.NotNull(macCatalyst); + Assert.Equal("mauiapp-maccatalyst", macCatalyst.Resource.Name); + Assert.Equal(maui.Resource, macCatalyst.Resource.Parent); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public void AddMacCatalystDevice_WithCustomName_UsesProvidedName() + { + // Arrange - Create a temporary project file with macOS Catalyst TFM + var projectContent = """ + + + net10.0-android;net10.0-ios;net10.0-maccatalyst + + + """; + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var macCatalyst = maui.AddMacCatalystDevice("custom-maccatalyst"); + + // Assert + Assert.Equal("custom-maccatalyst", macCatalyst.Resource.Name); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public void AddMacCatalystDevice_DuplicateName_ThrowsException() + { + // Arrange - Create a temporary project file with macOS Catalyst TFM + var projectContent = """ + + + net10.0-android;net10.0-ios;net10.0-maccatalyst + + + """; + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + maui.AddMacCatalystDevice("device1"); + + // Act & Assert + var exception = Assert.Throws(() => maui.AddMacCatalystDevice("device1")); + Assert.Contains("already exists", exception.Message); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public void AddMacCatalystDevice_MultipleDevices_AllowsMultipleWithDifferentNames() + { + // Arrange - Create a temporary project file with macOS Catalyst TFM + var projectContent = """ + + + net10.0-android;net10.0-ios;net10.0-maccatalyst + + + """; + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var device1 = maui.AddMacCatalystDevice("device1"); + var device2 = maui.AddMacCatalystDevice("device2"); + + // Assert + Assert.Equal(2, appBuilder.Resources.OfType().Count()); + Assert.Contains(device1.Resource, appBuilder.Resources); + Assert.Contains(device2.Resource, appBuilder.Resources); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public void AddMacCatalystDevice_SetsCorrectResourceProperties() + { + // Arrange - Create a temporary project file with macOS Catalyst TFM + var projectContent = """ + + + net10.0-android;net10.0-ios;net10.0-maccatalyst + + + """; + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var macCatalyst = maui.AddMacCatalystDevice(); + + // Assert + var executableAnnotation = macCatalyst.Resource.Annotations.OfType().Single(); + Assert.Equal("dotnet", executableAnnotation.Command); + Assert.NotNull(executableAnnotation.WorkingDirectory); + Assert.Equal(maui.Resource, macCatalyst.Resource.Parent); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public async Task AddMacCatalystDevice_SetsCorrectCommandLineArguments() + { + // Arrange - Create a temporary project file with macOS Catalyst TFM + var projectContent = """ + + + net10.0-android;net10.0-ios;net10.0-maccatalyst + + + """; + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var macCatalyst = maui.AddMacCatalystDevice(); + + using var app = appBuilder.Build(); + + // Assert + var args = await ArgumentEvaluator.GetArgumentListAsync(macCatalyst.Resource); + Assert.Contains("run", args); + Assert.Contains("-f", args); + Assert.Contains("net10.0-maccatalyst", args); + Assert.Contains("-p:OpenArguments=-W", args); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public async Task AddMacCatalystDevice_WithoutMacCatalystTfm_ThrowsOnBeforeStartEvent() + { + // Arrange - Create a temporary project file without macOS Catalyst TFM + var projectContent = """ + + + net10.0-android;net10.0-ios;net10.0-windows10.0.19041.0 + + + """; + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act - Adding the device should succeed (validation deferred to start) + var macCatalyst = maui.AddMacCatalystDevice(); + + // Assert - Resource is created + Assert.NotNull(macCatalyst); + Assert.Equal("mauiapp-maccatalyst", macCatalyst.Resource.Name); + + // Build the app to get access to eventing + await using var app = appBuilder.Build(); + + // Trigger the BeforeResourceStartedEvent which should throw + var exception = await Assert.ThrowsAsync(async () => + { + await app.Services.GetRequiredService() + .PublishAsync(new BeforeResourceStartedEvent(macCatalyst.Resource, app.Services), CancellationToken.None); + }); + + Assert.Contains("Unable to detect Mac Catalyst target framework", exception.Message); + Assert.Contains(tempFile, exception.Message); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public void AddMacCatalystDevice_DetectsMacCatalystTfmFromMultiTargetedProject() + { + // Arrange - Create a temporary project file with multiple TFMs including macOS Catalyst + var projectContent = """ + + + net10.0-android;net10.0-ios;net10.0-maccatalyst;net10.0-windows10.0.19041.0 + + + """; + var tempFile = CreateTempProjectFile(projectContent); + + try + { + // Act + var tfm = ProjectFileReader.GetPlatformTargetFramework(tempFile, "maccatalyst"); + + // Assert + Assert.NotNull(tfm); + Assert.Equal("net10.0-maccatalyst", tfm); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public void AddMacCatalystDevice_DetectsMacCatalystTfmFromSingleTargetProject() + { + // Arrange - Create a temporary project file with single macOS Catalyst TFM + var projectContent = """ + + + net10.0-maccatalyst + + + """; + var tempFile = CreateTempProjectFile(projectContent); + + try + { + // Act + var tfm = ProjectFileReader.GetPlatformTargetFramework(tempFile, "maccatalyst"); + + // Assert + Assert.NotNull(tfm); + Assert.Equal("net10.0-maccatalyst", tfm); + } + finally + { + CleanupTempFile(tempFile); + } + } + + private static string CreateTempProjectFile(string content) + { + var tempFile = Path.GetTempFileName(); + var tempProjectFile = Path.ChangeExtension(tempFile, ".csproj"); + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + File.WriteAllText(tempProjectFile, content); + return tempProjectFile; + } + + private static void CleanupTempFile(string tempFile) + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } +}