File: src\Aspire.Hosting.Browsers\BrowserLogsConfigurationManager.cs
Web Access
Project: src\tests\Aspire.Hosting.Browsers.Tests\Aspire.Hosting.Browsers.Tests.csproj (Aspire.Hosting.Browsers.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREUSERSECRETS001 // Type is for evaluation purposes only
#pragma warning disable ASPIREBROWSERLOGS001 // Type is for evaluation purposes only
 
using System.Globalization;
using Aspire.Hosting.Browsers.Resources;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting;
 
internal sealed class BrowserLogsConfigurationManager(
    IConfiguration configuration,
    IUserSecretsManager userSecretsManager,
    DistributedApplicationModel applicationModel,
    BrowserLogsConfigurationStore configurationStore,
    ResourceNotificationService resourceNotificationService,
    ILogger<BrowserLogsConfigurationManager> logger)
{
    internal const string ScopeInputName = "scope";
    internal const string BrowserInputName = "browser";
    internal const string UserDataModeInputName = "userDataMode";
    internal const string ProfileInputName = "profile";
    internal const string SaveToUserSecretsInputName = "saveToUserSecrets";
    internal const string ResourceScopeValue = "resource";
    internal const string GlobalScopeValue = "global";
    internal const string BrowserDefaultProfileValue = "__aspire_browser_default__";
 
    /// <summary>
    /// Creates the static argument definitions for the configure-tracked-browser command.
    /// Called at command registration time (before services are available).
    /// </summary>
    internal static IReadOnlyList<InteractionInput> CreateArgumentDefinitions(BrowserLogsResource resource, bool userSecretsAvailable)
    {
        var parentResourceName = resource.ParentResource.Name;
        var browserOptions = new List<KeyValuePair<string, string>>();
        AddKnownBrowser("msedge", BrowserCommandStrings.ConfigureTrackedBrowserEdgeOption);
        AddKnownBrowser("chrome", BrowserCommandStrings.ConfigureTrackedBrowserChromeOption);
        AddKnownBrowser("chromium", BrowserCommandStrings.ConfigureTrackedBrowserChromiumOption);
 
        var scopeInput = new InteractionInput
        {
            Name = ScopeInputName,
            Label = BrowserCommandStrings.ConfigureTrackedBrowserScopeLabel,
            InputType = InputType.Choice,
            Required = true,
            Value = ResourceScopeValue,
            Options =
            [
                new(ResourceScopeValue, string.Format(CultureInfo.CurrentCulture, BrowserCommandStrings.ConfigureTrackedBrowserResourceScopeOption, parentResourceName)),
                new(GlobalScopeValue, BrowserCommandStrings.ConfigureTrackedBrowserGlobalScopeOption)
            ]
        };
 
        var browserInput = new InteractionInput
        {
            Name = BrowserInputName,
            Label = BrowserCommandStrings.ConfigureTrackedBrowserBrowserLabel,
            Description = BrowserCommandStrings.ConfigureTrackedBrowserBrowserDescription,
            InputType = InputType.Choice,
            Required = true,
            AllowCustomChoice = true,
            Options = browserOptions,
            DynamicLoading = new InputLoadOptions
            {
                AlwaysLoadOnStart = true,
                LoadCallback = context =>
                {
                    var configurationManager = context.Services.GetRequiredService<BrowserLogsConfigurationManager>();
                    configurationManager.LoadBrowserValue(resource, context);
                    return Task.CompletedTask;
                }
            }
        };
 
        var userDataModeInput = new InteractionInput
        {
            Name = UserDataModeInputName,
            Label = BrowserCommandStrings.ConfigureTrackedBrowserUserDataModeLabel,
            InputType = InputType.Choice,
            Required = true,
            Options =
            [
                new(nameof(BrowserUserDataMode.Shared), nameof(BrowserUserDataMode.Shared)),
                new(nameof(BrowserUserDataMode.Isolated), nameof(BrowserUserDataMode.Isolated))
            ],
            DynamicLoading = new InputLoadOptions
            {
                AlwaysLoadOnStart = true,
                LoadCallback = context =>
                {
                    var configurationManager = context.Services.GetRequiredService<BrowserLogsConfigurationManager>();
                    configurationManager.LoadUserDataModeOptions(resource, context);
                    return Task.CompletedTask;
                }
            }
        };
 
        var profileInput = new InteractionInput
        {
            Name = ProfileInputName,
            Label = BrowserCommandStrings.ConfigureTrackedBrowserProfileLabel,
            Description = BrowserCommandStrings.ConfigureTrackedBrowserProfileDescription,
            InputType = InputType.Choice,
            Required = false,
            AllowCustomChoice = true,
            DynamicLoading = new InputLoadOptions
            {
                AlwaysLoadOnStart = true,
                DependsOnInputs = [BrowserInputName, UserDataModeInputName],
                LoadCallback = context =>
                {
                    var configurationManager = context.Services.GetRequiredService<BrowserLogsConfigurationManager>();
                    configurationManager.LoadProfileOptions(resource, context);
                    return Task.CompletedTask;
                }
            }
        };
 
        var saveInput = new InteractionInput
        {
            Name = SaveToUserSecretsInputName,
            Label = BrowserCommandStrings.ConfigureTrackedBrowserSaveToUserSecretsLabel,
            Description = userSecretsAvailable
                ? BrowserCommandStrings.ConfigureTrackedBrowserSaveToUserSecretsDescriptionConfigured
                : BrowserCommandStrings.ConfigureTrackedBrowserSaveToUserSecretsDescriptionNotConfigured,
            EnableDescriptionMarkdown = true,
            InputType = InputType.Boolean,
            Value = userSecretsAvailable ? "true" : null,
            Disabled = !userSecretsAvailable
        };
 
        return [scopeInput, browserInput, userDataModeInput, profileInput, saveInput];
 
        void AddKnownBrowser(string browser, string displayName)
        {
            if (ChromiumBrowserResolver.TryResolveExecutable(browser) is not null)
            {
                browserOptions.Add(new(browser, displayName));
            }
        }
    }
 
    public async Task<ExecuteCommandResult> ConfigureAsync(BrowserLogsResource resource, InteractionInputCollection arguments, CancellationToken _)
    {
        ArgumentNullException.ThrowIfNull(resource);
 
        var selected = BrowserLogsConfigurationSelection.FromInputs(arguments);
        var resolvedConfigurations = ResolveEffectiveConfigurations(resource, selected);
        Apply(resource, selected);
 
        foreach (var (browserLogsResource, browserConfiguration) in resolvedConfigurations)
        {
            await PublishConfigurationSnapshotAsync(browserLogsResource, browserConfiguration).ConfigureAwait(false);
        }
 
        var scopeName = selected.Scope == BrowserLogsConfigurationScope.Resource
            ? resource.ParentResource.Name
            : BrowserCommandStrings.ConfigureTrackedBrowserGlobalScopeResult;
        var resultMessage = selected.SaveToUserSecrets
            ? BrowserCommandStrings.ConfigureTrackedBrowserSaved
            : BrowserCommandStrings.ConfigureTrackedBrowserApplied;
 
        return new ExecuteCommandResult
        {
            Success = true,
            Message = string.Format(
                CultureInfo.CurrentCulture,
                resultMessage,
                scopeName)
        };
    }
 
    private void LoadBrowserValue(BrowserLogsResource resource, LoadInputContext context)
    {
        if (context.Input.Value is null)
        {
            var currentConfiguration = resource.ResolveCurrentConfiguration(configuration, configurationStore);
            context.Input.Value = currentConfiguration.Browser;
        }
    }
 
    private void LoadUserDataModeOptions(BrowserLogsResource resource, LoadInputContext context)
    {
        if (context.Input.Value is null)
        {
            var currentConfiguration = resource.ResolveCurrentConfiguration(configuration, configurationStore);
            context.Input.Value = currentConfiguration.UserDataMode.ToString();
        }
    }
 
    private void LoadProfileOptions(BrowserLogsResource resource, LoadInputContext context)
    {
        var currentConfiguration = resource.ResolveCurrentConfiguration(configuration, configurationStore);
        if (context.Input.Value is null)
        {
            context.Input.Value = currentConfiguration.Profile ?? BrowserDefaultProfileValue;
        }
 
        var browser = context.AllInputs[BrowserInputName].Value ?? currentConfiguration.Browser;
        var userDataModeValue = context.AllInputs[UserDataModeInputName].Value ?? currentConfiguration.UserDataMode.ToString();
        var profile = context.Input.Value;
 
        var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            [BrowserDefaultProfileValue] = BrowserCommandStrings.ConfigureTrackedBrowserDefaultProfileOption
        };
 
        var disableProfileInput = true;
        if (Enum.TryParse<BrowserUserDataMode>(userDataModeValue, ignoreCase: true, out var userDataMode) &&
            userDataMode == BrowserUserDataMode.Shared &&
            !string.IsNullOrWhiteSpace(browser))
        {
            disableProfileInput = false;
 
            try
            {
                // Profile discovery only makes sense for Shared mode. The Shared directory is a persistent
                // Aspire-managed Chromium user data directory with profile subdirectories such as "Default" and
                // "Profile 1". Isolated mode is per-AppHost and may not exist until the first browser launch, so
                // offering profile choices there would be misleading.
                var browserConfiguration = new BrowserConfiguration(
                    browser,
                    Profile: null,
                    BrowserUserDataMode.Shared,
                    configuration["AppHost:PathSha256"]);
                var userDataDirectory = BrowserUserDataPathResolver.Resolve(browserConfiguration, createDirectory: false);
                foreach (var browserProfile in ChromiumBrowserResolver.GetAvailableProfiles(userDataDirectory))
                {
                    options[browserProfile.DirectoryName] = FormatProfileOption(browserProfile);
                }
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Text.Json.JsonException)
            {
                logger.LogDebug(ex, "Unable to discover tracked browser profiles for '{Browser}'.", browser);
            }
        }
        else
        {
            context.Input.Value = BrowserDefaultProfileValue;
        }
 
        if (!string.IsNullOrWhiteSpace(profile) && !options.ContainsKey(profile))
        {
            options[profile] = profile;
        }
 
        context.Input.Options = [.. options.Select(static pair => new KeyValuePair<string, string>(pair.Key, pair.Value))];
        context.Input.Disabled = disableProfileInput;
    }
 
    private static string FormatProfileOption(ChromiumBrowserProfile profile)
    {
        if (!string.IsNullOrWhiteSpace(profile.DisplayName) &&
            !string.Equals(profile.DirectoryName, profile.DisplayName, StringComparison.OrdinalIgnoreCase))
        {
            return string.Format(
                CultureInfo.CurrentCulture,
                BrowserCommandStrings.ConfigureTrackedBrowserProfileOptionWithDisplayName,
                profile.DirectoryName,
                profile.DisplayName);
        }
 
        return profile.DirectoryName;
    }
 
    internal Task ValidateInputsAsync(BrowserLogsResource resource, InputsDialogValidationContext context)
    {
        var inputs = context.Inputs;
        var browser = inputs[BrowserInputName];
        var hasValidationErrors = false;
        if (string.IsNullOrWhiteSpace(browser.Value))
        {
            context.AddValidationError(browser, BrowserCommandStrings.ConfigureTrackedBrowserBrowserRequired);
            hasValidationErrors = true;
        }
 
        var userDataMode = inputs[UserDataModeInputName];
        if (!Enum.TryParse<BrowserUserDataMode>(userDataMode.Value, ignoreCase: true, out var parsedUserDataMode))
        {
            context.AddValidationError(userDataMode, BrowserCommandStrings.ConfigureTrackedBrowserUserDataModeRequired);
            hasValidationErrors = true;
        }
 
        var profile = inputs[ProfileInputName];
        if (parsedUserDataMode == BrowserUserDataMode.Isolated &&
            !string.IsNullOrWhiteSpace(profile.Value) &&
            !string.Equals(profile.Value, BrowserDefaultProfileValue, StringComparison.Ordinal))
        {
            context.AddValidationError(profile, BrowserCommandStrings.ConfigureTrackedBrowserProfileRequiresShared);
            hasValidationErrors = true;
        }
 
        var saveToUserSecrets = inputs[SaveToUserSecretsInputName];
        if (IsSaveToUserSecretsRequested(inputs) && !userSecretsManager.IsAvailable)
        {
            context.AddValidationError(saveToUserSecrets, BrowserCommandStrings.ConfigureTrackedBrowserUserSecretsUnavailable);
            hasValidationErrors = true;
        }
 
        if (!hasValidationErrors)
        {
            try
            {
                // Resolve the final effective configuration so explicit WithBrowserLogs values are validated before
                // applying runtime settings or mutating user secrets.
                _ = ResolveEffectiveConfigurations(resource, BrowserLogsConfigurationSelection.FromInputs(inputs));
            }
            catch (InvalidOperationException ex)
            {
                context.AddValidationError(userDataMode, ex.Message);
            }
        }
 
        return Task.CompletedTask;
    }
 
    private List<(BrowserLogsResource Resource, BrowserConfiguration Configuration)> ResolveEffectiveConfigurations(
        BrowserLogsResource commandResource,
        BrowserLogsConfigurationSelection selected)
    {
        var selectedConfiguration = ToBrowserConfiguration(selected);
        IEnumerable<BrowserLogsResource> resources = selected.Scope == BrowserLogsConfigurationScope.Global
            ? applicationModel.Resources.OfType<BrowserLogsResource>()
            : [commandResource];
 
        return [.. resources.Select(resource =>
            (resource, ResolveEffectiveConfiguration(resource, commandResource, selected, selectedConfiguration)))];
    }
 
    private BrowserConfiguration ResolveEffectiveConfiguration(
        BrowserLogsResource resource,
        BrowserLogsResource commandResource,
        BrowserLogsConfigurationSelection selected,
        BrowserConfiguration selectedConfiguration)
    {
        var (resourceConfiguration, globalConfiguration) = configurationStore.GetConfigurations(resource.ParentResource.Name);
        if (selected.Scope == BrowserLogsConfigurationScope.Global)
        {
            globalConfiguration = selectedConfiguration;
        }
        else if (ReferenceEquals(resource, commandResource))
        {
            resourceConfiguration = selectedConfiguration;
        }
 
        return BrowserConfiguration.Resolve(
            configuration,
            resource.ParentResource.Name,
            resource.ExplicitConfigurationValues,
            resourceConfiguration,
            globalConfiguration);
    }
 
    private BrowserConfiguration ToBrowserConfiguration(BrowserLogsConfigurationSelection selected)
    {
        return new BrowserConfiguration(
            selected.Browser,
            selected.Profile,
            selected.UserDataMode,
            configuration["AppHost:PathSha256"]);
    }
 
    private void Apply(BrowserLogsResource resource, BrowserLogsConfigurationSelection selected)
    {
        var configurationPrefix = selected.Scope == BrowserLogsConfigurationScope.Resource
            ? $"{BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName}:{resource.ParentResource.Name}"
            : BrowserLogsBuilderExtensions.BrowserLogsConfigurationSectionName;
 
        if (selected.SaveToUserSecrets)
        {
            // IUserSecretsManager persists one key at a time, so a later failure can leave earlier secret mutations
            // on disk. Only update the runtime store after every requested mutation succeeds, so the current AppHost
            // never observes a partial save.
            SaveValue($"{configurationPrefix}:{BrowserLogsBuilderExtensions.BrowserConfigurationKey}", selected.Browser);
            SaveValue($"{configurationPrefix}:{BrowserLogsBuilderExtensions.UserDataModeConfigurationKey}", selected.UserDataMode.ToString());
 
            var profileKey = $"{configurationPrefix}:{BrowserLogsBuilderExtensions.ProfileConfigurationKey}";
            if (selected.Profile is { } profile)
            {
                SaveValue(profileKey, profile);
            }
            else
            {
                DeleteValue(profileKey);
            }
        }
 
        configurationStore.Set(selected.Scope, resource.ParentResource.Name, ToBrowserConfiguration(selected));
    }
 
    private void SaveValue(string key, string value)
    {
        if (!userSecretsManager.TrySetSecret(key, value))
        {
            throw new InvalidOperationException(
                string.Format(
                    CultureInfo.CurrentCulture,
                    BrowserCommandStrings.ConfigureTrackedBrowserSaveFailed,
                    key));
        }
    }
 
    private void DeleteValue(string key)
    {
        if (!userSecretsManager.TryDeleteSecret(key))
        {
            throw new InvalidOperationException(
                string.Format(
                    CultureInfo.CurrentCulture,
                    BrowserCommandStrings.ConfigureTrackedBrowserSaveFailed,
                    key));
        }
    }
 
    private Task PublishConfigurationSnapshotAsync(BrowserLogsResource resource, BrowserConfiguration browserConfiguration)
    {
        return resourceNotificationService.PublishUpdateAsync(resource, snapshot => snapshot with
        {
            Properties = SetConfigurationProperties(snapshot.Properties, browserConfiguration)
        });
    }
 
    private static System.Collections.Immutable.ImmutableArray<ResourcePropertySnapshot> SetConfigurationProperties(
        System.Collections.Immutable.ImmutableArray<ResourcePropertySnapshot> properties,
        BrowserConfiguration browserConfiguration)
    {
        properties = properties
            .SetResourceProperty(BrowserLogsBuilderExtensions.BrowserPropertyName, browserConfiguration.Browser)
            .SetResourceProperty(BrowserLogsBuilderExtensions.UserDataModePropertyName, browserConfiguration.UserDataMode.ToString());
 
        return browserConfiguration.Profile is { } profile
            ? properties.SetResourceProperty(BrowserLogsBuilderExtensions.ProfilePropertyName, profile)
            : RemoveProperty(properties, BrowserLogsBuilderExtensions.ProfilePropertyName);
    }
 
    private static System.Collections.Immutable.ImmutableArray<ResourcePropertySnapshot> RemoveProperty(
        System.Collections.Immutable.ImmutableArray<ResourcePropertySnapshot> properties,
        string name)
    {
        for (var i = 0; i < properties.Length; i++)
        {
            if (string.Equals(properties[i].Name, name, StringComparisons.ResourcePropertyName))
            {
                return properties.RemoveAt(i);
            }
        }
 
        return properties;
    }
 
    private readonly record struct BrowserLogsConfigurationSelection(
        BrowserLogsConfigurationScope Scope,
        string Browser,
        BrowserUserDataMode UserDataMode,
        string? Profile,
        bool SaveToUserSecrets)
    {
        public static BrowserLogsConfigurationSelection FromInputs(InteractionInputCollection inputs)
        {
            var scope = string.Equals(inputs[ScopeInputName].Value, GlobalScopeValue, StringComparison.Ordinal)
                ? BrowserLogsConfigurationScope.Global
                : BrowserLogsConfigurationScope.Resource;
            var browser = inputs[BrowserInputName].Value ?? string.Empty;
            var userDataMode = Enum.Parse<BrowserUserDataMode>(inputs[UserDataModeInputName].Value!, ignoreCase: true);
            var profileValue = inputs[ProfileInputName].Value;
            var profile = string.IsNullOrWhiteSpace(profileValue) ||
                string.Equals(profileValue, BrowserDefaultProfileValue, StringComparison.Ordinal)
                    ? null
                    : profileValue;
            var saveToUserSecrets = IsSaveToUserSecretsRequested(inputs);
 
            return new BrowserLogsConfigurationSelection(scope, browser, userDataMode, profile, saveToUserSecrets);
        }
    }
 
    private static bool IsSaveToUserSecretsRequested(InteractionInputCollection inputs)
    {
        return inputs[SaveToUserSecretsInputName].Value is { Length: > 0 } saveValue &&
            bool.TryParse(saveValue, out var saveToUserSecrets) &&
            saveToUserSecrets;
    }
}
 
#pragma warning restore ASPIREUSERSECRETS001