| File: Terminal\TerminalViewSessionRegistry.cs | Web Access |
| Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Primitives; namespace Aspire.Dashboard.Terminal; /// <summary> /// Shares a component's per-view input policy with its terminal WebSocket handler. /// </summary> public sealed class TerminalViewSessionRegistry { private readonly ConcurrentDictionary<string, TerminalViewSession> _sessions = new(StringComparer.Ordinal); /// <summary> /// Registers a terminal view until the returned registration is disposed. /// </summary> /// <param name="endpointPathAndQuery">The resolved terminal endpoint, including any dashboard path base.</param> /// <param name="readOnly">Whether the view initially rejects input.</param> /// <returns>The component-owned registration.</returns> public TerminalViewSession Create(string endpointPathAndQuery, bool readOnly) { var session = new TerminalViewSession(endpointPathAndQuery, readOnly, id => _sessions.TryRemove(id, out _)); if (!_sessions.TryAdd(session.Id, session)) { throw new InvalidOperationException("A terminal view with the generated identifier already exists."); } return session; } /// <summary> /// Finds a live registration for the requested terminal endpoint. /// </summary> /// <param name="id">The view identifier supplied by the component.</param> /// <param name="endpointPathAndQuery">The requested endpoint, including its path base and optional viewId query parameter.</param> /// <param name="session">The matching registration.</param> /// <returns>Whether a matching registration exists.</returns> public bool TryGet(string id, string endpointPathAndQuery, [NotNullWhen(true)] out TerminalViewSession? session) { if (_sessions.TryGetValue(id, out var candidate) && candidate.MatchesEndpoint(endpointPathAndQuery)) { session = candidate; return true; } session = null; return false; } } /// <summary> /// Holds input policy for one browser view without owning the terminal or affecting other viewers. /// </summary> public sealed class TerminalViewSession : IDisposable { private readonly string _endpointPath; private readonly Dictionary<string, StringValues> _endpointQuery; private readonly Action<string> _unregister; private readonly TaskCompletionSource _ended = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _readOnly; private int _disposed; internal TerminalViewSession(string endpointPathAndQuery, bool readOnly, Action<string> unregister) { var separator = endpointPathAndQuery.IndexOf('?'); _endpointPath = separator < 0 ? endpointPathAndQuery : endpointPathAndQuery[..separator]; _endpointQuery = QueryHelpers.ParseQuery(separator < 0 ? string.Empty : endpointPathAndQuery[separator..]); _endpointQuery.Remove("viewId"); _readOnly = readOnly ? 1 : 0; _unregister = unregister; } /// <summary> /// Gets the opaque identifier that associates the component with its WebSocket connection. /// </summary> public string Id { get; } = Guid.NewGuid().ToString("N"); /// <summary> /// Gets a task that completes only when the upstream explicitly reports producer completion. /// </summary> public Task Ended => _ended.Task; /// <summary> /// Gets or sets whether this view rejects input. Ended and disposed registrations always reject input. /// </summary> public bool ReadOnly { get => Volatile.Read(ref _disposed) != 0 || _ended.Task.IsCompleted || Volatile.Read(ref _readOnly) != 0; set => Volatile.Write(ref _readOnly, value ? 1 : 0); } /// <summary> /// Records authoritative producer completion without closing the browser view. /// </summary> public void MarkEnded() => _ended.TrySetResult(); internal bool MatchesEndpoint(string endpointPathAndQuery) { if (Volatile.Read(ref _disposed) != 0) { return false; } // Compare endpoints such as "/dashboard/api/terminal?resource=a%20b&replica=0&viewId=..." // semantically: query ordering and equivalent escaping must not invalidate a registration. var separator = endpointPathAndQuery.IndexOf('?'); var path = separator < 0 ? endpointPathAndQuery : endpointPathAndQuery[..separator]; if (!string.Equals(_endpointPath, path, StringComparison.Ordinal)) { return false; } var query = QueryHelpers.ParseQuery(separator < 0 ? string.Empty : endpointPathAndQuery[separator..]); query.Remove("viewId"); return _endpointQuery.Count == query.Count && _endpointQuery.All(pair => query.TryGetValue(pair.Key, out var values) && pair.Value.SequenceEqual(values, StringComparer.Ordinal)); } /// <summary> /// Unregisters this view and disables any connection still holding its policy. /// </summary> public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 0) { _unregister(Id); } } }