#if WINDOWS using System.Collections.Concurrent; using CommunityToolkit.WinUI.Notifications; using MeetingAssistant.Notifications; namespace MeetingAssistant.Recording; public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPromptService, IHostedService, IDisposable { private const string NotificationSource = "meeting-inactivity-safeguard"; private const string NotificationGroup = "meeting-inactivity"; private readonly ConcurrentDictionary pendingPrompts = new(StringComparer.OrdinalIgnoreCase); private readonly ILogger logger; private readonly object registrationGate = new(); private bool registered; public WindowsMeetingInactivityPromptService(ILogger logger) { this.logger = logger; } public Task ShowStopPromptAsync( MeetingInactivityPromptRequest request, Func handleResponseAsync, CancellationToken cancellationToken) { if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) { logger.LogWarning("Windows app notifications require Windows 10 version 1809 or later"); return Task.CompletedTask; } if (cancellationToken.IsCancellationRequested) { return Task.FromCanceled(cancellationToken); } try { EnsureRegistered(); var promptId = Guid.NewGuid().ToString("N"); pendingPrompts[promptId] = new PendingPrompt(handleResponseAsync); logger.LogInformation( "Registered native Windows inactivity notification {PromptId} response callback at threshold {Threshold}", promptId, request.Threshold); var notification = BuildNotification(promptId, request); notification.Show(toast => { toast.Group = NotificationGroup; toast.Tag = promptId; toast.ExpirationTime = MeetingToastExpirationPolicy.StopReminderExpiration(DateTimeOffset.Now); }); logger.LogInformation( "Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}", promptId, request.Threshold); } catch (Exception exception) { logger.LogError(exception, "Failed to display native Windows inactivity notification"); } return Task.CompletedTask; } public Task StartAsync(CancellationToken cancellationToken) { if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) { logger.LogWarning("Windows app notifications require Windows 10 version 1809 or later"); return Task.CompletedTask; } EnsureRegistered(); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } public void Dispose() { if (!registered) { return; } try { ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked; } catch (Exception exception) { logger.LogWarning(exception, "Failed to detach native Windows toast notification activation handler"); } } private void EnsureRegistered() { if (registered) { return; } lock (registrationGate) { if (registered) { return; } ToastNotificationManagerCompat.OnActivated += OnNotificationInvoked; registered = true; logger.LogInformation("Registered native Windows toast notification activation handler"); } } private static ToastContentBuilder BuildNotification( string promptId, MeetingInactivityPromptRequest request) { var yesButton = new ToastButton() .SetContent("Yes") .AddArgument("source", NotificationSource) .AddArgument("promptId", promptId) .AddArgument("response", "stop") .SetBackgroundActivation(); var noButton = new ToastButton() .SetContent("No") .AddArgument("source", NotificationSource) .AddArgument("promptId", promptId) .AddArgument("response", "continue") .SetBackgroundActivation(); return new ToastContentBuilder() .AddArgument("source", NotificationSource) .AddArgument("promptId", promptId) .SetToastScenario(ToastScenario.Reminder) .SetToastDuration(ToastDuration.Long) .AddText("Stop meeting?") .AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.") .AddButton(yesButton) .AddButton(noButton); } private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args) { logger.LogInformation( "Native Windows inactivity notification activation received with arguments {Arguments}", args.Argument); var arguments = NotificationActivationArguments.Parse(args.Argument); if (!TryGetArgument(arguments, "source", out var source) || !string.Equals(source, NotificationSource, StringComparison.OrdinalIgnoreCase)) { logger.LogDebug( "Ignoring native Windows notification activation for unrelated source {Source}", source); return; } if (!TryGetArgument(arguments, "promptId", out var promptId)) { logger.LogWarning("Ignoring native Windows inactivity notification activation without prompt id"); return; } if (!pendingPrompts.TryRemove(promptId, out var pendingPrompt)) { logger.LogWarning( "Ignoring native Windows inactivity notification {PromptId} activation because no pending prompt callback exists", promptId); return; } var response = TryGetArgument(arguments, "response", out var responseValue) && string.Equals(responseValue, "stop", StringComparison.OrdinalIgnoreCase) ? MeetingInactivityPromptResponse.Stop : MeetingInactivityPromptResponse.Continue; _ = Task.Run(async () => { try { logger.LogInformation( "Native Windows inactivity notification {PromptId} activated with response {Response}", promptId, response); await pendingPrompt.HandleResponseAsync(response, CancellationToken.None); } catch (Exception exception) { logger.LogError( exception, "Failed to handle native Windows inactivity notification response {Response}", response); } }); } private static bool TryGetArgument( IReadOnlyDictionary arguments, string key, out string value) { if (arguments.TryGetValue(key, out value!)) { return true; } value = ""; return false; } private static string FormatDuration(TimeSpan duration) { if (duration.TotalMinutes >= 1) { return $"{Math.Round(duration.TotalMinutes):0} minutes"; } return $"{Math.Max(1, Math.Round(duration.TotalSeconds)):0} seconds"; } private sealed record PendingPrompt( Func HandleResponseAsync); } #endif