|
// 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.ObjectModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting;
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
internal class InteractionService : IInteractionService
{
internal const string DiagnosticId = "ASPIREINTERACTION001";
private Action<Interaction>? OnInteractionUpdated { get; set; }
private readonly object _onInteractionUpdatedLock = new();
private readonly InteractionCollection _interactionCollection = new();
private readonly ILogger<InteractionService> _logger;
private readonly DistributedApplicationOptions _distributedApplicationOptions;
private readonly IServiceProvider _serviceProvider;
public InteractionService(ILogger<InteractionService> logger, DistributedApplicationOptions distributedApplicationOptions, IServiceProvider serviceProvider)
{
_logger = logger;
_distributedApplicationOptions = distributedApplicationOptions;
_serviceProvider = serviceProvider;
}
public bool IsAvailable => !_distributedApplicationOptions.DisableDashboard;
public async Task<InteractionResult<bool>> PromptConfirmationAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default)
{
options ??= MessageBoxInteractionOptions.CreateDefault();
options.Intent = MessageIntent.Confirmation;
options.ShowDismiss ??= false;
options.ShowSecondaryButton ??= true;
return await PromptMessageBoxCoreAsync(title, message, options, cancellationToken).ConfigureAwait(false);
}
public async Task<InteractionResult<bool>> PromptMessageBoxAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default)
{
options ??= MessageBoxInteractionOptions.CreateDefault();
options.ShowSecondaryButton ??= false;
options.ShowDismiss ??= false;
return await PromptMessageBoxCoreAsync(title, message, options, cancellationToken).ConfigureAwait(false);
}
private async Task<InteractionResult<bool>> PromptMessageBoxCoreAsync(string title, string message, MessageBoxInteractionOptions options, CancellationToken cancellationToken)
{
EnsureServiceAvailable();
cancellationToken.ThrowIfCancellationRequested();
options ??= MessageBoxInteractionOptions.CreateDefault();
options.ShowDismiss ??= false;
var newState = new Interaction(title, message, options, new Interaction.MessageBoxInteractionInfo(intent: options.Intent ?? MessageIntent.None), cancellationToken);
AddInteractionUpdate(newState);
using var _ = cancellationToken.Register(OnInteractionCancellation, state: newState);
var completion = await newState.CompletionTcs.Task.ConfigureAwait(false);
var promptState = completion.State as bool?;
return promptState == null
? InteractionResultFactory.Cancel<bool>()
: InteractionResultFactory.Ok(promptState.Value);
}
public async Task<InteractionResult<InteractionInput>> PromptInputAsync(string title, string? message, string inputLabel, string placeHolder, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default)
{
return await PromptInputAsync(title, message, new InteractionInput { InputType = InputType.Text, Label = inputLabel, Required = true, Placeholder = placeHolder }, options, cancellationToken).ConfigureAwait(false);
}
public async Task<InteractionResult<InteractionInput>> PromptInputAsync(string title, string? message, InteractionInput input, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default)
{
var result = await PromptInputsAsync(title, message, [input], options, cancellationToken).ConfigureAwait(false);
if (result.Canceled)
{
return InteractionResultFactory.Cancel<InteractionInput>();
}
return InteractionResultFactory.Ok(result.Data[0]);
}
public async Task<InteractionResult<IReadOnlyList<InteractionInput>>> PromptInputsAsync(string title, string? message, IReadOnlyList<InteractionInput> inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default)
{
EnsureServiceAvailable();
cancellationToken.ThrowIfCancellationRequested();
options ??= InputsDialogInteractionOptions.Default;
var newState = new Interaction(title, message, options, new Interaction.InputsInteractionInfo(inputs), cancellationToken);
AddInteractionUpdate(newState);
using var _ = cancellationToken.Register(OnInteractionCancellation, state: newState);
var completion = await newState.CompletionTcs.Task.ConfigureAwait(false);
var inputState = completion.State as IReadOnlyList<InteractionInput>;
return inputState == null
? InteractionResultFactory.Cancel<IReadOnlyList<InteractionInput>>()
: InteractionResultFactory.Ok(inputState);
}
public async Task<InteractionResult<bool>> PromptMessageBarAsync(string title, string message, MessageBarInteractionOptions? options = null, CancellationToken cancellationToken = default)
{
EnsureServiceAvailable();
cancellationToken.ThrowIfCancellationRequested();
options ??= MessageBarInteractionOptions.CreateDefault();
var newState = new Interaction(title, message, options, new Interaction.MessageBarInteractionInfo(intent: options.Intent ?? MessageIntent.None, linkText: options.LinkText, linkUrl: options.LinkUrl), cancellationToken);
AddInteractionUpdate(newState);
using var _ = cancellationToken.Register(OnInteractionCancellation, state: newState);
var completion = await newState.CompletionTcs.Task.ConfigureAwait(false);
var promptState = completion.State as bool?;
return promptState == null
? InteractionResultFactory.Cancel<bool>()
: InteractionResultFactory.Ok(promptState.Value);
}
// For testing.
internal List<Interaction> GetCurrentInteractions()
{
lock (_onInteractionUpdatedLock)
{
return _interactionCollection.ToList();
}
}
private void OnInteractionCancellation(object? newState)
{
var interactionState = (Interaction)newState!;
interactionState.State = Interaction.InteractionState.Complete;
interactionState.CompletionTcs.TrySetResult(new InteractionCompletionState { Complete = true });
AddInteractionUpdate(interactionState);
}
private void AddInteractionUpdate(Interaction interactionUpdate)
{
lock (_onInteractionUpdatedLock)
{
var updateEvent = false;
if (interactionUpdate.State == Interaction.InteractionState.Complete)
{
Debug.Assert(
interactionUpdate.CompletionTcs.Task.IsCompleted,
"TaskCompletionSource should be completed when interaction is done.");
// Only update event if interaction was previously registered and not already removed.
updateEvent = _interactionCollection.Remove(interactionUpdate.InteractionId);
}
else
{
if (_interactionCollection.Contains(interactionUpdate.InteractionId))
{
// Should never happen, but throw descriptive exception if it does.
throw new InvalidOperationException($"An interaction with ID {interactionUpdate.InteractionId} already exists. Interaction IDs must be unique.");
}
_interactionCollection.Add(interactionUpdate);
updateEvent = true;
}
if (updateEvent)
{
OnInteractionUpdated?.Invoke(interactionUpdate);
}
}
}
internal async Task CompleteInteractionAsync(int interactionId, Func<Interaction, IServiceProvider, InteractionCompletionState> createResult, CancellationToken cancellationToken)
{
Interaction? interactionState = null;
lock (_onInteractionUpdatedLock)
{
if (!_interactionCollection.TryGetValue(interactionId, out interactionState))
{
_logger.LogDebug("No interaction found with ID {InteractionId}.", interactionId);
return;
}
}
var result = createResult(interactionState, _serviceProvider);
// Run validation for inputs interaction.
if (!await RunValidationAsync(interactionState, result, cancellationToken).ConfigureAwait(false))
{
// Interaction is not complete if there are validation errors.
result = new InteractionCompletionState { Complete = false, State = result.State };
}
lock (_onInteractionUpdatedLock)
{
// Double check interaction is still in collection after awaiting the result creation.
if (!_interactionCollection.TryGetValue(interactionId, out interactionState))
{
return;
}
if (result.Complete)
{
interactionState.CompletionTcs.TrySetResult(result);
interactionState.State = Interaction.InteractionState.Complete;
_interactionCollection.Remove(interactionId);
}
// Either broadcast out the interaction is complete, or its updated state.
OnInteractionUpdated?.Invoke(interactionState);
}
}
private async Task<bool> RunValidationAsync(Interaction interactionState, InteractionCompletionState result, CancellationToken cancellationToken)
{
if (result.Complete && interactionState.InteractionInfo is Interaction.InputsInteractionInfo inputsInfo)
{
// State could be null if the user dismissed the inputs dialog. There is nothing to validate in this situation.
if (result.State is IReadOnlyList<InteractionInput> inputs)
{
var options = (InputsDialogInteractionOptions)interactionState.Options;
if (options.ValidationCallback is { } validationCallback)
{
foreach (var input in inputs)
{
input.ValidationErrors.Clear();
}
var context = new InputsDialogValidationContext
{
CancellationToken = cancellationToken,
ServiceProvider = _serviceProvider,
Inputs = inputsInfo.Inputs
};
await validationCallback(context).ConfigureAwait(false);
return context.HasErrors;
}
}
}
return true;
}
internal async IAsyncEnumerable<Interaction> SubscribeInteractionUpdates([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var channel = Channel.CreateUnbounded<Interaction>();
void WriteToChannel(Interaction resourceEvent) =>
channel.Writer.TryWrite(resourceEvent);
List<Interaction> pendingInteractions;
lock (_onInteractionUpdatedLock)
{
OnInteractionUpdated += WriteToChannel;
pendingInteractions = _interactionCollection.ToList();
}
foreach (var interaction in pendingInteractions)
{
yield return interaction;
}
try
{
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return item;
}
}
finally
{
lock (_onInteractionUpdatedLock)
{
OnInteractionUpdated -= WriteToChannel;
}
channel.Writer.TryComplete();
}
}
private void EnsureServiceAvailable()
{
if (!IsAvailable)
{
throw new InvalidOperationException($"InteractionService is not available because the dashboard is not enabled. Use the {nameof(IsAvailable)} property to determine whether the service is available.");
}
}
}
internal class InteractionCollection : KeyedCollection<int, Interaction>
{
protected override int GetKeyForItem(Interaction item) => item.InteractionId;
}
internal static class InteractionResultFactory
{
internal static InteractionResult<T> Ok<T>(T result)
{
return new InteractionResult<T>(result, canceled: false);
}
internal static InteractionResult<T> Cancel<T>(T? data = default)
{
return new InteractionResult<T>(data ?? default, canceled: true);
}
}
[DebuggerDisplay("State = {State}, Complete = {Complete}")]
internal sealed class InteractionCompletionState
{
public bool Complete { get; init; }
public object? State { get; init; }
}
[DebuggerDisplay("InteractionId = {InteractionId}, State = {State}, Title = {Title}")]
internal class Interaction
{
private static int s_nextInteractionId;
public int InteractionId { get; }
public InteractionState State { get; set; }
public TaskCompletionSource<InteractionCompletionState> CompletionTcs { get; } = new TaskCompletionSource<InteractionCompletionState>(TaskCreationOptions.RunContinuationsAsynchronously);
public InteractionInfoBase InteractionInfo { get; }
public CancellationToken CancellationToken { get; }
public string Title { get; }
public string? Message { get; }
public InteractionOptions Options { get; }
public Interaction(string title, string? message, InteractionOptions options, InteractionInfoBase interactionInfo, CancellationToken cancellationToken)
{
InteractionId = Interlocked.Increment(ref s_nextInteractionId);
Title = title;
Message = message;
Options = options;
InteractionInfo = interactionInfo;
CancellationToken = cancellationToken;
}
internal enum InteractionState
{
InProgress,
Complete
}
internal abstract class InteractionInfoBase
{
}
internal sealed class MessageBoxInteractionInfo : InteractionInfoBase
{
public MessageBoxInteractionInfo(MessageIntent intent)
{
Intent = intent;
}
public MessageIntent Intent { get; }
}
internal sealed class MessageBarInteractionInfo : InteractionInfoBase
{
public MessageBarInteractionInfo(MessageIntent intent, string? linkText, string? linkUrl)
{
Intent = intent;
LinkText = linkText;
LinkUrl = linkUrl;
}
public MessageIntent Intent { get; }
public string? LinkText { get; }
public string? LinkUrl { get; }
}
internal sealed class InputsInteractionInfo : InteractionInfoBase
{
public InputsInteractionInfo(IReadOnlyList<InteractionInput> inputs)
{
Inputs = inputs;
}
public IReadOnlyList<InteractionInput> Inputs { get; }
}
}
#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|