| File: src\Aspire.Tray\Common\TraySavedState.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.Text.Json.Serialization; namespace Aspire.Tray; /// <summary> /// Stores source paths in most-recent-first order, independently of live process metadata. /// </summary> internal sealed record TraySavedState(IReadOnlyList<SavedAppHost> AppHosts) { internal const int DefaultRecentAppHostLimit = 10; internal const int MaximumRecentAppHosts = 50; public static TraySavedState Empty { get; } = new([]); // The source generator otherwise supplies default(bool) for an absent init-only // property, overriding its initializer. Make the legacy-file default explicit in // the JSON constructor so old files never silently opt out of stop confirmation. [JsonConstructor] public TraySavedState(IReadOnlyList<SavedAppHost> appHosts, bool confirmStop = true) : this(appHosts) { ConfirmStop = confirmStop; } /// <summary> /// Requires confirmation before stopping an AppHost, including for files written before this preference existed. /// </summary> public bool ConfirmStop { get; init; } = true; public TraySavedState Remember(string appHostPath) => Remember(appHostPath, DefaultRecentAppHostLimit); public TraySavedState Remember(string appHostPath, int recentAppHostLimit) { var existing = AppHosts.SingleOrDefault(host => TrayAppHostPath.Comparer.Equals(host.AppHostPath, appHostPath)); return (this with { AppHosts = [new(appHostPath, existing?.IsPinned ?? false, true), .. AppHosts.Where(host => !TrayAppHostPath.Comparer.Equals(host.AppHostPath, appHostPath))] }).Trim(recentAppHostLimit); } public TraySavedState SetPinned(string appHostPath, bool pinned) { var existing = AppHosts.SingleOrDefault(host => TrayAppHostPath.Comparer.Equals(host.AppHostPath, appHostPath)); if (existing is null) { return pinned ? this with { AppHosts = [.. AppHosts, new(appHostPath, true, false)] } : this; } return this with { AppHosts = AppHosts.Select(host => host == existing ? host with { IsPinned = pinned } : host) .Where(host => host.IsPinned || host.IsRecent).ToArray() }; } public TraySavedState ClearRecent() => this with { AppHosts = AppHosts.Where(host => host.IsPinned).Select(host => host with { IsRecent = false }).ToArray() }; public TraySavedState RemoveRecent(string appHostPath) => this with { AppHosts = AppHosts.Select(host => TrayAppHostPath.Comparer.Equals(host.AppHostPath, appHostPath) ? host with { IsRecent = false } : host) .Where(host => host.IsPinned || host.IsRecent).ToArray() }; public TraySavedState RemoveMissingPins(IReadOnlySet<string> missingPaths) => this with { AppHosts = AppHosts.Where(host => !host.IsPinned || !missingPaths.Contains(host.AppHostPath)).ToArray() }; public TraySavedState Trim(int recentAppHostLimit) { ArgumentOutOfRangeException.ThrowIfNegative(recentAppHostLimit); ArgumentOutOfRangeException.ThrowIfGreaterThan(recentAppHostLimit, MaximumRecentAppHosts); var recent = 0; return this with { AppHosts = AppHosts.Select(host => host.IsRecent && ++recent > recentAppHostLimit ? host with { IsRecent = false } : host) .Where(host => host.IsPinned || host.IsRecent).ToArray() }; } } internal sealed record SavedAppHost(string AppHostPath, bool IsPinned, bool IsRecent); /// <summary> /// Persists tray history and pins without process identities or dashboard credentials. /// </summary> internal interface ITraySavedStateStore { TraySavedState Load(); void Save(TraySavedState state); } internal sealed class MemoryTraySavedStateStore : ITraySavedStateStore { private TraySavedState _state = TraySavedState.Empty; public TraySavedState Load() => _state; public void Save(TraySavedState state) => _state = state; } internal static class TrayAppHostPath { public static StringComparer Comparer { get; } = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; public static string Normalize(string path) { if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || path.Contains('\0')) { throw new ArgumentException("An absolute AppHost source path is required.", nameof(path)); } return Path.GetFullPath(path); } public static string RequireExistingFile(string path) { var normalized = Normalize(path); try { if ((GetSourceAttributes(normalized) & FileAttributes.Directory) == 0) { return normalized; } } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { } throw new FileNotFoundException("The AppHost source file no longer exists."); } public static bool IsMissing(string path) { try { GetSourceAttributes(path); return false; } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { return true; } } private static FileAttributes GetSourceAttributes(string path) { // File.Exists also returns false for access and I/O failures. Those failures must // preserve preferences, not be treated as evidence that a project was deleted. var attributes = File.GetAttributes(path); if ((attributes & FileAttributes.ReparsePoint) != 0 && File.ResolveLinkTarget(path, returnFinalTarget: true) is { } target) { return File.GetAttributes(target.FullName); } return attributes; } }