Compare commits

...

1 Commits

Author SHA1 Message Date
Shelley Vohr
4abf4cf868 feat: expose Notification.getHistory() on Windows
Add a new `Notification.getHistory()` that retrieves all
notifications sent by the app from the Windows Action Center using
the `ToastNotificationHistory` API.

The implementation introduces a `ToastHistoryEntry` struct and parses
each toast's XML content to reconstruct notification properties
including title, body, icon, silent, hasReply, timeoutType, actions,
replyPlaceholder, sound, urgency, and closeButtonText. The raw toast
XML is also returned as `toastXml` for cases where the parsed fields
are insufficient.

Currently Windows-only; returns an empty array on other platforms.
2026-03-18 09:49:25 +01:00
7 changed files with 353 additions and 0 deletions

View File

@@ -76,6 +76,12 @@ app.whenReady().then(() => {
})
```
#### `Notification.getHistory()` _Windows_
Returns `Notification[]` - the notification history, for all notifications sent by this app, from Action Center.
See [`ToastNotificationHistory.GetHistory`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toastnotificationhistory.gethistory?view=winrt-26100#windows-ui-notifications-toastnotificationhistory-gethistory) for more information
### `new Notification([options])`
* `options` Object (optional)

View File

@@ -2,6 +2,7 @@ const binding = process._linkedBinding('electron_browser_notification');
const ElectronNotification = binding.Notification;
ElectronNotification.isSupported = binding.isSupported;
ElectronNotification.getHistory = binding.getHistory;
if (process.platform === 'win32' && binding.handleActivation) {
ElectronNotification.handleActivation = binding.handleActivation;

View File

@@ -26,6 +26,7 @@
#include "base/no_destructor.h"
#include "shell/browser/javascript_environment.h"
#include "shell/browser/notifications/win/windows_toast_activator.h"
#include "shell/browser/notifications/win/windows_toast_notification.h"
#endif
namespace gin {
@@ -61,6 +62,29 @@ struct Converter<electron::NotificationAction> {
}
};
#if BUILDFLAG(IS_WIN)
template <>
struct Converter<electron::ToastHistoryEntry> {
static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,
const electron::ToastHistoryEntry& entry) {
auto dict = gin::Dictionary::CreateEmpty(isolate);
dict.Set("title", entry.title);
dict.Set("body", entry.body);
dict.Set("icon", entry.icon_path);
dict.Set("silent", entry.silent);
dict.Set("hasReply", entry.has_reply);
dict.Set("timeoutType", entry.timeout_type);
dict.Set("replyPlaceholder", entry.reply_placeholder);
dict.Set("sound", entry.sound);
dict.Set("urgency", entry.urgency);
dict.Set("actions", entry.actions);
dict.Set("closeButtonText", entry.close_button_text);
dict.Set("toastXml", entry.toast_xml);
return ConvertToV8(isolate, dict);
}
};
#endif
} // namespace gin
namespace electron::api {
@@ -325,6 +349,7 @@ void InvokeJsCallback(const electron::ActivationArguments& details) {
}
} // namespace
#endif
// static
void Notification::HandleActivation(v8::Isolate* isolate,
@@ -340,7 +365,15 @@ void Notification::HandleActivation(v8::Isolate* isolate,
InvokeJsCallback(details);
});
}
v8::Local<v8::Value> Notification::GetHistory(v8::Isolate* isolate) {
#if BUILDFLAG(IS_WIN)
return gin::ConvertToV8(isolate,
electron::WindowsToastNotification::GetHistory());
#else
return v8::Array::New(isolate);
#endif
}
void Notification::FillObjectTemplate(v8::Isolate* isolate,
v8::Local<v8::ObjectTemplate> templ) {
@@ -394,6 +427,7 @@ void Initialize(v8::Local<v8::Object> exports,
dict.SetMethod("isSupported", &Notification::IsSupported);
#if BUILDFLAG(IS_WIN)
dict.SetMethod("handleActivation", &Notification::HandleActivation);
dict.SetMethod("getHistory", &Notification::GetHistory);
#endif
}

View File

@@ -38,6 +38,7 @@ class Notification final : public gin_helper::DeprecatedWrappable<Notification>,
public NotificationDelegate {
public:
static bool IsSupported();
static v8::Local<v8::Value> GetHistory(v8::Isolate* isolate);
#if BUILDFLAG(IS_WIN)
// Register a callback to handle all notification activations.

View File

@@ -9,8 +9,12 @@
#include "shell/browser/notifications/win/windows_toast_notification.h"
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <shlobj.h>
#include <windows.data.xml.dom.h>
#include <windows.foundation.collections.h>
#include <wrl\wrappers\corewrappers.h>
#include "base/containers/fixed_flat_map.h"
@@ -59,6 +63,13 @@ namespace winui = ABI::Windows::UI;
namespace electron {
ToastHistoryEntry::ToastHistoryEntry() = default;
ToastHistoryEntry::~ToastHistoryEntry() = default;
ToastHistoryEntry::ToastHistoryEntry(const ToastHistoryEntry&) = default;
ToastHistoryEntry& ToastHistoryEntry::operator=(const ToastHistoryEntry&) = default;
ToastHistoryEntry::ToastHistoryEntry(ToastHistoryEntry&&) = default;
ToastHistoryEntry& ToastHistoryEntry::operator=(ToastHistoryEntry&&) = default;
namespace {
// This string needs to be max 16 characters to work on Windows 10 prior to
@@ -146,6 +157,68 @@ constexpr char kDismiss[] = "dismiss";
// The XML version header that has to be stripped from the output.
constexpr char kXmlVersionHeader[] = "<?xml version=\"1.0\"?>\n";
const char* GetTemplateType(bool two_lines, bool has_icon) {
if (has_icon) {
return two_lines ? kToastImageAndText02 : kToastImageAndText01;
}
return two_lines ? kToastText02 : kToastText01;
}
std::u16string HstringToU16(HSTRING value) {
if (!value)
return {};
unsigned int length = 0;
const wchar_t* buffer = WindowsGetStringRawBuffer(value, &length);
return std::u16string(reinterpret_cast<const char16_t*>(buffer), length);
}
std::u16string GetAttributeValue(
const ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNode>& node,
const wchar_t* name) {
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlElement> element;
if (FAILED(node.As(&element)))
return {};
HSTRING value = nullptr;
if (FAILED(element->GetAttribute(HStringReference(name).Get(), &value)) ||
!value) {
return {};
}
auto result = HstringToU16(value);
WindowsDeleteString(value);
return result;
}
std::u16string GetInnerText(
const ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNode>& node) {
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNodeSerializer> serializer;
if (FAILED(node.As(&serializer)))
return {};
HSTRING value = nullptr;
if (FAILED(serializer->get_InnerText(&value)) || !value)
return {};
auto result = HstringToU16(value);
WindowsDeleteString(value);
return result;
}
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNodeList> SelectNodes(
const ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNode>& node,
const wchar_t* xpath) {
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNodeSelector> selector;
if (FAILED(node.As(&selector)))
return nullptr;
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNodeList> list;
if (FAILED(selector->SelectNodes(HStringReference(xpath).Get(), &list)))
return nullptr;
return list;
}
} // namespace
// static
@@ -209,6 +282,219 @@ bool WindowsToastNotification::Initialize() {
}
}
std::vector<ToastHistoryEntry> WindowsToastNotification::GetHistory() {
std::vector<ToastHistoryEntry> history;
if (!Initialize())
return history;
ComPtr<winui::Notifications::IToastNotificationManagerStatics2>
toast_manager2;
if (FAILED(toast_manager_->As(&toast_manager2)))
return history;
ComPtr<winui::Notifications::IToastNotificationHistory> notification_history;
if (FAILED(toast_manager2->get_History(&notification_history)))
return history;
ComPtr<winui::Notifications::IToastNotificationHistory2> notification_history2;
if (FAILED(notification_history.As(&notification_history2)))
return history;
ComPtr<ABI::Windows::Foundation::Collections::IVectorView<
winui::Notifications::ToastNotification*>>
toast_history;
ScopedHString app_id;
if (!GetAppUserModelID(&app_id))
return history;
HRESULT hr = notification_history2->GetHistoryWithId(app_id, &toast_history);
if (FAILED(hr) || !toast_history)
return history;
unsigned int size = 0;
if (FAILED(toast_history->get_Size(&size)))
return history;
history.reserve(size);
for (unsigned int i = 0; i < size; ++i) {
ComPtr<winui::Notifications::IToastNotification> toast;
if (FAILED(toast_history->GetAt(i, &toast)))
continue;
ToastHistoryEntry entry;
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlDocument> xml_content;
if (FAILED(toast->get_Content(&xml_content)))
continue;
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNodeSerializer> serializer;
if (SUCCEEDED(xml_content.As(&serializer))) {
HSTRING xml = nullptr;
if (SUCCEEDED(serializer->GetXml(&xml)) && xml) {
entry.toast_xml = HstringToU16(xml);
WindowsDeleteString(xml);
}
}
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNode> content_node;
if (FAILED(xml_content.As(&content_node)))
continue;
if (auto toast_nodes = SelectNodes(content_node, L"/toast")) {
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNode> toast_node;
if (SUCCEEDED(toast_nodes->Item(0, &toast_node)) && toast_node) {
auto scenario = GetAttributeValue(toast_node, L"scenario");
if (scenario == u"reminder")
entry.timeout_type = u"never";
}
}
if (entry.timeout_type.empty())
entry.timeout_type = u"default";
if (auto text_nodes =
SelectNodes(content_node, L"/toast/visual/binding/text")) {
unsigned int text_size = 0;
if (SUCCEEDED(text_nodes->get_Length(&text_size)) && text_size > 0) {
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNode> text_node;
if (SUCCEEDED(text_nodes->Item(0, &text_node)) && text_node) {
entry.title = GetInnerText(text_node);
}
if (text_size > 1 && SUCCEEDED(text_nodes->Item(1, &text_node)) &&
text_node) {
entry.body = GetInnerText(text_node);
}
}
}
if (auto image_nodes =
SelectNodes(content_node, L"/toast/visual/binding/image")) {
unsigned int image_size = 0;
if (SUCCEEDED(image_nodes->get_Length(&image_size))) {
for (unsigned int idx = 0; idx < image_size; ++idx) {
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNode> image_node;
if (FAILED(image_nodes->Item(idx, &image_node)) || !image_node)
continue;
auto placement = GetAttributeValue(image_node, L"placement");
auto src = GetAttributeValue(image_node, L"src");
if (!src.empty() &&
(placement.empty() || placement == u"appLogoOverride")) {
entry.icon_path = src;
break;
}
}
}
}
if (auto audio_nodes = SelectNodes(content_node, L"/toast/audio")) {
unsigned int audio_size = 0;
if (SUCCEEDED(audio_nodes->get_Length(&audio_size)) && audio_size > 0) {
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNode> audio_node;
if (SUCCEEDED(audio_nodes->Item(0, &audio_node)) && audio_node) {
auto silent = GetAttributeValue(audio_node, L"silent");
entry.silent = (silent == u"true");
auto src = GetAttributeValue(audio_node, L"src");
if (!src.empty())
entry.sound = src;
}
}
}
std::unordered_map<std::u16string, std::vector<std::u16string>>
selection_inputs;
if (auto input_nodes = SelectNodes(content_node, L"/toast/actions/input")) {
unsigned int input_size = 0;
if (SUCCEEDED(input_nodes->get_Length(&input_size))) {
for (unsigned int idx = 0; idx < input_size; ++idx) {
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNode> input_node;
if (FAILED(input_nodes->Item(idx, &input_node)) || !input_node)
continue;
auto type = GetAttributeValue(input_node, L"type");
auto id = GetAttributeValue(input_node, L"id");
if (type == u"text" && id == u"reply") {
entry.has_reply = true;
entry.reply_placeholder =
GetAttributeValue(input_node, L"placeHolderContent");
continue;
}
if (type == u"selection" && !id.empty()) {
std::vector<std::u16string> items;
if (auto selection_nodes = SelectNodes(input_node, L"selection")) {
unsigned int sel_size = 0;
if (SUCCEEDED(selection_nodes->get_Length(&sel_size))) {
for (unsigned int sel_i = 0; sel_i < sel_size; ++sel_i) {
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNode> sel_node;
if (FAILED(selection_nodes->Item(sel_i, &sel_node)) ||
!sel_node) {
continue;
}
auto sel_content = GetAttributeValue(sel_node, L"content");
if (!sel_content.empty())
items.push_back(sel_content);
}
}
}
selection_inputs.emplace(id, std::move(items));
}
}
}
}
std::unordered_set<std::u16string> used_selection_ids;
if (auto action_nodes =
SelectNodes(content_node, L"/toast/actions/action")) {
unsigned int action_size = 0;
if (SUCCEEDED(action_nodes->get_Length(&action_size))) {
for (unsigned int idx = 0; idx < action_size; ++idx) {
ComPtr<ABI::Windows::Data::Xml::Dom::IXmlNode> action_node;
if (FAILED(action_nodes->Item(idx, &action_node)) || !action_node)
continue;
auto activation = GetAttributeValue(action_node, L"activationType");
auto arguments = GetAttributeValue(action_node, L"arguments");
auto action_content = GetAttributeValue(action_node, L"content");
auto hint_input_id = GetAttributeValue(action_node, L"hint-inputId");
if (activation == u"system" && arguments == u"dismiss") {
entry.close_button_text = action_content;
continue;
}
if (hint_input_id == u"reply") {
entry.has_reply = true;
continue;
}
if (!hint_input_id.empty()) {
auto it = selection_inputs.find(hint_input_id);
if (it != selection_inputs.end() &&
!used_selection_ids.contains(hint_input_id)) {
NotificationAction action;
action.type = u"selection";
action.text = action_content;
// Note: selection items not stored in NotificationAction
entry.actions.push_back(std::move(action));
used_selection_ids.insert(hint_input_id);
continue;
}
}
if (!action_content.empty()) {
NotificationAction action;
action.type = u"button";
action.text = action_content;
entry.actions.push_back(std::move(action));
}
}
}
}
history.push_back(std::move(entry));
}
return history;
}
WindowsToastNotification::WindowsToastNotification(
NotificationDelegate* delegate,
NotificationPresenter* presenter)

View File

@@ -13,6 +13,7 @@
#include <windows.ui.notifications.h>
#include <wrl/implements.h>
#include <string>
#include <vector>
#include "base/memory/scoped_refptr.h"
#include "base/task/single_thread_task_runner.h"
@@ -28,6 +29,28 @@ namespace electron {
class ScopedHString;
struct ToastHistoryEntry {
ToastHistoryEntry();
~ToastHistoryEntry();
ToastHistoryEntry(const ToastHistoryEntry&);
ToastHistoryEntry& operator=(const ToastHistoryEntry&);
ToastHistoryEntry(ToastHistoryEntry&&);
ToastHistoryEntry& operator=(ToastHistoryEntry&&);
std::u16string toast_xml;
std::u16string title;
std::u16string body;
std::u16string icon_path;
std::u16string timeout_type;
std::u16string reply_placeholder;
std::u16string sound;
std::u16string urgency;
std::u16string close_button_text;
bool silent = false;
bool has_reply = false;
std::vector<NotificationAction> actions;
};
using DesktopToastActivatedEventHandler =
ABI::Windows::Foundation::ITypedEventHandler<
ABI::Windows::UI::Notifications::ToastNotification*,
@@ -45,6 +68,7 @@ class WindowsToastNotification : public Notification {
public:
// Should only be called by NotificationPresenterWin.
static bool Initialize();
static std::vector<ToastHistoryEntry> GetHistory();
WindowsToastNotification(NotificationDelegate* delegate,
NotificationPresenter* presenter);

View File

@@ -118,6 +118,7 @@ declare namespace NodeJS {
interface NotificationBinding {
isSupported(): boolean;
getHistory(): Electron.Notification[];
Notification: typeof Electron.Notification;
// Windows-only callback for cold-start notification activation
handleActivation?: (callback: (details: ActivationArgumentsInternal) => void) => void;