Files
meeting-assistant/MeetingAssistant/Recording/WindowsMeetingInactivityPromptService.Windows.cs
T
codex edade8ab62
PR and Push Build/Test / build-and-test (push) Failing after 14m53s
Add meeting inactivity safeguards
2026-06-03 08:30:15 +02:00

229 lines
7.6 KiB
C#

#if WINDOWS
using System.Collections.Concurrent;
using CommunityToolkit.WinUI.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<string, PendingPrompt> pendingPrompts = new(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<WindowsMeetingInactivityPromptService> logger;
private readonly object registrationGate = new();
private bool registered;
public WindowsMeetingInactivityPromptService(ILogger<WindowsMeetingInactivityPromptService> logger)
{
this.logger = logger;
}
public Task ShowStopPromptAsync(
MeetingInactivityPromptRequest request,
Func<MeetingInactivityPromptResponse, CancellationToken, Task> 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 = DateTimeOffset.Now.AddMinutes(15);
});
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)
.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<string, string> 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<MeetingInactivityPromptResponse, CancellationToken, Task> HandleResponseAsync);
}
#endif