feat: add id and groupId options to macOS notifications (#50097)

* feat: add custom `id` property to Notification API (macOS only)

* feat: add `groupId` property to Notification API (macOS). Notifications with the same groupId will be visually grouped together in Notification Center

* fix: move validation to construction time, add empty string check, remove setters

* docs: clarify id/group id properties, make instance properties read-only

* test: update tests to reflect read-only properties
This commit is contained in:
Keeley Hammond
2026-03-16 13:24:29 -07:00
committed by GitHub
parent b7e9bbed0c
commit 958278c273
6 changed files with 137 additions and 6 deletions

View File

@@ -79,6 +79,8 @@ app.whenReady().then(() => {
### `new Notification([options])`
* `options` Object (optional)
* `id` string (optional) _macOS_ - A unique identifier for the notification, mapping to `UNNotificationRequest`'s [`identifier`](https://developer.apple.com/documentation/usernotifications/unnotificationrequest/identifier) 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_ - A string identifier used to visually group notifications together in Notification Center. Maps to `UNNotificationContent`'s [`threadIdentifier`](https://developer.apple.com/documentation/usernotifications/unnotificationcontent/threadidentifier) property.
* `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.
* `body` string (optional) - The body text of the notification, which will be displayed below the title or subtitle.
@@ -323,6 +325,14 @@ app.whenReady().then(() => {
### Instance Properties
#### `notification.id` _macOS_ _Readonly_
A `string` property representing the unique identifier of the notification. This is set at construction time — either from the `id` option or as a generated UUID if none was provided.
#### `notification.groupId` _macOS_ _Readonly_
A `string` property representing the group identifier of the notification. Notifications with the same `groupId` will be visually grouped together in Notification Center.
#### `notification.title`
A `string` property representing the title of the notification.

View File

@@ -74,6 +74,8 @@ Notification::Notification(gin::Arguments* args) {
gin::Dictionary opts(nullptr);
if (args->GetNext(&opts)) {
opts.Get("id", &id_);
opts.Get("groupId", &group_id_);
opts.Get("title", &title_);
opts.Get("subtitle", &subtitle_);
opts.Get("body", &body_);
@@ -88,6 +90,9 @@ Notification::Notification(gin::Arguments* args) {
opts.Get("closeButtonText", &close_button_text_);
opts.Get("toastXml", &toast_xml_);
}
if (id_.empty())
id_ = base::Uuid::GenerateRandomV4().AsLowercaseString();
}
Notification::~Notification() {
@@ -236,8 +241,7 @@ void Notification::Close() {
void Notification::Show() {
Close();
if (presenter_) {
notification_ = presenter_->CreateNotification(
this, base::Uuid::GenerateRandomV4().AsLowercaseString());
notification_ = presenter_->CreateNotification(this, id_);
if (notification_) {
electron::NotificationOptions options;
options.title = title_;
@@ -254,6 +258,7 @@ void Notification::Show() {
options.close_button_text = close_button_text_;
options.urgency = urgency_;
options.toast_xml = toast_xml_;
options.group_id = group_id_;
notification_->Show(options);
}
}
@@ -342,6 +347,8 @@ void Notification::FillObjectTemplate(v8::Isolate* isolate,
gin::ObjectTemplateBuilder(isolate, GetClassName(), templ)
.SetMethod("show", &Notification::Show)
.SetMethod("close", &Notification::Close)
.SetProperty("id", &Notification::id)
.SetProperty("groupId", &Notification::group_id)
.SetProperty("title", &Notification::title, &Notification::SetTitle)
.SetProperty("subtitle", &Notification::subtitle,
&Notification::SetSubtitle)

View File

@@ -83,6 +83,8 @@ class Notification final : public gin_helper::DeprecatedWrappable<Notification>,
void Close();
// Prop Getters
const std::string& id() const { return id_; }
const std::string& group_id() const { return group_id_; }
const std::u16string& title() const { return title_; }
const std::u16string& subtitle() const { return subtitle_; }
const std::u16string& body() const { return body_; }
@@ -113,6 +115,8 @@ class Notification final : public gin_helper::DeprecatedWrappable<Notification>,
void SetToastXml(const std::u16string& new_toast_xml);
private:
std::string id_;
std::string group_id_;
std::u16string title_;
std::u16string subtitle_;
std::u16string body_;

View File

@@ -46,6 +46,10 @@ void CocoaNotification::Show(const NotificationOptions& options) {
content.subtitle = base::SysUTF16ToNSString(options.subtitle);
content.body = base::SysUTF16ToNSString(options.msg);
if (!options.group_id.empty()) {
content.threadIdentifier = base::SysUTF8ToNSString(options.group_id);
}
if (options.silent) {
content.sound = nil;
} else if (options.sound.empty()) {
@@ -174,10 +178,7 @@ void CocoaNotification::Show(const NotificationOptions& options) {
void CocoaNotification::ScheduleNotification(
UNMutableNotificationContent* content) {
NSString* identifier =
[NSString stringWithFormat:@"%@:notification:%@",
[[NSBundle mainBundle] bundleIdentifier],
[[NSUUID UUID] UUIDString]];
NSString* identifier = base::SysUTF8ToNSString(notification_id());
UNNotificationRequest* request =
[UNNotificationRequest requestWithIdentifier:identifier

View File

@@ -49,6 +49,7 @@ struct NotificationOptions {
std::vector<NotificationAction> actions;
std::u16string close_button_text;
std::u16string toast_xml;
std::string group_id;
NotificationOptions();
NotificationOptions(const NotificationOptions&);

View File

@@ -15,6 +15,75 @@ describe('Notification module', () => {
expect(Notification.isSupported()).to.be.a('boolean');
});
ifit(process.platform === 'darwin')('inits and gets id property', () => {
const n = new Notification({
id: 'my-custom-id',
title: 'title',
body: 'body'
});
expect(n.id).to.equal('my-custom-id');
});
ifit(process.platform === 'darwin')('id is read-only', () => {
const n = new Notification({
id: 'my-custom-id',
title: 'title',
body: 'body'
});
expect(() => { (n as any).id = 'new-id'; }).to.throw();
});
ifit(process.platform === 'darwin')('defaults id to a UUID when not provided', () => {
const n = new Notification({
title: 'title',
body: 'body'
});
expect(n.id).to.be.a('string').and.not.be.empty();
expect(n.id).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});
ifit(process.platform === 'darwin')('defaults id to a UUID when empty string is provided', () => {
const n = new Notification({
id: '',
title: 'title',
body: 'body'
});
expect(n.id).to.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});
ifit(process.platform === 'darwin')('inits and gets groupId property', () => {
const n = new Notification({
title: 'title',
body: 'body',
groupId: 'E017VKL2N8H|C07RBMNS9EK'
});
expect(n.groupId).to.equal('E017VKL2N8H|C07RBMNS9EK');
});
ifit(process.platform === 'darwin')('groupId is read-only', () => {
const n = new Notification({
title: 'title',
body: 'body',
groupId: 'E017VKL2N8H|C07RBMNS9EK'
});
expect(() => { (n as any).groupId = 'new-group'; }).to.throw();
});
ifit(process.platform === 'darwin')('defaults groupId to empty string when not provided', () => {
const n = new Notification({
title: 'title',
body: 'body'
});
expect(n.groupId).to.equal('');
});
it('inits, gets and sets basic string properties correctly', () => {
const n = new Notification({
title: 'title',
@@ -141,6 +210,45 @@ describe('Notification module', () => {
}
});
ifit(process.platform === 'darwin')('emits show and close events with custom id', async () => {
const n = new Notification({
id: 'test-custom-id',
title: 'test notification',
body: 'test body',
silent: true
});
{
const e = once(n, 'show');
n.show();
await e;
}
{
const e = once(n, 'close');
n.close();
await e;
}
});
ifit(process.platform === 'darwin')('emits show and close events with custom id and groupId', async () => {
const n = new Notification({
id: 'E017VKL2N8H|C07RBMNS9EK|1772656675.039',
groupId: 'E017VKL2N8H|C07RBMNS9EK',
title: 'test notification',
body: 'test body',
silent: true
});
{
const e = once(n, 'show');
n.show();
await e;
}
{
const e = once(n, 'close');
n.close();
await e;
}
});
ifit(process.platform === 'win32')('emits failed event', async () => {
const n = new Notification({
toastXml: 'not xml'