File: Components\Pages\TerminalWindow.razor.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 Aspire.DashboardService.Proto.V1;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Components.Pages;
 
/// <summary>
/// Renders a single terminal as an entire browser window, with no dashboard chrome around it.
/// </summary>
/// <remarks>
/// <para>
/// This is what the dashboard opens when the user detaches a terminal. Because terminals are multi-headed, the window
/// is just another viewer: it reaches the dashboard on its own and keeps working after the page that spawned it is
/// reloaded or closed.
/// </para>
/// <para>
/// Opening the window requests primary once and fits the grid at the opener's selected font size. While primary,
/// the window resizes the grid to its viewport without changing that font size.
/// </para>
/// </remarks>
public sealed partial class TerminalWindow : ComponentBase, IAsyncDisposable
{
    private string? _endpoint;
    private string _title = string.Empty;
    private bool _ended;
    private bool _disposed;
    private (string? TerminalId, string? ResourceName, int ReplicaIndex, string? WindowOwner, string? WindowGeneration)? _routeIdentity;
    private int _watchGeneration;
    private CancellationTokenSource? _watchCts;
    // Also tracks in-flight cancellation so overlapping route changes and disposal join the same cleanup.
    private Task _watchTask = Task.CompletedTask;
    private IJSObjectReference? _windowModule;
    private DotNetObjectReference<TerminalWindow>? _windowReference;
    private Task? _windowRegistrationTask;
    private string? _windowRegistrationId;
    private bool _windowReady = true;
    private bool _windowTrackingFailed;
 
    /// <summary>
    /// Gets or sets the id of an AppHost-owned dock terminal to attach to.
    /// </summary>
    [Parameter]
    public string? TerminalId { get; set; }
 
    /// <summary>
    /// Gets or sets the name of the resource whose terminal to attach to.
    /// </summary>
    [Parameter]
    public string? ResourceName { get; set; }
 
    /// <summary>
    /// Gets or sets the 0-based replica index of the resource terminal to attach to.
    /// </summary>
    [Parameter]
    public int ReplicaIndex { get; set; }
 
    /// <summary>Gets or sets the font size carried from the terminal's originating surface.</summary>
    [SupplyParameterFromQuery(Name = "fontSize")]
    public int? FontSize { get; set; }
 
    /// <summary>Gets or sets the opener identity carried by a coordinated dock window.</summary>
    [SupplyParameterFromQuery(Name = "windowOwner")]
    public string? WindowOwner { get; set; }
 
    /// <summary>Gets or sets the detachment generation carried by a coordinated dock window.</summary>
    [SupplyParameterFromQuery(Name = "windowGeneration")]
    public string? WindowGeneration { get; set; }
 
    [Inject]
    public required IJSRuntime JS { get; init; }
 
    [Inject]
    public required NavigationManager NavigationManager { get; init; }
 
    [Inject]
    public required IDashboardClient DashboardClient { get; init; }
 
    [Inject]
    public required IStringLocalizer<Dashboard.Resources.TerminalStrings> Loc { get; init; }
 
    [Inject]
    public required ILogger<TerminalWindow> Logger { get; init; }
 
