namespace MeetingAssistant.Notifications; public sealed record DesktopRequestAction(string Id, string Label); public sealed record DesktopUserRequest(string Id, string Kind, string Title, string Message, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt, IReadOnlyList Actions); public sealed record DesktopUserRequestSpec(string Kind, string Title, string Message, TimeSpan Timeout, IReadOnlyList Actions); public sealed record PendingUserRequestRegistration(string Id, Task Decision); public enum DesktopUserRequestRespondResult { Accepted, InvalidAction, NoLongerPending, Failed } public sealed class DesktopUserRequestStore : IDisposable { private readonly object gate = new(); private readonly Dictionary pending = new(StringComparer.Ordinal); private readonly TimeProvider timeProvider; private readonly IHostApplicationLifetime applicationLifetime; private readonly ILogger logger; private readonly CancellationTokenRegistration shutdownRegistration; private bool disposed; public DesktopUserRequestStore(TimeProvider timeProvider, IHostApplicationLifetime applicationLifetime, ILogger logger) { this.timeProvider = timeProvider; this.applicationLifetime = applicationLifetime; this.logger = logger; shutdownRegistration = applicationLifetime.ApplicationStopping.Register(Dispose); } public PendingUserRequestRegistration Add(DesktopUserRequestSpec spec, CancellationToken lifetime, Func? onResponse = null) { lifetime.ThrowIfCancellationRequested(); var now = timeProvider.GetUtcNow(); var request = new DesktopUserRequest(Guid.NewGuid().ToString("N"), spec.Kind, spec.Title, spec.Message, now, now.Add(spec.Timeout), spec.Actions.ToArray()); var entry = new PendingRequest(request, lifetime, onResponse); lock (gate) { ObjectDisposedException.ThrowIf(disposed, this); pending.Add(request.Id, entry); } var registration = lifetime.Register(() => Dismiss(request.Id)); var retained = false; lock (gate) { retained = pending.ContainsKey(request.Id); if (retained) entry.CancellationRegistration = registration; } if (!retained) registration.Dispose(); var deadlineTimer = timeProvider.CreateTimer(_ => Dismiss(request.Id), null, spec.Timeout, Timeout.InfiniteTimeSpan); lock (gate) { retained = pending.ContainsKey(request.Id); if (retained) entry.DeadlineTimer = deadlineTimer; } if (!retained) deadlineTimer.Dispose(); return new PendingUserRequestRegistration(request.Id, entry.Decision.Task); } public IReadOnlyList List() { PendingRequest[] expired; DesktopUserRequest[] snapshot; lock (gate) { var now = timeProvider.GetUtcNow(); expired = pending.Values.Where(entry => entry.Request.ExpiresAt <= now || entry.Lifetime.IsCancellationRequested).ToArray(); foreach (var entry in expired) pending.Remove(entry.Request.Id); snapshot = pending.Values.Select(entry => entry.Request).ToArray(); } foreach (var entry in expired) { entry.Decision.TrySetResult(null); entry.Dispose(); } return snapshot; } public async Task RespondAsync(string id, string action) { PendingRequest? entry; bool expired; lock (gate) { if (!pending.TryGetValue(id, out entry)) return DesktopUserRequestRespondResult.NoLongerPending; expired = entry.Request.ExpiresAt <= timeProvider.GetUtcNow(); if (!expired && !entry.Request.Actions.Any(candidate => candidate.Id == action)) return DesktopUserRequestRespondResult.InvalidAction; pending.Remove(id); } entry.Dispose(); if (expired || entry.Lifetime.IsCancellationRequested) { entry.Decision.TrySetResult(null); return DesktopUserRequestRespondResult.NoLongerPending; } try { if (entry.OnResponse is not null) await entry.OnResponse(action, entry.Lifetime); entry.Decision.TrySetResult(action); return DesktopUserRequestRespondResult.Accepted; } catch (OperationCanceledException) when (entry.Lifetime.IsCancellationRequested) { entry.Decision.TrySetResult(null); return DesktopUserRequestRespondResult.NoLongerPending; } catch (Exception exception) { entry.Decision.TrySetResult(null); logger.LogError(exception, "Desktop request {RequestId} action failed", id); return DesktopUserRequestRespondResult.Failed; } } public void Dismiss(string id) { PendingRequest? entry; lock (gate) { if (!pending.Remove(id, out entry)) return; } entry.Decision.TrySetResult(null); entry.Dispose(); } public void DismissKind(string kind) { PendingRequest[] removed; lock (gate) { removed = pending.Values.Where(entry => entry.Request.Kind == kind).ToArray(); foreach (var entry in removed) pending.Remove(entry.Request.Id); } foreach (var entry in removed) { entry.Decision.TrySetResult(null); entry.Dispose(); } } public void Dispose() { PendingRequest[] removed; lock (gate) { if (disposed) return; disposed = true; removed = pending.Values.ToArray(); pending.Clear(); } foreach (var entry in removed) { entry.Decision.TrySetResult(null); entry.Dispose(); } shutdownRegistration.Dispose(); } private sealed class PendingRequest(DesktopUserRequest request, CancellationToken lifetime, Func? onResponse) : IDisposable { public DesktopUserRequest Request { get; } = request; public CancellationToken Lifetime { get; } = lifetime; public CancellationTokenRegistration CancellationRegistration { get; set; } public ITimer? DeadlineTimer { get; set; } public void Dispose() { CancellationRegistration.Dispose(); DeadlineTimer?.Dispose(); } public Func? OnResponse { get; } = onResponse; public TaskCompletionSource Decision { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); } }