Skip to content

Commit 599c7e5

Browse files
committed
Reduce notification capture latency
1 parent 98d9560 commit 599c7e5

2 files changed

Lines changed: 100 additions & 24 deletions

File tree

WindowsNotificationListener.cs

Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Runtime.InteropServices;
2+
using System.Threading;
23
using System.Windows.Threading;
34
using Windows.UI.Notifications;
45
using Windows.UI.Notifications.Management;
@@ -7,12 +8,16 @@ namespace ToastDesk;
78

89
public sealed class WindowsNotificationListener : IDisposable
910
{
11+
private static readonly TimeSpan EventBackupPollInterval = TimeSpan.FromSeconds(5);
12+
private static readonly TimeSpan FastPollInterval = TimeSpan.FromMilliseconds(500);
1013
private readonly NotificationStore store;
1114
private readonly Dispatcher dispatcher;
1215
private readonly HashSet<uint> capturedNotificationIds = [];
1316
private readonly DispatcherTimer syncTimer;
1417
private UserNotificationListener? listener;
18+
private int isCapturing;
1519
private bool isDisposed;
20+
private bool isEventSubscriptionEnabled;
1621

1722
public string LastStatusMessage { get; private set; } = "Not started.";
1823
public bool IsEnabled { get; private set; }
@@ -23,7 +28,7 @@ public WindowsNotificationListener(NotificationStore store, Dispatcher dispatche
2328
this.dispatcher = dispatcher;
2429
syncTimer = new DispatcherTimer(DispatcherPriority.Background, dispatcher)
2530
{
26-
Interval = TimeSpan.FromSeconds(2)
31+
Interval = FastPollInterval
2732
};
2833
syncTimer.Tick += async (_, _) => await CaptureCurrentNotificationsAsync();
2934
}
@@ -64,6 +69,7 @@ public async Task<WindowsNotificationListenerResult> StartAsync(bool forcePrompt
6469
}
6570

6671
await SeedExistingNotificationsAsync();
72+
ConfigureNotificationChangeMode();
6773
syncTimer.Start();
6874

6975
if (isDisposed)
@@ -86,17 +92,72 @@ public void Dispose()
8692
isDisposed = true;
8793
syncTimer.Stop();
8894

95+
UnsubscribeNotificationChanged();
8996
listener = null;
9097
}
9198

9299
public void Stop()
93100
{
94101
syncTimer.Stop();
102+
UnsubscribeNotificationChanged();
95103
listener = null;
96104
IsEnabled = false;
97105
LastStatusMessage = "Windows notification capture is disabled.";
98106
}
99107