    protected override async Task OnParametersSetAsync()
    {
        var terminalId = TerminalId is { Length: > 0 } ? TerminalId : null;
        var resourceName = terminalId is null && ResourceName is { Length: > 0 } ? ResourceName : null;
        var replicaIndex = resourceName is not null ? ReplicaIndex : 0;
        var routeIdentity = (terminalId, resourceName, replicaIndex, WindowOwner, WindowGeneration);
        if (_disposed || _routeIdentity == routeIdentity)
        {
            return;
        }
 
        _routeIdentity = routeIdentity;
        var generation = ++_watchGeneration;
        _ended = false;
        _windowTrackingFailed = false;
        _windowReady = terminalId is null || (WindowOwner is null && WindowGeneration is null);
        _endpoint = terminalId is not null ? $"api/apphost-terminal?terminalId={Uri.EscapeDataString(terminalId)}" : null;
        _title = terminalId ?? (resourceName is not null
            ? replicaIndex > 0 ? $"{resourceName} #{replicaIndex}" : resourceName
            : string.Empty);
 
        await StopWindowTrackingAsync(release: true);
        if (_disposed || generation != _watchGeneration)
        {
            return;
        }
        _windowRegistrationTask = null;
        await StopWatchingAsync();
        if (_disposed || generation != _watchGeneration || terminalId is null)
        {
            return;
        }
 
        // Only AppHost terminals need metadata updates. A newer route may have replaced this one while
        // cancellation was awaiting an old watch, so don't start a subscription until its identity is rechecked.
        _watchCts = new CancellationTokenSource();
        var cancellationToken = _watchCts.Token;
        _watchTask = Task.Run(() => WatchTerminalsAsync(terminalId, generation, cancellationToken), cancellationToken);
    }
 
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (_disposed)
        {
            return;
        }
        if (_ended)
        {
            await StopWindowTrackingAsync(release: true);
        }
        else if (!_windowReady && !_windowTrackingFailed && _windowRegistrationTask is null && TerminalId is { } terminalId)
        {
            _windowRegistrationTask = RegisterWindowAsync(terminalId, _watchGeneration);
            await _windowRegistrationTask;
        }
    }
 
    private async Task RegisterWindowAsync(string terminalId, int generation)
    {
        try
        {
            var moduleUri = new Uri(new Uri(NavigationManager.BaseUri), Assets["js/app-terminalwindow.js"]);
            _windowModule ??= await JS.InvokeAsync<IJSObjectReference>("import", moduleUri.PathAndQuery);
            if (_disposed || generation != _watchGeneration)
            {
                return;
            }
            _windowReference ??= DotNetObjectReference.Create(this);
            var id = _windowRegistrationId = Guid.NewGuid().ToString("N");
            var ready = await _windowModule.InvokeAsync<bool>("registerDetachedTerminalWindow",
                id, terminalId, NavigationManager.BaseUri, _windowReference);
            if (!_disposed && generation == _watchGeneration && !_windowTrackingFailed)
            {
                // A reload must check durable revocation before mounting an auto-fit viewer. Returning a window
                // while this document was loading must not let it take sizing control again.
                _windowReady = ready && !_ended;
                _ended |= !ready;
                StateHasChanged();
            }
        }
        catch (JSDisconnectedException)
        {
            // A new document will independently validate its generation.
        }
        catch (Exception ex)
        {
            Logger.LogWarning(ex, "Failed to coordinate the detached terminal window.");
            if (!_disposed && generation == _watchGeneration)
            {
                _windowTrackingFailed = true;
                StateHasChanged();
            }
        }
    }
 
    /// <summary>Stops rendering a detached viewer after its generation was explicitly returned or replaced.</summary>
    /// <param name="id">The browser registration to revoke.</param>
    /// <returns>A task that completes after the viewer is removed.</returns>
    [JSInvokable]
    public Task OnDetachedTerminalWindowRevokedAsync(string id) => InvokeAsync(() =>
    {
        if (!_disposed && _windowRegistrationId == id)
        {
            _ended = true;
            _windowReady = false;
            StateHasChanged();
        }
    });
 
    /// <summary>Reports a browser coordination failure without treating it as a successfully recovered window.</summary>
    /// <param name="id">The affected browser registration.</param>
    /// <returns>A task that completes after the failure is displayed.</returns>
    [JSInvokable]
    public Task OnDetachedTerminalWindowTrackingFailedAsync(string id) => InvokeAsync(() =>
    {
        if (!_disposed && _windowRegistrationId == id)
        {
            _windowTrackingFailed = true;
            _windowReady = false;
            StateHasChanged();
        }
    });
 
    private async Task StopWindowTrackingAsync(bool release)
    {
        if (_windowRegistrationTask is { } registration)
        {
            await registration;
        }
        if (_windowModule is { } module && _windowRegistrationId is { } id)
        {
            _windowRegistrationId = null;
            try
            {
                await module.InvokeVoidAsync(release ? "releaseDetachedTerminalWindow" : "unregisterDetachedTerminalWindow", id);
            }
            catch (JSDisconnectedException)
            {
                // Disposal on document reload must not revoke the durable detachment.
            }
            catch (Exception ex)
            {
                Logger.LogWarning(ex, "Failed to release detached terminal window tracking.");
            }
        }
    }
 
    private async Task WatchTerminalsAsync(string terminalId, int generation, CancellationToken cancellationToken)
    {
        try
        {
            await foreach (var update in DashboardClient.SubscribeTerminalsAsync(cancellationToken).ConfigureAwait(false))
            {
                await InvokeAsync(() =>
                {
                    // An update can already be queued on the renderer when its subscription is cancelled.
                    // Compare generations, not just IDs: navigating away and back also replaces the watch.
                    if (_disposed || generation != _watchGeneration || cancellationToken.IsCancellationRequested)
                    {
                        return;
                    }
 
                    var changed = update.KindCase switch
                    {
                        WatchTerminalsUpdate.KindOneofCase.Snapshot => ApplySnapshot(terminalId, update.Snapshot),
                        WatchTerminalsUpdate.KindOneofCase.Change => ApplyChange(terminalId, update.Change),
                        _ => false
                    };
 
                    if (changed)
                    {
                        StateHasChanged();
                    }
                }).ConfigureAwait(false);
            }
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // The window is closing or has switched to another terminal.
        }
        catch (Exception ex)
        {
            // Transport failures are retried by the client. Log unexpected failures without failing the circuit.
            Logger.LogWarning(ex, "Terminal window watch stream ended unexpectedly.");
        }
    }
 
    private bool ApplySnapshot(string terminalId, TerminalDescriptorList snapshot)
    {
        var descriptor = snapshot.Terminals.FirstOrDefault(t => t.TerminalId == terminalId);
        if (descriptor is null)
        {
            // Detached windows can outlive the terminal they were opened for, including across a dashboard restart.
            return MarkEnded();
        }
 
        return SetTitle(descriptor.Title);
    }
 
    private bool ApplyChange(string terminalId, TerminalChangeNotification change)
    {
        if (change.Terminal.TerminalId != terminalId)
        {
            return false;
        }
 
        return change.ChangeType is TerminalChangeType.Removed
            ? MarkEnded()
            : SetTitle(change.Terminal.Title);
    }
 
    private bool SetTitle(string title)
    {
        if (string.IsNullOrEmpty(title) || _title == title)
        {
            return false;
        }
 
        _title = title;
        return true;
    }
 
    private bool MarkEnded()
    {
        if (_ended)
        {
            return false;
        }
 
        _ended = true;
        return true;
    }
 
    private Task StopWatchingAsync()
    {
        if (_watchCts is { } cts)
        {
            _watchCts = null;
            _watchTask = CancelWatchAsync(cts, _watchTask);
        }
 
        return _watchTask;
    }
 
    private static async Task CancelWatchAsync(CancellationTokenSource cts, Task watchTask)
    {
        try
        {
            await cts.CancelAsync().ConfigureAwait(false);
            await watchTask.ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (cts.IsCancellationRequested)
        {
            // Task.Run can be cancelled before the watch delegate starts.
        }
        finally
        {
            cts.Dispose();
        }
    }
 
    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (_disposed)
        {
            return;
        }
 
        _disposed = true;
        await StopWatchingAsync().ConfigureAwait(false);
        await StopWindowTrackingAsync(release: false).ConfigureAwait(false);
        if (_windowModule is { } module)
        {
            await Utils.JSInteropHelpers.SafeDisposeAsync(module).ConfigureAwait(false);
        }
        _windowReference?.Dispose();
    }
}