Compare commits

..

3 Commits

Author SHA1 Message Date
Keeley Hammond
ae1bf816f7 feat,refactor: rework getHistory, return Notification objects 2026-03-17 12:01:46 -07:00
Keeley Hammond
771a502c76 feat: add groupId support for remove (removeGroup) 2026-03-16 17:05:35 -07:00
Keeley Hammond
9e57b2bc04 feat: add static methods getHistory, remove, removeAll to Notification (macOS)
Add static methods for managing delivered notifications via the
UNUserNotificationCenter API:
- getHistory() returns all delivered notifications still in Notification Center
- remove(id) removes one or more notifications by identifier
- removeAll() removes all delivered notifications

These methods build on the custom id support to enable full notification
lifecycle management from the main process.
2026-03-16 16:37:49 -07:00
24 changed files with 622 additions and 181 deletions

View File

@@ -155,7 +155,7 @@ jobs:
await core.summary.write();
- name: Send Slack message if errors
if: ${{ always() && steps.audit-errors.outputs.errorsFound && github.ref == 'refs/heads/main' }}
uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
payload: |
link: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"

View File

@@ -14,7 +14,7 @@ jobs:
permissions: {}
steps:
- name: Trigger Slack workflow
uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
webhook: ${{ secrets.BACKPORT_REQUESTED_SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger

View File

@@ -51,6 +51,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v3.29.5
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5
with:
sarif_file: results.sarif

View File

@@ -76,11 +76,87 @@ app.whenReady().then(() => {
})
```
#### `Notification.getHistory()` _macOS_
Returns `Promise<Notification[]>` - Resolves with an array of `Notification` objects representing all delivered notifications still present in Notification Center.
Each returned `Notification` is a live object connected to the corresponding delivered notification. Interaction events (`click`, `reply`, `action`, `close`) will fire on these objects when the user interacts with the notification in Notification Center. This is useful after an app restart to re-attach event handlers to notifications from a previous session.
The returned notifications have their `id`, `groupId`, `title`, `subtitle`, and `body` properties populated from what macOS provides. Other properties (e.g., `actions`, `silent`, `icon`) are not available from delivered notifications and will have default values.
> [!NOTE]
> Like all macOS notification APIs, this method requires the application to be
> code-signed. In unsigned development builds, notifications are not delivered
> to Notification Center and this method will resolve with an empty array.
> [!NOTE]
> Unlike notifications created with `new Notification()`, notifications returned
> by `getHistory()` will remain visible in Notification Center when the object
> is garbage collected.
```js
const { Notification, app } = require('electron')
app.whenReady().then(async () => {
// Restore notifications from a previous session
const notifications = await Notification.getHistory()
for (const n of notifications) {
console.log(`Found delivered notification: ${n.id} - ${n.title}`)
n.on('click', () => {
console.log(`User clicked: ${n.id}`)
})
n.on('reply', (event) => {
console.log(`User replied to ${n.id}: ${event.reply}`)
})
}
// Keep references so events continue to fire
})
```
#### `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_ - 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.
* `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. 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_ - 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. Use this identifier with [`Notification.removeGroup()`](#notificationremovegroupgroupid-macos) to remove all notifications in a group.
* `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.

View File

@@ -1585,20 +1585,6 @@ Centers the current text selection in web page.
Copy the image at the given position to the clipboard.
#### `contents.copyVideoFrameAt(x, y)`
* `x` Integer
* `y` Integer
When executed on a video media element, copies the frame at (x, y) to the clipboard.
#### `contents.saveVideoFrameAs(x, y)`
* `x` Integer
* `y` Integer
When executed on a video media element, shows a save dialog and saves the frame at (x, y) to disk.
#### `contents.paste()`
Executes the editing command `paste` in web page.

View File

@@ -175,20 +175,6 @@ app.on('web-contents-created', (_, webContents) => {
})
```
#### `frame.copyVideoFrameAt(x, y)`
* `x` Integer
* `y` Integer
When executed on a video media element, copies the frame at (x, y) to the clipboard.
#### `frame.saveVideoFrameAs(x, y)`
* `x` Integer
* `y` Integer
When executed on a video media element, shows a save dialog and saves the frame at (x, y) to disk.
### Instance Properties
#### `frame.ipc` _Readonly_

View File

@@ -179,7 +179,6 @@ auto_filenames = {
"lib/common/define-properties.ts",
"lib/common/deprecate.ts",
"lib/common/ipc-messages.ts",
"lib/common/timers-shim.ts",
"lib/common/web-view-methods.ts",
"lib/common/webpack-globals-provider.ts",
"lib/renderer/api/context-bridge.ts",

View File

@@ -2,6 +2,10 @@ 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

@@ -437,14 +437,6 @@ WebContents.prototype.loadURL = function (url, options) {
return p;
};
WebContents.prototype.copyVideoFrameAt = function (x: number, y: number) {
this.mainFrame.copyVideoFrameAt(x, y);
};
WebContents.prototype.saveVideoFrameAs = function (x: number, y: number) {
this.mainFrame.saveVideoFrameAs(x, y);
};
WebContents.prototype.setWindowOpenHandler = function (handler: (details: Electron.HandlerDetails) => Electron.WindowOpenHandlerResponse) {
this._windowOpenHandler = handler;
};

View File

@@ -5,6 +5,7 @@
#include "shell/browser/api/electron_api_notification.h"
#include "base/functional/bind.h"
#include "base/strings/utf_string_conversions.h"
#include "base/uuid.h"
#include "build/build_config.h"
#include "content/public/browser/browser_task_traits.h"
@@ -13,10 +14,12 @@
#include "shell/browser/browser.h"
#include "shell/browser/electron_browser_client.h"
#include "shell/common/gin_converters/image_converter.h"
#include "shell/common/gin_converters/value_converter.h"
#include "shell/common/gin_helper/dictionary.h"
#include "shell/common/gin_helper/error_thrower.h"
#include "shell/common/gin_helper/handle.h"
#include "shell/common/gin_helper/object_template_builder.h"
#include "shell/common/gin_helper/promise.h"
#include "shell/common/node_includes.h"
#include "url/gurl.h"
@@ -72,27 +75,29 @@ Notification::Notification(gin::Arguments* args) {
presenter_ = static_cast<ElectronBrowserClient*>(ElectronBrowserClient::Get())
->GetNotificationPresenter();
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_);
opts.Get("icon", &icon_);
opts.Get("silent", &silent_);
opts.Get("replyPlaceholder", &reply_placeholder_);
opts.Get("urgency", &urgency_);
opts.Get("hasReply", &has_reply_);
opts.Get("timeoutType", &timeout_type_);
opts.Get("actions", &actions_);
opts.Get("sound", &sound_);
opts.Get("closeButtonText", &close_button_text_);
opts.Get("toastXml", &toast_xml_);
}
if (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_);
opts.Get("icon", &icon_);
opts.Get("silent", &silent_);
opts.Get("replyPlaceholder", &reply_placeholder_);
opts.Get("urgency", &urgency_);
opts.Get("hasReply", &has_reply_);
opts.Get("timeoutType", &timeout_type_);
opts.Get("actions", &actions_);
opts.Get("sound", &sound_);
opts.Get("closeButtonText", &close_button_text_);
opts.Get("toastXml", &toast_xml_);
}
if (id_.empty())
id_ = base::Uuid::GenerateRandomV4().AsLowercaseString();
if (id_.empty())
id_ = base::Uuid::GenerateRandomV4().AsLowercaseString();
}
}
Notification::~Notification() {
@@ -342,6 +347,115 @@ void Notification::HandleActivation(v8::Isolate* isolate,
}
#endif
// static
v8::Local<v8::Promise> Notification::GetHistory(v8::Isolate* isolate) {
gin_helper::Promise<v8::Local<v8::Value>> promise(isolate);
v8::Local<v8::Promise> handle = promise.GetHandle();
auto* presenter =
static_cast<ElectronBrowserClient*>(ElectronBrowserClient::Get())
->GetNotificationPresenter();
if (!presenter) {
promise.Resolve(v8::Array::New(isolate));
return handle;
}
presenter->GetDeliveredNotifications(base::BindOnce(
[](gin_helper::Promise<v8::Local<v8::Value>> promise,
electron::NotificationPresenter* presenter,
std::vector<electron::NotificationInfo> notifications) {
v8::Isolate* isolate = promise.isolate();
v8::HandleScope handle_scope(isolate);
v8::Local<v8::Array> result =
v8::Array::New(isolate, notifications.size());
for (size_t i = 0; i < notifications.size(); i++) {
const auto& info = notifications[i];
// Create a live Notification object for each delivered notification.
auto* notif = new Notification(/*args=*/nullptr);
notif->id_ = info.id;
notif->group_id_ = info.group_id;
notif->title_ = base::UTF8ToUTF16(info.title);
notif->subtitle_ = base::UTF8ToUTF16(info.subtitle);
notif->body_ = base::UTF8ToUTF16(info.body);
// Register with the presenter so click/reply events route here.
if (presenter) {
notif->notification_ =
presenter->CreateNotification(notif, notif->id_);
if (notif->notification_)
notif->notification_->Restore();
}
auto handle = gin_helper::CreateHandle(isolate, notif);
result
->Set(isolate->GetCurrentContext(), static_cast<uint32_t>(i),
handle.ToV8())
.Check();
}
promise.Resolve(result.As<v8::Value>());
},
std::move(promise), presenter));
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)
@@ -395,6 +509,10 @@ void Initialize(v8::Local<v8::Object> exports,
#if BUILDFLAG(IS_WIN)
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

@@ -38,6 +38,10 @@ class Notification final : public gin_helper::DeprecatedWrappable<Notification>,
public NotificationDelegate {
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

@@ -36,8 +36,6 @@
#include "shell/common/node_includes.h"
#include "shell/common/v8_util.h"
#include "third_party/abseil-cpp/absl/container/flat_hash_map.h"
#include "third_party/blink/public/mojom/frame/media_player_action.mojom.h"
#include "ui/gfx/geometry/point.h"
namespace {
@@ -262,28 +260,6 @@ v8::Local<v8::Promise> WebFrameMain::ExecuteJavaScript(
return handle;
}
void WebFrameMain::CopyVideoFrameAt(int x, int y) {
if (!CheckRenderFrame())
return;
auto location = gfx::Point(x, y);
auto action = blink::mojom::MediaPlayerAction(
blink::mojom::MediaPlayerActionType::kCopyVideoFrame,
/*enable=*/true);
return render_frame_host()->ExecuteMediaPlayerActionAtLocation(location,
action);
}
void WebFrameMain::SaveVideoFrameAs(int x, int y) {
if (!CheckRenderFrame())
return;
auto location = gfx::Point(x, y);
auto action = blink::mojom::MediaPlayerAction(
blink::mojom::MediaPlayerActionType::kSaveVideoFrameAs,
/*enable=*/true);
return render_frame_host()->ExecuteMediaPlayerActionAtLocation(location,
action);
}
bool WebFrameMain::Reload() {
if (!CheckRenderFrame())
return false;
@@ -617,8 +593,6 @@ void WebFrameMain::FillObjectTemplate(v8::Isolate* isolate,
.SetMethod("executeJavaScript", &WebFrameMain::ExecuteJavaScript)
.SetMethod("collectJavaScriptCallStack",
&WebFrameMain::CollectDocumentJSCallStack)
.SetMethod("copyVideoFrameAt", &WebFrameMain::CopyVideoFrameAt)
.SetMethod("saveVideoFrameAs", &WebFrameMain::SaveVideoFrameAs)
.SetMethod("reload", &WebFrameMain::Reload)
.SetMethod("isDestroyed", &WebFrameMain::IsDestroyed)
.SetMethod("_send", &WebFrameMain::Send)

View File

@@ -118,8 +118,6 @@ class WebFrameMain final : public gin_helper::DeprecatedWrappable<WebFrameMain>,
v8::Local<v8::Promise> ExecuteJavaScript(gin::Arguments* args,
const std::u16string& code);
void CopyVideoFrameAt(int x, int y);
void SaveVideoFrameAs(int x, int y);
bool Reload();
bool IsDestroyed() const;
void Send(v8::Isolate* isolate,

View File

@@ -24,6 +24,7 @@ class CocoaNotification : public Notification {
// Notification:
void Show(const NotificationOptions& options) override;
void Dismiss() override;
void Restore() override;
void NotificationDisplayed();
void NotificationReplied(const std::string& reply);
@@ -38,6 +39,7 @@ class CocoaNotification : public Notification {
void LogAction(const char* action);
void ScheduleNotification(UNMutableNotificationContent* content);
bool is_restored_ = false;
UNNotificationRequest* __strong notification_request_;
};

View File

@@ -31,7 +31,10 @@ CocoaNotification::CocoaNotification(NotificationDelegate* delegate,
: Notification(delegate, presenter) {}
CocoaNotification::~CocoaNotification() {
if (notification_request_)
// Don't remove from Notification Center if this was a restored notification.
// Restored notifications are observed, not owned — destruction should just
// disconnect the event handler, not remove the visible notification.
if (notification_request_ && !is_restored_)
[[UNUserNotificationCenter currentNotificationCenter]
removeDeliveredNotificationsWithIdentifiers:@[
notification_request_.identifier
@@ -233,6 +236,24 @@ void CocoaNotification::ScheduleNotification(
}];
}
void CocoaNotification::Restore() {
// Create a minimal UNNotificationRequest with just the identifier so that
// GetNotification() can match this object when the user interacts with the
// notification in Notification Center.
NSString* identifier = base::SysUTF8ToNSString(notification_id());
UNMutableNotificationContent* content =
[[UNMutableNotificationContent alloc] init];
notification_request_ =
[UNNotificationRequest requestWithIdentifier:identifier
content:content
trigger:nil];
is_restored_ = true;
if (electron::debug_notifications) {
LOG(INFO) << "Notification restored (" << [identifier UTF8String] << ")";
}
}
void CocoaNotification::Dismiss() {
if (notification_request_)
[[UNUserNotificationCenter currentNotificationCenter]

View File

@@ -24,6 +24,15 @@ class NotificationPresenterMac : public NotificationPresenter {
NotificationPresenterMac();
~NotificationPresenterMac() override;
// 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() {
return image_task_runner_;

View File

@@ -2,11 +2,16 @@
// Use of this source code is governed by the MIT license that can be
// found in the LICENSE file.
#include "base/logging.h"
#include "base/task/thread_pool.h"
#include "shell/browser/notifications/mac/notification_presenter_mac.h"
#include <string>
#include <utility>
#include <vector>
#include "base/logging.h"
#include "base/strings/sys_string_conversions.h"
#include "base/task/sequenced_task_runner.h"
#include "base/task/thread_pool.h"
#include "shell/browser/notifications/mac/cocoa_notification.h"
#include "shell/browser/notifications/mac/notification_center_delegate.h"
@@ -80,4 +85,72 @@ Notification* NotificationPresenterMac::CreateNotificationObject(
return new CocoaNotification(delegate, this);
}
void NotificationPresenterMac::GetDeliveredNotifications(
GetDeliveredNotificationsCallback callback) {
scoped_refptr<base::SequencedTaskRunner> task_runner =
base::SequencedTaskRunner::GetCurrentDefault();
__block GetDeliveredNotificationsCallback block_callback =
std::move(callback);
[[UNUserNotificationCenter currentNotificationCenter]
getDeliveredNotificationsWithCompletionHandler:^(
NSArray<UNNotification*>* _Nonnull notifications) {
std::vector<NotificationInfo> results;
results.reserve([notifications count]);
for (UNNotification* notification in notifications) {
UNNotificationContent* content = notification.request.content;
NotificationInfo info;
info.id = base::SysNSStringToUTF8(notification.request.identifier);
info.title = base::SysNSStringToUTF8(content.title);
info.subtitle = base::SysNSStringToUTF8(content.subtitle);
info.body = base::SysNSStringToUTF8(content.body);
info.group_id = base::SysNSStringToUTF8(content.threadIdentifier);
results.push_back(std::move(info));
}
task_runner->PostTask(
FROM_HERE,
base::BindOnce(std::move(block_callback), std::move(results)));
}];
}
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

@@ -22,6 +22,15 @@ NotificationOptions& NotificationOptions::operator=(NotificationOptions&&) =
default;
NotificationOptions::~NotificationOptions() = default;
NotificationInfo::NotificationInfo() = default;
NotificationInfo::~NotificationInfo() = default;
NotificationInfo::NotificationInfo(const NotificationInfo&) = default;
NotificationInfo& NotificationInfo::operator=(const NotificationInfo&) =
default;
NotificationInfo::NotificationInfo(NotificationInfo&&) noexcept = default;
NotificationInfo& NotificationInfo::operator=(NotificationInfo&&) noexcept =
default;
NotificationAction::NotificationAction() = default;
NotificationAction::~NotificationAction() = default;
NotificationAction::NotificationAction(const NotificationAction&) = default;

View File

@@ -59,6 +59,21 @@ struct NotificationOptions {
~NotificationOptions();
};
struct NotificationInfo {
std::string id;
std::string title;
std::string subtitle;
std::string body;
std::string group_id;
NotificationInfo();
~NotificationInfo();
NotificationInfo(const NotificationInfo&);
NotificationInfo& operator=(const NotificationInfo&);
NotificationInfo(NotificationInfo&&) noexcept;
NotificationInfo& operator=(NotificationInfo&&) noexcept;
};
class Notification {
public:
virtual ~Notification();
@@ -75,6 +90,11 @@ class Notification {
// as can happen on some platforms including Windows.
virtual void Remove() {}
// Restores a previously delivered notification for event handling without
// re-showing it. Sets up platform state so interaction events (click, reply,
// etc.) route to this object.
virtual void Restore() {}
// Should be called by derived classes.
void NotificationClicked();
void NotificationDismissed(bool should_destroy = true,

View File

@@ -44,4 +44,24 @@ void NotificationPresenter::CloseNotificationWithId(
}
}
void NotificationPresenter::GetDeliveredNotifications(
GetDeliveredNotificationsCallback callback) {
// Default: return empty list. Overridden on macOS.
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

@@ -8,12 +8,14 @@
#include <memory>
#include <set>
#include <string>
#include <vector>
#include "base/functional/callback.h"
#include "base/memory/weak_ptr.h"
#include "shell/browser/notifications/notification.h"
namespace electron {
class Notification;
class NotificationDelegate;
class NotificationPresenter {
@@ -22,11 +24,22 @@ class NotificationPresenter {
virtual ~NotificationPresenter();
using GetDeliveredNotificationsCallback =
base::OnceCallback<void(std::vector<NotificationInfo>)>;
base::WeakPtr<Notification> CreateNotification(
NotificationDelegate* delegate,
const std::string& notification_id);
void CloseNotificationWithId(const std::string& notification_id);
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_; }
// disable copy

View File

@@ -261,4 +261,223 @@ describe('Notification module', () => {
});
// TODO(sethlu): Find way to test init with notification icon?
describe('static methods', () => {
ifit(process.platform === 'darwin')('getHistory returns a promise that resolves to an array', async () => {
const result = Notification.getHistory();
expect(result).to.be.a('promise');
const history = await result;
expect(history).to.be.an('array');
});
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')('removeAll does not throw', () => {
expect(() => Notification.removeAll()).to.not.throw();
});
ifit(process.platform === 'darwin')('getHistory returns Notification instances with correct properties', async () => {
const n = new Notification({
id: 'history-test-id',
title: 'history test',
subtitle: 'history subtitle',
body: 'history body',
groupId: 'history-group',
silent: true
});
const shown = once(n, 'show');
n.show();
await shown;
const history = await Notification.getHistory();
// getHistory requires code-signed builds to return results;
// skip the content assertions if Notification Center is empty.
if (history.length > 0) {
const found = history.find((item: any) => item.id === 'history-test-id');
expect(found).to.not.be.undefined();
expect(found).to.be.an.instanceOf(Notification);
expect(found.title).to.equal('history test');
expect(found.subtitle).to.equal('history subtitle');
expect(found.body).to.equal('history body');
expect(found.groupId).to.equal('history-group');
}
n.close();
});
ifit(process.platform === 'darwin')('getHistory returned notifications can be shown and closed', async () => {
const n = new Notification({
id: 'history-show-close',
title: 'show close test',
body: 'body',
silent: true
});
const shown = once(n, 'show');
n.show();
await shown;
const history = await Notification.getHistory();
if (history.length > 0) {
const found = history.find((item: any) => item.id === 'history-show-close');
expect(found).to.not.be.undefined();
// Calling show() and close() on a restored notification should not throw
expect(() => {
found.show();
found.close();
}).to.not.throw();
}
Notification.removeAll();
});
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')('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')('removeGroup does not throw', () => {
expect(() => Notification.removeGroup('nonexistent-group')).to.not.throw();
});
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

@@ -1,4 +1,3 @@
import { clipboard } from 'electron/common';
import { BrowserWindow, WebFrameMain, webFrameMain, ipcMain, app, WebContents } from 'electron/main';
import { expect } from 'chai';
@@ -535,91 +534,6 @@ describe('webFrameMain module', () => {
});
});
describe('webFrameMain.copyVideoFrameAt', () => {
const insertVideoInFrame = async (frame: WebFrameMain) => {
const videoFilePath = url.pathToFileURL(path.join(fixtures, 'cat-spin.mp4')).href;
await frame.executeJavaScript(`
const video = document.createElement('video');
video.src = '${videoFilePath}';
video.muted = true;
video.loop = true;
video.play();
document.body.appendChild(video);
`);
};
const getFramePosition = async (frame: WebFrameMain) => {
const point = await frame.executeJavaScript(`(${() => {
const iframe = document.querySelector('iframe');
if (!iframe) return;
const rect = iframe.getBoundingClientRect();
return { x: Math.floor(rect.x), y: Math.floor(rect.y) };
}})()`) as Electron.Point;
expect(point).to.be.an('object');
return point;
};
const copyVideoFrameInFrame = async (frame: WebFrameMain) => {
const point = await frame.executeJavaScript(`(${() => {
const video = document.querySelector('video');
if (!video) return;
const rect = video.getBoundingClientRect();
return {
x: Math.floor(rect.x + rect.width / 2),
y: Math.floor(rect.y + rect.height / 2)
};
}})()`) as Electron.Point;
expect(point).to.be.an('object');
// Translate coordinate to be relative of main frame
if (frame.parent) {
const framePosition = await getFramePosition(frame.parent);
point.x += framePosition.x;
point.y += framePosition.y;
}
expect(clipboard.readImage().isEmpty()).to.be.true();
// wait for video to load
await frame.executeJavaScript(`(${() => {
const video = document.querySelector('video');
if (!video) return;
return new Promise(resolve => {
if (video.readyState >= 4) resolve(null);
else video.addEventListener('canplaythrough', resolve, { once: true });
});
}})()`);
frame.copyVideoFrameAt(point.x, point.y);
await waitUntil(() => clipboard.availableFormats().includes('image/png'));
expect(clipboard.readImage().isEmpty()).to.be.false();
};
beforeEach(() => {
clipboard.clear();
});
// TODO: Re-enable on Windows CI once Chromium fixes the intermittent
// backwards-time DCHECK hit while copying video frames:
// DCHECK failed: !delta.is_negative().
ifit(!(process.platform === 'win32' && process.env.CI))('copies video frame in main frame', async () => {
const w = new BrowserWindow({ show: false });
await w.webContents.loadFile(path.join(fixtures, 'blank.html'));
await insertVideoInFrame(w.webContents.mainFrame);
await copyVideoFrameInFrame(w.webContents.mainFrame);
await waitUntil(() => clipboard.availableFormats().includes('image/png'));
});
ifit(!(process.platform === 'win32' && process.env.CI))('copies video frame in subframe', async () => {
const w = new BrowserWindow({ show: false });
await w.webContents.loadFile(path.join(subframesPath, 'frame-with-frame.html'));
const subframe = w.webContents.mainFrame.frames[0];
expect(subframe).to.exist();
await insertVideoInFrame(subframe);
await copyVideoFrameInFrame(subframe);
await waitUntil(() => clipboard.availableFormats().includes('image/png'));
});
});
describe('"frame-created" event', () => {
it('emits when the main frame is created', async () => {
const w = new BrowserWindow({ show: false });

View File

@@ -118,6 +118,10 @@ 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;