File: Utilities\MauiEnvironmentHelper.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 System.Globalization;
using System.Text;
using System.Xml.Linq;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Maui.Utilities;
 
/// <summary>
/// Provides utilities for handling environment variables in MAUI projects.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static class MauiEnvironmentHelper
{
    /// <summary>
    /// Creates an MSBuild targets file for Android that sets environment variables.
    /// </summary>
    /// <param name="resource">The resource to collect environment variables from.</param>
    /// <param name="executionContext">The execution context.</param>
    /// <param name="logger">Logger for diagnostic output.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The path to the generated targets file, or null if no environment variables are present.</returns>
    public static async Task<string?> CreateAndroidEnvironmentTargetsFileAsync(
        IResource resource,
        DistributedApplicationExecutionContext executionContext,
        ILogger logger,
        CancellationToken cancellationToken)
    {
        var environmentVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        var encodedKeys = new HashSet<string>(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;
    }
 
    /// <summary>
    /// Generates the content of an MSBuild targets file for Android environment variables.
    /// </summary>
    private static string GenerateAndroidTargetsFileContent(Dictionary<string, string> 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<string>();
 
        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);
    }
 
    /// <summary>
    /// Creates an MSBuild targets file for iOS that sets environment variables.
    /// </summary>
    /// <param name="resource">The resource to collect environment variables from.</param>
    /// <param name="executionContext">The execution context.</param>
    /// <param name="logger">Logger for diagnostic output.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The path to the generated targets file, or null if no environment variables are present.</returns>
    public static async Task<string?> CreateiOSEnvironmentTargetsFileAsync(
        IResource resource,
        DistributedApplicationExecutionContext executionContext,
        ILogger logger,
        CancellationToken cancellationToken)
    {
        var environmentVariables = new Dictionary<string, string>(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;
    }
 
    /// <summary>
    /// Generates the content of an MSBuild targets file for iOS environment variables.
    /// </summary>
    private static string GenerateiOSTargetsFileContent(Dictionary<string, string> 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<string>();
 
        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));
        }
    }
}