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);
+ }
+ }
+}