File: Model\TerminalWindowLauncher.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 Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
 
namespace Aspire.Dashboard.Model;
 
/// <summary>
/// The outcome of asking the browser to open a terminal in its own window.
/// </summary>
public enum TerminalWindowOpenResult
{
    /// <summary>
    /// A new window was opened.
    /// </summary>
    Opened,
 
    /// <summary>
    /// A window was already open for this terminal, so it was brought to the front instead.
    /// </summary>
    Focused,
 
    /// <summary>
    /// The browser blocked the popup. The caller is expected to tell the user, because from their point of view
    /// nothing happened.
    /// </summary>
    Blocked,
 
    /// <summary>
    /// The browser could not open or focus the window.
    /// </summary>
    Failed,
 
    /// <summary>
    /// A surviving window was adopted without opening, focusing, or navigating it.
    /// </summary>
    Adopted,
 
    /// <summary>
    /// A durable detachment record exists, but its window has not yet responded after a document reload.
    /// </summary>
    Recovering
}
 
/// <summary>
/// Opens terminals in their own browser windows on behalf of a component, and reports when the user closes one.
/// </summary>
/// <remarks>
/// <para>
/// Detaching a terminal does not move it. Terminals are multi-headed — several viewers can attach to one PTY — and
/// the popup reaches the dashboard on its own, so it outlives the page that opened it. This type only owns the
/// window handle, so a component can focus the window, close it, and learn when it went away.
/// </para>
/// <para>
/// Whether the in-page view keeps rendering while a window is open is the caller's policy, not this type's. The
/// terminal dock replaces the pane with a placeholder because a dock tab and its window are the same viewport in
/// two places; a resource terminal keeps rendering inline, because seeing it in both is the point.
/// </para>
/// </remarks>
public sealed class TerminalWindowLauncher : IAsyncDisposable
{
    private readonly IJSRuntime _js;
    private readonly NavigationManager _navigationManager;
    private readonly string _modulePath;
    private readonly Func<string, TerminalWindowOpenResult, Task> _onWindowOpened;
    private readonly Func<string, Task> _onWindowClosed;
    private readonly string _id = Guid.NewGuid().ToString("N");
 
    private DotNetObjectReference<TerminalWindowLauncher>? _selfRef;
    private IJSObjectReference? _module;
    private Task? _registrationTask;
    private bool _disposed;
 
    /// <summary>
    /// Initializes a new instance of the <see cref="TerminalWindowLauncher"/> class.
    /// </summary>
    /// <param name="js">The JS runtime for the owning component's circuit.</param>
    /// <param name="navigationManager">The navigation manager providing the dashboard's base URI.</param>
    /// <param name="modulePath">The asset-resolved path to the terminal window JavaScript module.</param>
    /// <param name="onWindowOpened">Invoked after opening, focusing, or adopting a window, with its captured key and outcome.</param>
    /// <param name="onWindowClosed">
    /// Invoked with the terminal key when the user closes a detached window. Not raised for windows closed through
    /// <see cref="CloseAsync"/>, because the caller already knows about those.
    /// </param>
    public TerminalWindowLauncher(
        IJSRuntime js,
        NavigationManager navigationManager,
        string modulePath,
        Func<string, TerminalWindowOpenResult, Task> onWindowOpened,
        Func<string, Task> onWindowClosed)
    {
        ArgumentNullException.ThrowIfNull(js);
        ArgumentNullException.ThrowIfNull(navigationManager);
        ArgumentException.ThrowIfNullOrEmpty(modulePath);
        ArgumentNullException.ThrowIfNull(onWindowOpened);
        ArgumentNullException.ThrowIfNull(onWindowClosed);
 
        _js = js;
        _navigationManager = navigationManager;
        _modulePath = modulePath;
        _onWindowOpened = onWindowOpened;
        _onWindowClosed = onWindowClosed;
    }
 
