diff --git a/playground/AspireWithMaui/AspireWithMaui.AppHost/AppHost.cs b/playground/AspireWithMaui/AspireWithMaui.AppHost/AppHost.cs index a5fb64bf1b9..0e7ceeda206 100644 --- a/playground/AspireWithMaui/AspireWithMaui.AppHost/AppHost.cs +++ b/playground/AspireWithMaui/AspireWithMaui.AppHost/AppHost.cs @@ -2,6 +2,10 @@ var weatherApi = builder.AddProject("webapi", @"../AspireWithMaui.WeatherApi/AspireWithMaui.WeatherApi.csproj"); +var publicDevTunnel = builder.AddDevTunnel("devtunnel-public") + .WithAnonymousAccess() // All ports on this tunnel default to allowing anonymous access + .WithReference(weatherApi.GetEndpoint("https")); + var mauiapp = builder.AddMauiProject("mauiapp", @"../AspireWithMaui.MauiClient/AspireWithMaui.MauiClient.csproj"); mauiapp.AddWindowsDevice() @@ -10,4 +14,9 @@ mauiapp.AddMacCatalystDevice() .WithReference(weatherApi); +// Add Android emulator with default emulator (uses running or default emulator) +mauiapp.AddAndroidEmulator() + .WithOtlpDevTunnel() // Needed to get the OpenTelemetry data to "localhost" + .WithReference(weatherApi, publicDevTunnel); // Needs a dev tunnel to reach "localhost" + builder.Build().Run(); diff --git a/playground/AspireWithMaui/AspireWithMaui.MauiClient/EnvironmentPage.xaml.cs b/playground/AspireWithMaui/AspireWithMaui.MauiClient/EnvironmentPage.xaml.cs index f9a1325aa41..cb956856a21 100644 --- a/playground/AspireWithMaui/AspireWithMaui.MauiClient/EnvironmentPage.xaml.cs +++ b/playground/AspireWithMaui/AspireWithMaui.MauiClient/EnvironmentPage.xaml.cs @@ -26,7 +26,6 @@ private void LoadAspireEnvironmentVariables() var variables = Environment.GetEnvironmentVariables() .Cast() .Select(entry => new KeyValuePair(entry.Key?.ToString() ?? string.Empty, DecodeValue(entry.Value?.ToString()))) - .Where(item => IsAspireVariable(item.Key)) .OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase); foreach (var variable in variables) @@ -62,18 +61,4 @@ private static string DecodeValue(string? value) return value; } } - - private static bool IsAspireVariable(string key) - => key.StartsWith("services__", StringComparison.OrdinalIgnoreCase) - || key.StartsWith("connectionstrings__", StringComparison.OrdinalIgnoreCase) - || key.StartsWith("ASPIRE_", StringComparison.OrdinalIgnoreCase) - || key.StartsWith("AppHost__", StringComparison.OrdinalIgnoreCase) - || key.StartsWith("OTEL_", StringComparison.OrdinalIgnoreCase) - || key.StartsWith("LOGGING__CONSOLE", StringComparison.OrdinalIgnoreCase) - || key.Equals("ASPNETCORE_ENVIRONMENT", StringComparison.OrdinalIgnoreCase) - || key.Equals("ASPNETCORE_URLS", StringComparison.OrdinalIgnoreCase) - || key.Equals("DOTNET_ENVIRONMENT", StringComparison.OrdinalIgnoreCase) - || key.Equals("DOTNET_URLS", StringComparison.OrdinalIgnoreCase) - || key.Equals("DOTNET_LAUNCH_PROFILE", StringComparison.OrdinalIgnoreCase) - || key.Equals("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", StringComparison.OrdinalIgnoreCase); } diff --git a/playground/AspireWithMaui/README.md b/playground/AspireWithMaui/README.md index d694cf362ac..3a3e146ad3b 100644 --- a/playground/AspireWithMaui/README.md +++ b/playground/AspireWithMaui/README.md @@ -67,19 +67,86 @@ After running the restore script with `-restore-maui`, you can build and run the 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()` +- **Android Device**: Configures the MAUI app with `.AddAndroidDevice()` to run on physical Android devices + - Use `.AddAndroidDevice()` to target the only attached device (default, requires exactly one device) + - Use `.AddAndroidDevice("device-name", "abc12345")` to target a specific device by serial number or IP + - Works with USB-connected devices and WiFi debugging (e.g., "192.168.1.100:5555") + - Get device IDs from `adb devices` command + - Use `.WithOtlpDevTunnel()` to send telemetry to the dashboard (Android cannot reach localhost) +- **Android Emulator**: Configures the MAUI app with `.AddAndroidEmulator()` to run on Android emulators + - Use `.AddAndroidEmulator()` to target the only running emulator (default) + - Use `.AddAndroidEmulator("emulator-name", "Pixel_5_API_33")` to target a specific emulator by AVD name + - Can also use emulator serial number like "emulator-5554" + - Get emulator names from `adb devices` or `emulator -list-avds` command + - Use `.WithOtlpDevTunnel()` to send telemetry to the dashboard (emulators cannot reach localhost) - 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 -The MAUI client uses OpenTelemetry to send traces and metrics to the Aspire dashboard via dev tunnels. +The MAUI client uses OpenTelemetry to send traces and metrics to the Aspire dashboard. For mobile platforms that cannot reach `localhost`, the playground demonstrates using dev tunnels to expose the dashboard's OTLP endpoint: + +```csharp +// Android devices and emulators need dev tunnel for OTLP +mauiapp.AddAndroidEmulator() + .WithOtlpDevTunnel() // Automatically creates and configures a dev tunnel for telemetry + .WithReference(weatherApi, publicDevTunnel); // Dev tunnel for API communication +``` + +The `.WithOtlpDevTunnel()` method: +- Automatically resolves the dashboard's OTLP endpoint from configuration +- Creates a dev tunnel for the OTLP endpoint +- Configures the MAUI platform to send telemetry through the tunnel +- Handles all service discovery and environment variable setup + +**Requirements for dev tunnels:** +- Dev tunnel CLI must be installed (automatic prompt if missing) +- User must be logged in to dev tunnel service (automatic prompt if needed) ### Service Discovery The MAUI app discovers and connects to backend services (WeatherApi) using Aspire's service discovery. +### Environment Variables + +All MAUI platform resources support environment variables using the standard `.WithEnvironment()` method. Environment variables are automatically forwarded to the MAUI application regardless of platform: + +```csharp +// For Windows and Mac Catalyst, environment variables are passed directly: +mauiapp.AddWindowsDevice() + .WithEnvironment("DEBUG_MODE", "true") + .WithEnvironment("API_TIMEOUT", "30"); + +// For Android, environment variables are passed via an intermediate MSBuild targets file, but the syntax is identical: +mauiapp.AddAndroidDevice("my-device", "abc12345") + .WithEnvironment("DEBUG_MODE", "true") + .WithEnvironment("API_TIMEOUT", "30") + .WithEnvironment("LOG_LEVEL", "Debug"); + +mauiapp.AddAndroidEmulator("my-emulator", "Pixel_5_API_33") + .WithEnvironment("CUSTOM_VAR", "value") + .WithReference(weatherApi); // Service discovery environment variables also forwarded +``` + +#### What Gets Forwarded + +**ALL Aspire-managed environment variables** are automatically forwarded to MAUI applications: +- **Custom variables**: Set via `.WithEnvironment(key, value)` +- **Service discovery**: Connection strings and endpoints from `.WithReference(service)` +- **OpenTelemetry**: OTEL configuration from `.WithOtlpExporter()` +- **Resource metadata**: Automatically added by Aspire + +#### Platform-Specific Implementation + +- **Windows & Mac Catalyst**: Environment variables are passed directly through the process environment when launching via `dotnet run`. +- **Android**: Due to Android platform limitations, environment variables are written to a temporary MSBuild targets file that gets imported during the build. The targets file is generated automatically before each build and cleaned up after 24 hours (when a next build happens). Environment variable names are normalized to UPPERCASE (Android requirement), and semicolons are encoded as `%3B`. +- **iOS**: (Coming soon) Will use a similar approach to Android with MSBuild targets file. + +Environment variables are available in your MAUI app code regardless of platform through standard .NET environment APIs (`Environment.GetEnvironmentVariable()`). + ### Future Platform Support -The architecture is designed to support additional platforms (Android, iOS) through: -- `.AddAndroidDevice()`, `.AddIosDevice()` extension methods (coming in future updates) +The architecture is designed to support additional platforms: +- Android support: `.AddAndroidDevice()` for physical devices, `.AddAndroidEmulator()` for emulators (implemented) +- iOS support: `.AddIosDevice()` extension method (coming in future updates) - Parallel extension patterns for each platform ## Troubleshooting @@ -99,7 +166,12 @@ If you encounter build errors: ### Platform-Specific Issues - **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) +- **Android Device**: Requires a physical Android device connected via USB/WiFi debugging. Ensure the device is visible via `adb devices`. Works on Windows, macOS, and Linux. +- **Android Emulator**: Requires an Android emulator running and visible via `adb devices`. To target a specific emulator: + 1. List available emulators: `adb devices` (shows emulator IDs like "emulator-5554") + 2. Or list AVDs: `emulator -list-avds` (shows AVD names like "Pixel_5_API_33") + 3. Use either ID format in code: `.AddAndroidEmulator(emulatorId: "Pixel_5_API_33")` or `.AddAndroidEmulator(emulatorId: "emulator-5554")` + 4. Works on Windows, macOS, and Linux. - **iOS**: Not yet implemented in this playground (coming soon) ## Current Status @@ -107,6 +179,8 @@ If you encounter build errors: ✅ **Implemented:** - Windows platform support via `AddWindowsDevice()` - Mac Catalyst platform support via `AddMacCatalystDevice()` +- Android device support via `AddAndroidDevice()` +- Android emulator support via `AddAndroidEmulator()` - Automatic platform-specific TFM detection from project file - Platform validation with "Unsupported" state for incompatible hosts - Dev tunnel configuration for MAUI-to-backend communication @@ -114,7 +188,6 @@ If you encounter build errors: - OpenTelemetry integration 🚧 **Coming Soon:** -- Android platform support via `AddAndroidDevice()` - iOS platform support via `AddIosDevice()` - Multi-platform simultaneous debugging diff --git a/src/Aspire.Hosting.Maui/Annotations/OtlpDevTunnelConfigurationAnnotation.cs b/src/Aspire.Hosting.Maui/Annotations/OtlpDevTunnelConfigurationAnnotation.cs new file mode 100644 index 00000000000..9fb5f23117a --- /dev/null +++ b/src/Aspire.Hosting.Maui/Annotations/OtlpDevTunnelConfigurationAnnotation.cs @@ -0,0 +1,40 @@ +// 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.DevTunnels; +using Aspire.Hosting.Maui.Otlp; + +namespace Aspire.Hosting.Maui.Annotations; + +/// +/// Annotation that stores the OTLP dev tunnel configuration for a MAUI project. +/// This allows sharing a single dev tunnel infrastructure across multiple platform resources. +/// +internal sealed class OtlpDevTunnelConfigurationAnnotation : IResourceAnnotation +{ + /// + /// The OTLP loopback stub resource that acts as the service discovery target. + /// + public OtlpLoopbackResource OtlpStub { get; } + + /// + /// The resource builder for the OTLP stub (used for WithReference calls). + /// + public IResourceBuilder OtlpStubBuilder { get; } + + /// + /// The dev tunnel resource that tunnels the OTLP endpoint. + /// + public IResourceBuilder DevTunnel { get; } + + public OtlpDevTunnelConfigurationAnnotation( + OtlpLoopbackResource otlpStub, + IResourceBuilder otlpStubBuilder, + IResourceBuilder devTunnel) + { + OtlpStub = otlpStub; + OtlpStubBuilder = otlpStubBuilder; + DevTunnel = devTunnel; + } +} diff --git a/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj b/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj index 5a372ba2182..f762e77eda5 100644 --- a/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj +++ b/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj @@ -22,5 +22,6 @@ + diff --git a/src/Aspire.Hosting.Maui/IMauiPlatformResource.cs b/src/Aspire.Hosting.Maui/IMauiPlatformResource.cs index 9fb3efab808..9a2e54ab913 100644 --- a/src/Aspire.Hosting.Maui/IMauiPlatformResource.cs +++ b/src/Aspire.Hosting.Maui/IMauiPlatformResource.cs @@ -1,6 +1,8 @@ // 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; /// @@ -9,7 +11,8 @@ namespace Aspire.Hosting.Maui; /// /// 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. +/// All MAUI platform resources have a parent . /// -internal interface IMauiPlatformResource +public interface IMauiPlatformResource : IResourceWithParent { } diff --git a/src/Aspire.Hosting.Maui/MauiAndroidDeviceResource.cs b/src/Aspire.Hosting.Maui/MauiAndroidDeviceResource.cs new file mode 100644 index 00000000000..01b6b6d965f --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiAndroidDeviceResource.cs @@ -0,0 +1,20 @@ +// 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; + +/// +/// A resource that represents an Android physical device for running a .NET MAUI application. +/// +/// The name of the Android device resource. +/// The parent MAUI project resource. +public sealed class MauiAndroidDeviceResource(string name, MauiProjectResource parent) + : ProjectResource(name), IMauiPlatformResource +{ + /// + /// Gets the parent MAUI project resource. + /// + public MauiProjectResource Parent { get; } = parent; +} diff --git a/src/Aspire.Hosting.Maui/MauiAndroidEmulatorResource.cs b/src/Aspire.Hosting.Maui/MauiAndroidEmulatorResource.cs new file mode 100644 index 00000000000..20c64fbeeb8 --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiAndroidEmulatorResource.cs @@ -0,0 +1,20 @@ +// 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; + +/// +/// A resource that represents an Android emulator for running a .NET MAUI application. +/// +/// The name of the Android emulator resource. +/// The parent MAUI project resource. +public sealed class MauiAndroidEmulatorResource(string name, MauiProjectResource parent) + : ProjectResource(name), IMauiPlatformResource +{ + /// + /// Gets the parent MAUI project resource. + /// + public MauiProjectResource Parent { get; } = parent; +} diff --git a/src/Aspire.Hosting.Maui/MauiAndroidExtensions.cs b/src/Aspire.Hosting.Maui/MauiAndroidExtensions.cs new file mode 100644 index 00000000000..70641189ab6 --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiAndroidExtensions.cs @@ -0,0 +1,377 @@ +// 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; +using Aspire.Hosting.Maui.Utilities; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Android platform resources to MAUI projects. +/// +public static class MauiAndroidExtensions +{ + /// + /// Adds an Android physical device resource to run the MAUI application on an Android device. + /// + /// The MAUI project resource builder. + /// A reference to the . + /// + /// This method creates a new Android device platform resource that will run the MAUI application + /// targeting the Android 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}-android-device". + /// + /// + /// This will run the application on a physical Android device connected via USB/WiFi debugging. + /// If only one device is attached, it will automatically use that device. If multiple devices + /// are attached, use the overload with deviceId parameter to specify which device to use. + /// Make sure an Android device is connected and visible via adb devices. + /// + /// + /// + /// Add an Android device to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// var androidDevice = maui.AddAndroidDevice(); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddAndroidDevice( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var name = $"{builder.Resource.Name}-android-device"; + return builder.AddAndroidDevice(name, deviceId: null); + } + + /// + /// Adds an Android physical device resource to run the MAUI application on an Android device with a specific name. + /// + /// The MAUI project resource builder. + /// The name of the Android device resource. + /// A reference to the . + /// + /// This method creates a new Android device platform resource that will run the MAUI application + /// targeting the Android platform using dotnet run. The resource does not auto-start + /// and must be explicitly started from the dashboard by clicking the start button. + /// + /// Multiple Android device resources can be added to the same MAUI project if needed, each with + /// a unique name. + /// + /// + /// This will run the application on a physical Android device connected via USB/WiFi debugging. + /// If only one device is attached, it will automatically use that device. If multiple devices + /// are attached, use the overload with deviceId parameter to specify which device to use. + /// Make sure an Android device is connected and visible via adb devices. + /// + /// + /// + /// Add multiple Android devices to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// var device1 = maui.AddAndroidDevice("android-device-1"); + /// var device2 = maui.AddAndroidDevice("android-device-2"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddAndroidDevice( + this IResourceBuilder builder, + [ResourceName] string name) + { + return builder.AddAndroidDevice(name, deviceId: null); + } + + /// + /// Adds an Android physical device resource to run the MAUI application on an Android device with a specific name and device ID. + /// + /// The MAUI project resource builder. + /// The name of the Android device resource. + /// Optional device ID to target a specific Android device. If not specified, uses the only attached device (requires exactly one device to be connected). + /// A reference to the . + /// + /// This method creates a new Android device platform resource that will run the MAUI application + /// targeting the Android platform using dotnet run. The resource does not auto-start + /// and must be explicitly started from the dashboard by clicking the start button. + /// + /// Multiple Android device resources can be added to the same MAUI project if needed, each with + /// a unique name. + /// + /// + /// This will run the application on a physical Android device connected via USB/WiFi debugging. + /// Make sure an Android device is connected and visible via adb devices. + /// + /// + /// To target a specific device when multiple are attached, provide the device ID (e.g., "abc12345" or "192.168.1.100:5555" for WiFi debugging). + /// Use adb devices to list available device IDs. + /// + /// + /// + /// Add multiple Android devices to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// + /// // Default device (only one attached) + /// var device1 = maui.AddAndroidDevice("android-device-default"); + /// + /// // Specific device by serial number + /// var device2 = maui.AddAndroidDevice("android-device-pixel", "abc12345"); + /// + /// // WiFi debugging device + /// var device3 = maui.AddAndroidDevice("android-device-wifi", "192.168.1.100:5555"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddAndroidDevice( + this IResourceBuilder builder, + [ResourceName] string name, + string? deviceId = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + // Get the absolute project path and working directory + var (projectPath, workingDirectory) = MauiPlatformHelper.GetProjectPaths(builder); + + var androidDeviceResource = new MauiAndroidDeviceResource(name, builder.Resource); + + var resourceBuilder = builder.ApplicationBuilder.AddResource(androidDeviceResource) + .WithAnnotation(new MauiProjectMetadata(projectPath)) + .WithAnnotation(new MauiAndroidEnvironmentAnnotation()) // Enable environment variable support via targets file + .WithAnnotation(new ExecutableAnnotation + { + Command = "dotnet", + WorkingDirectory = workingDirectory + }); + + // Build additional arguments for device ID if specified + // For Android devices, we need to use the MSBuild property AdbTarget to specify which device to target + // See: https://learn.microsoft.com/dotnet/maui/whats-new/dotnet-10#dotnet-run-support + // Valid formats: + // -p:AdbTarget=-d (run on only attached device) + // -p:AdbTarget=-s abc12345 (run on specific device by serial) + var additionalArgs = new List(); + if (!string.IsNullOrWhiteSpace(deviceId)) + { + // Specific device - use -s prefix (no quotes around the value) + additionalArgs.Add($"-p:AdbTarget=-s {deviceId}"); + } + else + { + // No specific device ID - use -d to target the only attached device + additionalArgs.Add("-p:AdbTarget=-d"); + } + + // Configure the platform resource with common settings + // Android runs on Windows, macOS, and Linux - check for Android SDK/tooling availability is complex + // For now, allow on all platforms and let dotnet run fail gracefully if Android SDK is not available + MauiPlatformHelper.ConfigurePlatformResource( + resourceBuilder, + projectPath, + "android", + "Android", + "net10.0-android", + () => true, // Allow on all platforms, validation happens at dotnet run time + "PhoneTablet", + additionalArgs.ToArray()); + + return resourceBuilder; + } + + /// + /// Adds an Android emulator resource to run the MAUI application on an Android emulator. + /// + /// The MAUI project resource builder. + /// A reference to the . + /// + /// This method creates a new Android emulator platform resource that will run the MAUI application + /// targeting the Android 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}-android-emulator". + /// + /// + /// This will run the application on an Android emulator. Make sure you have created an Android + /// Virtual Device (AVD) using Android Studio or avdmanager. The emulator should be running + /// and visible via adb devices. + /// + /// + /// To target a specific emulator, use the overload that accepts an emulatorId parameter. + /// + /// + /// + /// Add an Android emulator to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// + /// // Uses default/running emulator + /// var defaultEmulator = maui.AddAndroidEmulator(); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddAndroidEmulator( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var name = $"{builder.Resource.Name}-android-emulator"; + return builder.AddAndroidEmulator(name, emulatorId: null); + } + + /// + /// Adds an Android emulator resource to run the MAUI application on an Android emulator with a specific name. + /// + /// The MAUI project resource builder. + /// The name of the Android emulator resource. + /// A reference to the . + /// + /// This method creates a new Android emulator platform resource that will run the MAUI application + /// targeting the Android platform using dotnet run. The resource does not auto-start + /// and must be explicitly started from the dashboard by clicking the start button. + /// + /// Multiple Android emulator resources can be added to the same MAUI project if needed, each with + /// a unique name. + /// + /// + /// This will run the application on an Android emulator. Make sure you have created an Android + /// Virtual Device (AVD) using Android Studio or avdmanager. The emulator should be running + /// and visible via adb devices. + /// + /// + /// To target a specific emulator, use the overload that accepts an emulatorId parameter. + /// + /// + /// + /// Add multiple Android emulators to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// var emulator1 = maui.AddAndroidEmulator("android-emulator-1"); + /// var emulator2 = maui.AddAndroidEmulator("android-emulator-2"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddAndroidEmulator( + this IResourceBuilder builder, + [ResourceName] string name) + { + return builder.AddAndroidEmulator(name, emulatorId: null); + } + + /// + /// Adds an Android emulator resource to run the MAUI application on an Android emulator with a specific name. + /// + /// The MAUI project resource builder. + /// The name of the Android emulator resource. + /// Optional emulator ID to target a specific Android emulator. If not specified, uses the currently running emulator or starts the default emulator. + /// A reference to the . + /// + /// This method creates a new Android emulator platform resource that will run the MAUI application + /// targeting the Android platform using dotnet run. The resource does not auto-start + /// and must be explicitly started from the dashboard by clicking the start button. + /// + /// Multiple Android emulator resources can be added to the same MAUI project if needed, each with + /// a unique name. + /// + /// + /// This will run the application on an Android emulator. Make sure you have created an Android + /// Virtual Device (AVD) using Android Studio or avdmanager. The emulator should be running + /// and visible via adb devices. + /// + /// + /// To target a specific emulator, provide the emulator ID (e.g., "Pixel_5_API_33" or "emulator-5554"). + /// Use adb devices to list available emulator IDs. + /// + /// + /// + /// Add multiple Android emulators to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// + /// // Default emulator + /// var emulator1 = maui.AddAndroidEmulator("android-emulator-default"); + /// + /// // Specific Pixel 5 emulator + /// var emulator2 = maui.AddAndroidEmulator("android-emulator-pixel5", "Pixel_5_API_33"); + /// + /// // Specific emulator by serial + /// var emulator3 = maui.AddAndroidEmulator("android-emulator-5554", "emulator-5554"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddAndroidEmulator( + this IResourceBuilder builder, + [ResourceName] string name, + string? emulatorId = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + // Get the absolute project path and working directory + var (projectPath, workingDirectory) = MauiPlatformHelper.GetProjectPaths(builder); + + var androidEmulatorResource = new MauiAndroidEmulatorResource(name, builder.Resource); + + var resourceBuilder = builder.ApplicationBuilder.AddResource(androidEmulatorResource) + .WithAnnotation(new MauiProjectMetadata(projectPath)) + .WithAnnotation(new MauiAndroidEnvironmentAnnotation()) // Enable environment variable support via targets file + .WithAnnotation(new ExecutableAnnotation + { + Command = "dotnet", + WorkingDirectory = workingDirectory + }); + + // Build additional arguments for emulator ID if specified + // For Android, we need to use the MSBuild property AdbTarget to specify which device/emulator to target + // See: https://learn.microsoft.com/dotnet/maui/whats-new/dotnet-10#dotnet-run-support + // Valid formats: + // -p:AdbTarget=-e (run on only running emulator) + // -p:AdbTarget=-s emulator-5554 (run on specific emulator/device by serial) + var additionalArgs = new List(); + if (!string.IsNullOrWhiteSpace(emulatorId)) + { + // Specific emulator - use -s prefix (no quotes around the value) + additionalArgs.Add($"-p:AdbTarget=-s {emulatorId}"); + } + else + { + // No specific emulator ID - use -e to target the only running emulator + additionalArgs.Add("-p:AdbTarget=-e"); + } + + // Configure the platform resource with common settings + // Android runs on Windows, macOS, and Linux - check for Android SDK/tooling availability is complex + // For now, allow on all platforms and let dotnet run fail gracefully if Android SDK is not available + MauiPlatformHelper.ConfigurePlatformResource( + resourceBuilder, + projectPath, + "android", + "Android", + "net10.0-android", + () => true, // Allow on all platforms, validation happens at dotnet run time + "PhoneTablet", + additionalArgs.ToArray()); + + return resourceBuilder; + } +} diff --git a/src/Aspire.Hosting.Maui/MauiHostingExtensions.cs b/src/Aspire.Hosting.Maui/MauiHostingExtensions.cs new file mode 100644 index 00000000000..0626a416f57 --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiHostingExtensions.cs @@ -0,0 +1,19 @@ +// 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.Lifecycle; +using Aspire.Hosting.Maui.Utilities; + +namespace Aspire.Hosting.Maui; + +internal static class MauiHostingExtensions +{ + /// + /// Registers MAUI-specific lifecycle hooks and services. + /// + public static void AddMauiHostingServices(this IDistributedApplicationBuilder builder) + { + // Register the Android environment variable eventing subscriber + builder.Services.TryAddEventingSubscriber(); + } +} diff --git a/src/Aspire.Hosting.Maui/MauiMacCatalystExtensions.cs b/src/Aspire.Hosting.Maui/MauiMacCatalystExtensions.cs index 2b121d5e2f2..5b0cec204ff 100644 --- a/src/Aspire.Hosting.Maui/MauiMacCatalystExtensions.cs +++ b/src/Aspire.Hosting.Maui/MauiMacCatalystExtensions.cs @@ -55,8 +55,7 @@ public static IResourceBuilder AddMacCatalystDe /// 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. + /// You can add multiple Mac Catalyst device resources to a MAUI project by calling this method multiple times with different names. /// /// /// @@ -78,19 +77,6 @@ public static IResourceBuilder AddMacCatalystDe 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); diff --git a/src/Aspire.Hosting.Maui/MauiMacCatalystPlatformResource.cs b/src/Aspire.Hosting.Maui/MauiMacCatalystPlatformResource.cs index 7442be410bf..d345eac826d 100644 --- a/src/Aspire.Hosting.Maui/MauiMacCatalystPlatformResource.cs +++ b/src/Aspire.Hosting.Maui/MauiMacCatalystPlatformResource.cs @@ -20,7 +20,7 @@ namespace Aspire.Hosting.Maui; /// /// public class MauiMacCatalystPlatformResource(string name, MauiProjectResource parent) - : ProjectResource(name), IResourceWithParent, IMauiPlatformResource + : ProjectResource(name), IMauiPlatformResource { /// /// Gets the parent MAUI project resource. diff --git a/src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs b/src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs new file mode 100644 index 00000000000..73d212299a1 --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs @@ -0,0 +1,166 @@ +// 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.DevTunnels; +using Aspire.Hosting.Maui; +using Aspire.Hosting.Maui.Annotations; +using Aspire.Hosting.Maui.Otlp; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for configuring OpenTelemetry endpoints for MAUI platform resources. +/// +public static class MauiOtlpExtensions +{ + /// + /// Configures the MAUI platform resource to send OpenTelemetry data through an automatically created dev tunnel. + /// This is the easiest option for most scenarios, as it handles tunnel creation, configuration, and endpoint + /// injection automatically. + /// + /// The MAUI platform resource type. + /// The resource builder. + /// The resource builder. + /// + /// + /// This method creates a dev tunnel automatically and configures the MAUI platform resource to route + /// OTLP traffic through it. This is the recommended approach for most scenarios as it requires minimal + /// configuration and works reliably across all mobile platforms. + /// + /// + /// Prerequisites: + /// + /// Aspire.Hosting.DevTunnels package must be referenced + /// Dev tunnel CLI must be installed (automatic prompt if missing) + /// User must be logged in to dev tunnel service (automatic prompt if needed) + /// + /// + /// + /// + /// Configure a MAUI Android device to automatically use a dev tunnel for telemetry: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// maui.AddAndroidDevice() + /// .WithOtlpDevTunnel(); // That's it - everything is configured automatically! + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder WithOtlpDevTunnel( + this IResourceBuilder builder) + where T : IMauiPlatformResource, IResourceWithEnvironment + { + ArgumentNullException.ThrowIfNull(builder); + + // Get shared state - only create stub + tunnel once per app + var platformResource = builder.Resource; + var parentBuilder = builder.ApplicationBuilder.CreateResourceBuilder(platformResource.Parent); + var configuration = builder.ApplicationBuilder.Configuration; + + // Check if we already created the stub + tunnel for this MAUI project + if (!parentBuilder.Resource.TryGetLastAnnotation(out var tunnelConfig)) + { + // First time - create stub and dev tunnel + tunnelConfig = CreateOtlpDevTunnelInfrastructure(parentBuilder, configuration); + parentBuilder.Resource.Annotations.Add(tunnelConfig); + } + + // Now apply the configuration to this specific platform + ApplyOtlpConfigurationToPlatform(builder, tunnelConfig); + + return builder; + } + + /// + /// Creates the OTLP dev tunnel infrastructure (stub resource + dev tunnel). + /// This is only created once per MAUI project and shared across all platforms. + /// + private static OtlpDevTunnelConfigurationAnnotation CreateOtlpDevTunnelInfrastructure( + IResourceBuilder parentBuilder, + Microsoft.Extensions.Configuration.IConfiguration configuration) + { + var appBuilder = parentBuilder.ApplicationBuilder; + + // Resolve OTLP scheme and port from configuration + var (otlpScheme, otlpPort) = OtlpEndpointResolver.Resolve(configuration); + + // Create names for the tunnel infrastructure + // Use a short random suffix to ensure uniqueness (similar to DCP naming strategy) + // The dev tunnel port resource name will be: {parent resource name}-{random}-otlp + var randomSuffix = Guid.NewGuid().ToString("N")[..8]; + var tunnelName = parentBuilder.Resource.Name; + var stubName = $"t{randomSuffix}"; // Prefix with 't' to ensure valid resource name + + // Create OtlpLoopbackResource - a synthetic IResourceWithEndpoints for service discovery + var stubResource = new OtlpLoopbackResource(stubName, otlpPort, otlpScheme); + + var stubBuilder = appBuilder.AddResource(stubResource) + .ExcludeFromManifest(); + + // Hide the stub from the dashboard UI + stubBuilder.WithInitialState(new CustomResourceSnapshot + { + ResourceType = "OtlpStub", + Properties = [], + IsHidden = true + }); + + // Create dev tunnel with anonymous access for OTLP + var devTunnel = appBuilder.AddDevTunnel(tunnelName) + .WithAnonymousAccess() + .WithReference(stubBuilder, new DevTunnelPortOptions { Protocol = "https" }); + + // Manually allocate the stub endpoint so dev tunnel can start + // Dev tunnels wait for ResourceEndpointsAllocatedEvent before starting + appBuilder.Eventing.Subscribe((evt, ct) => + { + var endpoint = stubResource.Annotations.OfType().FirstOrDefault(); + if (endpoint is not null && endpoint.AllocatedEndpoint is null) + { + endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", otlpPort); + return appBuilder.Eventing.PublishAsync(new ResourceEndpointsAllocatedEvent(stubResource, evt.Services), ct); + } + return Task.CompletedTask; + }); + + return new OtlpDevTunnelConfigurationAnnotation(stubResource, stubBuilder, devTunnel); + } + + /// + /// Applies OTLP configuration to a specific MAUI platform resource. + /// Uses service discovery through WithReference to get the tunneled endpoint, then overrides OTEL_EXPORTER_OTLP_ENDPOINT. + /// + private static void ApplyOtlpConfigurationToPlatform( + IResourceBuilder platformBuilder, + OtlpDevTunnelConfigurationAnnotation tunnelConfig) + where T : IMauiPlatformResource, IResourceWithEnvironment + { + // Use WithReference to inject service discovery variables for the stub through the dev tunnel + // This adds SERVICES____OTLP__0=https://tunnel-url which we'll use and then clean up + platformBuilder.WithReference(tunnelConfig.OtlpStubBuilder, tunnelConfig.DevTunnel); + + // Override OTEL_EXPORTER_OTLP_ENDPOINT with the tunneled URL and clean up extra variables + platformBuilder.WithEnvironment(context => + { + // Read the service discovery variable that WithReference just added + // Format: services__{resourcename}__otlp__0 (lowercase) + var serviceDiscoveryKey = $"services__{tunnelConfig.OtlpStub.Name}__otlp__0"; + if (context.EnvironmentVariables.TryGetValue(serviceDiscoveryKey, out var tunnelUrl)) + { + // Override OTEL_EXPORTER_OTLP_ENDPOINT with the tunnel URL + context.EnvironmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = tunnelUrl; + + // Remove the service discovery variables since we're using direct OTLP configuration + context.EnvironmentVariables.Remove(serviceDiscoveryKey); + + // Also remove the {RESOURCENAME}_{ENDPOINTNAME} format variable (e.g., MAUIAPP-OTLP_OTLP) + // The resource name keeps its case/dashes, endpoint name is uppercased + var directEndpointKey = $"{tunnelConfig.OtlpStub.Name.ToUpperInvariant()}_OTLP"; + context.EnvironmentVariables.Remove(directEndpointKey); + } + }); + } +} diff --git a/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs b/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs index 65ccbc0b6d6..efab37a4e02 100644 --- a/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs +++ b/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs @@ -76,8 +76,10 @@ internal static void ConfigurePlatformResource( } }); + // Configure OTLP exporter with custom endpoint support + ConfigureOtlpExporter(resourceBuilder); + resourceBuilder - .WithOtlpExporter() .WithIconName(iconName) .WithExplicitStart(); @@ -109,4 +111,58 @@ internal static void ConfigurePlatformResource( appBuilder.Services.TryAddEventingSubscriber(); } } + + /// + /// Configures OTLP exporter with support for Android-specific template replacement. + /// + /// + /// + /// For Android resources, we replace DCP template placeholders ({{...}}) with actual values + /// because Android environment files are generated before DCP's template replacement happens. + /// DCP normally replaces these templates when writing to the actual running process, but we + /// need the resolved values earlier for the MSBuild targets file. + /// + /// + /// This matches the pattern used by other non-DCP-launched resources like Docker Compose and + /// Azure App Service, which also manually set OTEL values instead of relying on DCP templates. + /// + /// + private static void ConfigureOtlpExporter(IResourceBuilder resourceBuilder) where T : ProjectResource + { + // Call the standard WithOtlpExporter which sets up all other OTLP configuration + resourceBuilder.WithOtlpExporter(); + + // For Android resources, replace DCP template placeholders that won't be resolved in time + var resource = resourceBuilder.Resource; + var instanceId = Guid.NewGuid().ToString(); // Generate unique instance ID + + resourceBuilder.WithEnvironment(async context => + { + await Task.CompletedTask.ConfigureAwait(false); + + // Replace OTEL_SERVICE_NAME template with actual resource name + // DCP would normally set this to the resource name, so we do the same + if (context.EnvironmentVariables.TryGetValue("OTEL_SERVICE_NAME", out var serviceName)) + { + if (serviceName is string serviceNameStr && + serviceNameStr.Contains("{{", StringComparison.Ordinal) && + serviceNameStr.Contains("}}", StringComparison.Ordinal)) + { + context.EnvironmentVariables["OTEL_SERVICE_NAME"] = resource.Name; + } + } + + // Replace OTEL_RESOURCE_ATTRIBUTES template with unique instance ID + // DCP would normally set this to a generated suffix, so we use a GUID + if (context.EnvironmentVariables.TryGetValue("OTEL_RESOURCE_ATTRIBUTES", out var resourceAttrs)) + { + if (resourceAttrs is string resourceAttrsStr && + resourceAttrsStr.Contains("{{", StringComparison.Ordinal) && + resourceAttrsStr.Contains("}}", StringComparison.Ordinal)) + { + context.EnvironmentVariables["OTEL_RESOURCE_ATTRIBUTES"] = $"service.instance.id={instanceId}"; + } + } + }); + } } diff --git a/src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs b/src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs index f2449563554..36f974bcc53 100644 --- a/src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs +++ b/src/Aspire.Hosting.Maui/MauiProjectResourceExtensions.cs @@ -51,6 +51,10 @@ public static IResourceBuilder AddMauiProject( ArgumentException.ThrowIfNullOrEmpty(name); ArgumentException.ThrowIfNullOrEmpty(projectPath); + // Register MAUI-specific hosting services (lifecycle hooks, etc.) + // This is safe to call multiple times - it only registers once + builder.AddMauiHostingServices(); + // Create the MAUI project resource and configuration // Do not register the logical grouping resource with AddResource so it stays invisible in the dashboard // Only MAUI project targets added through their extension methods will show up diff --git a/src/Aspire.Hosting.Maui/MauiWindowsExtensions.cs b/src/Aspire.Hosting.Maui/MauiWindowsExtensions.cs index 5bf98fbc8eb..db5450e7821 100644 --- a/src/Aspire.Hosting.Maui/MauiWindowsExtensions.cs +++ b/src/Aspire.Hosting.Maui/MauiWindowsExtensions.cs @@ -55,8 +55,7 @@ public static IResourceBuilder AddWindowsDevice( /// targeting the Windows platform using dotnet run. The resource does not auto-start /// and must be explicitly started from the dashboard by clicking the start button. /// - /// Multiple Windows device resources can be added to the same MAUI project if needed, each with - /// a unique name. + /// You can add multiple Windows device resources to a MAUI project by calling this method multiple times with different names. /// /// /// @@ -78,19 +77,6 @@ public static IResourceBuilder AddWindowsDevice( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrWhiteSpace(name); - // Check if a Windows device with this name already exists in the application model - var existingWindowsDevice = builder.ApplicationBuilder.Resources - .OfType() - .FirstOrDefault(r => r.Parent == builder.Resource && - string.Equals(r.Name, name, StringComparisons.ResourceName)); - - if (existingWindowsDevice is not null) - { - throw new DistributedApplicationException( - $"Windows device with name '{name}' already exists on MAUI project '{builder.Resource.Name}'. " + - $"Provide a unique name parameter when calling AddWindowsDevice() to add multiple Windows devices."); - } - // Get the absolute project path and working directory var (projectPath, workingDirectory) = MauiPlatformHelper.GetProjectPaths(builder); diff --git a/src/Aspire.Hosting.Maui/MauiWindowsPlatformResource.cs b/src/Aspire.Hosting.Maui/MauiWindowsPlatformResource.cs index f5ab9e86ed0..cd1015f465a 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, IMauiPlatformResource + : ProjectResource(name), IMauiPlatformResource { /// /// Gets the parent MAUI project resource. diff --git a/src/Aspire.Hosting.Maui/Otlp/OtlpEndpointResolver.cs b/src/Aspire.Hosting.Maui/Otlp/OtlpEndpointResolver.cs new file mode 100644 index 00000000000..b8895088839 --- /dev/null +++ b/src/Aspire.Hosting.Maui/Otlp/OtlpEndpointResolver.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; + +namespace Aspire.Hosting.Maui.Otlp; + +/// +/// Resolves OTLP endpoint configuration (scheme and port) from standard OTLP environment variables. +/// +internal static class OtlpEndpointResolver +{ + /// + /// Resolves the OTLP endpoint scheme and port from configuration. + /// + /// The configuration to read from. + /// A tuple of (scheme, port) for the OTLP endpoint. + /// + /// Priority order: + /// 1. Unified endpoint (OTEL_EXPORTER_OTLP_ENDPOINT) + /// 2. HTTP-specific endpoint (ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL) + /// 3. gRPC-specific endpoint (ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL) + /// 4. Default: http://localhost:18889 (gRPC default) + /// + public static (string Scheme, int Port) Resolve(IConfiguration configuration) + { + // Try unified endpoint first + var unifiedEndpoint = configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; + if (!string.IsNullOrWhiteSpace(unifiedEndpoint) && Uri.TryCreate(unifiedEndpoint, UriKind.Absolute, out var unifiedUri)) + { + return (unifiedUri.Scheme, unifiedUri.Port); + } + + // Try HTTP-specific endpoint + var httpEndpoint = configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"]; + if (!string.IsNullOrWhiteSpace(httpEndpoint) && Uri.TryCreate(httpEndpoint, UriKind.Absolute, out var httpUri)) + { + return (httpUri.Scheme, httpUri.Port); + } + + // Try gRPC-specific endpoint (most common for Aspire dashboard) + var grpcEndpoint = configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]; + if (!string.IsNullOrWhiteSpace(grpcEndpoint) && Uri.TryCreate(grpcEndpoint, UriKind.Absolute, out var grpcUri)) + { + return (grpcUri.Scheme, grpcUri.Port); + } + + // Default to gRPC endpoint on port 18889 (Aspire dashboard default) + return ("http", 18889); + } +} diff --git a/src/Aspire.Hosting.Maui/Otlp/OtlpLoopbackResource.cs b/src/Aspire.Hosting.Maui/Otlp/OtlpLoopbackResource.cs new file mode 100644 index 00000000000..9944f27fa0e --- /dev/null +++ b/src/Aspire.Hosting.Maui/Otlp/OtlpLoopbackResource.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Maui.Otlp; + +/// +/// Represents a synthetic OTLP resource that acts as a loopback endpoint for service discovery. +/// +/// +/// This resource is used internally for MAUI OTLP configurations (especially with dev tunnels). +/// It creates an endpoint annotation that can be referenced by MAUI platform resources through service discovery. +/// The endpoint always points to localhost at the specified port and scheme, but can be tunneled externally. +/// +internal sealed class OtlpLoopbackResource : Resource, IResourceWithEndpoints +{ + /// + /// Initializes a new instance of . + /// + /// The name of the resource. + /// The port number for the OTLP endpoint. + /// The URI scheme (http or https). + public OtlpLoopbackResource(string name, int port, string scheme) : base(name) + { + // Create an endpoint annotation for service discovery + // This endpoint represents the OTLP collector endpoint that MAUI apps will connect to + Annotations.Add(new EndpointAnnotation( + ProtocolType.Tcp, + uriScheme: scheme, + name: "otlp", + port: port, + isProxied: false) + { + // TargetHost = localhost means this resource is running on the local machine + // When tunneled through dev tunnels, the service discovery will rewrite this to the tunnel URL + TargetHost = "localhost" + }); + } +} diff --git a/src/Aspire.Hosting.Maui/README.md b/src/Aspire.Hosting.Maui/README.md new file mode 100644 index 00000000000..00942f0b340 --- /dev/null +++ b/src/Aspire.Hosting.Maui/README.md @@ -0,0 +1,124 @@ +# Aspire.Hosting.Maui + +This library provides support for running .NET MAUI applications within an Aspire application model. It enables local development and debugging of MAUI apps alongside other services in your distributed application. + +## Getting Started + +### Adding a MAUI Application + +Add a MAUI project to your Aspire app host: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var mauiApp = builder.AddMauiProject("mauiapp", "../MauiApp/MauiApp.csproj"); +``` + +### Adding Platform Targets + +Add specific platform targets for your MAUI application: + +```csharp +// Windows +mauiApp.AddWindowsDevice(); + +// macOS Catalyst +mauiApp.AddMacCatalystDevice(); + +// Android Device +mauiApp.AddAndroidDevice(); + +// Android Emulator +mauiApp.AddAndroidEmulator(); +``` + +You can optionally specify custom names: + +```csharp +mauiApp.AddWindowsDevice("my-windows-app"); +mauiApp.AddAndroidEmulator("pixel-7-emulator"); +``` + +## OpenTelemetry Connectivity for Mobile Platforms + +Mobile devices, Android emulators, and iOS simulators cannot reach `localhost` where the Aspire dashboard's OTLP endpoint typically runs. This library provides a simple way to configure OpenTelemetry connectivity using dev tunnels. + +### Using Dev Tunnel + +Automatically create and configure a dev tunnel for the dashboard's OTLP endpoint, this is needed when running a .NET MAUI app on an Android emulator/device or iOS Simulator/device. By default, Aspire will send the OpenTelemetry data back to localhost, however localhost is different when running on a emulator/Simulator/device. + +You should not need to use this when running on Windows or Mac Catalyst. + +```csharp +// Automatically creates a dev tunnel for OTLP +mauiApp.AddAndroidEmulator() + .WithOtlpDevTunnel(); +``` + +When `WithOtlpDevTunnel()` is not added, things will still work, however tracing, metrics and telemetry data will not be complete. + +This method automatically: +- Resolves the dashboard's OTLP endpoint from configuration +- Creates a dev tunnel for it +- Configures the MAUI platform to use the tunneled endpoint +- Handles all service discovery and environment variable configuration + +**Requirements:** +- Aspire.Hosting.DevTunnels package must be referenced +- Dev tunnel CLI must be installed (automatic prompt if missing) +- User must be logged in to dev tunnel service (automatic prompt if needed) + +### Environment Variables Set + +When you configure OTLP with dev tunnel, the following environment variables are automatically set: + +- `OTEL_EXPORTER_OTLP_ENDPOINT`: The dev tunnel URL for the OTLP endpoint +- `OTEL_EXPORTER_OTLP_PROTOCOL`: Set to `grpc` (standard Aspire configuration) +- `OTEL_SERVICE_NAME`: The resource name +- `OTEL_RESOURCE_ATTRIBUTES`: Service instance ID + +## Example: Complete Aspire App with MAUI + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +// Add backend services +var apiService = builder.AddProject("apiservice"); + +// Create a dev tunnel for the API service +var apiTunnel = builder.AddDevTunnel("api-tunnel") + .WithAnonymousAccess() + .WithReference(apiService.GetEndpoint("https")); + +// Add MAUI app with multiple platform targets +var mauiApp = builder.AddMauiProject("mauiapp", "../MauiApp/MauiApp.csproj"); + +// Windows - can access localhost directly +mauiApp.AddWindowsDevice() + .WithReference(apiService); + +// Android Emulator - needs dev tunnels for both API and OTLP +mauiApp.AddAndroidEmulator() + .WithOtlpDevTunnel() // For telemetry + .WithReference(apiService, apiTunnel); // For API calls + +// Android Device - same configuration +mauiApp.AddAndroidDevice() + .WithOtlpDevTunnel() + .WithReference(apiService, apiTunnel); + +builder.Build().Run(); +``` + +## Requirements + +- .NET 10.0 or later +- MAUI workload must be installed: `dotnet workload install maui` +- Platform-specific SDKs: + - Windows: Windows SDK 10.0.19041.0 or later + - macOS: Xcode with command-line tools + - Android: Android SDK via Visual Studio or Android Studio + +## Feedback & Issues + +Please file issues at https://github.com/dotnet/aspire/issues diff --git a/src/Aspire.Hosting.Maui/Utilities/MauiAndroidEnvironmentAnnotation.cs b/src/Aspire.Hosting.Maui/Utilities/MauiAndroidEnvironmentAnnotation.cs new file mode 100644 index 00000000000..d13637a95dd --- /dev/null +++ b/src/Aspire.Hosting.Maui/Utilities/MauiAndroidEnvironmentAnnotation.cs @@ -0,0 +1,122 @@ +// 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.Lifecycle; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Maui.Utilities; + +/// +/// Annotation that enables Android environment variable support via MSBuild targets file. +/// +/// +/// Android MAUI applications cannot receive environment variables directly through the process environment +/// when launched via `dotnet run`. Instead, environment variables must be passed through MSBuild properties. +/// This annotation marks a resource for processing by . +/// +internal sealed class MauiAndroidEnvironmentAnnotation : IResourceAnnotation +{ + // Marker annotation - actual logic is in the eventing subscriber +} + +/// +/// Internal annotation to track that the callback for Android environment variables has been registered. +/// +/// +/// This is a marker annotation used to prevent duplicate callback registration. +/// The actual file path is managed within the callback closure and doesn't need to be stored here. +/// +internal sealed class MauiAndroidEnvironmentProcessedAnnotation : IResourceAnnotation +{ +} + +/// +/// Event subscriber that processes annotations. +/// +internal sealed class MauiAndroidEnvironmentSubscriber( + DistributedApplicationExecutionContext executionContext, + ResourceLoggerService loggerService, + ResourceNotificationService notificationService) : IDistributedApplicationEventingSubscriber +{ + public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext execContext, CancellationToken cancellationToken) + { + eventing.Subscribe(OnBeforeResourceStartedAsync); + return Task.CompletedTask; + } + + private async Task OnBeforeResourceStartedAsync(BeforeResourceStartedEvent @event, CancellationToken cancellationToken) + { + var resource = @event.Resource; + + // Only process Android resources with the environment annotation + if (resource is not (MauiAndroidDeviceResource or MauiAndroidEmulatorResource)) + { + return; + } + + if (!resource.TryGetLastAnnotation(out _)) + { + return; + } + + var logger = loggerService.GetLogger(resource); + + // Check if we've already added the callback + if (resource.TryGetLastAnnotation(out _)) + { + // Already processed - callback is already registered + return; + } + + try + { + // Add a CommandLineArgsCallback that will generate the targets file + // This runs AFTER all environment callbacks have been processed + // The callback itself ensures idempotency by only generating the file once + string? generatedFilePath = null; + + resource.Annotations.Add(new CommandLineArgsCallbackAnnotation(async context => + { + // Only generate the file once, even if this callback is invoked multiple times + if (generatedFilePath is null) + { + generatedFilePath = await MauiEnvironmentHelper.CreateAndroidEnvironmentTargetsFileAsync( + resource, + executionContext, + logger, + cancellationToken + ).ConfigureAwait(false); + + if (generatedFilePath is not null) + { + logger.LogInformation("Generated environment targets file for Android: {Path}", generatedFilePath); + } + } + + if (generatedFilePath is not null) + { + // Add the targets file as an MSBuild property via command-line argument + var commandLineArg = $"-p:CustomAfterMicrosoftCommonTargets={generatedFilePath}"; + context.Args.Add(commandLineArg); + } + })); + + // Mark as processed to avoid duplicate callbacks + resource.Annotations.Add(new MauiAndroidEnvironmentProcessedAnnotation()); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to configure Android environment variables"); + + // Report the error through the notification service + await notificationService.PublishUpdateAsync(resource, s => s with + { + State = new ResourceStateSnapshot("Failed to configure environment", KnownResourceStateStyles.Error) + }).ConfigureAwait(false); + + throw; + } + } +} diff --git a/src/Aspire.Hosting.Maui/Utilities/MauiEnvironmentHelper.cs b/src/Aspire.Hosting.Maui/Utilities/MauiEnvironmentHelper.cs new file mode 100644 index 00000000000..ae2930fed69 --- /dev/null +++ b/src/Aspire.Hosting.Maui/Utilities/MauiEnvironmentHelper.cs @@ -0,0 +1,214 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text; +using System.Xml.Linq; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Maui.Utilities; + +/// +/// Provides utilities for handling environment variables in MAUI projects. +/// +/// +/// Some MAUI platforms (like Android and iOS) require environment variables to be passed via +/// an intermediate MSBuild targets file rather than directly through the process environment. +/// This class provides reusable infrastructure for generating these targets files. +/// +internal static class MauiEnvironmentHelper +{ + /// + /// Creates an MSBuild targets file for Android that sets environment variables. + /// + /// The resource to collect environment variables from. + /// The execution context. + /// Logger for diagnostic output. + /// Cancellation token. + /// The path to the generated targets file, or null if no environment variables are present. + public static async Task CreateAndroidEnvironmentTargetsFileAsync( + IResource resource, + DistributedApplicationExecutionContext executionContext, + ILogger logger, + CancellationToken cancellationToken) + { + var environmentVariables = new Dictionary(StringComparer.OrdinalIgnoreCase); + var encodedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Collect all environment variables from the resource + await resource.ProcessEnvironmentVariableValuesAsync( + executionContext, + (key, unprocessed, processed, ex) => + { + if (ex is not null || string.IsNullOrEmpty(key) || processed is not string value) + { + return; + } + + // Android environment variables must be uppercase to be properly read by the runtime + var normalizedKey = key.ToUpperInvariant(); + var encodedValue = EncodeSemicolons(value, out var wasEncoded); + + environmentVariables[normalizedKey] = encodedValue; + + if (wasEncoded) + { + encodedKeys.Add(normalizedKey); + } + }, + logger, + cancellationToken: cancellationToken + ).ConfigureAwait(false); + + // If no environment variables, return null + if (environmentVariables.Count == 0) + { + return null; + } + + // Create a temporary targets file + var tempDirectory = Path.Combine(Path.GetTempPath(), "aspire", "maui", "android-env"); + Directory.CreateDirectory(tempDirectory); + + // Prune old targets files + PruneOldTargets(tempDirectory, logger); + + var sanitizedName = SanitizeFileName(resource.Name + "-android"); + var uniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + var targetsFilePath = Path.Combine(tempDirectory, $"{sanitizedName}-{uniqueId}.targets"); + + // Generate the targets file content + var targetsContent = GenerateAndroidTargetsFileContent(environmentVariables); + + // Write the file + await File.WriteAllTextAsync(targetsFilePath, targetsContent, Encoding.UTF8, cancellationToken).ConfigureAwait(false); + + return targetsFilePath; + } + + /// + /// Generates the content of an MSBuild targets file for Android environment variables. + /// + private static string GenerateAndroidTargetsFileContent(Dictionary environmentVariables) + { + var projectElement = new XElement("Project"); + + // Import the standard Custom.After.Microsoft.Common.targets if it exists + projectElement.Add(new XElement( + "Import", + new XAttribute("Project", "$(MSBuildExtensionsPath)/v$(MSBuildToolsVersion)/Custom.After.Microsoft.Common.targets"), + new XAttribute("Condition", "Exists('$(MSBuildExtensionsPath)/v$(MSBuildToolsVersion)/Custom.After.Microsoft.Common.targets')") + )); + + // Create an ItemGroup for AndroidEnvironment files to be generated + var itemGroup = new XElement("ItemGroup"); + foreach (var (key, value) in environmentVariables.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)) + { + itemGroup.Add(new XElement("_GeneratedAndroidEnvironment", new XAttribute("Include", $"{key}={value}"))); + } + projectElement.Add(itemGroup); + + // Add target to generate environment file(s) + var targetElement = new XElement( + "Target", + new XAttribute("Name", "AspireGenerateAndroidEnvironmentFiles"), + new XAttribute("BeforeTargets", "_GenerateEnvironmentFiles"), + new XAttribute("Condition", "'@(_GeneratedAndroidEnvironment)' != ''") + ); + + // Write environment variables to a temporary file in IntermediateOutputPath + targetElement.Add(new XElement( + "WriteLinesToFile", + new XAttribute("File", "$(IntermediateOutputPath)__aspire_environment__.txt"), + new XAttribute("Lines", "@(_GeneratedAndroidEnvironment)"), + new XAttribute("Overwrite", "True"), + new XAttribute("WriteOnlyWhenDifferent", "True") + )); + + // Add the file to AndroidEnvironment items + targetElement.Add(new XElement( + "ItemGroup", + new XElement("AndroidEnvironment", new XAttribute("Include", "$(IntermediateOutputPath)__aspire_environment__.txt")) + )); + + // Add the file to FileWrites for clean + targetElement.Add(new XElement( + "ItemGroup", + new XElement("FileWrites", new XAttribute("Include", "$(IntermediateOutputPath)__aspire_environment__.txt")) + )); + + // Force the GeneratePackageManagerJava target to re-run by deleting its stamp file + targetElement.Add(new XElement( + "Delete", + new XAttribute("Files", "$(_AndroidStampDirectory)_GeneratePackageManagerJava.stamp") + )); + + projectElement.Add(targetElement); + + var document = new XDocument(new XDeclaration("1.0", "utf-8", "yes"), projectElement); + + using var stringWriter = new StringWriter(); + document.Save(stringWriter); + return stringWriter.ToString(); + } + + private static void PruneOldTargets(string directory, ILogger logger) + { + var expiration = DateTimeOffset.UtcNow - TimeSpan.FromDays(1); + var deletedFiles = new List(); + + foreach (var file in Directory.EnumerateFiles(directory, "*.targets", SearchOption.TopDirectoryOnly)) + { + try + { + var info = new FileInfo(file); + if (info.Exists && info.LastWriteTimeUtc < expiration) + { + info.Delete(); + deletedFiles.Add(info.Name); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to prune stale Android environment targets file '{TargetsFile}'.", file); + } + } + + if (deletedFiles.Count > 0) + { + logger.LogDebug("Pruned {Count} stale Android environment targets file(s): {Files}", deletedFiles.Count, string.Join(", ", deletedFiles)); + } + } + + private static string SanitizeFileName(string name) + { + var invalidCharacters = Path.GetInvalidFileNameChars(); + if (name.IndexOfAny(invalidCharacters) < 0) + { + return name; + } + + var chars = name.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + { + if (Array.IndexOf(invalidCharacters, chars[i]) >= 0) + { + chars[i] = '_'; + } + } + + return new string(chars); + } + + private static string EncodeSemicolons(string value, out bool wasEncoded) + { + wasEncoded = value.Contains(';', StringComparison.Ordinal); + if (!wasEncoded) + { + return value; + } + + return value.Replace(";", "%3B", StringComparison.Ordinal); + } +} diff --git a/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs b/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs index 1a2557f9ce5..28b62e7a226 100644 --- a/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs +++ b/src/Aspire.Hosting.Maui/api/Aspire.Hosting.Maui.cs @@ -8,6 +8,21 @@ //------------------------------------------------------------------------------ namespace Aspire.Hosting { + public static partial class MauiAndroidExtensions + { + public static ApplicationModel.IResourceBuilder AddAndroidDevice(this ApplicationModel.IResourceBuilder builder) { throw null; } + + public static ApplicationModel.IResourceBuilder AddAndroidDevice(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } + + public static ApplicationModel.IResourceBuilder AddAndroidDevice(this ApplicationModel.IResourceBuilder builder, string name, string? deviceId = null) { throw null; } + + public static ApplicationModel.IResourceBuilder AddAndroidEmulator(this ApplicationModel.IResourceBuilder builder) { throw null; } + + public static ApplicationModel.IResourceBuilder AddAndroidEmulator(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } + + public static ApplicationModel.IResourceBuilder AddAndroidEmulator(this ApplicationModel.IResourceBuilder builder, string name, string? emulatorId = null) { throw null; } + } + public static partial class MauiMacCatalystExtensions { public static ApplicationModel.IResourceBuilder AddMacCatalystDevice(this ApplicationModel.IResourceBuilder builder, string name) { throw null; } @@ -15,6 +30,11 @@ public static partial class MauiMacCatalystExtensions public static ApplicationModel.IResourceBuilder AddMacCatalystDevice(this ApplicationModel.IResourceBuilder builder) { throw null; } } + public static partial class MauiOtlpExtensions + { + public static ApplicationModel.IResourceBuilder WithOtlpDevTunnel(this ApplicationModel.IResourceBuilder builder) where T : Maui.IMauiPlatformResource { throw null; } + } + public static partial class MauiProjectExtensions { public static ApplicationModel.IResourceBuilder AddMauiProject(this IDistributedApplicationBuilder builder, string name, string projectPath) { throw null; } @@ -30,7 +50,25 @@ public static partial class MauiWindowsExtensions namespace Aspire.Hosting.Maui { - public partial class MauiMacCatalystPlatformResource : ApplicationModel.ProjectResource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource + public partial interface IMauiPlatformResource : ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource + { + } + + public partial class MauiAndroidDeviceResource : ApplicationModel.ProjectResource, IMauiPlatformResource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource + { + public MauiAndroidDeviceResource(string name, MauiProjectResource parent) : base(default!) { } + + public MauiProjectResource Parent { get { throw null; } } + } + + public partial class MauiAndroidEmulatorResource : ApplicationModel.ProjectResource, IMauiPlatformResource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource + { + public MauiAndroidEmulatorResource(string name, MauiProjectResource parent) : base(default!) { } + + public MauiProjectResource Parent { get { throw null; } } + } + + public partial class MauiMacCatalystPlatformResource : ApplicationModel.ProjectResource, IMauiPlatformResource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource { public MauiMacCatalystPlatformResource(string name, MauiProjectResource parent) : base(default!) { } @@ -44,10 +82,10 @@ public MauiProjectResource(string name, string projectPath) : base(default!) { } public string ProjectPath { get { throw null; } } } - public partial class MauiWindowsPlatformResource : ApplicationModel.ProjectResource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource + public partial class MauiWindowsPlatformResource : ApplicationModel.ProjectResource, IMauiPlatformResource, ApplicationModel.IResourceWithParent, ApplicationModel.IResourceWithParent, ApplicationModel.IResource { public MauiWindowsPlatformResource(string name, MauiProjectResource parent) : base(default!) { } public MauiProjectResource Parent { get { throw null; } } } -} \ No newline at end of file +} diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiMacCatalystExtensionsTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiMacCatalystExtensionsTests.cs deleted file mode 100644 index bcdc661fc35..00000000000 --- a/tests/Aspire.Hosting.Maui.Tests/MauiMacCatalystExtensionsTests.cs +++ /dev/null @@ -1,326 +0,0 @@ -// 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); - } - } -} diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs new file mode 100644 index 00000000000..ff2423f77d9 --- /dev/null +++ b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs @@ -0,0 +1,552 @@ +// 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; +using Aspire.Hosting.Maui.Annotations; +using Aspire.Hosting.Maui.Utilities; +using Aspire.Hosting.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Tests; + +/// +/// Consolidated tests for all MAUI platform extensions (Windows, macOS Catalyst, Android Device, Android Emulator). +/// This reduces test duplication by using theory-based tests with platform-specific data. +/// +public class MauiPlatformExtensionsTests +{ + // Test data provider for platform configurations + public static TheoryData AllPlatforms => new() + { + new PlatformTestConfig("Windows", "Windows", "windows", "mauiapp-windows", "net10.0-windows10.0.19041.0", + (maui) => maui.AddWindowsDevice(), + (maui, name) => maui.AddWindowsDevice(name), + typeof(MauiWindowsPlatformResource)), + + new PlatformTestConfig("MacCatalyst", "Mac Catalyst", "maccatalyst", "mauiapp-maccatalyst", "net10.0-maccatalyst", + (maui) => maui.AddMacCatalystDevice(), + (maui, name) => maui.AddMacCatalystDevice(name), + typeof(MauiMacCatalystPlatformResource)), + + new PlatformTestConfig("AndroidDevice", "Android", "android", "mauiapp-android-device", "net10.0-android", + (maui) => maui.AddAndroidDevice(), + (maui, name) => maui.AddAndroidDevice(name), + typeof(MauiAndroidDeviceResource)), + + new PlatformTestConfig("AndroidEmulator", "Android", "android", "mauiapp-android-emulator", "net10.0-android", + (maui) => maui.AddAndroidEmulator(), + (maui, name) => maui.AddAndroidEmulator(name), + typeof(MauiAndroidEmulatorResource)) + }; + + [Theory] + [MemberData(nameof(AllPlatforms))] + public void AddPlatform_CreatesResourceWithCorrectName(PlatformTestConfig config) + { + // Arrange + var projectContent = CreateProjectContent(config.RequiredTfm); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var platform = config.AddPlatformWithDefaultName(maui); + + // Assert + Assert.NotNull(platform); + Assert.Equal(config.ExpectedDefaultName, platform.Resource.Name); + var resourceWithParent = Assert.IsAssignableFrom>(platform.Resource); + Assert.Same(maui.Resource, resourceWithParent.Parent); + Assert.IsType(config.ExpectedResourceType, platform.Resource); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Theory] + [MemberData(nameof(AllPlatforms))] + public void AddPlatform_WithCustomName_UsesProvidedName(PlatformTestConfig config) + { + // Arrange + var projectContent = CreateProjectContent(config.RequiredTfm); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + var customName = $"custom-{config.PlatformName}"; + + // Act + var platform = config.AddPlatformWithCustomName(maui, customName); + + // Assert + Assert.Equal(customName, platform.Resource.Name); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Theory] + [MemberData(nameof(AllPlatforms))] + public void AddPlatform_DuplicateName_ThrowsException(PlatformTestConfig config) + { + // Arrange + var projectContent = CreateProjectContent(config.RequiredTfm); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + var name = "duplicate-name"; + config.AddPlatformWithCustomName(maui, name); + + // Act & Assert + var exception = Assert.Throws(() => + config.AddPlatformWithCustomName(maui, name)); + Assert.Contains("already exists", exception.Message); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Theory] + [MemberData(nameof(AllPlatforms))] + public void AddPlatform_HasCorrectAnnotations(PlatformTestConfig config) + { + // Arrange + var projectContent = CreateProjectContent(config.RequiredTfm); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var platform = config.AddPlatformWithDefaultName(maui); + + // Assert + var resource = platform.Resource; + + // Check ExecutableAnnotation + var execAnnotation = resource.Annotations.OfType().FirstOrDefault(); + Assert.NotNull(execAnnotation); + Assert.Equal("dotnet", execAnnotation.Command); + Assert.NotNull(execAnnotation.WorkingDirectory); + + // Check MauiProjectMetadata + var metadata = resource.Annotations.OfType().FirstOrDefault(); + Assert.NotNull(metadata); + Assert.Equal(tempFile, metadata.ProjectPath); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Theory] + [MemberData(nameof(AllPlatforms))] + public void AddPlatform_ImplementsIMauiPlatformResource(PlatformTestConfig config) + { + // Arrange + var projectContent = CreateProjectContent(config.RequiredTfm); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var platform = config.AddPlatformWithDefaultName(maui); + + // Assert + Assert.IsAssignableFrom(platform.Resource); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Theory] + [MemberData(nameof(AllPlatforms))] + public void AddPlatform_MultiplePlatforms_AllCreated(PlatformTestConfig config) + { + // Arrange + var projectContent = CreateProjectContent(config.RequiredTfm); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var platform1 = config.AddPlatformWithCustomName(maui, $"{config.PlatformName}-1"); + var platform2 = config.AddPlatformWithCustomName(maui, $"{config.PlatformName}-2"); + + // Assert + Assert.NotEqual(platform1.Resource.Name, platform2.Resource.Name); + var parent1 = Assert.IsAssignableFrom>(platform1.Resource); + var parent2 = Assert.IsAssignableFrom>(platform2.Resource); + Assert.Same(parent1.Parent, parent2.Parent); + Assert.Same(maui.Resource, parent1.Parent); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Theory] + [MemberData(nameof(AllPlatforms))] + public async Task AddPlatform_WithoutRequiredTfm_ThrowsOnBeforeStartEvent(PlatformTestConfig config) + { + // Arrange - Create project without the required TFM + var projectContent = CreateProjectContentWithout(config.PlatformIdentifier); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act - Adding the platform should succeed (validation deferred to start) + var platform = config.AddPlatformWithDefaultName(maui); + Assert.NotNull(platform); + + // 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(platform.Resource, app.Services), CancellationToken.None); + }); + + Assert.Contains($"Unable to detect {config.DisplayName}", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public async Task AddAndroidEmulator_WithEnvironment_EnvironmentVariablesAreSet() + { + // Arrange + var projectContent = CreateProjectContent("net10.0-android"); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var androidEmulator = maui.AddAndroidEmulator() + .WithEnvironment("DEBUG_MODE", "true") + .WithEnvironment("API_TIMEOUT", "30"); + + // Assert + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + androidEmulator.Resource, + DistributedApplicationOperation.Run, + TestServiceProvider.Instance); + + Assert.Contains(envVars, kvp => kvp.Key == "DEBUG_MODE" && kvp.Value == "true"); + Assert.Contains(envVars, kvp => kvp.Key == "API_TIMEOUT" && kvp.Value == "30"); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public void AddAndroidDeviceAndEmulator_CanCoexist() + { + // Arrange + var projectContent = CreateProjectContent("net10.0-android"); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var androidDevice = maui.AddAndroidDevice(); + var androidEmulator = maui.AddAndroidEmulator(); + + // Assert + Assert.NotNull(androidDevice); + Assert.NotNull(androidEmulator); + Assert.NotEqual(androidDevice.Resource.Name, androidEmulator.Resource.Name); + Assert.IsType(androidDevice.Resource); + Assert.IsType(androidEmulator.Resource); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public void AddAndroidDevice_WithDeviceId_CreatesResourceWithCorrectName() + { + // Arrange + var projectContent = CreateProjectContent("net10.0-android"); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var device = maui.AddAndroidDevice("my-device", "abc12345"); + + // Assert + Assert.NotNull(device); + Assert.Equal("my-device", device.Resource.Name); + Assert.IsType(device.Resource); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public void AddAndroidEmulator_WithEmulatorId_CreatesResourceWithCorrectName() + { + // Arrange + var projectContent = CreateProjectContent("net10.0-android"); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var emulator = maui.AddAndroidEmulator("my-emulator", "Pixel_5_API_33"); + + // Assert + Assert.NotNull(emulator); + Assert.Equal("my-emulator", emulator.Resource.Name); + Assert.IsType(emulator.Resource); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Theory] + [InlineData(true)] // Device + [InlineData(false)] // Emulator + public void AddAndroid_HasEnvironmentAnnotation(bool isDevice) + { + // Arrange + var projectContent = CreateProjectContent("net10.0-android"); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + IResource resource; + if (isDevice) + { + resource = maui.AddAndroidDevice().Resource; + } + else + { + resource = maui.AddAndroidEmulator().Resource; + } + + // Assert + var annotation = resource.Annotations.OfType().FirstOrDefault(); + Assert.NotNull(annotation); + } + finally + { + CleanupTempFile(tempFile); + } + } + + // OTLP Dev Tunnel Configuration Tests + + [Theory] + [MemberData(nameof(AllPlatforms))] + public void WithOtlpDevTunnel_AddsOtlpDevTunnelAnnotation(PlatformTestConfig config) + { + // Arrange + var projectContent = CreateProjectContent(config.RequiredTfm); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + var platform = config.AddPlatformWithDefaultName(maui); + + // Act - WithOtlpDevTunnel works on the concrete platform resource builder + config.ApplyWithOtlpDevTunnel(platform); + + // Assert + // Verify that the tunnel infrastructure was created on the parent + var tunnelConfig = maui.Resource.Annotations.OfType().FirstOrDefault(); + Assert.NotNull(tunnelConfig); + Assert.NotNull(tunnelConfig.OtlpStub); + Assert.NotNull(tunnelConfig.DevTunnel); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Theory] + [MemberData(nameof(AllPlatforms))] + public void WithOtlpDevTunnel_MultiplePlatforms_SharesSameInfrastructure(PlatformTestConfig config) + { + // Arrange + var projectContent = CreateProjectContent(config.RequiredTfm); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + var platform1 = config.AddPlatformWithCustomName(maui, $"{config.PlatformName}-1"); + var platform2 = config.AddPlatformWithCustomName(maui, $"{config.PlatformName}-2"); + + // Act - Apply dev tunnel to both platforms + config.ApplyWithOtlpDevTunnel(platform1); + config.ApplyWithOtlpDevTunnel(platform2); + + // Assert - Both platforms should share the same tunnel infrastructure + var annotations = maui.Resource.Annotations.OfType().ToList(); + Assert.Single(annotations); // Only one tunnel infrastructure created + } + finally + { + CleanupTempFile(tempFile); + } + } + + // Helper methods + + private static string CreateProjectContent(string requiredTfm) + { + return $$""" + + + {{requiredTfm}};net10.0-ios + + + """; + } + + private static string CreateProjectContentWithout(string excludePlatform) + { + // Create project with all TFMs except the one being tested + var tfms = new List { "net10.0-ios", "net10.0-windows10.0.19041.0", "net10.0-maccatalyst" }; + if (excludePlatform != "android") + { + tfms.Add("net10.0-android"); + } + tfms.RemoveAll(tfm => tfm.Contains(excludePlatform, StringComparison.OrdinalIgnoreCase)); + + return $""" + + + {string.Join(";", tfms)} + + + """; + } + + private static string CreateTempProjectFile(string content) + { + var tempFile = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid()}.csproj"); + File.WriteAllText(tempFile, content); + return tempFile; + } + + private static void CleanupTempFile(string filePath) + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + + // Configuration class for platform-specific test data + public class PlatformTestConfig + { + public string PlatformName { get; } + public string DisplayName { get; } + public string PlatformIdentifier { get; } + public string ExpectedDefaultName { get; } + public string RequiredTfm { get; } + public Func, IResourceBuilder> AddPlatformWithDefaultName { get; } + public Func, string, IResourceBuilder> AddPlatformWithCustomName { get; } + public Action> ApplyWithOtlpDevTunnel { get; } + public Type ExpectedResourceType { get; } + + public PlatformTestConfig( + string platformName, + string displayName, + string platformIdentifier, + string expectedDefaultName, + string requiredTfm, + Func, IResourceBuilder> addDefault, + Func, string, IResourceBuilder> addCustom, + Type expectedResourceType) + { + PlatformName = platformName; + DisplayName = displayName; + PlatformIdentifier = platformIdentifier; + ExpectedDefaultName = expectedDefaultName; + RequiredTfm = requiredTfm; + AddPlatformWithDefaultName = addDefault; + AddPlatformWithCustomName = addCustom; + ExpectedResourceType = expectedResourceType; + + // Set up WithOtlpDevTunnel based on the expected resource type + ApplyWithOtlpDevTunnel = expectedResourceType.Name switch + { + nameof(MauiWindowsPlatformResource) => builder => ((IResourceBuilder)builder).WithOtlpDevTunnel(), + nameof(MauiMacCatalystPlatformResource) => builder => ((IResourceBuilder)builder).WithOtlpDevTunnel(), + nameof(MauiAndroidDeviceResource) => builder => ((IResourceBuilder)builder).WithOtlpDevTunnel(), + nameof(MauiAndroidEmulatorResource) => builder => ((IResourceBuilder)builder).WithOtlpDevTunnel(), + _ => throw new NotSupportedException($"Unsupported resource type: {expectedResourceType.Name}") + }; + } + + public override string ToString() => PlatformName; + } +} diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiWindowsExtensionsTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiWindowsExtensionsTests.cs deleted file mode 100644 index ad57d63d771..00000000000 --- a/tests/Aspire.Hosting.Maui.Tests/MauiWindowsExtensionsTests.cs +++ /dev/null @@ -1,384 +0,0 @@ -// 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; -using Aspire.Hosting.Maui.Utilities; -using Microsoft.Extensions.DependencyInjection; - -namespace Aspire.Hosting.Tests; - -public class MauiWindowsExtensionsTests -{ - [Fact] - public void AddWindowsDevice_CreatesResource() - { - // Arrange - Create a temporary project file with Windows 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 - var windows = maui.AddWindowsDevice(); - - // Assert - Assert.NotNull(windows); - Assert.Equal("mauiapp-windows", windows.Resource.Name); - Assert.Same(maui.Resource, windows.Resource.Parent); - - // Verify the resource is in the application model - var windowsDeviceInModel = appBuilder.Resources - .OfType() - .FirstOrDefault(r => r.Name == "mauiapp-windows"); - Assert.NotNull(windowsDeviceInModel); - Assert.Same(windows.Resource, windowsDeviceInModel); - } - finally - { - CleanupTempFile(tempFile); - } - } - - [Fact] - public void AddWindowsDevice_WithCustomName_UsesProvidedName() - { - // Arrange - Create a temporary project file with Windows 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 - var windows = maui.AddWindowsDevice("custom-windows"); - - // Assert - Assert.Equal("custom-windows", windows.Resource.Name); - } - finally - { - CleanupTempFile(tempFile); - } - } - - [Fact] - public void AddWindowsDevice_DuplicateName_ThrowsException() - { - // Arrange - Create a temporary project file with Windows 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); - maui.AddWindowsDevice("device1"); - - // Act & Assert - var exception = Assert.Throws(() => maui.AddWindowsDevice("device1")); - Assert.Contains("already exists", exception.Message); - } - finally - { - CleanupTempFile(tempFile); - } - } - - [Fact] - public void AddWindowsDevice_MultipleDevices_AllCreated() - { - // Arrange - Create a temporary project file with Windows 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 - var windows1 = maui.AddWindowsDevice("device1"); - var windows2 = maui.AddWindowsDevice("device2"); - - // Assert - var windowsDevices = appBuilder.Resources - .OfType() - .Where(r => r.Parent == maui.Resource) - .ToList(); - - Assert.Equal(2, windowsDevices.Count); - Assert.Contains(windows1.Resource, windowsDevices); - Assert.Contains(windows2.Resource, windowsDevices); - } - finally - { - CleanupTempFile(tempFile); - } - } - - [Fact] - public void AddWindowsDevice_HasCorrectConfiguration() - { - // Arrange - Create a temporary project file with Windows 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 - var windows = maui.AddWindowsDevice(); - - // Assert - var resource = windows.Resource; - - // Check ExecutableAnnotation - var execAnnotation = resource.Annotations.OfType().FirstOrDefault(); - Assert.NotNull(execAnnotation); - Assert.Equal("dotnet", execAnnotation.Command); - - // Check for MauiProjectMetadata annotation - var projectMetadata = resource.Annotations.OfType().FirstOrDefault(); - Assert.NotNull(projectMetadata); - Assert.Equal(tempFile, projectMetadata.ProjectPath); - - // Check for explicit start annotation - var hasExplicitStart = resource.TryGetAnnotationsOfType(out _); - Assert.True(hasExplicitStart); - } - finally - { - CleanupTempFile(tempFile); - } - } - - [Fact] - public async Task AddWindowsDevice_WithoutWindowsTfm_ThrowsOnBeforeStartEvent() - { - // Arrange - Create a temporary project file without Windows 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 - Adding the device should succeed (validation deferred to start) - var windows = maui.AddWindowsDevice(); - - // Assert - Resource is created - Assert.NotNull(windows); - Assert.Equal("mauiapp-windows", windows.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(windows.Resource, app.Services), CancellationToken.None); - }); - - Assert.Contains("Unable to detect Windows target framework", exception.Message); - Assert.Contains(tempFile, exception.Message); - } - 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 filePath) - { - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - } - - [Fact] - public async Task GetWindowsTargetFramework_WithWindowsTfm_ReturnsCorrectTfm() - { - // Arrange - var projectContent = """ - - - net10.0-android;net10.0-ios;net10.0-windows10.0.19041.0 - - - """; - var tempFile = CreateTempProjectFile(projectContent); - - try - { - // Act - var tfm = ProjectFileReader.GetPlatformTargetFramework(tempFile, "windows"); - - // Assert - Assert.Equal("net10.0-windows10.0.19041.0", tfm); - } - finally - { - CleanupTempFile(tempFile); - } - } - - [Fact] - public async Task GetWindowsTargetFramework_WithConditionalWindowsTfm_ReturnsCorrectTfm() - { - if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) - { - Assert.Skip("This test requires Windows because MSBuild only evaluates the conditional Windows TFM on Windows platforms."); - } - - // Arrange - var projectContent = """ - - - net10.0-android;net10.0-ios - $(TargetFrameworks);net10.0-windows10.0.19041.0 - - - """; - var tempFile = CreateTempProjectFile(projectContent); - - try - { - // Act - var tfm = ProjectFileReader.GetPlatformTargetFramework(tempFile, "windows"); - - // Assert - Assert.Equal("net10.0-windows10.0.19041.0", tfm); - } - finally - { - CleanupTempFile(tempFile); - } - } - - [Fact] - public async Task GetWindowsTargetFramework_WithoutWindowsTfm_ReturnsNull() - { - // Arrange - var projectContent = """ - - - net10.0-android;net10.0-ios;net10.0-maccatalyst - - - """; - var tempFile = CreateTempProjectFile(projectContent); - - try - { - // Act - var tfm = ProjectFileReader.GetPlatformTargetFramework(tempFile, "windows"); - - // Assert - Assert.Null(tfm); - } - finally - { - CleanupTempFile(tempFile); - } - } - - [Fact] - public async Task GetWindowsTargetFramework_WithSingleWindowsTfm_ReturnsCorrectTfm() - { - // Arrange - var projectContent = """ - - - net10.0-windows10.0.19041.0 - - - """; - var tempFile = CreateTempProjectFile(projectContent); - - try - { - // Act - var tfm = ProjectFileReader.GetPlatformTargetFramework(tempFile, "windows"); - - // Assert - Assert.Equal("net10.0-windows10.0.19041.0", tfm); - } - finally - { - CleanupTempFile(tempFile); - } - } - - [Fact] - public async Task GetWindowsTargetFramework_InvalidFile_ReturnsNull() - { - // Arrange - var nonExistentFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".csproj"); - - // Act - var tfm = ProjectFileReader.GetPlatformTargetFramework(nonExistentFile, "windows"); - - // Assert - returns null when file can't be read - Assert.Null(tfm); - } -}