ci: consolidate platform pipelines and test prerequisites on macOS support
PR and Push Build/Test / windows-build-and-test (push) Failing after 38m25s
PR and Push Build/Test / portable-build-and-test (push) Successful in 8m9s
PR and Push Build/Test / macos-native-full (push) Skipped

This commit is contained in:
dh committed 2026-10-06 09:02:19 +02:00
1 parent 1b19b08f2e
commit 728e66dd72
76 files changed
+7477 -239

No files matched your search

@@ -0,0 +1,176 @@
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<DesktopRequestAction> Actions);
public sealed record DesktopUserRequestSpec(string Kind, string Title, string Message,
TimeSpan Timeout, IReadOnlyList<DesktopRequestAction> Actions);
public sealed record PendingUserRequestRegistration(string Id, Task<string?> Decision);
public enum DesktopUserRequestRespondResult { Accepted, InvalidAction, NoLongerPending, Failed }
public sealed class DesktopUserRequestStore : IDisposable
{
private readonly object gate = new();
private readonly Dictionary<string, PendingRequest> pending = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
private readonly IHostApplicationLifetime applicationLifetime;
private readonly ILogger<DesktopUserRequestStore> logger;
private readonly CancellationTokenRegistration shutdownRegistration;
private bool disposed;
public DesktopUserRequestStore(TimeProvider timeProvider, IHostApplicationLifetime applicationLifetime,
ILogger<DesktopUserRequestStore> logger)
{
this.timeProvider = timeProvider;
this.applicationLifetime = applicationLifetime;
this.logger = logger;
shutdownRegistration = applicationLifetime.ApplicationStopping.Register(Dispose);
}
public PendingUserRequestRegistration Add(DesktopUserRequestSpec spec, CancellationToken lifetime,
Func<string, CancellationToken, Task>? 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<DesktopUserRequest> 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<DesktopUserRequestRespondResult> 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<string, CancellationToken, Task>? 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<string, CancellationToken, Task>? OnResponse { get; } = onResponse;
public TaskCompletionSource<string?> Decision { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
}