diff --git a/docs/api/app.md b/docs/api/app.md index 68e243cfc4..38a6f0fd14 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -1121,6 +1121,19 @@ Updates the current activity if its type matches `type`, merging the entries fro Changes the [Application User Model ID][app-user-model-id] to `id`. +### `app.setToastActivatorCLSID(id)` _Windows_ + +* `id` string + +Changes the [Toast Activator CLSID][toast-activator-clsid] to `id`. If one is not set via this method, it will be randomly generated for the app. + +* The value must be a valid GUID/CLSID in one of the following forms: + * Canonical brace-wrapped: `{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}` (preferred) + * Canonical without braces: `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` (braces will be added automatically) +* Hex digits are case-insensitive. + +This method should be called early (before showing notifications) so the value is baked into the registration/shortcut. Supplying an empty string or an unparsable value throws and leaves the existing (or generated) CLSID unchanged. If this method is never called, a random CLSID is generated once per run and exposed via `app.toastActivatorCLSID`. + ### `app.setActivationPolicy(policy)` _macOS_ * `policy` string - Can be 'regular', 'accessory', or 'prohibited'. @@ -1703,8 +1716,13 @@ platforms) that allows you to perform actions on your app icon in the user's doc A `boolean` property that returns `true` if the app is packaged, `false` otherwise. For many apps, this property can be used to distinguish development and production environments. +### `app.toastActivatorCLSID` _Windows_ _Readonly_ + +A `string` property that returns the app's [Toast Activator CLSID][toast-activator-clsid]. + [tasks]:https://learn.microsoft.com/en-us/windows/win32/shell/taskbar-extensions#tasks [app-user-model-id]: https://learn.microsoft.com/en-us/windows/win32/shell/appids +[toast-activator-clsid]: https://learn.microsoft.com/en-us/windows/win32/properties/props-system-appusermodel-toastactivatorclsid [electron-forge]: https://www.electronforge.io/ [electron-packager]: https://github.com/electron/packager [CFBundleURLTypes]: https://developer.apple.com/library/ios/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html#//apple_ref/doc/uid/TP40009249-102207-TPXREF115 diff --git a/docs/api/notification.md b/docs/api/notification.md index 1c5a6e9c98..c42b932e43 100644 --- a/docs/api/notification.md +++ b/docs/api/notification.md @@ -67,6 +67,22 @@ Emitted when the notification is shown to the user. Note that this event can be multiple times as a notification can be shown multiple times through the `show()` method. +```js +const { Notification, app } = require('electron') + +app.whenReady().then(() => { + const n = new Notification({ + title: 'Title!', + subtitle: 'Subtitle!', + body: 'Body!' + }) + + n.on('show', () => console.log('Notification shown!')) + + n.show() +}) +``` + #### Event: 'click' Returns: @@ -75,6 +91,22 @@ Returns: Emitted when the notification is clicked by the user. +```js +const { Notification, app } = require('electron') + +app.whenReady().then(() => { + const n = new Notification({ + title: 'Title!', + subtitle: 'Subtitle!', + body: 'Body!' + }) + + n.on('click', () => console.log('Notification clicked!')) + + n.show() +}) +``` + #### Event: 'close' Returns: @@ -88,21 +120,85 @@ is closed. On Windows, the `close` event can be emitted in one of three ways: programmatic dismissal with `notification.close()`, by the user closing the notification, or via system timeout. If a notification is in the Action Center after the initial `close` event is emitted, a call to `notification.close()` will remove the notification from the action center but the `close` event will not be emitted again. -#### Event: 'reply' _macOS_ +```js +const { Notification, app } = require('electron') + +app.whenReady().then(() => { + const n = new Notification({ + title: 'Title!', + subtitle: 'Subtitle!', + body: 'Body!' + }) + + n.on('close', () => console.log('Notification closed!')) + + n.show() +}) +``` + +#### Event: 'reply' _macOS_ _Windows_ Returns: -* `event` Event -* `reply` string - The string the user entered into the inline reply field. +* `details` Event\<\> + * `reply` string - The string the user entered into the inline reply field. +* `reply` string _Deprecated_ Emitted when the user clicks the "Reply" button on a notification with `hasReply: true`. -#### Event: 'action' _macOS_ +```js +const { Notification, app } = require('electron') + +app.whenReady().then(() => { + const n = new Notification({ + title: 'Send a Message', + body: 'Body Text', + hasReply: true, + replyPlaceholder: 'Message text...' + }) + + n.on('reply', (e, reply) => console.log(`User replied: ${reply}`)) + n.on('click', () => console.log('Notification clicked')) + + n.show() +}) +``` + +#### Event: 'action' _macOS_ _Windows_ Returns: -* `event` Event -* `index` number - The index of the action that was activated. +* `details` Event\<\> + * `actionIndex` number - The index of the action that was activated. + * `selectionIndex` number _Windows_ - The index of the selected item, if one was chosen. -1 if none was chosen. +* `actionIndex` number _Deprecated_ +* `selectionIndex` number _Windows_ _Deprecated_ + +```js +const { Notification, app } = require('electron') + +app.whenReady().then(() => { + const items = ['One', 'Two', 'Three'] + const n = new Notification({ + title: 'Choose an Action!', + actions: [ + { type: 'button', text: 'Action 1' }, + { type: 'button', text: 'Action 2' }, + { type: 'selection', text: 'Apply', items } + ] + }) + + n.on('click', () => console.log('Notification clicked')) + n.on('action', (e) => { + console.log(`User triggered action at index: ${e.actionIndex}`) + if (e.selectionIndex > -1) { + console.log(`User chose selection item '${items[e.selectionIndex]}'`) + } + }) + + n.show() +}) +``` #### Event: 'failed' _Windows_ @@ -113,6 +209,22 @@ Returns: Emitted when an error is encountered while creating and showing the native notification. +```js +const { Notification, app } = require('electron') + +app.whenReady().then(() => { + const n = new Notification({ + title: 'Bad Action' + }) + + n.on('failed', (e, err) => { + console.log('Notification failed: ', err) + }) + + n.show() +}) +``` + ### Instance Methods Objects created with the `new Notification()` constructor have the following instance methods: @@ -126,12 +238,42 @@ call this method before the OS will display it. If the notification has been shown before, this method will dismiss the previously shown notification and create a new one with identical properties. +```js +const { Notification, app } = require('electron') + +app.whenReady().then(() => { + const n = new Notification({ + title: 'Title!', + subtitle: 'Subtitle!', + body: 'Body!' + }) + + n.show() +}) +``` + #### `notification.close()` Dismisses the notification. On Windows, calling `notification.close()` while the notification is visible on screen will dismiss the notification and remove it from the Action Center. If `notification.close()` is called after the notification is no longer visible on screen, calling `notification.close()` will try remove it from the Action Center. +```js +const { Notification, app } = require('electron') + +app.whenReady().then(() => { + const n = new Notification({ + title: 'Title!', + subtitle: 'Subtitle!', + body: 'Body!' + }) + + n.show() + + setTimeout(() => n.close(), 5000) +}) +``` + ### Instance Properties #### `notification.title` diff --git a/docs/api/structures/notification-action.md b/docs/api/structures/notification-action.md index 6e00cc68ea..24229682da 100644 --- a/docs/api/structures/notification-action.md +++ b/docs/api/structures/notification-action.md @@ -1,13 +1,15 @@ # NotificationAction Object -* `type` string - The type of action, can be `button`. +* `type` string - The type of action, can be `button` or `selection`. `selection` is only supported on Windows. * `text` string (optional) - The label for the given action. +* `items` string[] (optional) _Windows_ - The list of items for the `selection` action `type`. ## Platform / Action Support | Action Type | Platform Support | Usage of `text` | Default `text` | Limitations | |-------------|------------------|-----------------|----------------|-------------| -| `button` | macOS | Used as the label for the button | "Show" (or a localized string by system default if first of such `button`, otherwise empty) | Only the first one is used. If multiple are provided, those beyond the first will be listed as additional actions (displayed when mouse active over the action button). Any such action also is incompatible with `hasReply` and will be ignored if `hasReply` is `true`. | +| `button` | macOS, Windows | Used as the label for the button | "Show" on macOS (localized) if first `button`, otherwise empty; Windows uses provided `text` | macOS: Only the first one is used as primary; others shown as additional actions (hover). Incompatible with `hasReply` (beyond first ignored). | +| `selection` | Windows | Used as the label for the submit button for the selection menu | "Select" | Requires an `items` array property specifying option labels. Emits the `action` event with `(index, selectedIndex)` where `selectedIndex` is the chosen option (>= 0). Ignored on platforms that do not support selection actions. | ### Button support on macOS @@ -18,3 +20,34 @@ following criteria. * App has its `NSUserNotificationAlertStyle` set to `alert` in the `Info.plist`. If either of these requirements are not met the button won't appear. + +### Selection support on Windows + +To add a selection (combo box) style action, include an action with `type: 'selection'`, a `text` label for the submit button, and an `items` array of strings: + +```js +const { Notification, app } = require('electron') + +app.whenReady().then(() => { + const items = ['One', 'Two', 'Three'] + const n = new Notification({ + title: 'Choose an option', + actions: [{ + type: 'selection', + text: 'Apply', + items + }] + }) + + n.on('action', (e) => { + console.log(`User triggered action at index: ${e.actionIndex}`) + if (e.selectionIndex > 0) { + console.log(`User chose selection item '${items[e.selectionIndex]}'`) + } + }) + + n.show() +}) +``` + +When the user activates the selection action, the notification's `action` event will be emitted with two parameters: `actionIndex` (the action's index in the `actions` array) and `selectedIndex` (the zero-based index of the chosen item, or `-1` if unavailable). On non-Windows platforms selection actions are ignored. diff --git a/filenames.gni b/filenames.gni index 5c3a4fd466..e5c873e47d 100644 --- a/filenames.gni +++ b/filenames.gni @@ -79,6 +79,8 @@ filenames = { "shell/browser/notifications/win/notification_presenter_win.h", "shell/browser/notifications/win/windows_toast_notification.cc", "shell/browser/notifications/win/windows_toast_notification.h", + "shell/browser/notifications/win/windows_toast_activator.cc", + "shell/browser/notifications/win/windows_toast_activator.h", "shell/browser/relauncher_win.cc", "shell/browser/ui/certificate_trust_win.cc", "shell/browser/ui/file_dialog_win.cc", diff --git a/shell/browser/api/electron_api_app.cc b/shell/browser/api/electron_api_app.cc index b5cfbd2368..1c919ff581 100644 --- a/shell/browser/api/electron_api_app.cc +++ b/shell/browser/api/electron_api_app.cc @@ -88,6 +88,7 @@ #if BUILDFLAG(IS_WIN) #include "base/strings/utf_string_conversions.h" +#include "shell/browser/notifications/win/windows_toast_activator.h" #include "shell/browser/ui/win/jump_list.h" #endif @@ -1840,6 +1841,10 @@ gin::ObjectTemplateBuilder App::GetObjectTemplateBuilder(v8::Isolate* isolate) { #if BUILDFLAG(IS_WIN) .SetMethod("setAppUserModelId", base::BindRepeating(&Browser::SetAppUserModelID, browser)) + .SetMethod("setToastActivatorCLSID", + base::BindRepeating(&App::SetToastActivatorCLSID, + base::Unretained(this))) + .SetProperty("toastActivatorCLSID", &App::GetToastActivatorCLSID) #endif .SetMethod( "isDefaultProtocolClient", @@ -1967,6 +1972,34 @@ gin::ObjectTemplateBuilder App::GetObjectTemplateBuilder(v8::Isolate* isolate) { .SetMethod("resolveProxy", &App::ResolveProxy); } +#if BUILDFLAG(IS_WIN) +void App::SetToastActivatorCLSID(gin_helper::ErrorThrower thrower, + const std::string& id) { + std::wstring wide = base::UTF8ToWide(id); + CLSID parsed; + if (FAILED(::CLSIDFromString(wide.c_str(), &parsed))) { + if (!wide.empty() && wide.front() != L'{') { + std::wstring with_braces = L"{" + wide + L"}"; + if (FAILED(::CLSIDFromString(with_braces.c_str(), &parsed))) { + thrower.ThrowError("Invalid CLSID format"); + return; + } + wide = std::move(with_braces); + } else { + thrower.ThrowError("Invalid CLSID format"); + return; + } + } + + SetAppToastActivatorCLSID(wide); +} + +v8::Local App::GetToastActivatorCLSID(v8::Isolate* isolate) { + return gin::ConvertToV8(isolate, + base::WideToUTF8(GetAppToastActivatorCLSID())); +} +#endif + const char* App::GetHumanReadableName() const { return "Electron / App"; } diff --git a/shell/browser/api/electron_api_app.h b/shell/browser/api/electron_api_app.h index fd0565b51c..4e71a2b798 100644 --- a/shell/browser/api/electron_api_app.h +++ b/shell/browser/api/electron_api_app.h @@ -265,6 +265,12 @@ class App final : public gin::Wrappable, // Set or remove a custom Jump List for the application. JumpListResult SetJumpList(v8::Isolate* isolate, v8::Local val); + + // Set the toast activator CLSID. + void SetToastActivatorCLSID(gin_helper::ErrorThrower thrower, + const std::string& id); + // Get the toast activator CLSID. + v8::Local GetToastActivatorCLSID(v8::Isolate* isolate); #endif // BUILDFLAG(IS_WIN) std::unique_ptr process_singleton_; diff --git a/shell/browser/api/electron_api_notification.cc b/shell/browser/api/electron_api_notification.cc index 9229abe86f..8176ba7157 100644 --- a/shell/browser/api/electron_api_notification.cc +++ b/shell/browser/api/electron_api_notification.cc @@ -31,6 +31,9 @@ struct Converter { return false; } dict.Get("text", &(out->text)); + std::vector items; + if (dict.Get("items", &items)) + out->items = std::move(items); return true; } @@ -39,6 +42,9 @@ struct Converter { auto dict = gin::Dictionary::CreateEmpty(isolate); dict.Set("text", val.text); dict.Set("type", val.type); + if (!val.items.empty()) { + dict.Set("items", val.items); + } return ConvertToV8(isolate, dict); } }; @@ -138,8 +144,20 @@ void Notification::SetToastXml(const std::u16string& new_toast_xml) { toast_xml_ = new_toast_xml; } -void Notification::NotificationAction(int index) { - Emit("action", index); +void Notification::NotificationAction(int action_index, int selection_index) { + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope handle_scope(isolate); + + gin_helper::internal::Event* event = + gin_helper::internal::Event::New(isolate); + v8::Local event_object = + event->GetWrapper(isolate).ToLocalChecked(); + + gin_helper::Dictionary dict(isolate, event_object); + dict.Set("selectionIndex", selection_index); + dict.Set("actionIndex", action_index); + + EmitWithoutEvent("action", event_object, action_index, selection_index); } void Notification::NotificationClick() { @@ -147,7 +165,18 @@ void Notification::NotificationClick() { } void Notification::NotificationReplied(const std::string& reply) { - Emit("reply", reply); + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope handle_scope(isolate); + + gin_helper::internal::Event* event = + gin_helper::internal::Event::New(isolate); + v8::Local event_object = + event->GetWrapper(isolate).ToLocalChecked(); + + gin_helper::Dictionary dict(isolate, event_object); + dict.Set("reply", reply); + + EmitWithoutEvent("reply", event_object, reply); } void Notification::NotificationDisplayed() { diff --git a/shell/browser/api/electron_api_notification.h b/shell/browser/api/electron_api_notification.h index 9d9201e0f9..f25c4a7a7a 100644 --- a/shell/browser/api/electron_api_notification.h +++ b/shell/browser/api/electron_api_notification.h @@ -45,7 +45,7 @@ class Notification final : public gin_helper::DeprecatedWrappable, static const char* GetClassName() { return "Notification"; } // NotificationDelegate: - void NotificationAction(int index) override; + void NotificationAction(int action_index, int selection_index) override; void NotificationClick() override; void NotificationReplied(const std::string& reply) override; void NotificationDisplayed() override; diff --git a/shell/browser/notifications/mac/cocoa_notification.mm b/shell/browser/notifications/mac/cocoa_notification.mm index 2408fbd98c..191558c0e3 100644 --- a/shell/browser/notifications/mac/cocoa_notification.mm +++ b/shell/browser/notifications/mac/cocoa_notification.mm @@ -139,7 +139,7 @@ void CocoaNotification::NotificationReplied(const std::string& reply) { void CocoaNotification::NotificationActivated() { if (delegate()) - delegate()->NotificationAction(action_index_); + delegate()->NotificationAction(action_index_, -1); this->LogAction("button clicked"); } @@ -156,7 +156,7 @@ void CocoaNotification::NotificationActivated( } } - delegate()->NotificationAction(index); + delegate()->NotificationAction(index, -1); } this->LogAction("button clicked"); diff --git a/shell/browser/notifications/notification.cc b/shell/browser/notifications/notification.cc index 901be4fb2e..d02d68cdfb 100644 --- a/shell/browser/notifications/notification.cc +++ b/shell/browser/notifications/notification.cc @@ -22,6 +22,15 @@ NotificationOptions& NotificationOptions::operator=(NotificationOptions&&) = default; NotificationOptions::~NotificationOptions() = default; +NotificationAction::NotificationAction() = default; +NotificationAction::~NotificationAction() = default; +NotificationAction::NotificationAction(const NotificationAction&) = default; +NotificationAction& NotificationAction::operator=(const NotificationAction&) = + default; +NotificationAction::NotificationAction(NotificationAction&&) noexcept = default; +NotificationAction& NotificationAction::operator=( + NotificationAction&&) noexcept = default; + Notification::Notification(NotificationDelegate* delegate, NotificationPresenter* presenter) : delegate_(delegate), presenter_(presenter) {} diff --git a/shell/browser/notifications/notification.h b/shell/browser/notifications/notification.h index 79f2de7bcb..0ff02589f0 100644 --- a/shell/browser/notifications/notification.h +++ b/shell/browser/notifications/notification.h @@ -23,6 +23,14 @@ class NotificationPresenter; struct NotificationAction { std::u16string type; std::u16string text; + std::vector items; + + NotificationAction(); + ~NotificationAction(); + NotificationAction(const NotificationAction&); + NotificationAction& operator=(const NotificationAction&); + NotificationAction(NotificationAction&&) noexcept; + NotificationAction& operator=(NotificationAction&&) noexcept; }; struct NotificationOptions { diff --git a/shell/browser/notifications/notification_delegate.h b/shell/browser/notifications/notification_delegate.h index 8a44f420a1..aa9bcc3d32 100644 --- a/shell/browser/notifications/notification_delegate.h +++ b/shell/browser/notifications/notification_delegate.h @@ -19,7 +19,9 @@ class NotificationDelegate { // Notification was replied to virtual void NotificationReplied(const std::string& reply) {} - virtual void NotificationAction(int index) {} + // |selection_index| is >= 0 only for selection actions (Windows), otherwise + // -1. + virtual void NotificationAction(int action_index, int selection_index = -1) {} virtual void NotificationClick() {} virtual void NotificationClosed() {} diff --git a/shell/browser/notifications/win/notification_presenter_win.cc b/shell/browser/notifications/win/notification_presenter_win.cc index 2649ce40a9..cc8f088812 100644 --- a/shell/browser/notifications/win/notification_presenter_win.cc +++ b/shell/browser/notifications/win/notification_presenter_win.cc @@ -16,6 +16,7 @@ #include "base/strings/string_number_conversions.h" #include "base/strings/utf_string_conversions.h" #include "base/time/time.h" +#include "shell/browser/notifications/win/windows_toast_activator.h" #include "shell/browser/notifications/win/windows_toast_notification.h" #include "shell/common/thread_restrictions.h" #include "third_party/skia/include/core/SkBitmap.h" @@ -45,6 +46,9 @@ std::unique_ptr NotificationPresenter::Create() { if (!presenter->Init()) return {}; + // Ensure COM toast activator is registered once the presenter is ready. + NotificationActivator::RegisterActivator(); + if (electron::debug_notifications) LOG(INFO) << "Successfully created Windows notifications presenter"; diff --git a/shell/browser/notifications/win/windows_toast_activator.cc b/shell/browser/notifications/win/windows_toast_activator.cc new file mode 100644 index 0000000000..b66092bebf --- /dev/null +++ b/shell/browser/notifications/win/windows_toast_activator.cc @@ -0,0 +1,429 @@ +// Copyright (c) 2025 Microsoft, GmbH +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/browser/notifications/win/windows_toast_activator.h" + +#include +#include +#include +#include +#include +#ifdef StrCat +// Undefine Windows shlwapi.h StrCat macro to avoid conflict with +// base/strings/strcat.h which deliberately defines a StrCat macro sentinel. +#undef StrCat +#endif + +#include +#include + +#include +#include + +#include "base/files/file_path.h" +#include "base/files/file_util.h" +#include "base/hash/hash.h" +#include "base/logging.h" +#include "base/no_destructor.h" +#include "base/strings/string_number_conversions_win.h" +#include "base/strings/string_split.h" +#include "base/strings/string_util.h" +#include "base/strings/utf_string_conversions.h" +#include "base/win/registry.h" +#include "base/win/scoped_propvariant.h" +#include "base/win/win_util.h" +#include "content/public/browser/browser_task_traits.h" +#include "content/public/browser/browser_thread.h" +#include "shell/browser/api/electron_api_notification.h" // nogncheck - for delegate events +#include "shell/browser/electron_browser_client.h" +#include "shell/browser/notifications/notification.h" +#include "shell/browser/notifications/notification_delegate.h" +#include "shell/browser/notifications/notification_presenter.h" +#include "shell/browser/notifications/win/notification_presenter_win.h" +#include "shell/common/application_info.h" + +namespace electron { + +namespace { + +class NotificationActivatorFactory final : public IClassFactory { + public: + NotificationActivatorFactory() : ref_count_(1) {} + ~NotificationActivatorFactory() = default; + IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv) override { + if (!ppv) + return E_POINTER; + if (riid == IID_IUnknown || riid == IID_IClassFactory) { + *ppv = static_cast(this); + } else { + *ppv = nullptr; + return E_NOINTERFACE; + } + AddRef(); + return S_OK; + } + IFACEMETHODIMP_(ULONG) AddRef() override { return ++ref_count_; } + IFACEMETHODIMP_(ULONG) Release() override { + ULONG res = --ref_count_; + if (res == 0) + delete this; + return res; + } + + IFACEMETHODIMP CreateInstance(IUnknown* outer, + REFIID riid, + void** ppv) override { + if (outer) + return CLASS_E_NOAGGREGATION; + auto activator = Microsoft::WRL::Make(); + return activator.CopyTo(riid, ppv); + } + IFACEMETHODIMP LockServer(BOOL) override { return S_OK; } + + private: + std::atomic ref_count_; +}; + +NotificationActivatorFactory* g_factory_instance = nullptr; +bool g_registration_in_progress = false; + +std::wstring GetExecutablePath() { + wchar_t path[MAX_PATH]; + DWORD len = ::GetModuleFileNameW(nullptr, path, std::size(path)); + if (len == 0 || len == std::size(path)) + return L""; + return std::wstring(path, len); +} + +void EnsureCLSIDRegistry() { + std::wstring exe = GetExecutablePath(); + if (exe.empty()) + return; + std::wstring clsid = GetAppToastActivatorCLSID(); + std::wstring key_path = L"Software\\Classes\\CLSID\\" + clsid; + base::win::RegKey key(HKEY_CURRENT_USER, key_path.c_str(), KEY_SET_VALUE); + if (!key.Valid()) + return; + key.WriteValue(nullptr, L"Electron Notification Activator"); + key.WriteValue(L"CustomActivator", 1U); + + std::wstring local_server = key_path + L"\\LocalServer32"; + base::win::RegKey server_key(HKEY_CURRENT_USER, local_server.c_str(), + KEY_SET_VALUE); + if (!server_key.Valid()) + return; + server_key.WriteValue(nullptr, exe.c_str()); +} + +bool ExistingShortcutValid(const base::FilePath& lnk_path, PCWSTR aumid) { + if (!base::PathExists(lnk_path)) + return false; + Microsoft::WRL::ComPtr existing; + if (FAILED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&existing)))) + return false; + Microsoft::WRL::ComPtr pf; + if (FAILED(existing.As(&pf)) || + FAILED(pf->Load(lnk_path.value().c_str(), STGM_READ))) { + return false; + } + Microsoft::WRL::ComPtr store; + if (FAILED(existing.As(&store))) + return false; + base::win::ScopedPropVariant pv_id; + base::win::ScopedPropVariant pv_clsid; + if (FAILED(store->GetValue(PKEY_AppUserModel_ID, pv_id.Receive())) || + FAILED(store->GetValue(PKEY_AppUserModel_ToastActivatorCLSID, + pv_clsid.Receive()))) { + return false; + } + if (pv_id.get().vt == VT_LPWSTR && pv_clsid.get().vt == VT_CLSID && + pv_id.get().pwszVal && pv_clsid.get().puuid) { + CLSID desired; + if (SUCCEEDED(::CLSIDFromString(GetAppToastActivatorCLSID(), &desired))) { + return _wcsicmp(pv_id.get().pwszVal, aumid) == 0 && + IsEqualGUID(*pv_clsid.get().puuid, desired); + } + } + return false; +} + +void EnsureShortcut() { + PCWSTR aumid = GetRawAppUserModelID(); + if (!aumid || !*aumid) + return; + + std::wstring exe = GetExecutablePath(); + if (exe.empty()) + return; + + PWSTR programs_path = nullptr; + if (FAILED( + SHGetKnownFolderPath(FOLDERID_Programs, 0, nullptr, &programs_path))) + return; + base::FilePath programs(programs_path); + CoTaskMemFree(programs_path); + + std::wstring product_name = base::UTF8ToWide(GetApplicationName()); + if (product_name.empty()) + product_name = L"ElectronApp"; + base::CreateDirectory(programs); + base::FilePath lnk_path = programs.Append(product_name + L".lnk"); + if (base::PathExists(lnk_path)) { + Microsoft::WRL::ComPtr existing; + if (SUCCEEDED(CoCreateInstance(CLSID_ShellLink, nullptr, + CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&existing)))) { + Microsoft::WRL::ComPtr pf; + if (SUCCEEDED(existing.As(&pf)) && + SUCCEEDED(pf->Load(lnk_path.value().c_str(), STGM_READ))) { + Microsoft::WRL::ComPtr store; + if (SUCCEEDED(existing.As(&store))) { + base::win::ScopedPropVariant pv_clsid; + if (SUCCEEDED(store->GetValue(PKEY_AppUserModel_ToastActivatorCLSID, + pv_clsid.Receive())) && + pv_clsid.get().vt == VT_CLSID && pv_clsid.get().puuid) { + wchar_t buf[64] = {0}; + if (StringFromGUID2(*pv_clsid.get().puuid, buf, std::size(buf)) > + 0) { + SetAppToastActivatorCLSID(buf); + } + } + } + } + } + } + + if (ExistingShortcutValid(lnk_path, aumid)) + return; + + Microsoft::WRL::ComPtr shell_link; + if (FAILED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&shell_link)))) + return; + shell_link->SetPath(exe.c_str()); + shell_link->SetArguments(L""); + shell_link->SetDescription(product_name.c_str()); + shell_link->SetWorkingDirectory( + base::FilePath(exe).DirName().value().c_str()); + + Microsoft::WRL::ComPtr prop_store; + if (SUCCEEDED(shell_link.As(&prop_store))) { + PROPVARIANT pv_id; + PropVariantInit(&pv_id); + if (SUCCEEDED(InitPropVariantFromString(aumid, &pv_id))) { + prop_store->SetValue(PKEY_AppUserModel_ID, pv_id); + PropVariantClear(&pv_id); + } + PROPVARIANT pv_clsid; + PropVariantInit(&pv_clsid); + GUID clsid; + if (SUCCEEDED(::CLSIDFromString(GetAppToastActivatorCLSID(), &clsid)) && + SUCCEEDED(InitPropVariantFromCLSID(clsid, &pv_clsid))) { + prop_store->SetValue(PKEY_AppUserModel_ToastActivatorCLSID, pv_clsid); + PropVariantClear(&pv_clsid); + } + prop_store->Commit(); + } + + Microsoft::WRL::ComPtr persist_file; + if (SUCCEEDED(shell_link.As(&persist_file))) { + persist_file->Save(lnk_path.value().c_str(), TRUE); + } +} + +} // namespace + +DWORD NotificationActivator::g_cookie_ = 0; +bool NotificationActivator::g_registered_ = false; + +NotificationActivator::NotificationActivator() = default; +NotificationActivator::~NotificationActivator() = default; + +// static + +// static +void NotificationActivator::RegisterActivator() { + if (g_registered_ || g_registration_in_progress) + return; + g_registration_in_progress = true; + + // For packaged (MSIX) apps, COM server registration is handled via the app + // manifest (com:Extension and desktop:ToastNotificationActivation), so we + // skip the registry and shortcut creation. We still need to call + // CoRegisterClassObject to handle activations at runtime. + // + // For unpackaged apps, we need to create the Start Menu shortcut with + // AUMID/CLSID properties and register the COM server in the registry. + bool is_packaged = IsRunningInDesktopBridge(); + + // Perform all blocking filesystem / registry work off the UI thread. + base::ThreadPool::PostTask( + FROM_HERE, {base::MayBlock(), base::TaskPriority::BEST_EFFORT}, + base::BindOnce( + [](bool is_packaged) { + // Skip shortcut and registry setup for packaged apps - these are + // declared in the app manifest instead. + if (!is_packaged) { + EnsureShortcut(); + EnsureCLSIDRegistry(); + } + content::GetUIThreadTaskRunner({})->PostTask( + FROM_HERE, base::BindOnce([]() { + g_registration_in_progress = false; + if (g_registered_) + return; + CLSID clsid; + if (FAILED(::CLSIDFromString(GetAppToastActivatorCLSID(), + &clsid))) { + LOG(ERROR) << "Invalid toast activator CLSID"; + return; + } + if (!g_factory_instance) + g_factory_instance = + new (std::nothrow) NotificationActivatorFactory(); + if (!g_factory_instance) + return; + DWORD cookie = 0; + HRESULT hr = ::CoRegisterClassObject( + clsid, static_cast(g_factory_instance), + CLSCTX_LOCAL_SERVER, + REGCLS_MULTIPLEUSE | REGCLS_SUSPENDED, &cookie); + if (FAILED(hr)) + return; + hr = ::CoResumeClassObjects(); + if (FAILED(hr)) { + ::CoRevokeClassObject(cookie); + return; + } + g_cookie_ = cookie; + g_registered_ = true; + })); + }, + is_packaged)); +} + +// static +void NotificationActivator::UnregisterActivator() { + if (!g_registered_) + return; + ::CoRevokeClassObject(g_cookie_); + g_cookie_ = 0; + g_registered_ = false; + // Factory instance lifetime intentionally leaked after revoke to avoid + // race; could be deleted here if ensured no pending COM calls. +} + +IFACEMETHODIMP NotificationActivator::Activate( + LPCWSTR app_user_model_id, + LPCWSTR invoked_args, + const NOTIFICATION_USER_INPUT_DATA* data, + ULONG data_count) { + std::wstring args = invoked_args ? invoked_args : L""; + std::wstring aumid = app_user_model_id ? app_user_model_id : L""; + + std::vector copied_inputs; + if (data && data_count) { + std::vector temp; + temp.resize(static_cast(data_count)); + std::copy_n(data, static_cast(data_count), temp.begin()); + copied_inputs.reserve(temp.size()); + for (const auto& entry : temp) { + ActivationUserInput ui; + if (entry.Key) + ui.key = entry.Key; + if (entry.Value) + ui.value = entry.Value; + copied_inputs.push_back(std::move(ui)); + } + } + + content::GetUIThreadTaskRunner({})->PostTask( + FROM_HERE, base::BindOnce(&HandleToastActivation, std::move(args), + std::move(copied_inputs))); + return S_OK; +} + +void HandleToastActivation(const std::wstring& invoked_args, + std::vector inputs) { + // Expected invoked_args format: + // type=&action=&tag= Parse simple key=value + // pairs separated by '&'. + std::wstring args = invoked_args; + std::wstring type; + std::wstring action_index_str; + std::wstring tag_str; + + for (const auto& token : base::SplitString(args, L"&", base::KEEP_WHITESPACE, + base::SPLIT_WANT_NONEMPTY)) { + auto kv = base::SplitString(token, L"=", base::KEEP_WHITESPACE, + base::SPLIT_WANT_NONEMPTY); + if (kv.size() != 2) + continue; + if (kv[0] == L"type") + type = kv[1]; + else if (kv[0] == L"action") + action_index_str = kv[1]; + else if (kv[0] == L"tag") + tag_str = kv[1]; + } + + int action_index = -1; + if (!action_index_str.empty()) { + action_index = std::stoi(action_index_str); + } + + std::string reply_text; + for (const auto& entry : inputs) { + std::wstring_view key_view(entry.key); + if (key_view == L"reply") { + reply_text = base::WideToUTF8(entry.value); + } + } + + auto* browser_client = + static_cast(ElectronBrowserClient::Get()); + if (!browser_client) + return; + + NotificationPresenter* presenter = browser_client->GetNotificationPresenter(); + if (!presenter) + return; + + Notification* target = nullptr; + for (auto* n : presenter->notifications()) { + std::wstring tag_hash = + base::NumberToWString(base::FastHash(n->notification_id())); + if (tag_hash == tag_str) { + target = n; + break; + } + } + + if (!target) + return; + + if (type == L"action" && target->delegate()) { + int selection_index = -1; + for (const auto& entry : inputs) { + std::wstring_view key_view(entry.key); + if (base::StartsWith(key_view, L"selection", + base::CompareCase::SENSITIVE)) { + int parsed = -1; + if (base::StringToInt(entry.value, &parsed) && parsed >= 0) + selection_index = parsed; + break; + } + } + if (action_index >= 0) + target->delegate()->NotificationAction(action_index, selection_index); + } else if ((type == L"reply" || (!reply_text.empty() && type.empty())) && + target->delegate()) { + target->delegate()->NotificationReplied(reply_text); + } else if ((type == L"click" || type.empty()) && target->delegate()) { + target->NotificationClicked(); + } +} + +} // namespace electron diff --git a/shell/browser/notifications/win/windows_toast_activator.h b/shell/browser/notifications/win/windows_toast_activator.h new file mode 100644 index 0000000000..bed5f6e466 --- /dev/null +++ b/shell/browser/notifications/win/windows_toast_activator.h @@ -0,0 +1,49 @@ +// Copyright (c) 2025 Microsoft, GmbH +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_BROWSER_NOTIFICATIONS_WIN_WINDOWS_TOAST_ACTIVATOR_H_ +#define ELECTRON_SHELL_BROWSER_NOTIFICATIONS_WIN_WINDOWS_TOAST_ACTIVATOR_H_ + +#include +#include +#include +#include +#include + +namespace electron { + +class NotificationPresenterWin; + +class NotificationActivator + : public Microsoft::WRL::RuntimeClass< + Microsoft::WRL::RuntimeClassFlags, + INotificationActivationCallback> { + public: + NotificationActivator(); + ~NotificationActivator() override; + + IFACEMETHODIMP Activate(LPCWSTR app_user_model_id, + LPCWSTR invoked_args, + const NOTIFICATION_USER_INPUT_DATA* data, + ULONG data_count) override; + + static void RegisterActivator(); + static void UnregisterActivator(); + + private: + static DWORD g_cookie_; + static bool g_registered_; +}; + +struct ActivationUserInput { + std::wstring key; + std::wstring value; +}; + +void HandleToastActivation(const std::wstring& invoked_args, + std::vector inputs); + +} // namespace electron + +#endif // ELECTRON_SHELL_BROWSER_NOTIFICATIONS_WIN_WINDOWS_TOAST_ACTIVATOR_H_ diff --git a/shell/browser/notifications/win/windows_toast_notification.cc b/shell/browser/notifications/win/windows_toast_notification.cc index 3d99393363..c9e48274b8 100644 --- a/shell/browser/notifications/win/windows_toast_notification.cc +++ b/shell/browser/notifications/win/windows_toast_notification.cc @@ -111,43 +111,41 @@ const std::string FailureResultToString(HRESULT failure_reason) { return hresult_str; } +constexpr char kPlaceholderContent[] = "placeHolderContent"; +constexpr char kContent[] = "content"; constexpr char kToast[] = "toast"; constexpr char kVisual[] = "visual"; constexpr char kBinding[] = "binding"; constexpr char kTemplate[] = "template"; -constexpr char kToastText01[] = "ToastText01"; -constexpr char kToastText02[] = "ToastText02"; -constexpr char kToastImageAndText01[] = "ToastImageAndText01"; -constexpr char kToastImageAndText02[] = "ToastImageAndText02"; +constexpr char kToastTemplate[] = "ToastGeneric"; constexpr char kText[] = "text"; constexpr char kImage[] = "image"; constexpr char kPlacement[] = "placement"; constexpr char kAppLogoOverride[] = "appLogoOverride"; constexpr char kHintCrop[] = "hint-crop"; +constexpr char kHintInputId[] = "hint-inputId"; constexpr char kHintCropNone[] = "none"; constexpr char kSrc[] = "src"; constexpr char kAudio[] = "audio"; constexpr char kSilent[] = "silent"; +constexpr char kReply[] = "reply"; constexpr char kTrue[] = "true"; constexpr char kID[] = "id"; +constexpr char kInput[] = "input"; +constexpr char kType[] = "type"; +constexpr char kSelection[] = "selection"; constexpr char kScenario[] = "scenario"; constexpr char kReminder[] = "reminder"; constexpr char kActions[] = "actions"; constexpr char kAction[] = "action"; constexpr char kActivationType[] = "activationType"; -constexpr char kSystem[] = "system"; +constexpr char kActivationTypeForeground[] = "foreground"; +constexpr char kActivationTypeSystem[] = "system"; constexpr char kArguments[] = "arguments"; constexpr char kDismiss[] = "dismiss"; // The XML version header that has to be stripped from the output. constexpr char kXmlVersionHeader[] = "\n"; -const char* GetTemplateType(bool two_lines, bool has_icon) { - if (has_icon) { - return two_lines ? kToastImageAndText02 : kToastImageAndText01; - } - return two_lines ? kToastText02 : kToastText01; -} - } // namespace // static @@ -171,8 +169,9 @@ WindowsToastNotification::GetToastTaskRunner() { } bool WindowsToastNotification::Initialize() { - // Just initialize, don't care if it fails or already initialized. - Windows::Foundation::Initialize(RO_INIT_MULTITHREADED); + HRESULT hr = Windows::Foundation::Initialize(RO_INIT_SINGLETHREADED); + if (FAILED(hr)) + Windows::Foundation::Initialize(RO_INIT_MULTITHREADED); ScopedHString toast_manager_str( RuntimeClass_Windows_UI_Notifications_ToastNotificationManager); @@ -273,8 +272,8 @@ void WindowsToastNotification::CreateToastNotificationOnBackgroundThread( DebugLog("CreateToastXmlOnBackgroundThread called"); ComPtr toast_xml; - if (!CreateToastXmlDocument(options, presenter, weak_notification, - ui_task_runner, &toast_xml)) { + if (!CreateToastXmlDocument(options, presenter, notification_id, + weak_notification, ui_task_runner, &toast_xml)) { return; // Error already posted to UI thread } @@ -300,13 +299,14 @@ void WindowsToastNotification::CreateToastNotificationOnBackgroundThread( bool WindowsToastNotification::CreateToastXmlDocument( const NotificationOptions& options, NotificationPresenter* presenter, + const std::string& notification_id, base::WeakPtr weak_notification, scoped_refptr ui_task_runner, ComPtr* toast_xml) { // The custom xml takes priority over the preset template. if (!options.toast_xml.empty()) { - DebugLog(base::StrCat({"Processing custom toast_xml, length: ", - base::NumberToString(options.toast_xml.length())})); + DebugLog("Toast XML (custom) id=" + notification_id + ": " + + base::UTF16ToUTF8(options.toast_xml)); HRESULT hr = XmlDocumentFromString(base::as_wcstr(options.toast_xml), toast_xml->GetAddressOf()); DebugLog(base::StrCat({"XmlDocumentFromString returned HRESULT: ", @@ -316,7 +316,6 @@ bool WindowsToastNotification::CreateToastXmlDocument( base::StrCat({"XML: Invalid XML, ERROR ", FailureResultToString(hr)}); DebugLog(base::StrCat({"XML parsing failed, posting error: ", err})); PostNotificationFailedToUIThread(weak_notification, err, ui_task_runner); - DebugLog("PostNotificationFailedToUIThread called"); return false; } DebugLog("XML parsing succeeded"); @@ -325,8 +324,13 @@ bool WindowsToastNotification::CreateToastXmlDocument( std::wstring icon_path = presenter_win->SaveIconToFilesystem(options.icon, options.icon_url); std::u16string toast_xml_str = - GetToastXml(options.title, options.msg, icon_path, options.timeout_type, - options.silent); + GetToastXml(notification_id, options.title, options.msg, icon_path, + options.timeout_type, options.silent, options.actions, + options.has_reply, options.reply_placeholder); + + DebugLog("Toast XML (generated) id=" + notification_id + ": " + + base::UTF16ToUTF8(toast_xml_str)); + HRESULT hr = XmlDocumentFromString(base::as_wcstr(toast_xml_str), toast_xml->GetAddressOf()); if (FAILED(hr)) { @@ -523,11 +527,15 @@ void WindowsToastNotification::Dismiss() { } std::u16string WindowsToastNotification::GetToastXml( + const std::string& notification_id, const std::u16string& title, const std::u16string& msg, const std::wstring& icon_path, const std::u16string& timeout_type, - bool silent) { + bool silent, + const std::vector& actions, + bool has_reply, + const std::u16string& reply_placeholder) { XmlWriter xml_writer; xml_writer.StartWriting(); @@ -543,36 +551,33 @@ std::u16string WindowsToastNotification::GetToastXml( xml_writer.StartElement(kVisual); // xml_writer.StartElement(kBinding); - const bool two_lines = (!title.empty() && !msg.empty()); - xml_writer.AddAttribute(kTemplate, - GetTemplateType(two_lines, !icon_path.empty())); + xml_writer.AddAttribute(kTemplate, kToastTemplate); - // Add text nodes. std::u16string line1; std::u16string line2; if (title.empty() || msg.empty()) { line1 = title.empty() ? msg : title; if (line1.empty()) line1 = u"[no message]"; + // xml_writer.StartElement(kText); - xml_writer.AddAttribute(kID, "1"); xml_writer.AppendElementContent(base::UTF16ToUTF8(line1)); xml_writer.EndElement(); // } else { line1 = title; line2 = msg; + // xml_writer.StartElement(kText); - xml_writer.AddAttribute(kID, "1"); xml_writer.AppendElementContent(base::UTF16ToUTF8(line1)); - xml_writer.EndElement(); + xml_writer.EndElement(); // + // xml_writer.StartElement(kText); - xml_writer.AddAttribute(kID, "2"); xml_writer.AppendElementContent(base::UTF16ToUTF8(line2)); - xml_writer.EndElement(); + xml_writer.EndElement(); // } - // Optional icon as app logo override (small icon). if (!icon_path.empty()) { + // xml_writer.StartElement(kImage); xml_writer.AddAttribute(kID, "1"); xml_writer.AddAttribute(kPlacement, kAppLogoOverride); @@ -584,15 +589,85 @@ std::u16string WindowsToastNotification::GetToastXml( xml_writer.EndElement(); // xml_writer.EndElement(); // - // (only to ensure reminder has a dismiss button). - if (is_reminder) { + if (is_reminder || has_reply || !actions.empty()) { + // xml_writer.StartElement(kActions); - xml_writer.StartElement(kAction); - xml_writer.AddAttribute(kActivationType, kSystem); - xml_writer.AddAttribute(kArguments, kDismiss); - xml_writer.AddAttribute( - "content", base::WideToUTF8(l10n_util::GetWideString(IDS_APP_CLOSE))); - xml_writer.EndElement(); // + if (is_reminder) { + xml_writer.StartElement(kAction); + xml_writer.AddAttribute(kActivationType, kActivationTypeSystem); + xml_writer.AddAttribute(kArguments, kDismiss); + xml_writer.AddAttribute( + kContent, base::WideToUTF8(l10n_util::GetWideString(IDS_APP_CLOSE))); + xml_writer.EndElement(); // + } + + if (has_reply) { + // + xml_writer.StartElement(kInput); + xml_writer.AddAttribute(kID, kReply); + xml_writer.AddAttribute(kType, kText); + if (!reply_placeholder.empty()) { + xml_writer.AddAttribute(kPlaceholderContent, + base::UTF16ToUTF8(reply_placeholder)); + } + xml_writer.EndElement(); // + } + + for (size_t i = 0; i < actions.size(); ++i) { + const auto& act = actions[i]; + if (act.type == u"button" || act.type.empty()) { + // + xml_writer.StartElement(kAction); + xml_writer.AddAttribute(kActivationType, kActivationTypeForeground); + std::string args = base::StrCat( + {"type=action&action=", base::NumberToString(i), + "&tag=", base::NumberToString(base::FastHash(notification_id))}); + xml_writer.AddAttribute(kArguments, args.c_str()); + xml_writer.AddAttribute(kContent, base::UTF16ToUTF8(act.text)); + xml_writer.EndElement(); // + } else if (act.type == u"selection") { + std::string input_id = + base::StrCat({kSelection, base::NumberToString(i)}); + xml_writer.StartElement(kInput); // + xml_writer.AddAttribute(kID, input_id.c_str()); + xml_writer.AddAttribute(kType, kSelection); + for (size_t opt_i = 0; opt_i < act.items.size(); ++opt_i) { + xml_writer.StartElement(kSelection); // + xml_writer.AddAttribute(kID, base::NumberToString(opt_i).c_str()); + xml_writer.AddAttribute(kContent, + base::UTF16ToUTF8(act.items[opt_i])); + xml_writer.EndElement(); // + } + xml_writer.EndElement(); // + + // The button that submits the selection. + xml_writer.StartElement(kAction); + xml_writer.AddAttribute(kActivationType, kActivationTypeForeground); + std::string args = base::StrCat( + {"type=action&action=", base::NumberToString(i), + "&tag=", base::NumberToString(base::FastHash(notification_id))}); + xml_writer.AddAttribute(kArguments, args.c_str()); + xml_writer.AddAttribute( + kContent, + base::UTF16ToUTF8(act.text.empty() ? u"Select" : act.text)); + xml_writer.AddAttribute(kHintInputId, input_id.c_str()); + xml_writer.EndElement(); // + } + } + + if (has_reply) { + // + xml_writer.StartElement(kAction); + xml_writer.AddAttribute(kActivationType, kActivationTypeForeground); + std::string args = + base::StrCat({"type=reply&tag=", + base::NumberToString(base::FastHash(notification_id))}); + xml_writer.AddAttribute(kArguments, args.c_str()); + // TODO(codebytere): we should localize this. + xml_writer.AddAttribute(kContent, "Reply"); + xml_writer.AddAttribute(kHintInputId, kReply); + xml_writer.EndElement(); // + } xml_writer.EndElement(); // } @@ -663,6 +738,57 @@ ToastEventHandler::~ToastEventHandler() = default; IFACEMETHODIMP ToastEventHandler::Invoke( winui::Notifications::IToastNotification* sender, IInspectable* args) { + std::wstring arguments_w; + std::wstring tag_w; + std::wstring group_w; + + if (args) { + Microsoft::WRL::ComPtr + activated_args; + if (SUCCEEDED(args->QueryInterface(IID_PPV_ARGS(&activated_args)))) { + HSTRING args_hs = nullptr; + if (SUCCEEDED(activated_args->get_Arguments(&args_hs)) && args_hs) { + UINT32 len = 0; + const wchar_t* raw = WindowsGetStringRawBuffer(args_hs, &len); + if (raw && len) + arguments_w.assign(raw, len); + } + } + } + + if (sender) { + Microsoft::WRL::ComPtr toast2; + if (SUCCEEDED(sender->QueryInterface(IID_PPV_ARGS(&toast2)))) { + HSTRING tag_hs = nullptr; + if (SUCCEEDED(toast2->get_Tag(&tag_hs)) && tag_hs) { + UINT32 len = 0; + const wchar_t* raw = WindowsGetStringRawBuffer(tag_hs, &len); + if (raw && len) + tag_w.assign(raw, len); + } + HSTRING group_hs = nullptr; + if (SUCCEEDED(toast2->get_Group(&group_hs)) && group_hs) { + UINT32 len = 0; + const wchar_t* raw = WindowsGetStringRawBuffer(group_hs, &len); + if (raw && len) + group_w.assign(raw, len); + } + } + } + + std::string notif_id; + std::string notif_hash; + if (notification_) { + notif_id = notification_->notification_id(); + notif_hash = base::NumberToString(base::FastHash(notif_id)); + } + + bool structured = arguments_w.find(L"&tag=") != std::wstring::npos || + arguments_w.find(L"type=action") != std::wstring::npos || + arguments_w.find(L"type=reply") != std::wstring::npos; + if (structured) + return S_OK; + content::GetUIThreadTaskRunner({})->PostTask( FROM_HERE, base::BindOnce(&Notification::NotificationClicked, notification_)); diff --git a/shell/browser/notifications/win/windows_toast_notification.h b/shell/browser/notifications/win/windows_toast_notification.h index 885f4934a7..29d68c4f2b 100644 --- a/shell/browser/notifications/win/windows_toast_notification.h +++ b/shell/browser/notifications/win/windows_toast_notification.h @@ -60,11 +60,16 @@ class WindowsToastNotification : public Notification { friend class ToastEventHandler; HRESULT ShowInternal(const NotificationOptions& options); - static std::u16string GetToastXml(const std::u16string& title, - const std::u16string& msg, - const std::wstring& icon_path, - const std::u16string& timeout_type, - const bool silent); + static std::u16string GetToastXml( + const std::string& notification_id, + const std::u16string& title, + const std::u16string& msg, + const std::wstring& icon_path, + const std::u16string& timeout_type, + const bool silent, + const std::vector& actions, + bool has_reply, + const std::u16string& reply_placeholder); static HRESULT XmlDocumentFromString( const wchar_t* xmlString, ABI::Windows::Data::Xml::Dom::IXmlDocument** doc); @@ -77,6 +82,7 @@ class WindowsToastNotification : public Notification { static bool CreateToastXmlDocument( const NotificationOptions& options, NotificationPresenter* presenter, + const std::string& notification_id, base::WeakPtr weak_notification, scoped_refptr ui_task_runner, ComPtr* toast_xml); diff --git a/shell/common/application_info.h b/shell/common/application_info.h index 622c98a056..1340507c5d 100644 --- a/shell/common/application_info.h +++ b/shell/common/application_info.h @@ -32,6 +32,8 @@ PCWSTR GetRawAppUserModelID(); bool GetAppUserModelID(ScopedHString* app_id); void SetAppUserModelID(const std::wstring& name); bool IsRunningInDesktopBridge(); +PCWSTR GetAppToastActivatorCLSID(); +void SetAppToastActivatorCLSID(const std::wstring& clsid); #endif } // namespace electron diff --git a/shell/common/application_info_win.cc b/shell/common/application_info_win.cc index 76eacffce1..3d0d47d2fc 100644 --- a/shell/common/application_info_win.cc +++ b/shell/common/application_info_win.cc @@ -28,6 +28,11 @@ std::wstring& GetAppUserModelId() { return *g_app_user_model_id; } +std::wstring& GetToastActivatorCLSID() { + static base::NoDestructor g_toast_activator_clsid; + return *g_toast_activator_clsid; +} + std::string GetApplicationName() { auto* module = GetModuleHandle(nullptr); std::unique_ptr info( @@ -82,4 +87,37 @@ bool IsRunningInDesktopBridge() { return result; } +PCWSTR GetAppToastActivatorCLSID() { + if (GetToastActivatorCLSID().empty()) { + GUID guid; + if (SUCCEEDED(::CoCreateGuid(&guid))) { + wchar_t buf[64] = {0}; + if (StringFromGUID2(guid, buf, std::size(buf)) > 0) + GetToastActivatorCLSID() = buf; + } + } + + return GetToastActivatorCLSID().c_str(); +} + +void SetAppToastActivatorCLSID(const std::wstring& clsid) { + CLSID parsed; + if (SUCCEEDED(::CLSIDFromString(clsid.c_str(), &parsed))) { + // Normalize formatting. + wchar_t buf[64] = {0}; + if (StringFromGUID2(parsed, buf, std::size(buf)) > 0) + GetToastActivatorCLSID() = buf; + } else { + // Try adding braces if user omitted them. + if (!clsid.empty() && clsid.front() != L'{') { + std::wstring with_braces = L"{" + clsid + L"}"; + if (SUCCEEDED(::CLSIDFromString(with_braces.c_str(), &parsed))) { + wchar_t buf[64] = {0}; + if (StringFromGUID2(parsed, buf, std::size(buf)) > 0) + GetToastActivatorCLSID() = buf; + } + } + } +} + } // namespace electron diff --git a/spec/api-app-spec.ts b/spec/api-app-spec.ts index f074e0cd47..ae17385e4a 100644 --- a/spec/api-app-spec.ts +++ b/spec/api-app-spec.ts @@ -155,6 +155,14 @@ describe('app module', () => { }); }); + ifdescribe(process.platform === 'win32')('app.setToastActivatorCLSID()', () => { + it('throws on invalid format', () => { + expect(() => { + app.setToastActivatorCLSID('1234567890'); + }).to.throw(/Invalid CLSID format/); + }); + }); + describe('app.isPackaged', () => { it('should be false during tests', () => { expect(app.isPackaged).to.equal(false);