diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 80cfa4a53..b4737a72f 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -2213,6 +2213,17 @@ private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatu { _toastService!.MarkPairedToastShown(deviceKey); AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId); + AppNotificationPublisher.Show( + _appNotificationService, + LocalizationHelper.GetString("Toast_NodePaired"), + LocalizationHelper.GetString("Toast_NodePairedDetail"), + "node", + "pairing", + AppNotificationSeverity.Success, + "node-paired:" + HashNotificationKey(deviceKey), + "connection", + LocalizationHelper.GetString("AppNotification_ActionOpenConnection"), + id: BuildPairingPairedNotificationId(deviceKey)); _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_NodePaired")) .AddText(LocalizationHelper.GetString("Toast_NodePairedDetail")), @@ -2269,6 +2280,9 @@ public static string BuildPairingApprovalCommand(string deviceId) => private static string BuildPairingPendingNotificationId(string deviceId) => $"node-pairing-pending:{deviceId.Trim().ToLowerInvariant()}"; + private static string BuildPairingPairedNotificationId(string deviceId) => + $"node-paired:{deviceId.Trim().ToLowerInvariant()}"; + private static string BuildPairingRejectedNotificationId(string deviceId) => $"node-pairing-rejected:{deviceId.Trim().ToLowerInvariant()}"; @@ -2461,9 +2475,14 @@ private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabil // Agent requested a notification via node.invoke system.notify try { - _toastService!.ShowToast(new ToastContentBuilder() - .AddText(args.Title) - .AddText(args.Body)); + AppNotificationPublisher.Publish( + _appNotificationService, + _toastService, + new AppNotificationPublishRequest( + AppNotificationMapper.FromNodeSystemNotification(args), + new ToastContentBuilder() + .AddText(args.Title) + .AddText(args.Body))); } catch (Exception ex) { @@ -2471,9 +2490,18 @@ private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabil } } - private void OnNodeToastRequested(object? sender, Microsoft.Toolkit.Uwp.Notifications.ToastContentBuilder builder) + private void OnNodeToastRequested(object? sender, NodeToastRequestedEventArgs args) => OnUiThread(() => - NonFatalAction.Run(() => _toastService!.ShowToast(builder), msg => Logger.Warn($"Failed to show node toast: {msg}"))); + NonFatalAction.Run( + () => AppNotificationPublisher.Publish( + _appNotificationService, + _toastService, + new AppNotificationPublishRequest( + args.AppNotification, + args.ToastBuilder, + args.ToastTag, + args.ToastDeviceId)), + msg => Logger.Warn($"Failed to show node toast: {msg}"))); private void OnLocalExecApprovalRequested(object? sender, ExecApprovalPromptRequestedEventArgs args) { @@ -2787,9 +2815,26 @@ private void OnGatewaySessionCommandCompleted(object? sender, SessionCommandResu dashboardPath: !string.IsNullOrWhiteSpace(result.Key) ? $"sessions/{result.Key}" : "sessions", sessionKey: result.Key); - _toastService!.ShowToast(new ToastContentBuilder() - .AddText(title) - .AddText(message)); + AppNotification? appNotification = result.Ok + ? null + : new AppNotification + { + Title = title, + Message = message, + Source = "session", + Category = "status", + Severity = AppNotificationSeverity.Error, + DedupeKey = "session-command:" + HashNotificationKey($"{result.Method}|{key}|{message}") + }; + + AppNotificationPublisher.Publish( + _appNotificationService, + _toastService, + new AppNotificationPublishRequest( + appNotification, + new ToastContentBuilder() + .AddText(title) + .AddText(message))); } catch (Exception ex) { @@ -2870,7 +2915,14 @@ private void OnGatewayNotificationReceived(object? sender, OpenClawNotification .AddArgument("sessionKey", notification.SessionKey ?? "")); } - _toastService!.ShowToast(builder); + AppNotificationPublisher.Publish( + _appNotificationService, + _toastService, + new AppNotificationPublishRequest( + AppNotificationMapper.FromGatewayNotification( + notification, + LocalizationHelper.GetString("AppNotification_ExecApprovalPending_OpenChatAction")), + builder)); } catch (Exception ex) { diff --git a/src/OpenClaw.Tray.WinUI/Services/AppNotificationMapper.cs b/src/OpenClaw.Tray.WinUI/Services/AppNotificationMapper.cs new file mode 100644 index 000000000..602c95a83 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/AppNotificationMapper.cs @@ -0,0 +1,116 @@ +using OpenClaw.Shared; +using OpenClaw.Shared.Capabilities; +using System; +using System.Security.Cryptography; +using System.Text; + +namespace OpenClawTray.Services; + +internal static class AppNotificationMapper +{ + public static AppNotification FromGatewayNotification(OpenClawNotification notification, string? chatActionLabel = null) + { + ArgumentNullException.ThrowIfNull(notification); + + var title = NormalizeTitle(notification.Title); + var message = NormalizeMessage(notification.Message, title); + var category = NormalizeCategory(notification.Type); + var hasChatAction = notification.IsChat && !string.IsNullOrWhiteSpace(chatActionLabel); + + return new AppNotification + { + Title = title, + Message = message, + Source = "gateway", + Category = category, + Severity = SeverityFromGatewayType(category), + DedupeKey = BuildDedupeKey( + "gateway", + notification.Type, + notification.Title, + notification.Message, + notification.SessionKey), + ActionLabel = hasChatAction ? chatActionLabel : null, + ActionRoute = hasChatAction ? GetChatActionRoute(notification.SessionKey) : null + }; + } + + public static AppNotification FromNodeSystemNotification(SystemNotifyArgs args) + { + ArgumentNullException.ThrowIfNull(args); + + var title = NormalizeTitle(args.Title); + var message = NormalizeMessage(args.Body, title); + + return new AppNotification + { + Title = title, + Message = message, + Source = "node", + Category = "system.notify", + Severity = SeverityFromText(title, message), + DedupeKey = BuildDedupeKey("node-system-notify", args.Title, args.Body) + }; + } + + public static AppNotification FromNodeActivity( + string title, + string message, + string category, + AppNotificationSeverity severity, + string dedupeKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(category); + ArgumentException.ThrowIfNullOrWhiteSpace(dedupeKey); + + var normalizedTitle = NormalizeTitle(title); + return new AppNotification + { + Title = normalizedTitle, + Message = NormalizeMessage(message, normalizedTitle), + Source = "node", + Category = category.Trim(), + Severity = severity, + DedupeKey = dedupeKey.Trim() + }; + } + + private static AppNotificationSeverity SeverityFromGatewayType(string? type) => type?.Trim().ToLowerInvariant() switch + { + "error" => AppNotificationSeverity.Error, + "urgent" or "health" => AppNotificationSeverity.Warning, + _ => AppNotificationSeverity.Informational + }; + + private static AppNotificationSeverity SeverityFromText(string title, string message) + { + var text = string.Concat(title, " ", message); + return text.Contains("error", StringComparison.OrdinalIgnoreCase) || + text.Contains("failed", StringComparison.OrdinalIgnoreCase) || + text.Contains("blocked", StringComparison.OrdinalIgnoreCase) || + text.Contains("denied", StringComparison.OrdinalIgnoreCase) + ? AppNotificationSeverity.Error + : AppNotificationSeverity.Informational; + } + + private static string NormalizeTitle(string? title) => + string.IsNullOrWhiteSpace(title) ? "OpenClaw" : title.Trim(); + + private static string NormalizeMessage(string? message, string title) => + string.IsNullOrWhiteSpace(message) ? title : message.Trim(); + + private static string NormalizeCategory(string? category) => + string.IsNullOrWhiteSpace(category) ? "info" : category.Trim(); + + private static string GetChatActionRoute(string? sessionKey) => + string.IsNullOrWhiteSpace(sessionKey) + ? "chat" + : AppNotificationActionRoutes.Chat(sessionKey); + + private static string BuildDedupeKey(string scope, params string?[] parts) + { + var raw = string.Join("\u001f", parts.Select(part => part?.Trim() ?? string.Empty)); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant(); + return $"{scope}:{hash}"; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/AppNotificationPublisher.cs b/src/OpenClaw.Tray.WinUI/Services/AppNotificationPublisher.cs index c2a8f191f..13ba66d09 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AppNotificationPublisher.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AppNotificationPublisher.cs @@ -1,7 +1,78 @@ +using Microsoft.Toolkit.Uwp.Notifications; +using System; +using System.Collections.Generic; +using System.Runtime.ExceptionServices; + namespace OpenClawTray.Services; +internal sealed record AppNotificationPublishRequest( + AppNotification? AppNotification = null, + ToastContentBuilder? ToastBuilder = null, + string? ToastTag = null, + string? ToastDeviceId = null); + +internal interface IToastNotificationPublisher +{ + void ShowToast(ToastContentBuilder builder, string? toastTag = null, string? deviceId = null); +} + internal static class AppNotificationPublisher { + public static void Publish( + AppNotificationService? appNotificationService, + IToastNotificationPublisher? toastService, + AppNotificationPublishRequest request) + { + Action? showToast = toastService is null + ? null + : (builder, tag, deviceId) => toastService.ShowToast(builder, tag, deviceId); + Publish(appNotificationService, showToast, request); + } + + internal static void Publish( + AppNotificationService? appNotificationService, + Action? showToast, + AppNotificationPublishRequest request) + { + ArgumentNullException.ThrowIfNull(request); + if (request.AppNotification is null && request.ToastBuilder is null) + throw new ArgumentException("Publish request must include an app notification, a toast builder, or both.", nameof(request)); + + List? failures = null; + + if (request.ToastBuilder is not null && showToast is not null) + { + try + { + showToast(request.ToastBuilder, request.ToastTag, request.ToastDeviceId); + } + catch (Exception ex) + { + (failures ??= new()).Add(ex); + } + } + + if (request.AppNotification is not null && appNotificationService is not null) + { + try + { + appNotificationService.Show(request.AppNotification); + } + catch (Exception ex) + { + (failures ??= new()).Add(ex); + } + } + + if (failures is null) + return; + + if (failures.Count == 1) + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + + throw new AggregateException(failures); + } + public static void Show( AppNotificationService? service, string title, diff --git a/src/OpenClaw.Tray.WinUI/Services/AppNotificationService.cs b/src/OpenClaw.Tray.WinUI/Services/AppNotificationService.cs index 9a1b91337..04ef57a63 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AppNotificationService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AppNotificationService.cs @@ -135,6 +135,7 @@ public sealed class AppNotificationChangedEventArgs(AppNotificationSnapshot snap internal sealed class AppNotificationService { + private const int MaxActiveNotifications = 100; private readonly object _gate = new(); private readonly List _queue = new(); private AppNotification? _current; @@ -170,6 +171,7 @@ public void Show(AppNotification notification) else { _queue.Add(normalized); + PruneQueueLocked(); snapshot = CreateSnapshotLocked(); } } @@ -378,6 +380,13 @@ private AppNotification DequeueLocked() return next; } + private void PruneQueueLocked() + { + var maxQueued = _current is null ? MaxActiveNotifications : MaxActiveNotifications - 1; + while (_queue.Count > maxQueued) + _queue.RemoveAt(0); + } + private AppNotificationSnapshot CreateSnapshotLocked() { var queued = _queue.ToList(); diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index ed6f2fd98..b344183af 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -170,7 +170,7 @@ public sealed class NodeService : IDisposable, IAsyncDisposable public event EventHandler? InvokeCompleted; public event EventHandler? GatewaySelfUpdated; public event EventHandler? RecordingStateChanged; - public event EventHandler? ToastRequested; + public event EventHandler? ToastRequested; public event EventHandler? LocalExecApprovalRequested; public event EventHandler? LocalExecApprovalDecided; @@ -1711,6 +1711,31 @@ private void OnCanvasA2UIReset(object? sender, EventArgs args) #endregion #region Screen Capability Handlers + + private void RequestNodeToast( + string title, + string message, + string dedupeKey, + AppNotificationSeverity severity = AppNotificationSeverity.Informational, + string category = "node.invoke", + bool mirrorInApp = false) + { + var appNotification = mirrorInApp + ? AppNotificationMapper.FromNodeActivity( + title, + message, + category, + severity, + dedupeKey) + : null; + ToastRequested?.Invoke( + this, + new NodeToastRequestedEventArgs( + new ToastContentBuilder() + .AddText(title) + .AddText(message), + appNotification)); + } private async Task OnScreenCapture(ScreenCaptureArgs args) { @@ -1724,9 +1749,10 @@ private async Task OnScreenCapture(ScreenCaptureArgs args) if ((now - _lastScreenCaptureNotification).TotalSeconds > 10) { _lastScreenCaptureNotification = now; - ToastRequested?.Invoke(this, new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_ScreenCaptured")) - .AddText(LocalizationHelper.GetString("Toast_ScreenCapturedDetail"))); + RequestNodeToast( + LocalizationHelper.GetString("Toast_ScreenCaptured"), + LocalizationHelper.GetString("Toast_ScreenCapturedDetail"), + "node:screen-captured"); } return await _screenCaptureService.CaptureAsync(args); @@ -1745,20 +1771,26 @@ private async Task OnScreenRecord(ScreenRecordArgs args) SetRecordingState(RecordingType.Screen, true, args.DurationMs); try { - ToastRequested?.Invoke(this, new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingStarted")) - .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingStartedDetail"))); + RequestNodeToast( + LocalizationHelper.GetString("Toast_ScreenRecordingStarted"), + LocalizationHelper.GetString("Toast_ScreenRecordingStartedDetail"), + "node:screen-recording-started"); var result = await _screenRecordingService.RecordAsync(args); - ToastRequested?.Invoke(this, new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingComplete")) - .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingCompleteDetail"))); + RequestNodeToast( + LocalizationHelper.GetString("Toast_ScreenRecordingComplete"), + LocalizationHelper.GetString("Toast_ScreenRecordingCompleteDetail"), + "node:screen-recording-complete", + AppNotificationSeverity.Success); return result; } catch (Exception ex) when (ex is not InvalidOperationException) { - ToastRequested?.Invoke(this, new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingFailed")) - .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingFailedDetail"))); + RequestNodeToast( + LocalizationHelper.GetString("Toast_ScreenRecordingFailed"), + LocalizationHelper.GetString("Toast_ScreenRecordingFailedDetail"), + "node:screen-recording-failed", + AppNotificationSeverity.Error, + mirrorInApp: true); throw; } finally @@ -1794,9 +1826,12 @@ private async Task OnCameraSnap(CameraSnapArgs args) } catch (UnauthorizedAccessException ex) { - ToastRequested?.Invoke(this, new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_CameraBlocked")) - .AddText(LocalizationHelper.GetString("Toast_CameraBlockedDetail"))); + RequestNodeToast( + LocalizationHelper.GetString("Toast_CameraBlocked"), + LocalizationHelper.GetString("Toast_CameraBlockedDetail"), + "node:camera-blocked", + AppNotificationSeverity.Error, + mirrorInApp: true); throw new InvalidOperationException( "Camera access blocked. Enable camera access for desktop apps in Windows Privacy settings.", ex); @@ -1816,20 +1851,26 @@ private async Task OnCameraClip(CameraClipArgs args) SetRecordingState(RecordingType.Camera, true, args.DurationMs); try { - ToastRequested?.Invoke(this, new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_CameraRecordingStarted")) - .AddText(LocalizationHelper.GetString("Toast_CameraRecordingStartedDetail"))); + RequestNodeToast( + LocalizationHelper.GetString("Toast_CameraRecordingStarted"), + LocalizationHelper.GetString("Toast_CameraRecordingStartedDetail"), + "node:camera-recording-started"); var result = await _cameraCaptureService.ClipAsync(args); - ToastRequested?.Invoke(this, new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_CameraRecordingComplete")) - .AddText(LocalizationHelper.GetString("Toast_CameraRecordingCompleteDetail"))); + RequestNodeToast( + LocalizationHelper.GetString("Toast_CameraRecordingComplete"), + LocalizationHelper.GetString("Toast_CameraRecordingCompleteDetail"), + "node:camera-recording-complete", + AppNotificationSeverity.Success); return result; } catch (UnauthorizedAccessException ex) { - ToastRequested?.Invoke(this, new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_CameraBlocked")) - .AddText(LocalizationHelper.GetString("Toast_CameraBlockedDetail"))); + RequestNodeToast( + LocalizationHelper.GetString("Toast_CameraBlocked"), + LocalizationHelper.GetString("Toast_CameraBlockedDetail"), + "node:camera-blocked", + AppNotificationSeverity.Error, + mirrorInApp: true); throw new InvalidOperationException( "Camera access blocked. Enable camera access for desktop apps in Windows Privacy settings.", ex); diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeToastRequestedEventArgs.cs b/src/OpenClaw.Tray.WinUI/Services/NodeToastRequestedEventArgs.cs new file mode 100644 index 000000000..b911e41dc --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/NodeToastRequestedEventArgs.cs @@ -0,0 +1,16 @@ +using Microsoft.Toolkit.Uwp.Notifications; +using System; + +namespace OpenClawTray.Services; + +public sealed class NodeToastRequestedEventArgs( + ToastContentBuilder toastBuilder, + AppNotification? appNotification = null, + string? toastTag = null, + string? toastDeviceId = null) : EventArgs +{ + public ToastContentBuilder ToastBuilder { get; } = toastBuilder ?? throw new ArgumentNullException(nameof(toastBuilder)); + public AppNotification? AppNotification { get; } = appNotification; + public string? ToastTag { get; } = toastTag; + public string? ToastDeviceId { get; } = toastDeviceId; +} diff --git a/src/OpenClaw.Tray.WinUI/Services/ToastService.cs b/src/OpenClaw.Tray.WinUI/Services/ToastService.cs index 920cf4697..7d2941d1f 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ToastService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ToastService.cs @@ -9,7 +9,7 @@ namespace OpenClawTray.Services; /// Toast notification display with dedup and sound configuration. /// Extracted from App.xaml.cs to group toast-related state and logic. /// -internal sealed class ToastService +internal sealed class ToastService : IToastNotificationPublisher { private readonly Func _getSettings; private readonly Dictionary _recentToastKeys = new(StringComparer.OrdinalIgnoreCase); diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 47a3b212b..62021e7f0 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -23,6 +23,7 @@ + @@ -65,6 +66,8 @@ + + diff --git a/tests/OpenClaw.Tray.Tests/Services/AppNotificationMapperTests.cs b/tests/OpenClaw.Tray.Tests/Services/AppNotificationMapperTests.cs new file mode 100644 index 000000000..b4022ba3a --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/Services/AppNotificationMapperTests.cs @@ -0,0 +1,99 @@ +using OpenClaw.Shared; +using OpenClaw.Shared.Capabilities; +using OpenClawTray.Services; +using Xunit; + +namespace OpenClaw.Tray.Tests.Services; + +public sealed class AppNotificationMapperTests +{ + [Fact] + public void FromGatewayNotification_MapsChatActionAndUsesHashedStableDedupeKey() + { + var notification = new OpenClawNotification + { + Title = "Build complete", + Message = "The build passed", + Type = "info", + IsChat = true, + SessionKey = "agent:main:default" + }; + + var mapped = AppNotificationMapper.FromGatewayNotification(notification, "Open Chat"); + var mappedAgain = AppNotificationMapper.FromGatewayNotification(notification, "Open Chat"); + + Assert.Equal("gateway", mapped.Source); + Assert.Equal("info", mapped.Category); + Assert.Equal(AppNotificationSeverity.Informational, mapped.Severity); + Assert.Equal("Open Chat", mapped.ActionLabel); + Assert.True(AppNotificationActionRoutes.TryGetChatSessionKey(mapped.ActionRoute, out var sessionKey)); + Assert.Equal("agent:main:default", sessionKey); + Assert.Equal(mapped.DedupeKey, mappedAgain.DedupeKey); + Assert.DoesNotContain(notification.Title, mapped.DedupeKey); + Assert.DoesNotContain(notification.Message, mapped.DedupeKey); + } + + [Fact] + public void FromGatewayNotification_ChatWithoutSessionKey_FallsBackToChatPageAction() + { + var mapped = AppNotificationMapper.FromGatewayNotification(new OpenClawNotification + { + Title = "Chat response", + Message = "A response is ready", + Type = "info", + IsChat = true + }, "Open Chat"); + + Assert.Equal("Open Chat", mapped.ActionLabel); + Assert.Equal("chat", mapped.ActionRoute); + } + + [Theory] + [InlineData("error", AppNotificationSeverity.Error)] + [InlineData("urgent", AppNotificationSeverity.Warning)] + [InlineData("health", AppNotificationSeverity.Warning)] + [InlineData("reminder", AppNotificationSeverity.Informational)] + public void FromGatewayNotification_MapsSeverityFromType(string type, AppNotificationSeverity expected) + { + var mapped = AppNotificationMapper.FromGatewayNotification(new OpenClawNotification + { + Title = "Notification", + Message = "Message", + Type = type + }); + + Assert.Equal(expected, mapped.Severity); + } + + [Fact] + public void FromNodeSystemNotification_UsesNodeSourceAndSystemNotifyCategory() + { + var mapped = AppNotificationMapper.FromNodeSystemNotification(new SystemNotifyArgs + { + Title = "Agent notice", + Body = "Something happened" + }); + + Assert.Equal("node", mapped.Source); + Assert.Equal("system.notify", mapped.Category); + Assert.Equal("Agent notice", mapped.Title); + Assert.Equal("Something happened", mapped.Message); + Assert.Equal(AppNotificationSeverity.Informational, mapped.Severity); + } + + [Fact] + public void FromNodeActivity_UsesExplicitMetadata() + { + var mapped = AppNotificationMapper.FromNodeActivity( + "Camera blocked", + "Enable camera access.", + "node.invoke", + AppNotificationSeverity.Error, + "node:camera-blocked"); + + Assert.Equal("node", mapped.Source); + Assert.Equal("node.invoke", mapped.Category); + Assert.Equal(AppNotificationSeverity.Error, mapped.Severity); + Assert.Equal("node:camera-blocked", mapped.DedupeKey); + } +} diff --git a/tests/OpenClaw.Tray.Tests/Services/AppNotificationPublisherTests.cs b/tests/OpenClaw.Tray.Tests/Services/AppNotificationPublisherTests.cs new file mode 100644 index 000000000..bc60401ef --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/Services/AppNotificationPublisherTests.cs @@ -0,0 +1,61 @@ +using Microsoft.Toolkit.Uwp.Notifications; +using OpenClawTray.Services; +using Xunit; + +namespace OpenClaw.Tray.Tests.Services; + +public sealed class AppNotificationPublisherTests +{ + [Fact] + public void Publish_AppNotificationFailure_DoesNotSuppressToast() + { + var service = new AppNotificationService(); + var expected = new InvalidOperationException("app notification subscriber failed"); + var toastShown = false; + service.Changed += (_, _) => throw expected; + + var actual = Assert.Throws(() => + AppNotificationPublisher.Publish( + service, + (_, _, _) => toastShown = true, + new AppNotificationPublishRequest(Notification(), new ToastContentBuilder()))); + + Assert.Same(expected, actual); + Assert.True(toastShown); + } + + [Fact] + public void Publish_ToastFailure_DoesNotSuppressAppNotification() + { + var service = new AppNotificationService(); + var expected = new InvalidOperationException("toast failed"); + + var actual = Assert.Throws(() => + AppNotificationPublisher.Publish( + service, + (_, _, _) => throw expected, + new AppNotificationPublishRequest(Notification(), new ToastContentBuilder()))); + + Assert.Same(expected, actual); + Assert.Equal("Title", service.Snapshot.Current?.Title); + } + + [Fact] + public void Publish_EmptyRequest_Throws() + { + var service = new AppNotificationService(); + + Assert.Throws(() => + AppNotificationPublisher.Publish( + service, + (_, _, _) => { }, + new AppNotificationPublishRequest())); + } + + private static AppNotification Notification() => new() + { + Title = "Title", + Message = "Message", + Source = "test" + }; +} diff --git a/tests/OpenClaw.Tray.Tests/Services/AppNotificationServiceTests.cs b/tests/OpenClaw.Tray.Tests/Services/AppNotificationServiceTests.cs index 3be5f7986..0474a3b1c 100644 --- a/tests/OpenClaw.Tray.Tests/Services/AppNotificationServiceTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/AppNotificationServiceTests.cs @@ -297,6 +297,20 @@ public void Show_DedupeKey_CoalescesWithoutDroppingDistinctNotifications() Assert.Equal("Different", service.Snapshot.Queued[0].Title); } + [Fact] + public void Show_CapsActiveNotificationsAndKeepsNewestQueuedItems() + { + var service = new AppNotificationService(); + + for (var index = 1; index <= 105; index++) + service.Show(Notification($"Notification {index}", "Message")); + + Assert.Equal(100, service.Snapshot.ActiveNotifications.Count); + Assert.Equal("Notification 1", service.Snapshot.Current?.Title); + Assert.DoesNotContain(service.Snapshot.ActiveNotifications, notification => notification.Title == "Notification 2"); + Assert.Contains(service.Snapshot.ActiveNotifications, notification => notification.Title == "Notification 105"); + } + [Fact] public void ClearSource_RemovesCurrentAndQueuedNotifications() {