File: MauiPlatformHelper.cs
Web Access
Project: src\src\Aspire.Hosting.Maui\Aspire.Hosting.Maui.csproj (Aspire.Hosting.Maui)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Lifecycle;
using Aspire.Hosting.Maui.Annotations;
using Aspire.Hosting.Maui.Lifecycle;
using Aspire.Hosting.Maui.Utilities;
using Aspire.Hosting.Utils;
 
namespace Aspire.Hosting.Maui;
 
/// <summary>
/// Helper methods for adding platform-specific MAUI device resources.
/// </summary>
internal static class MauiPlatformHelper
{
    /// <summary>
    /// Gets the absolute project path and working directory from a MAUI project resource.
    /// </summary>
    /// <param name="builder">The MAUI project resource builder.</param>
    /// <returns>A tuple containing the absolute project path and working directory.</returns>
    internal static (string ProjectPath, string WorkingDirectory) GetProjectPaths(IResourceBuilder<MauiProjectResource> builder)
    {
        var projectPath = builder.Resource.ProjectPath;
        if (!Path.IsPathRooted(projectPath))
        {
            projectPath = PathNormalizer.NormalizePathForCurrentPlatform(
                Path.Combine(builder.ApplicationBuilder.AppHostDirectory, projectPath));
        }
 
        var workingDirectory = Path.GetDirectoryName(projectPath)
            ?? throw new InvalidOperationException($"Unable to determine directory from project path: {projectPath}");
 
        return (projectPath, workingDirectory);
    }
 
    /// <summary>
    /// Configures a platform resource with common settings and TFM validation.
    /// </summary>
    /// <typeparam name="T">The type of platform resource.</typeparam>
    /// <param name="resourceBuilder">The resource builder.</param>
    /// <param name="projectPath">The absolute path to the project file.</param>
    /// <param name="platformName">The platform name (e.g., "windows", "maccatalyst").</param>
    /// <param name="platformDisplayName">The display name for the platform (e.g., "Windows", "Mac Catalyst").</param>
    /// <param name="tfmExample">Example TFM for error messages (e.g., "net10.0-windows10.0.19041.0").</param>
    /// <param name="isSupported">Function to check if the platform is supported on the current host.</param>
    /// <param name="iconName">The icon name for the resource.</param>
    /// <param name="additionalArgs">Optional additional command-line arguments to pass to dotnet run.</param>
    internal static void ConfigurePlatformResource<T>(
        IResourceBuilder<T> resourceBuilder,
        string projectPath,
        string platformName,
        string platformDisplayName,
        string tfmExample,
        Func<bool> isSupported,
        string iconName = "Desktop",
        params string[] additionalArgs) where T : ProjectResource
    {
        // Check if the project has the platform TFM and get the actual TFM value
        var platformTfm = ProjectFileReader.GetPlatformTargetFramework(projectPath, platformName);
 
        // Set the command line arguments with the detected TFM if available
        resourceBuilder.WithArgs(context =>
        {
            context.Args.Add("run");
            if (!string.IsNullOrEmpty(platformTfm))
            {
                context.Args.Add("-f");
                context.Args.Add(platformTfm);
            }
            // Add any additional platform-specific arguments
            foreach (var arg in additionalArgs)
            {
                context.Args.Add(arg);
            }
        });
 
        // Configure OTLP exporter with custom endpoint support
        ConfigureOtlpExporter(resourceBuilder);
 
        resourceBuilder
            .WithIconName(iconName)
            .WithExplicitStart();
 
        // Validate the platform TFM when the resource is about to start
        resourceBuilder.OnBeforeResourceStarted((resource, eventing, ct) =>
        {
            // If we couldn't detect the TFM earlier, fail the resource start
            if (string.IsNullOrEmpty(platformTfm))
            {
                throw new DistributedApplicationException(
                    $"Unable to detect {platformDisplayName} target framework in project '{projectPath}'. " +
                    $"Ensure the project file contains a TargetFramework or TargetFrameworks element with a {platformDisplayName} target framework (e.g., {tfmExample}) " +
                    $"or remove the Add{platformDisplayName.Replace(" ", "")}Device() call from your AppHost.");
            }
 
            return Task.CompletedTask;
        });
 
        // Check if platform is supported on the current host
        if (!isSupported())
        {
            var reason = $"{platformDisplayName} platform not available on this host";
 
            // Mark as unsupported
            resourceBuilder.WithAnnotation(new UnsupportedPlatformAnnotation(reason), ResourceAnnotationMutationBehavior.Append);
 
            // Add an event subscriber to set the "Unsupported" state after orchestrator initialization
            var appBuilder = resourceBuilder.ApplicationBuilder;
            appBuilder.Services.TryAddEventingSubscriber<UnsupportedPlatformEventSubscriber>();
        }
    }
 
    /// <summary>
    /// Configures OTLP exporter with support for Android-specific template replacement.
    /// </summary>
    /// <remarks>
    /// <para>
    /// 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.
    /// </para>
    /// <para>
    /// 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.
    /// </para>
    /// </remarks>
    private static void ConfigureOtlpExporter<T>(IResourceBuilder<T> 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}";
                }
            }
        });
    }
}