diff --git a/playground/AspireWithMaui/AspireWithMaui.AppHost/AppHost.cs b/playground/AspireWithMaui/AspireWithMaui.AppHost/AppHost.cs index a5fb64bf1b9..e1a18fa1566 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,20 @@ mauiapp.AddMacCatalystDevice() .WithReference(weatherApi); +// Add iOS simulator with default simulator (uses running or default simulator) +mauiapp.AddiOSSimulator() + .WithOtlpDevTunnel() // Needed to get the OpenTelemetry data to "localhost" + .WithReference(weatherApi, publicDevTunnel); // Needs a dev tunnel to reach "localhost" + +// Add iOS device (requires device provisioning) +// Uncomment to use with a physical iOS device: +// mauiapp.AddiOSDevice() +// .WithOtlpDevTunnel() // Needed to get the OpenTelemetry data to "localhost" +// .WithReference(weatherApi, publicDevTunnel); // Needs a dev tunnel to reach "localhost" + +// 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..0c765a235f9 100644 --- a/playground/AspireWithMaui/README.md +++ b/playground/AspireWithMaui/README.md @@ -67,20 +67,110 @@ 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()` +- **iOS Device**: Configures the MAUI app with `.AddiOSDevice()` to run on physical iOS devices + - Requires device provisioning before deployment (see https://learn.microsoft.com/dotnet/maui/ios/device-provisioning) + - Use `.AddiOSDevice()` to target the only attached device (default, requires exactly one device) + - Use `.AddiOSDevice("device-name", "00008030-001234567890123A")` to target a specific device by UDID + - Find device UDID in Xcode under Window > Devices and Simulators > Devices tab, or right-click device and select "Copy Identifier" + - Requires macOS to run (iOS development is macOS-only) + - Use `.WithOtlpDevTunnel()` to send telemetry to the dashboard (iOS devices cannot reach localhost) +- **iOS Simulator**: Configures the MAUI app with `.AddiOSSimulator()` to run on iOS simulators + - Use `.AddiOSSimulator()` to target the default simulator + - Use `.AddiOSSimulator("simulator-name", "E25BBE37-69BA-4720-B6FD-D54C97791E79")` to target a specific simulator by UDID + - Find simulator UDIDs in Xcode under Window > Devices and Simulators > Simulators tab, or use `/Applications/Xcode.app/Contents/Developer/usr/bin/simctl list` + - Requires macOS to run (iOS development is macOS-only) + - Use `.WithOtlpDevTunnel()` to send telemetry to the dashboard (simulators cannot reach localhost) +- **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 + +// iOS simulators and devices also need dev tunnel for OTLP +mauiapp.AddiOSSimulator() + .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 + +// For iOS, environment variables are also passed via an intermediate MSBuild targets file: +mauiapp.AddiOSSimulator("my-simulator", "E25BBE37-69BA-4720-B6FD-D54C97791E79") + .WithEnvironment("DEBUG_MODE", "true") + .WithEnvironment("API_TIMEOUT", "30") + .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**: Due to iOS 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. Environment variables are passed via the `--setenv` argument to `mtouch`/`mlaunch`. + +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) -- Parallel extension patterns for each platform +The architecture is designed to support additional platforms: +- Android support: `.AddAndroidDevice()` for physical devices, `.AddAndroidEmulator()` for emulators (implemented) +- iOS support: `.AddiOSDevice()` for physical devices, `.AddiOSSimulator()` for simulators (implemented) ## Troubleshooting @@ -97,16 +187,30 @@ If you encounter build errors: 3. Try running `dotnet build` from the repository root first ### Platform-Specific Issues -- **Windows**: Requires Windows 10 build 19041 or higher for WinUI support. Mac Catalyst devices will show as "Unsupported" when running on Windows. -- **Mac Catalyst**: Requires macOS to run. Windows devices will show as "Unsupported" when running on macOS. -- **Android**: Not yet implemented in this playground (coming soon) -- **iOS**: Not yet implemented in this playground (coming soon) +- **Windows**: Requires Windows 10 build 19041 or higher for WinUI support. Mac Catalyst and iOS devices will show as "Unsupported" when running on Windows. +- **Mac Catalyst**: Requires macOS to run. Windows, Android, and iOS devices will show as "Unsupported" when running on non-macOS platforms. +- **iOS Device**: Requires macOS and a physical iOS device connected via USB. Device must be provisioned before deployment (https://learn.microsoft.com/dotnet/maui/ios/device-provisioning). Find device UDID in Xcode under Window > Devices and Simulators. +- **iOS Simulator**: Requires macOS and Xcode with iOS simulator runtimes installed. To target a specific simulator: + 1. List available simulators: `/Applications/Xcode.app/Contents/Developer/usr/bin/simctl list` + 2. Or find in Xcode: Window > Devices and Simulators > Simulators tab + 3. Right-click simulator and select "Copy Identifier" for UDID + 4. Use in code: `.AddiOSSimulator(simulatorId: "E25BBE37-69BA-4720-B6FD-D54C97791E79")` +- **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. ## Current Status ✅ **Implemented:** - Windows platform support via `AddWindowsDevice()` - Mac Catalyst platform support via `AddMacCatalystDevice()` +- iOS device support via `AddiOSDevice()` +- iOS simulator support via `AddiOSSimulator()` +- 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,8 +218,6 @@ If you encounter build errors: - OpenTelemetry integration 🚧 **Coming Soon:** -- Android platform support via `AddAndroidDevice()` -- iOS platform support via `AddIosDevice()` - Multi-platform simultaneous debugging ## Learn More 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..d34857988c3 100644 --- a/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj +++ b/src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj @@ -16,11 +16,15 @@ + + + + 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..f401835e1eb --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiHostingExtensions.cs @@ -0,0 +1,22 @@ +// 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(); + + // Register the iOS 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..3a19917bab4 --- /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.ResolveSchemeAndPort(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/MauiiOSDeviceResource.cs b/src/Aspire.Hosting.Maui/MauiiOSDeviceResource.cs new file mode 100644 index 00000000000..42de76a8122 --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiiOSDeviceResource.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 iOS physical device for running a .NET MAUI application. +/// +/// The name of the iOS device resource. +/// The parent MAUI project resource. +public sealed class MauiiOSDeviceResource(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/MauiiOSExtensions.cs b/src/Aspire.Hosting.Maui/MauiiOSExtensions.cs new file mode 100644 index 00000000000..4e7bb3e54cd --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiiOSExtensions.cs @@ -0,0 +1,408 @@ +// 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 iOS platform resources to MAUI projects. +/// +public static class MauiiOSExtensions +{ + /// + /// Adds an iOS physical device resource to run the MAUI application on an iOS device. + /// + /// The MAUI project resource builder. + /// A reference to the . + /// + /// This method creates a new iOS device platform resource that will run the MAUI application + /// targeting the iOS 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}-ios-device". + /// + /// + /// This will run the application on a physical iOS device connected via USB. + /// The device must be provisioned before deployment. For more information, see + /// https://learn.microsoft.com/dotnet/maui/ios/device-provisioning + /// + /// + /// If only one device is attached, it will automatically use that device. If multiple devices + /// are connected, use the overload with deviceId parameter to specify which device to use by UDID. + /// You can find the device UDID in Xcode under Window > Devices and Simulators > Devices tab. + /// + /// + /// + /// Add an iOS device to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// var iOSDevice = maui.AddiOSDevice(); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddiOSDevice( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var name = $"{builder.Resource.Name}-ios-device"; + return builder.AddiOSDevice(name, deviceId: null); + } + + /// + /// Adds an iOS physical device resource to run the MAUI application on an iOS device with a specific name. + /// + /// The MAUI project resource builder. + /// The name of the iOS device resource. + /// A reference to the . + /// + /// This method creates a new iOS device platform resource that will run the MAUI application + /// targeting the iOS platform using dotnet run. The resource does not auto-start + /// and must be explicitly started from the dashboard by clicking the start button. + /// + /// Multiple iOS 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 iOS device connected via USB. + /// The device must be provisioned before deployment. For more information, see + /// https://learn.microsoft.com/dotnet/maui/ios/device-provisioning + /// + /// + /// If only one device is attached, it will automatically use that device. If multiple devices + /// are connected, use the overload with deviceId parameter to specify which device to use by UDID. + /// You can find the device UDID in Xcode under Window > Devices and Simulators > Devices tab. + /// + /// + /// + /// Add multiple iOS devices to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// var device1 = maui.AddiOSDevice("ios-device-1"); + /// var device2 = maui.AddiOSDevice("ios-device-2"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddiOSDevice( + this IResourceBuilder builder, + [ResourceName] string name) + { + return builder.AddiOSDevice(name, deviceId: null); + } + + /// + /// Adds an iOS physical device resource to run the MAUI application on an iOS device with a specific name and device UDID. + /// + /// The MAUI project resource builder. + /// The name of the iOS device resource. + /// Optional device UDID to target a specific iOS 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 iOS device platform resource that will run the MAUI application + /// targeting the iOS platform using dotnet run. The resource does not auto-start + /// and must be explicitly started from the dashboard by clicking the start button. + /// + /// Multiple iOS 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 iOS device connected via USB. + /// The device must be provisioned before deployment. For more information, see + /// https://learn.microsoft.com/dotnet/maui/ios/device-provisioning + /// + /// + /// To target a specific device when multiple are connected, provide the device UDID. + /// You can find the device UDID in Xcode under Window > Devices and Simulators > Devices tab, + /// or right-click on the device and select "Copy Identifier". + /// + /// + /// + /// Add multiple iOS 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.AddiOSDevice("ios-device-default"); + /// + /// // Specific device by UDID + /// var device2 = maui.AddiOSDevice("ios-device-iphone13", "00008030-001234567890123A"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddiOSDevice( + 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 iOSDeviceResource = new MauiiOSDeviceResource(name, builder.Resource); + + var resourceBuilder = builder.ApplicationBuilder.AddResource(iOSDeviceResource) + .WithAnnotation(new MauiProjectMetadata(projectPath)) + .WithAnnotation(new MauiiOSEnvironmentAnnotation()) // Enable environment variable support via targets file + .WithAnnotation(new ExecutableAnnotation + { + Command = "dotnet", + WorkingDirectory = workingDirectory + }); + + // Build additional arguments for device UDID if specified + // For iOS devices, we need to use the MSBuild property _DeviceName to specify which device to target + // and RuntimeIdentifier must be ios-arm64 for physical devices + // See: https://learn.microsoft.com/dotnet/maui/ios/cli#launch-the-app-on-a-device + // Format: -p:_DeviceName= -p:RuntimeIdentifier=ios-arm64 + var additionalArgs = new List(); + + // iOS devices always need RuntimeIdentifier=ios-arm64 + additionalArgs.Add("-p:RuntimeIdentifier=ios-arm64"); + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + // Specific device - use the UDID directly (no :v2:udid= prefix for devices) + additionalArgs.Add($"-p:_DeviceName={deviceId}"); + } + // If no device ID specified, dotnet run will use the only attached device + + // Configure the platform resource with common settings + // iOS runs only on macOS - check for macOS platform + MauiPlatformHelper.ConfigurePlatformResource( + resourceBuilder, + projectPath, + "ios", + "iOS", + "net10.0-ios", + OperatingSystem.IsMacOS, // iOS development requires macOS + "PhoneTablet", + additionalArgs.ToArray()); + + // Validate device ID format before starting the resource + if (!string.IsNullOrWhiteSpace(deviceId)) + { + resourceBuilder.OnBeforeResourceStarted((resource, eventing, ct) => + { + // Validate that the device ID doesn't look like a simulator ID (which has GUID format) + if (IsLikelySimulatorId(deviceId)) + { + throw new DistributedApplicationException( + $"Device ID '{deviceId}' for iOS device resource '{name}' appears to be an iOS Simulator UDID (GUID format). " + + $"iOS physical devices typically use a different UDID format (e.g., 00008030-001234567890123A). " + + $"If you intended to target an iOS Simulator, use AddiOSSimulator(\"{name}\", \"{deviceId}\") instead."); + } + + return Task.CompletedTask; + }); + } + + return resourceBuilder; + } + + /// + /// Adds an iOS simulator resource to run the MAUI application on an iOS simulator. + /// + /// The MAUI project resource builder. + /// A reference to the . + /// + /// This method creates a new iOS simulator platform resource that will run the MAUI application + /// targeting the iOS 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}-ios-simulator". + /// + /// + /// This will run the application on the default iOS simulator. If no simulator is currently running, + /// Xcode will launch the default simulator. To target a specific simulator, use the overload with + /// simulatorId parameter. + /// + /// + /// + /// Add an iOS simulator to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// var iOSSimulator = maui.AddiOSSimulator(); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddiOSSimulator( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + var name = $"{builder.Resource.Name}-ios-simulator"; + return builder.AddiOSSimulator(name, simulatorId: null); + } + + /// + /// Adds an iOS simulator resource to run the MAUI application on an iOS simulator with a specific name. + /// + /// The MAUI project resource builder. + /// The name of the iOS simulator resource. + /// A reference to the . + /// + /// This method creates a new iOS simulator platform resource that will run the MAUI application + /// targeting the iOS platform using dotnet run. The resource does not auto-start + /// and must be explicitly started from the dashboard by clicking the start button. + /// + /// Multiple iOS simulator resources can be added to the same MAUI project if needed, each with + /// a unique name. + /// + /// + /// This will run the application on the default iOS simulator. If no simulator is currently running, + /// Xcode will launch the default simulator. To target a specific simulator, use the overload with + /// simulatorId parameter. + /// + /// + /// + /// Add multiple iOS simulators to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// var simulator1 = maui.AddiOSSimulator("ios-simulator-1"); + /// var simulator2 = maui.AddiOSSimulator("ios-simulator-2"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddiOSSimulator( + this IResourceBuilder builder, + [ResourceName] string name) + { + return builder.AddiOSSimulator(name, simulatorId: null); + } + + /// + /// Adds an iOS simulator resource to run the MAUI application on an iOS simulator with a specific name and simulator UDID. + /// + /// The MAUI project resource builder. + /// The name of the iOS simulator resource. + /// Optional simulator UDID to target a specific iOS simulator. If not specified, uses the default simulator. + /// A reference to the . + /// + /// This method creates a new iOS simulator platform resource that will run the MAUI application + /// targeting the iOS platform using dotnet run. The resource does not auto-start + /// and must be explicitly started from the dashboard by clicking the start button. + /// + /// Multiple iOS simulator resources can be added to the same MAUI project if needed, each with + /// a unique name. + /// + /// + /// To target a specific simulator, provide the simulator UDID. You can find simulator UDIDs in Xcode + /// under Window > Devices and Simulators > Simulators tab, right-click on a simulator and select + /// "Copy Identifier", or use the command: /Applications/Xcode.app/Contents/Developer/usr/bin/simctl list + /// + /// + /// + /// Add multiple iOS simulators to a MAUI project: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var maui = builder.AddMauiProject("mauiapp", "../MyMauiApp/MyMauiApp.csproj"); + /// + /// // Default simulator + /// var simulator1 = maui.AddiOSSimulator("ios-simulator-default"); + /// + /// // Specific simulator by UDID + /// var simulator2 = maui.AddiOSSimulator("ios-simulator-iphone15", "E25BBE37-69BA-4720-B6FD-D54C97791E79"); + /// + /// builder.Build().Run(); + /// + /// + public static IResourceBuilder AddiOSSimulator( + this IResourceBuilder builder, + [ResourceName] string name, + string? simulatorId = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + // Get the absolute project path and working directory + var (projectPath, workingDirectory) = MauiPlatformHelper.GetProjectPaths(builder); + + var iOSSimulatorResource = new MauiiOSSimulatorResource(name, builder.Resource); + + var resourceBuilder = builder.ApplicationBuilder.AddResource(iOSSimulatorResource) + .WithAnnotation(new MauiProjectMetadata(projectPath)) + .WithAnnotation(new MauiiOSEnvironmentAnnotation()) // Enable environment variable support via targets file + .WithAnnotation(new ExecutableAnnotation + { + Command = "dotnet", + WorkingDirectory = workingDirectory + }); + + // Build additional arguments for simulator UDID if specified + // For iOS simulators, we need to use the MSBuild property _DeviceName with the :v2:udid= prefix + // See: https://learn.microsoft.com/dotnet/maui/ios/cli#launch-the-app-on-a-specific-simulator + // Format: -p:_DeviceName=:v2:udid= + var additionalArgs = new List(); + + if (!string.IsNullOrWhiteSpace(simulatorId)) + { + // Specific simulator - use :v2:udid= prefix (note: no quotes around the value to avoid Android issue) + additionalArgs.Add($"-p:_DeviceName=:v2:udid={simulatorId}"); + } + // If no simulator ID specified, dotnet run will use the default simulator + + // Configure the platform resource with common settings + // iOS runs only on macOS - check for macOS platform + MauiPlatformHelper.ConfigurePlatformResource( + resourceBuilder, + projectPath, + "ios", + "iOS", + "net10.0-ios", + OperatingSystem.IsMacOS, // iOS development requires macOS + "PhoneTablet", + additionalArgs.ToArray()); + + // Validate simulator ID format before starting the resource + if (!string.IsNullOrWhiteSpace(simulatorId)) + { + resourceBuilder.OnBeforeResourceStarted((resource, eventing, ct) => + { + // Validate that the simulator ID looks like a GUID (expected format for iOS Simulator UDIDs) + if (!IsLikelySimulatorId(simulatorId)) + { + throw new DistributedApplicationException( + $"Simulator ID '{simulatorId}' for iOS simulator resource '{name}' does not appear to be an iOS Simulator UDID (GUID format). " + + "iOS Simulator UDIDs are typically GUIDs (e.g., E25BBE37-69BA-4720-B6FD-D54C97791E79). " + + $"If you intended to target a physical iOS device, use AddiOSDevice(\"{name}\", \"{simulatorId}\") instead."); + } + + return Task.CompletedTask; + }); + } + + return resourceBuilder; + } + + /// + /// Checks if a device ID appears to be an iOS Simulator UDID. + /// iOS Simulator UDIDs are standard GUIDs (8-4-4-4-12 format). + /// + private static bool IsLikelySimulatorId(string deviceId) + { + // iOS Simulator UDIDs are standard GUIDs (8-4-4-4-12 format) + // Example: E25BBE37-69BA-4720-B6FD-D54C97791E79 + return Guid.TryParse(deviceId, out _); + } +} diff --git a/src/Aspire.Hosting.Maui/MauiiOSSimulatorResource.cs b/src/Aspire.Hosting.Maui/MauiiOSSimulatorResource.cs new file mode 100644 index 00000000000..ae17975a964 --- /dev/null +++ b/src/Aspire.Hosting.Maui/MauiiOSSimulatorResource.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 iOS simulator for running a .NET MAUI application. +/// +/// The name of the iOS simulator resource. +/// The parent MAUI project resource. +public sealed class MauiiOSSimulatorResource(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/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..d1e290685da --- /dev/null +++ b/src/Aspire.Hosting.Maui/README.md @@ -0,0 +1,200 @@ +# 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(); + +// iOS Simulator +mauiApp.AddiOSSimulator(); + +// iOS Device +mauiApp.AddiOSDevice(); + +// Android Device +mauiApp.AddAndroidDevice(); + +// Android Emulator +mauiApp.AddAndroidEmulator(); +``` + +You can optionally specify custom names and device/simulator IDs: + +```csharp +mauiApp.AddWindowsDevice("my-windows-app"); + +// iOS with specific simulator UDID +mauiApp.AddiOSSimulator("iphone-15-sim", "E25BBE37-69BA-4720-B6FD-D54C97791E79"); + +// iOS with specific device UDID (requires device provisioning) +mauiApp.AddiOSDevice("my-iphone", "00008030-001234567890123A"); + +// Android with specific emulator +mauiApp.AddAndroidEmulator("pixel-7-emulator", "Pixel_7_API_33"); + +// Android with specific device serial +mauiApp.AddAndroidDevice("my-pixel", "abc12345"); +``` + +> **Note on Device/Simulator ID Validation**: The iOS methods include validation to help detect common mistakes: +> - `AddiOSDevice()` will fail at startup if you pass a GUID-format ID (which is typical for Simulator UDIDs) +> - `AddiOSSimulator()` will fail at startup if you pass a non-GUID format ID (which is typical for device UDIDs) +> +> These validation errors appear in the dashboard when you try to start the resource, making it clear if you've accidentally swapped device and simulator IDs. + +## 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: +- Android emulator or device +- iOS simulator or device + +You should not need this when running on Windows or Mac Catalyst (they can access localhost directly). + +```csharp +// Android emulator with OTLP dev tunnel +mauiApp.AddAndroidEmulator() + .WithOtlpDevTunnel(); + +// iOS simulator with OTLP dev tunnel +mauiApp.AddiOSSimulator() + .WithOtlpDevTunnel(); + +// iOS device with OTLP dev tunnel +mauiApp.AddiOSDevice() + .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); + +// iOS Simulator - needs dev tunnels for both API and OTLP +mauiApp.AddiOSSimulator() + .WithOtlpDevTunnel() // For telemetry + .WithReference(apiService, apiTunnel); // For API calls + +// iOS Device - same configuration +mauiApp.AddiOSDevice() + .WithOtlpDevTunnel() + .WithReference(apiService, apiTunnel); + +builder.Build().Run(); +``` + +## Platform-Specific Notes + +### iOS + +**Finding Simulator UDIDs:** +```bash +# Using simctl +/Applications/Xcode.app/Contents/Developer/usr/bin/simctl list + +# Or use Xcode: Window > Devices and Simulators +``` + +**Device Requirements:** +- iOS development requires macOS +- Physical devices require provisioning: [Microsoft Learn - iOS Device Provisioning](https://learn.microsoft.com/dotnet/maui/ios/device-provisioning) +- Find device UDID in Xcode: Window > Devices and Simulators + +### Android + +**Finding Emulator Names:** +```bash +# List all available Android virtual devices +%ANDROID_HOME%\emulator\emulator.exe -list-avds + +# Or use Android Studio: Tools > Device Manager +``` + +**Finding Device Serials:** +```bash +# List connected Android devices +adb devices +``` + +## 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..68673d43e6e --- /dev/null +++ b/src/Aspire.Hosting.Maui/Utilities/MauiEnvironmentHelper.cs @@ -0,0 +1,350 @@ +// 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); + } + + /// + /// Creates an MSBuild targets file for iOS 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 CreateiOSEnvironmentTargetsFileAsync( + IResource resource, + DistributedApplicationExecutionContext executionContext, + ILogger logger, + CancellationToken cancellationToken) + { + var environmentVariables = new Dictionary(StringComparer.Ordinal); + + // 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; + } + + environmentVariables[key] = value; + }, + 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", "mlaunch-env"); + Directory.CreateDirectory(tempDirectory); + + // Prune old targets files + PruneOldTargetsiOS(tempDirectory, logger); + + var sanitizedName = SanitizeFileName(resource.Name + "-ios"); + var uniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + var targetsFilePath = Path.Combine(tempDirectory, $"{sanitizedName}-{uniqueId}.targets"); + + // Generate the targets file content + var targetsContent = GenerateiOSTargetsFileContent(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 iOS environment variables. + /// + private static string GenerateiOSTargetsFileContent(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 to add environment variables using MlaunchEnvironmentVariables + // iOS apps need environment variables passed to mlaunch as KEY=VALUE pairs + var itemGroup = new XElement("ItemGroup"); + + foreach (var (key, value) in environmentVariables.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)) + { + // Encode semicolons as %3B to prevent MSBuild from treating them as item separators + var encodedValue = value.Replace(";", "%3B", StringComparison.Ordinal); + + // Add as MlaunchEnvironmentVariables item with Include="KEY=VALUE" + itemGroup.Add(new XElement("MlaunchEnvironmentVariables", + new XAttribute("Include", $"{key}={encodedValue}"))); + } + + projectElement.Add(itemGroup); + + // Add a diagnostic message target to show what's being forwarded + projectElement.Add(new XElement( + "Target", + new XAttribute("Name", "AspireLogMlaunchEnvironmentVariables"), + new XAttribute("AfterTargets", "PrepareForBuild"), + new XAttribute("Condition", "'@(MlaunchEnvironmentVariables)' != ''"), + new XElement( + "Message", + new XAttribute("Importance", "High"), + new XAttribute("Text", "Aspire forwarding mlaunch environment variables: @(MlaunchEnvironmentVariables, ', ')") + ) + )); + + 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 PruneOldTargetsiOS(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 iOS environment targets file '{TargetsFile}'.", file); + } + } + + if (deletedFiles.Count > 0) + { + logger.LogDebug("Pruned {Count} stale iOS environment targets file(s): {Files}", deletedFiles.Count, string.Join(", ", deletedFiles)); + } + } +} diff --git a/src/Aspire.Hosting.Maui/Utilities/MauiiOSEnvironmentAnnotation.cs b/src/Aspire.Hosting.Maui/Utilities/MauiiOSEnvironmentAnnotation.cs new file mode 100644 index 00000000000..3a45e11b400 --- /dev/null +++ b/src/Aspire.Hosting.Maui/Utilities/MauiiOSEnvironmentAnnotation.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 iOS environment variable support via MSBuild targets file. +/// +/// +/// iOS 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 MauiiOSEnvironmentAnnotation : IResourceAnnotation +{ + // Marker annotation - actual logic is in the eventing subscriber +} + +/// +/// Internal annotation to track that the callback for iOS 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 MauiiOSEnvironmentProcessedAnnotation : IResourceAnnotation +{ +} + +/// +/// Event subscriber that processes annotations. +/// +internal sealed class MauiiOSEnvironmentSubscriber( + 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 iOS resources with the environment annotation + if (resource is not (MauiiOSDeviceResource or MauiiOSSimulatorResource)) + { + 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.CreateiOSEnvironmentTargetsFileAsync( + resource, + executionContext, + logger, + cancellationToken + ).ConfigureAwait(false); + + if (generatedFilePath is not null) + { + logger.LogInformation("Generated environment targets file for iOS: {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 MauiiOSEnvironmentProcessedAnnotation()); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to configure iOS 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/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/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 65535adf8aa..5bd5f1174b3 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs index f5d79d72da6..4bbe4d1cdf8 100644 --- a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs +++ b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs @@ -15,8 +15,6 @@ namespace Aspire.Hosting; /// public static class OtlpConfigurationExtensions { - private const string DashboardOtlpUrlDefaultValue = "http://localhost:18889"; - /// /// Configures OpenTelemetry in projects using environment variables. /// @@ -108,48 +106,18 @@ private static void RegisterOtlpEnvironment(IResource resource, IConfiguration c context.EnvironmentVariables["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"; } })); + } - static void SetOtel(EnvironmentCallbackContext context, IConfiguration configuration, OtlpProtocol? requiredProtocol) - { - var dashboardOtlpGrpcUrl = configuration.GetString(KnownConfigNames.DashboardOtlpGrpcEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpGrpcEndpointUrl); - var dashboardOtlpHttpUrl = configuration.GetString(KnownConfigNames.DashboardOtlpHttpEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpHttpEndpointUrl); - - // Check if a specific protocol is required by the annotation - if (requiredProtocol is OtlpProtocol.Grpc) - { - SetOtelEndpointAndProtocol(context.EnvironmentVariables, dashboardOtlpGrpcUrl ?? DashboardOtlpUrlDefaultValue, "grpc"); - } - else if (requiredProtocol is OtlpProtocol.HttpProtobuf) - { - SetOtelEndpointAndProtocol(context.EnvironmentVariables, dashboardOtlpHttpUrl ?? throw new InvalidOperationException("OtlpExporter is configured to require http/protobuf, but no endpoint was configured for ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"), "http/protobuf"); - } - else - { - // No specific protocol required, use the existing preference logic - // The dashboard can support OTLP/gRPC and OTLP/HTTP endpoints at the same time, but it can - // only tell resources about one of the endpoints via environment variables. - // If both OTLP/gRPC and OTLP/HTTP are available then prefer gRPC. - if (dashboardOtlpGrpcUrl is not null) - { - SetOtelEndpointAndProtocol(context.EnvironmentVariables, dashboardOtlpGrpcUrl, "grpc"); - } - else if (dashboardOtlpHttpUrl is not null) - { - SetOtelEndpointAndProtocol(context.EnvironmentVariables, dashboardOtlpHttpUrl, "http/protobuf"); - } - else - { - // No endpoints provided to host. Use default value for URL. - SetOtelEndpointAndProtocol(context.EnvironmentVariables, DashboardOtlpUrlDefaultValue, "grpc"); - } - } - } + private static void SetOtel(EnvironmentCallbackContext context, IConfiguration configuration, OtlpProtocol? requiredProtocol) + { + var (url, protocol) = OtlpEndpointResolver.ResolveOtlpEndpoint(configuration, requiredProtocol); + SetOtelEndpointAndProtocol(context.EnvironmentVariables, url, protocol); + } - static void SetOtelEndpointAndProtocol(Dictionary environmentVariables, string url, string protocol) - { - environmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = new HostUrl(url); - environmentVariables["OTEL_EXPORTER_OTLP_PROTOCOL"] = protocol; - } + private static void SetOtelEndpointAndProtocol(Dictionary environmentVariables, string url, string protocol) + { + environmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = new HostUrl(url); + environmentVariables["OTEL_EXPORTER_OTLP_PROTOCOL"] = protocol; } /// diff --git a/src/Shared/OtlpEndpointResolver.cs b/src/Shared/OtlpEndpointResolver.cs new file mode 100644 index 00000000000..7529dba8f8a --- /dev/null +++ b/src/Shared/OtlpEndpointResolver.cs @@ -0,0 +1,80 @@ +// 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; + +/// +/// Resolves OTLP endpoint configuration (URL, scheme, port, and protocol) from configuration. +/// +internal static class OtlpEndpointResolver +{ + private const int DashboardOtlpUrlDefaultPort = 18889; + private static readonly string s_dashboardOtlpUrlDefaultValue = $"http://localhost:{DashboardOtlpUrlDefaultPort}"; + + /// + /// Resolves the OTLP endpoint URL and protocol from configuration. + /// + /// The configuration to read from. + /// The required protocol, or null to use preference logic (gRPC preferred over HTTP). + /// A tuple containing the endpoint URL and protocol string ("grpc" or "http/protobuf"). + /// Thrown when requires HTTP but no HTTP endpoint is configured. + public static (string Url, string Protocol) ResolveOtlpEndpoint(IConfiguration configuration, OtlpProtocol? requiredProtocol = null) + { + var dashboardOtlpGrpcUrl = configuration.GetString(KnownConfigNames.DashboardOtlpGrpcEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpGrpcEndpointUrl); + var dashboardOtlpHttpUrl = configuration.GetString(KnownConfigNames.DashboardOtlpHttpEndpointUrl, KnownConfigNames.Legacy.DashboardOtlpHttpEndpointUrl); + + // Check if a specific protocol is required + if (requiredProtocol is OtlpProtocol.Grpc) + { + return (dashboardOtlpGrpcUrl ?? s_dashboardOtlpUrlDefaultValue, "grpc"); + } + else if (requiredProtocol is OtlpProtocol.HttpProtobuf) + { + if (dashboardOtlpHttpUrl is null) + { + throw new InvalidOperationException("OtlpExporter is configured to require http/protobuf, but no endpoint was configured for ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"); + } + return (dashboardOtlpHttpUrl, "http/protobuf"); + } + else + { + // No specific protocol required, use the existing preference logic + // The dashboard can support OTLP/gRPC and OTLP/HTTP endpoints at the same time, but it can + // only tell resources about one of the endpoints via environment variables. + // If both OTLP/gRPC and OTLP/HTTP are available then prefer gRPC. + if (dashboardOtlpGrpcUrl is not null) + { + return (dashboardOtlpGrpcUrl, "grpc"); + } + else if (dashboardOtlpHttpUrl is not null) + { + return (dashboardOtlpHttpUrl, "http/protobuf"); + } + else + { + // No endpoints provided to host. Use default value for URL. + return (s_dashboardOtlpUrlDefaultValue, "grpc"); + } + } + } + + /// + /// Resolves the OTLP endpoint scheme and port from configuration. + /// + /// The configuration to read from. + /// A tuple of (scheme, port) for the OTLP endpoint. + public static (string Scheme, int Port) ResolveSchemeAndPort(IConfiguration configuration) + { + var (url, _) = ResolveOtlpEndpoint(configuration); + + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return (uri.Scheme, uri.Port); + } + + // Fallback to default (should not normally reach here as ResolveOtlpEndpoint always returns a valid URL) + return ("http", DashboardOtlpUrlDefaultPort); + } +} 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..f8a7c728ce4 --- /dev/null +++ b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs @@ -0,0 +1,712 @@ +// 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)), + + new PlatformTestConfig("iOSDevice", "iOS", "ios", "mauiapp-ios-device", "net10.0-ios", + (maui) => maui.AddiOSDevice(), + (maui, name) => maui.AddiOSDevice(name), + typeof(MauiiOSDeviceResource)), + + new PlatformTestConfig("iOSSimulator", "iOS", "ios", "mauiapp-ios-simulator", "net10.0-ios", + (maui) => maui.AddiOSSimulator(), + (maui, name) => maui.AddiOSSimulator(name), + typeof(MauiiOSSimulatorResource)) + }; + + [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); + } + } + + [Fact] + public void AddiOSDevice_WithDeviceId_CreatesResourceWithCorrectName() + { + // Arrange + var projectContent = CreateProjectContent("net10.0-ios"); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var device = maui.AddiOSDevice("my-device", "00008030-001234567890123A"); + + // Assert + Assert.NotNull(device); + Assert.Equal("my-device", device.Resource.Name); + Assert.IsType(device.Resource); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public void AddiOSSimulator_WithSimulatorId_CreatesResourceWithCorrectName() + { + // Arrange + var projectContent = CreateProjectContent("net10.0-ios"); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var simulator = maui.AddiOSSimulator("my-simulator", "E25BBE37-69BA-4720-B6FD-D54C97791E79"); + + // Assert + Assert.NotNull(simulator); + Assert.Equal("my-simulator", simulator.Resource.Name); + Assert.IsType(simulator.Resource); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public void AddiOSDeviceAndSimulator_CanCoexist() + { + // Arrange + var projectContent = CreateProjectContent("net10.0-ios"); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var device = maui.AddiOSDevice(); + var simulator = maui.AddiOSSimulator(); + + // Assert + Assert.NotNull(device); + Assert.NotNull(simulator); + Assert.NotEqual(device.Resource.Name, simulator.Resource.Name); + Assert.IsType(device.Resource); + Assert.IsType(simulator.Resource); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Theory] + [InlineData(true)] // Device + [InlineData(false)] // Simulator + public void AddiOS_HasEnvironmentAnnotation(bool isDevice) + { + // Arrange + var projectContent = CreateProjectContent("net10.0-ios"); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + IResource resource; + if (isDevice) + { + resource = maui.AddiOSDevice().Resource; + } + else + { + resource = maui.AddiOSSimulator().Resource; + } + + // Assert + var annotation = resource.Annotations.OfType().FirstOrDefault(); + Assert.NotNull(annotation); + } + finally + { + CleanupTempFile(tempFile); + } + } + + [Fact] + public async Task AddiOSSimulator_WithEnvironment_EnvironmentVariablesAreSet() + { + // Arrange + var projectContent = CreateProjectContent("net10.0-ios"); + var tempFile = CreateTempProjectFile(projectContent); + + try + { + var appBuilder = DistributedApplication.CreateBuilder(); + var maui = appBuilder.AddMauiProject("mauiapp", tempFile); + + // Act + var iosSimulator = maui.AddiOSSimulator() + .WithEnvironment("DEBUG_MODE", "true") + .WithEnvironment("API_TIMEOUT", "30"); + + // Assert + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + iosSimulator.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); + } + } + + [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(), + nameof(MauiiOSDeviceResource) => builder => ((IResourceBuilder)builder).WithOtlpDevTunnel(), + nameof(MauiiOSSimulatorResource) => 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); - } -}