File: BrowserUserDataPathResolver.cs
Web Access
Project: src\src\Aspire.Hosting.Browsers\Aspire.Hosting.Browsers.csproj (Aspire.Hosting.Browsers)
// 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 ASPIREBROWSERLOGS001 // Type is for evaluation purposes only
 
using System.Globalization;
using Aspire.Hosting.Browsers.Resources;
 
namespace Aspire.Hosting;
 
// Resolves the local filesystem location used as a Chromium --user-data-dir for a tracked browser session.
//
// The resolver intentionally never points at the user's real browser profile root. Pointing Aspire at the real
// profile is fragile in practice: Chromium's per-user-data-dir singleton means a second launch silently hands
// off to the already-running browser, App-Bound Encryption (Chrome/Edge 127+) ties cookies to the launching
// process identity, and a normal user browser holds locks that prevent us from ever opening a debug-enabled
// instance. Both Shared and Isolated therefore live under an Aspire-managed root that can always be opened with
// remote debugging enabled.
//
// Layout (Windows shown; macOS/Linux mirror the same shape under the OS-appropriate data root):
//   %LocalAppData%\Aspire\BrowserData\shared\<browser>                              (Shared)
//   %LocalAppData%\Aspire\BrowserData\isolated\<AppHost:PathSha256>\<browser>       (Isolated)
//     Local State                                                                  Chromium user-data metadata
//     Default\                                                                     default profile directory
//       Preferences
//       Cookies
//       ...
//     Profile 1\                                                                   additional profile directory
//       Preferences
//       Cookies
//       ...
//     Profile 2\
//       ...
//
// Chromium calls the outer <browser> folder a user data directory. Profiles are subdirectories inside that user
// data directory. The stable command-line value for --profile-directory is the directory name ("Default",
// "Profile 1", ...), not the display name the user sees in the browser. Display names live in the user-data-root
// "Local State" file under profile.info_cache, keyed by the profile directory name.
//
// Both paths are persistent. AppHost shutdown does not delete them, but the default pipe-backed browser process is
// still owned by the current AppHost. WebSocket endpoint metadata is only for explicit attach/adoption paths.
internal static class BrowserUserDataPathResolver
{
    // SHA-256 prefix length used for the per-AppHost segment. AppHost:PathSha256 is the full hex-encoded
    // SHA-256 of the AppHost project path; the full hash is unnecessary for a filesystem segment and a
    // shorter prefix keeps the resulting paths well under Windows' MAX_PATH for nested Chromium profile
    // sub-directories.
    private const int AppHostShaSegmentLength = 16;
 
    public static string Resolve(BrowserConfiguration configuration, bool createDirectory = true)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(configuration.Browser);
 
        var root = GetAspireBrowserDataRoot();
        var browserSegment = NormalizeBrowserSegment(configuration.Browser);
 
        var path = configuration.UserDataMode switch
        {
            BrowserUserDataMode.Shared => Path.Combine(root, "shared", browserSegment),
            BrowserUserDataMode.Isolated => Path.Combine(root, "isolated", GetAppHostSegment(configuration), browserSegment),
            _ => throw new ArgumentOutOfRangeException(nameof(configuration), configuration.UserDataMode, null)
        };
 
        if (createDirectory)
        {
            Directory.CreateDirectory(path);
        }
 
        return path;
    }
 
    private static string GetAspireBrowserDataRoot()
    {
        if (OperatingSystem.IsWindows())
        {
            return Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
                "Aspire",
                "BrowserData");
        }
 
        if (OperatingSystem.IsMacOS())
        {
            return Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
                "Library",
                "Application Support",
                "Aspire",
                "BrowserData");
        }
 
        // XDG: prefer XDG_DATA_HOME, fall back to ~/.local/share. Lower-case segment names match the
        // conventional XDG layout (e.g. ~/.config/google-chrome).
        var xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
        var dataHome = !string.IsNullOrEmpty(xdgDataHome)
            ? xdgDataHome
            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share");
        return Path.Combine(dataHome, "aspire", "browser-data");
    }
 
    private static string GetAppHostSegment(BrowserConfiguration configuration)
    {
        if (string.IsNullOrWhiteSpace(configuration.AppHostKey))
        {
            throw new InvalidOperationException(
                string.Format(
                    CultureInfo.CurrentCulture,
                    BrowserMessageStrings.BrowserLogsAppHostPathShaNotAvailable,
                    BrowserUserDataMode.Isolated));
        }
 
        return configuration.AppHostKey.Length > AppHostShaSegmentLength
            ? configuration.AppHostKey[..AppHostShaSegmentLength]
            : configuration.AppHostKey;
    }
 
    // Maps a logical browser name or executable path to a stable lower-case folder segment so a Chrome -> Edge
    // configuration flip never silently shares state with the previous browser. Unknown executables fall back to
    // a sanitized form of the file name without extension.
    private static string NormalizeBrowserSegment(string browser)
    {
        var name = Path.IsPathRooted(browser) || Path.IsPathFullyQualified(browser)
            ? Path.GetFileNameWithoutExtension(browser)
            : browser;
 
        var lower = name.ToLowerInvariant();
        return lower switch
        {
            "msedge" or "edge" or "microsoft-edge" or "microsoft-edge-stable" or "microsoft edge" => "msedge",
            "chrome" or "google-chrome" or "google-chrome-stable" or "google chrome" => "chrome",
            "chromium" or "chromium-browser" => "chromium",
            _ => Sanitize(lower)
        };
    }
 
    private static string Sanitize(string value)
    {
        var invalidChars = Path.GetInvalidFileNameChars();
        var buffer = new char[value.Length];
        for (var i = 0; i < value.Length; i++)
        {
            var c = value[i];
            buffer[i] = Array.IndexOf(invalidChars, c) >= 0 || c == ' ' ? '_' : c;
        }
 
        var result = new string(buffer);
        return string.IsNullOrEmpty(result) ? "browser" : result;
    }
}