108+
private void ConfigureNotificationChangeMode()
109+
{
110+
UnsubscribeNotificationChanged();
111+
112+
if (listener is null)
113+
{
114+
syncTimer.Interval = FastPollInterval;
115+
return;
116+
}
117+
118+
try
119+
{
120+
listener.NotificationChanged += OnNotificationChanged;
121+
isEventSubscriptionEnabled = true;
122+
syncTimer.Interval = EventBackupPollInterval;
123+
}
124+
catch (COMException)
125+
{
126+
isEventSubscriptionEnabled = false;
127+
syncTimer.Interval = FastPollInterval;
128+
}
129+
}
130+
131+
private void UnsubscribeNotificationChanged()
132+
{
133+
if (!isEventSubscriptionEnabled || listener is null)
134+
{
135+
isEventSubscriptionEnabled = false;
136+
return;
137+
}
138+
139+
try
140+
{
141+
listener.NotificationChanged -= OnNotificationChanged;
142+
}
143+
catch (COMException)
144+
{
145+
// Ignore unsubscribe failures during shutdown or fallback transitions.
146+
}
147+
148+
isEventSubscriptionEnabled = false;
149+
}
150+
151+
private async void OnNotificationChanged(UserNotificationListener sender, UserNotificationChangedEventArgs args)
152+
{
153+
if (args.ChangeKind != UserNotificationChangedKind.Added)
154+
{
155+
return;
156+
}
157+
158+
await CaptureCurrentNotificationsAsync();
159+
}
160+
100161
private async Task SeedExistingNotificationsAsync()
101162
{
102163
if (isDisposed || listener is null)
@@ -113,38 +174,45 @@ private async Task SeedExistingNotificationsAsync()
113174

114175
private async Task CaptureCurrentNotificationsAsync()
115176
{
116-
if (isDisposed || listener is null)
177+
if (isDisposed || listener is null || Interlocked.Exchange(ref isCapturing, 1) == 1)
117178
{
118179
return;
119180
}
120181

121-
var notifications = await GetCurrentNotificationsAsync();
122-
123-
foreach (var notification in notifications)
182+
try
124183
{
125-
if (isDisposed || dispatcher.HasShutdownStarted)
126-
{
127-
return;
128-
}
184+
var notifications = await GetCurrentNotificationsAsync();
129185

130-
if (!capturedNotificationIds.Add(notification.Id))
186+
foreach (var notification in notifications)
131187
{
132-
continue;
133-
}
188+
if (isDisposed || dispatcher.HasShutdownStarted)
189+
{
190+
return;
191+
}
134192

135-
var details = ExtractDetails(notification);
136-
_ = dispatcher.InvokeAsync(() =>
137-
{
138-
if (!isDisposed)
193+
if (!capturedNotificationIds.Add(notification.Id))
139194
{
140-
store.Add(
141-
details.Title,
142-
details.Message,
143-
NotificationOrigin.Windows,
144-
details.SourceAppName,
145-
details.SourceAppUserModelId);
195+
continue;
146196
}
147-
});
197+
198+
var details = ExtractDetails(notification);
199+
_ = dispatcher.InvokeAsync(() =>
200+
{
201+
if (!isDisposed)
202+
{
203+
store.Add(
204+
details.Title,
205+
details.Message,
206+
NotificationOrigin.Windows,
207+
details.SourceAppName,
208+
details.SourceAppUserModelId);
209+
}
210+
});
211+
}
212+
}
213+
finally
214+
{
215+
Interlocked.Exchange(ref isCapturing, 0);
148216
}
149217
}
150218

@@ -196,6 +264,13 @@ private WindowsNotificationListenerResult SetStatus(bool isEnabled, string messa
196264
{
197265
IsEnabled = isEnabled;
198266
LastStatusMessage = message;
199-
return new WindowsNotificationListenerResult(isEnabled, message);
267+
if (isEnabled && !string.IsNullOrWhiteSpace(message))
268+
{
269+
LastStatusMessage = isEventSubscriptionEnabled
270+
? $"{message} Using Windows change events with polling backup."
271+
: $"{message} Using fast polling because Windows change events are unavailable.";
272+
}
273+
274+
return new WindowsNotificationListenerResult(isEnabled, LastStatusMessage);
200275
}
201276
}

docs/production-readiness.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- Improve lifecycle behavior: startup, minimized tray launch, graceful exit, duplicate process handling, and clean shutdown while capture is active.
1313
- Improve notification behavior: capture filtering, deduplication, do-not-disturb behavior, overlay queue limits, and action handling.
1414
- Toast cards now use explicit `Open` and `Dismiss` actions. `Open` attempts to launch the source app by AppUserModelID when Windows exposes one, with ToastDesk as fallback.
15+
- Notification capture prefers Windows change events when available. If WinRT event subscription fails in the unpackaged desktop app, ToastDesk falls back to guarded 500ms polling to keep latency low without overlapping capture calls.
1516
- Improve settings: durable migration, clear defaults, restore defaults, notification sound presets/custom audio, and direct links to Windows notification permissions.
1617
- Prepare packaging: self-contained `.exe`, installer path, icon, versioning, and uninstall cleanup.
1718
- Add diagnostics: local logs for notification permission, shortcut registration, startup registration, and capture polling failures.

0 commit comments

Comments
 (0)