    /// <summary>
    /// Registers a native click listener before the owning component enables its button.
    /// </summary>
    /// <param name="buttonId">The ID of the Fluent button carrying the current terminal key and complete URL.</param>
    /// <returns>A task that completes when the listener is registered.</returns>
    public Task RegisterAsync(string buttonId)
        => _registrationTask ??= RegisterCoreAsync(buttonId);
 
    private async Task RegisterCoreAsync(string buttonId)
    {
        _selfRef = DotNetObjectReference.Create(this);
        var moduleUri = new Uri(new Uri(_navigationManager.BaseUri), _modulePath);
        _module = await _js.InvokeAsync<IJSObjectReference>("import", moduleUri.PathAndQuery).ConfigureAwait(false);
        if (!_disposed)
        {
            await _module.InvokeVoidAsync("registerTerminalWindowButton", buttonId, _id, _selfRef, _navigationManager.BaseUri).ConfigureAwait(false);
        }
    }
 
    /// <summary>
    /// Adopts surviving windows or durable detachment records for the supplied terminal keys.
    /// </summary>
    /// <param name="keys">The current terminal identities belonging to the component.</param>
    /// <returns>A task that completes after the component has reconciled the surviving windows.</returns>
    public async Task AdoptAsync(string[] keys)
    {
        if (!_disposed && _module is { } module)
        {
            await module.InvokeVoidAsync("adoptTerminalWindows", _id, keys).ConfigureAwait(false);
        }
    }
 
    /// <summary>Receives the captured terminal key and browser result after a native launch or window adoption.</summary>
    /// <param name="key">The terminal key at the time of the click, not the current selection.</param>
    /// <param name="result">The browser's launch outcome.</param>
    /// <returns>A task that completes when the owning component has reconciled the outcome.</returns>
    [JSInvokable]
    public Task OnTerminalWindowOpenedAsync(string key, string result)
        => _disposed ? Task.CompletedTask : _onWindowOpened(key, result switch
        {
            "opened" => TerminalWindowOpenResult.Opened,
            "focused" => TerminalWindowOpenResult.Focused,
            "adopted" => TerminalWindowOpenResult.Adopted,
            "recovering" => TerminalWindowOpenResult.Recovering,
            "blocked" => TerminalWindowOpenResult.Blocked,
            _ => TerminalWindowOpenResult.Failed
        });
 
    /// <summary>
    /// Brings the window for <paramref name="key"/> to the front. Returns <see langword="false"/> if no window is open
    /// for it, which the caller can treat as a cue to reattach.
    /// </summary>
    public async Task<bool> FocusAsync(string key)
    {
        return !_disposed && _module is { } module &&
            await module.InvokeAsync<bool>("focusTerminalWindow", key).ConfigureAwait(false);
    }
 
    /// <summary>
    /// Closes the window for <paramref name="key"/>. The close callback is deliberately not raised.
    /// </summary>
    public async Task CloseAsync(string key)
    {
        if (!_disposed && _module is { } module)
        {
            await module.InvokeVoidAsync("closeTerminalWindow", key).ConfigureAwait(false);
        }
    }
 
    /// <summary>
    /// Called from JS when a detached window is observed to have closed.
    /// </summary>
    [JSInvokable]
    public Task OnTerminalWindowClosedAsync(string key)
        => _disposed ? Task.CompletedTask : _onWindowClosed(key);
 
    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (_disposed)
        {
            return;
        }
        _disposed = true;
 
        if (_registrationTask is { } registration)
        {
            try
            {
                await registration.ConfigureAwait(false);
            }
            catch (Exception)
            {
                // The component reports registration failures. Still release a partially initialized module.
            }
        }
 
        if (_module is { } module)
        {
            try
            {
                // Release this callback, but retain the browser's handles for replacement components. The windows
                // are independent viewers, so closing them because the opener navigated away would lose live work.
                await module.InvokeVoidAsync("unregisterTerminalWindowButton", _id).ConfigureAwait(false);
                await module.DisposeAsync().ConfigureAwait(false);
            }
            catch (JSDisconnectedException)
            {
                // The circuit is already gone, so there is nothing left to untrack.
            }
        }
 
        _selfRef?.Dispose();
    }
}