Compare commits

...

2 Commits

Author SHA1 Message Date
Keeley Hammond
f9cdfeb205 chore: fix lint 2026-04-27 12:50:26 -07:00
Keeley Hammond
1f35c12d83 feat: add Notification.remove(), removeAll(), removeGroup() static methods (macOS)
Add static methods for removing delivered notifications from Notification
Center via the UNUserNotificationCenter API:

- `remove(id)` removes one or more notifications by identifier (accepts
  string or string array)
- `removeAll()` removes all of the app's delivered notifications
- `removeGroup(groupId)` removes all notifications with a given groupId

These methods build on the custom id/groupId support and getHistory() to
enable full notification lifecycle management from the main process.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
2026-04-27 12:30:01 -07:00
10 changed files with 322 additions and 2 deletions

View File

@@ -115,11 +115,50 @@ app.whenReady().then(async () => {
})
```
#### `Notification.remove(id)` _macOS_
* `id` (string | string[]) - The notification identifier(s) to remove. These correspond to the `id` values set in the [`Notification` constructor](#new-notificationoptions).
Removes one or more delivered notifications from Notification Center by their identifier(s).
```js
const { Notification } = require('electron')
// Remove a single notification
Notification.remove('my-notification-id')
// Remove multiple notifications
Notification.remove(['msg-1', 'msg-2', 'msg-3'])
```
#### `Notification.removeAll()` _macOS_
Removes all of the app's delivered notifications from Notification Center.
```js
const { Notification } = require('electron')
Notification.removeAll()
```
#### `Notification.removeGroup(groupId)` _macOS_
* `groupId` string - The group identifier of the notifications to remove. This corresponds to the `groupId` value set in the [`Notification` constructor](#new-notificationoptions).
Removes all delivered notifications with the given `groupId` from Notification Center.
```js
const { Notification } = require('electron')
// Remove all notifications in the 'chat-thread-1' group
Notification.removeGroup('chat-thread-1')
```
### `new Notification([options])`
* `options` Object (optional)
* `id` string (optional) _macOS_ _Windows_ - A unique identifier for the notification. On macOS, maps to `UNNotificationRequest`'s [`identifier`](https://developer.apple.com/documentation/usernotifications/unnotificationrequest/identifier) property. On Windows, maps to the toast notification's [`Tag`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toastnotification.tag) property. Defaults to a random UUID if not provided or if an empty string is passed. This can be used to remove or update previously delivered notifications.
* `groupId` string (optional) _macOS_ _Windows_ - A string identifier used to visually group notifications together in Notification Center / Action Center. On macOS, maps to `UNNotificationContent`'s [`threadIdentifier`](https://developer.apple.com/documentation/usernotifications/unnotificationcontent/threadidentifier) property. On Windows, maps to the toast notification's [`Group`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toastnotification.group) property.
* `id` string (optional) _macOS_ _Windows_ - A unique identifier for the notification. On macOS, maps to `UNNotificationRequest`'s [`identifier`](https://developer.apple.com/documentation/usernotifications/unnotificationrequest/identifier) property. On Windows, maps to the toast notification's [`Tag`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toastnotification.tag) property. Defaults to a random UUID if not provided or if an empty string is passed. Use this identifier with [`Notification.remove()`](#notificationremoveid-macos) to remove specific delivered notifications, or with [`Notification.getHistory()`](#notificationgethistory-macos) to identify them.
* `groupId` string (optional) _macOS_ _Windows_ - A string identifier used to visually group notifications together in Notification Center / Action Center. On macOS, maps to `UNNotificationContent`'s [`threadIdentifier`](https://developer.apple.com/documentation/usernotifications/unnotificationcontent/threadidentifier) property. On Windows, maps to the toast notification's [`Group`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toastnotification.group) property. Use this identifier with [`Notification.removeGroup()`](#notificationremovegroupgroupid-macos) to remove all notifications in a group.
* `groupTitle` string (optional) _Windows_ - A title for the notification group header. When both `groupId` and `groupTitle` are specified, Windows will display a header above the notification that groups related notifications together. Maps to the toast notification's [`header`](https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-headers) element.
* `title` string (optional) - A title for the notification, which will be displayed at the top of the notification window when it is shown.
* `subtitle` string (optional) _macOS_ - A subtitle for the notification, which will be displayed below the title.

View File

@@ -3,6 +3,9 @@ const binding = process._linkedBinding('electron_browser_notification');
const ElectronNotification = binding.Notification;
ElectronNotification.isSupported = binding.isSupported;
ElectronNotification.getHistory = binding.getHistory;
ElectronNotification.remove = binding.remove;
ElectronNotification.removeAll = binding.removeAll;
ElectronNotification.removeGroup = binding.removeGroup;
if (process.platform === 'win32' && binding.handleActivation) {
ElectronNotification.handleActivation = binding.handleActivation;

View File

@@ -460,6 +460,60 @@ v8::Local<v8::Promise> Notification::GetHistory(v8::Isolate* isolate) {
return handle;
}
// static
void Notification::Remove(gin::Arguments* args) {
auto* presenter =
static_cast<ElectronBrowserClient*>(ElectronBrowserClient::Get())
->GetNotificationPresenter();
if (!presenter)
return;
// Accept either a single string or an array of strings.
// Peek at the value type first to avoid gin::Arguments cursor issues.
v8::Local<v8::Value> val;
if (!args->GetNext(&val)) {
args->ThrowTypeError("Expected a string or array of strings");
return;
}
if (val->IsString()) {
std::string id;
gin::ConvertFromV8(args->isolate(), val, &id);
presenter->RemoveDeliveredNotifications({id});
} else if (val->IsArray()) {
std::vector<std::string> ids;
if (!gin::ConvertFromV8(args->isolate(), val, &ids)) {
args->ThrowTypeError("Expected a string or array of strings");
return;
}
presenter->RemoveDeliveredNotifications(ids);
} else {
args->ThrowTypeError("Expected a string or array of strings");
}
}
// static
void Notification::RemoveAll() {
auto* presenter =
static_cast<ElectronBrowserClient*>(ElectronBrowserClient::Get())
->GetNotificationPresenter();
if (!presenter)
return;
presenter->RemoveAllDeliveredNotifications();
}
// static
void Notification::RemoveGroup(const std::string& group_id) {
auto* presenter =
static_cast<ElectronBrowserClient*>(ElectronBrowserClient::Get())
->GetNotificationPresenter();
if (!presenter)
return;
presenter->RemoveDeliveredNotificationsByGroupId(group_id);
}
void Notification::FillObjectTemplate(v8::Isolate* isolate,
v8::Local<v8::ObjectTemplate> templ) {
gin::ObjectTemplateBuilder(isolate, GetClassName(), templ)
@@ -514,6 +568,9 @@ void Initialize(v8::Local<v8::Object> exports,
dict.SetMethod("handleActivation", &Notification::HandleActivation);
#endif
dict.SetMethod("getHistory", &Notification::GetHistory);
dict.SetMethod("remove", &Notification::Remove);
dict.SetMethod("removeAll", &Notification::RemoveAll);
dict.SetMethod("removeGroup", &Notification::RemoveGroup);
}
} // namespace

View File

@@ -39,6 +39,9 @@ class Notification final : public gin_helper::DeprecatedWrappable<Notification>,
public:
static bool IsSupported();
static v8::Local<v8::Promise> GetHistory(v8::Isolate* isolate);
static void Remove(gin::Arguments* args);
static void RemoveAll();
static void RemoveGroup(const std::string& group_id);
#if BUILDFLAG(IS_WIN)
// Register a callback to handle all notification activations.

View File

@@ -27,6 +27,11 @@ class NotificationPresenterMac : public NotificationPresenter {
// NotificationPresenter
void GetDeliveredNotifications(
GetDeliveredNotificationsCallback callback) override;
void RemoveDeliveredNotifications(
const std::vector<std::string>& identifiers) override;
void RemoveAllDeliveredNotifications() override;
void RemoveDeliveredNotificationsByGroupId(
const std::string& group_id) override;
NotificationImageRetainer* image_retainer() { return image_retainer_.get(); }
scoped_refptr<base::SequencedTaskRunner> image_task_runner() {

View File

@@ -116,4 +116,41 @@ void NotificationPresenterMac::GetDeliveredNotifications(
}];
}
void NotificationPresenterMac::RemoveDeliveredNotifications(
const std::vector<std::string>& identifiers) {
NSMutableArray* ns_identifiers =
[NSMutableArray arrayWithCapacity:identifiers.size()];
for (const auto& id : identifiers) {
[ns_identifiers addObject:base::SysUTF8ToNSString(id)];
}
[[UNUserNotificationCenter currentNotificationCenter]
removeDeliveredNotificationsWithIdentifiers:ns_identifiers];
}
void NotificationPresenterMac::RemoveAllDeliveredNotifications() {
[[UNUserNotificationCenter currentNotificationCenter]
removeAllDeliveredNotifications];
}
void NotificationPresenterMac::RemoveDeliveredNotificationsByGroupId(
const std::string& group_id) {
NSString* target_group = base::SysUTF8ToNSString(group_id);
UNUserNotificationCenter* center =
[UNUserNotificationCenter currentNotificationCenter];
[center getDeliveredNotificationsWithCompletionHandler:^(
NSArray<UNNotification*>* _Nonnull notifications) {
NSMutableArray* matching_ids = [NSMutableArray array];
for (UNNotification* notification in notifications) {
if ([notification.request.content.threadIdentifier
isEqualToString:target_group]) {
[matching_ids addObject:notification.request.identifier];
}
}
if (matching_ids.count > 0) {
[center removeDeliveredNotificationsWithIdentifiers:matching_ids];
}
}];
}
} // namespace electron

View File

@@ -50,4 +50,18 @@ void NotificationPresenter::GetDeliveredNotifications(
std::move(callback).Run({});
}
void NotificationPresenter::RemoveDeliveredNotifications(
const std::vector<std::string>& identifiers) {
// Default: no-op. Overridden on macOS.
}
void NotificationPresenter::RemoveAllDeliveredNotifications() {
// Default: no-op. Overridden on macOS.
}
void NotificationPresenter::RemoveDeliveredNotificationsByGroupId(
const std::string& group_id) {
// Default: no-op. Overridden on macOS.
}
} // namespace electron

View File

@@ -34,6 +34,11 @@ class NotificationPresenter {
virtual void GetDeliveredNotifications(
GetDeliveredNotificationsCallback callback);
virtual void RemoveDeliveredNotifications(
const std::vector<std::string>& identifiers);
virtual void RemoveAllDeliveredNotifications();
virtual void RemoveDeliveredNotificationsByGroupId(
const std::string& group_id);
std::set<Notification*> notifications() const { return notifications_; }

View File

@@ -420,6 +420,38 @@ describe('Notification module', () => {
}
);
ifit(process.platform === 'darwin')('remove does not throw with a string argument', () => {
expect(() => Notification.remove('nonexistent-id')).to.not.throw();
});
ifit(process.platform === 'darwin')('remove does not throw with an array argument', () => {
expect(() => Notification.remove(['id-1', 'id-2'])).to.not.throw();
});
ifit(process.platform === 'darwin')('remove throws with no arguments', () => {
expect(() => (Notification.remove as any)()).to.throw(/Expected a string or array of strings/);
});
ifit(process.platform === 'darwin')('remove throws with an invalid argument type', () => {
expect(() => (Notification.remove as any)(123)).to.throw(/Expected a string or array of strings/);
});
ifit(process.platform === 'darwin')('remove does not throw with an empty array', () => {
expect(() => Notification.remove([])).to.not.throw();
});
ifit(process.platform === 'darwin')('remove does not throw with an empty string', () => {
expect(() => Notification.remove('')).to.not.throw();
});
ifit(process.platform === 'darwin')('removeAll does not throw', () => {
expect(() => Notification.removeAll()).to.not.throw();
});
ifit(process.platform === 'darwin')('removeGroup does not throw', () => {
expect(() => Notification.removeGroup('nonexistent-group')).to.not.throw();
});
ifit(process.platform === 'darwin')('getHistory returned notifications can be shown and closed', async () => {
const n = new Notification({
id: 'history-show-close',
@@ -445,5 +477,127 @@ describe('Notification module', () => {
}).to.not.throw();
}
});
ifit(process.platform === 'darwin')('remove removes a notification by id', async () => {
const n = new Notification({
id: 'remove-test-id',
title: 'remove test',
body: 'remove body',
silent: true
});
const shown = once(n, 'show');
n.show();
await shown;
Notification.remove('remove-test-id');
// Give the notification center a moment to process the removal
await new Promise((resolve) => setTimeout(resolve, 100));
const history = await Notification.getHistory();
const found = history.find((item: any) => item.id === 'remove-test-id');
expect(found).to.be.undefined();
});
ifit(process.platform === 'darwin')('remove accepts an array of ids', async () => {
const n1 = new Notification({
id: 'remove-array-1',
title: 'test 1',
body: 'body 1',
silent: true
});
const n2 = new Notification({
id: 'remove-array-2',
title: 'test 2',
body: 'body 2',
silent: true
});
const shown1 = once(n1, 'show');
n1.show();
await shown1;
const shown2 = once(n2, 'show');
n2.show();
await shown2;
Notification.remove(['remove-array-1', 'remove-array-2']);
await new Promise((resolve) => setTimeout(resolve, 100));
const history = await Notification.getHistory();
const found1 = history.find((item: any) => item.id === 'remove-array-1');
const found2 = history.find((item: any) => item.id === 'remove-array-2');
expect(found1).to.be.undefined();
expect(found2).to.be.undefined();
});
ifit(process.platform === 'darwin')('removeAll removes all notifications', async () => {
const n = new Notification({
id: 'remove-all-test',
title: 'removeAll test',
body: 'body',
silent: true
});
const shown = once(n, 'show');
n.show();
await shown;
Notification.removeAll();
await new Promise((resolve) => setTimeout(resolve, 100));
const history = await Notification.getHistory();
const found = history.find((item: any) => item.id === 'remove-all-test');
expect(found).to.be.undefined();
});
ifit(process.platform === 'darwin')('removeGroup removes notifications by groupId', async () => {
const n1 = new Notification({
id: 'group-keep',
title: 'keep',
body: 'body',
groupId: 'group-a',
silent: true
});
const n2 = new Notification({
id: 'group-remove-1',
title: 'remove 1',
body: 'body',
groupId: 'group-b',
silent: true
});
const n3 = new Notification({
id: 'group-remove-2',
title: 'remove 2',
body: 'body',
groupId: 'group-b',
silent: true
});
for (const n of [n1, n2, n3]) {
const shown = once(n, 'show');
n.show();
await shown;
}
Notification.removeGroup('group-b');
// Give the notification center a moment to fetch and remove
await new Promise((resolve) => setTimeout(resolve, 500));
const history = await Notification.getHistory();
// In code-signed builds, group-a notification should remain
// while group-b notifications should be gone
const foundB1 = history.find((item: any) => item.id === 'group-remove-1');
const foundB2 = history.find((item: any) => item.id === 'group-remove-2');
expect(foundB1).to.be.undefined();
expect(foundB2).to.be.undefined();
// Clean up
Notification.removeAll();
});
});
});

View File

@@ -119,6 +119,9 @@ declare namespace NodeJS {
interface NotificationBinding {
isSupported(): boolean;
getHistory(): Promise<Electron.Notification[]>;
remove(id: string | string[]): void;
removeAll(): void;
removeGroup(groupId: string): void;
Notification: typeof Electron.Notification;
// Windows-only callback for cold-start notification activation
handleActivation?: (callback: (details: ActivationArgumentsInternal) => void) => void;