| File: src\Aspire.Tray\Common\TrayConfiguration.cs | Web Access |
| Project: src\tests\Aspire.Tray.Tests\Aspire.Tray.Tests.csproj (Aspire.Tray.Tests) |
// 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.Json; namespace Aspire.Tray; /// <summary> /// Reads tray options from the CLI's existing user configuration without modifying it. /// </summary> internal static class TrayConfiguration { internal const string RecentAppHostLimitKey = "tray.recentAppHostLimit"; public static string GetSettingsPath(string cliPath) => GetSettingsPath(cliPath, Environment.GetEnvironmentVariable("ASPIRE_HOME"), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); public static string GetSettingsPath(string cliPath, string? configuredAspireHome, string userProfileDirectory) => Path.Combine(GetAspireHome(cliPath, configuredAspireHome, userProfileDirectory), "aspire.config.json"); public static string GetSavedStatePath(string cliPath) => GetSavedStatePath(cliPath, Environment.GetEnvironmentVariable("ASPIRE_HOME"), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); public static string GetSavedStatePath(string cliPath, string? configuredAspireHome, string userProfileDirectory) => Path.Combine(GetAspireHome(cliPath, configuredAspireHome, userProfileDirectory), "tray", "apphosts.json"); private static string GetAspireHome(string cliPath, string? configuredAspireHome, string userProfileDirectory) { // Match CLI Program.GetGlobalSettingsPath and CliPathHelper.GetAspireHomeDirectory: // script/localhive: <home>/bin/aspire; PR: <home>/dogfood/pr-123/bin/aspire. // Other installation routes use ASPIRE_HOME or ~/.aspire. settings.json is the // legacy project-local file, not the user configuration written by config --global. var home = TryGetInstalledHome(cliPath) ?? (string.IsNullOrWhiteSpace(configuredAspireHome) ? Path.Combine(userProfileDirectory, ".aspire") : configuredAspireHome); return TrayAppHostPath.Normalize(home); } /// <summary> /// Reads a limit of 0–50 from an explicitly selected configuration file; missing settings use 10. /// </summary> public static int LoadRecentAppHostLimit(string settingsPath) { settingsPath = TrayAppHostPath.Normalize(settingsPath); try { using var stream = File.OpenRead(settingsPath); // `aspire config set tray.recentAppHostLimit 10 --global` writes: // { "tray": { "recentAppHostLimit": "10" } } // Hand-edited integer values are also accepted. As in CLI ConfigurationHelper, // comments and trailing commas are supported; unrelated settings are never rewritten. // Legacy flattened "tray:recentAppHostLimit" keys are also accepted, but duplicate // case-insensitive keys or nested/flattened conflicts are rejected as ambiguous. using var document = JsonDocument.Parse(stream, new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }); var root = document.RootElement; if (root.ValueKind != JsonValueKind.Object) { throw InvalidConfiguration(settingsPath); } var hasValue = TryGetProperty(root, "tray:recentAppHostLimit", settingsPath, out var value); if (TryGetProperty(root, "tray", settingsPath, out var tray)) { if (tray.ValueKind != JsonValueKind.Object) { throw InvalidConfiguration(settingsPath); } if (TryGetProperty(tray, "recentAppHostLimit", settingsPath, out var nestedValue)) { if (hasValue) { throw InvalidConfiguration(settingsPath); } value = nestedValue; hasValue = true; } } if (!hasValue) { return TraySavedState.DefaultRecentAppHostLimit; } var limit = 0; var valid = value.ValueKind switch { JsonValueKind.Number => value.TryGetInt32(out limit), JsonValueKind.String => int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out limit), _ => false }; if (!valid || limit is < 0 or > TraySavedState.MaximumRecentAppHosts) { throw InvalidConfiguration(settingsPath); } return limit; } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { return TraySavedState.DefaultRecentAppHostLimit; } catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or System.Security.SecurityException) { throw InvalidConfiguration(settingsPath, ex); } } private static bool TryGetProperty(JsonElement element, string name, string settingsPath, out JsonElement value) { value = default; var found = false; foreach (var property in element.EnumerateObject()) { if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) { if (found) { throw InvalidConfiguration(settingsPath); } value = property.Value; found = true; } } return found; } private static InvalidOperationException InvalidConfiguration(string settingsPath, Exception? inner = null) => new($"Unable to read {RecentAppHostLimitKey} from '{settingsPath}'. The file must contain a valid JSON object " + $"and {RecentAppHostLimitKey} must be an integer from 0 to {TraySavedState.MaximumRecentAppHosts}. " + $"Fix the file or run 'aspire config set {RecentAppHostLimitKey} 10 --global', then restart the tray.", inner); private static string? TryGetInstalledHome(string cliPath) { try { var binary = new FileInfo(cliPath).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? cliPath; var directory = Path.GetDirectoryName(binary)!; using var stream = File.OpenRead(Path.Combine(directory, ".aspire-install.json")); // Keep the CLI InstallSidecarReader's 64 KiB bound and source-only read: // {"source":"script"}; missing/unknown installation metadata uses the default home. if (stream.Length > 64 * 1024) { return null; } using var document = JsonDocument.Parse(stream); if (document.RootElement.ValueKind != JsonValueKind.Object || !document.RootElement.TryGetProperty("source", out var source) || source.ValueKind != JsonValueKind.String) { return null; } if (source.GetString() is "script" or "localhive") { return Path.GetDirectoryName(directory) ?? directory; } var dogfood = Path.GetDirectoryName(Path.GetDirectoryName(directory)); return source.GetString() == "pr" && dogfood is not null && Path.GetFileName(dogfood) == "dogfood" ? Path.GetDirectoryName(dogfood) : null; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or ArgumentException or NotSupportedException or System.Security.SecurityException) { // Match the CLI's fallback for absent or unreadable installation metadata. // This does not suppress errors from the selected configuration file. return null; } } }