Repository: electron/electron
Pull request: fix: forward activation token from libnotify on notification click

#### Description of Change

On Wayland, clicking a native notification does not focus the originating window. Instead, the compositor shows an "[App] is ready" prompt because no XDG activation token is presented to `xdg_activation_v1`.

Since libnotify 0.7.10, `notify_notification_get_activation_token()` returns the activation token from the [FDN 1.2 spec](https://specifications.freedesktop.org/notification/latest-single). This PR reads that token in `OnNotificationView` and passes it to `base::nix::SetActivationToken()` so the subsequent `gtk_window_present()` can activate the window via `xdg_activation_v1`.

The function is resolved at runtime via `dlsym`, so older libnotify versions are unaffected — the token lookup is simply skipped.

Fixes #9919 (Wayland portion)

#### Checklist

- [X] PR description included
- [X] I have built and tested this PR
- [X] `npm test` passes
- [X] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [X] relevant API documentation, tutorials, and examples are updated and follow the [documentation style guide](https://github.com/electron/electron/blob/main/docs/development/style-guide.md)
- [X] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Fixed native notification clicks not focusing the application window on Wayland.


Changed files:
filename: shell/browser/notifications/linux/libnotify_notification.cc
patch: @@ -4,12 +4,16 @@
 
 #include "shell/browser/notifications/linux/libnotify_notification.h"
 
+#include <dlfcn.h>
+
+#include <array>
 #include <string>
 
 #include "base/containers/flat_set.h"
 #include "base/files/file_enumerator.h"
 #include "base/functional/bind.h"
 #include "base/logging.h"
+#include "base/nix/xdg_util.h"
 #include "base/no_destructor.h"
 #include "base/process/process_handle.h"
 #include "base/strings/utf_string_conversions.h"
@@ -50,6 +54,9 @@ bool NotifierSupportsActions() {
   return HasCapability("actions");
 }
 
+using GetActivationTokenFunc = const char* (*)(NotifyNotification*);
+GetActivationTokenFunc g_get_activation_token = nullptr;
+
 void log_and_clear_error(GError* error, const char* context) {
   LOG(ERROR) << context << ": domain=" << error->domain
              << " code=" << error->code << " message=\"" << error->message
@@ -61,18 +68,40 @@ void log_and_clear_error(GError* error, const char* context) {
 
 // static
 bool LibnotifyNotification::Initialize() {
-  if (!GetLibNotifyLoader().Load("libnotify.so.4") &&  // most common one
-      !GetLibNotifyLoader().Load("libnotify.so.5") &&
-      !GetLibNotifyLoader().Load("libnotify.so.1") &&
-      !GetLibNotifyLoader().Load("libnotify.so")) {
+  constexpr std::array kLibnotifySonames = {
+      "libnotify.so.4",
+      "libnotify.so.5",
+      "libnotify.so.1",
+      "libnotify.so",
+  };
+
+  const char* loaded_soname = nullptr;
+  for (const char* soname : kLibnotifySonames) {
+    if (GetLibNotifyLoader().Load(soname)) {
+      loaded_soname = soname;
+      break;
+    }
+  }
+
+  if (!loaded_soname) {
     LOG(WARNING) << "Unable to find libnotify; notifications disabled";
     return false;
   }
+
   if (!GetLibNotifyLoader().notify_is_initted() &&
       !GetLibNotifyLoader().notify_init(GetApplicationName().c_str())) {
     LOG(WARNING) << "Unable to initialize libnotify; notifications disabled";
     return false;
   }
+
+  // Safe to cache the symbol after dlclose(handle) because libnotify remains
+  // loaded via GetLibNotifyLoader() for the process lifetime.
+  if (void* handle = dlopen(loaded_soname, RTLD_LAZY)) {
+    g_get_activation_token = reinterpret_cast<GetActivationTokenFunc>(
+        dlsym(handle, "notify_notification_get_activation_token"));
+    dlclose(handle);
+  }
+
   return true;
 }
 
@@ -192,6 +221,14 @@ void LibnotifyNotification::OnNotificationView(NotifyNotification* notification,
                                                gpointer user_data) {
   LibnotifyNotification* that = static_cast<LibnotifyNotification*>(user_data);
   DCHECK(that);
+
+  if (g_get_activation_token) {
+    const char* token = g_get_activation_token(notification);
+    if (token && *token) {
+      base::nix::SetActivationToken(std::string(token));
+    }
+  }
+
   that->NotificationClicked();
 }
 

filename: spec/api-notification-dbus-spec.ts
patch: @@ -12,6 +12,7 @@ import { app } from 'electron/main';
 import { expect } from 'chai';
 import * as dbus from 'dbus-native';
 
+import { once } from 'node:events';
 import * as path from 'node:path';
 import { promisify } from 'node:util';
 
@@ -25,7 +26,7 @@ const skip = process.platform !== 'linux' ||
              !process.env.DBUS_SESSION_BUS_ADDRESS;
 
 ifdescribe(!skip)('Notification module (dbus)', () => {
-  let mock: any, Notification, getCalls: any, reset: any;
+  let mock: any, Notification: any, getCalls: any, emitSignal: any, reset: any;
   const realAppName = app.name;
   const realAppVersion = app.getVersion();
   const appName = 'api-notification-dbus-spec';
@@ -45,7 +46,17 @@ ifdescribe(!skip)('Notification module (dbus)', () => {
     const getInterface = promisify(service.getInterface.bind(service));
     mock = await getInterface(path, iface);
     getCalls = promisify(mock.GetCalls.bind(mock));
+    emitSignal = promisify(mock.EmitSignal.bind(mock));
     reset = promisify(mock.Reset.bind(mock));
+
+    // Override GetCapabilities to include "actions" so that libnotify
+    // registers the "default" action callback on notifications.
+    const addMethod = promisify(mock.AddMethod.bind(mock));
+    await addMethod(
+      serviceName, 'GetCapabilities', '', 'as',
+      'ret = ["body", "body-markup", "icon-static", "image/svg+xml", ' +
+      '"private-synchronous", "append", "private-icon-only", "truncation", "actions"]'
+    );
   });
 
   after(async () => {
@@ -122,7 +133,7 @@ ifdescribe(!skip)('Notification module (dbus)', () => {
         app_icon: '',
         title: 'title',
         body: 'body',
-        actions: [],
+        actions: ['default', 'View'],
         hints: {
           append: 'true',
           image_data: [3, 3, 12, true, 8, 4, Buffer.from([255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 0, 76, 255, 0, 255, 0, 0, 0, 255, 0, 0, 0, 0, 0, 38, 255, 255, 0, 0, 0, 255, 0, 0, 0, 0])],
@@ -133,4 +144,30 @@ ifdescribe(!skip)('Notification module (dbus)', () => {
       });
     });
   });
+
+  describe('ActivationToken on notification click', () => {
+    it('should emit click when ActionInvoked is sent by the daemon', async () => {
+      const n = new Notification({ title: 'activation-token-test', body: 'test' });
+      const clicked = once(n, 'click');
+
+      n.show();
+
+      // getCalls returns all D-Bus method calls. The mock assigns sequential
+      // notification IDs starting at 1 for each Notify call.
+      const calls = await getCalls();
+      const notifyCalls = calls.filter((c: any) => c[1] === 'Notify');
+      const notificationId = notifyCalls.length;
+
+      // Simulate the notification daemon emitting ActivationToken (FDN 1.2)
+      // followed by ActionInvoked for a "default" click.
+      emitSignal(
+        'org.freedesktop.Notifications', 'ActivationToken',
+        'us', [['u', notificationId], ['s', 'test-activation-token']]);
+      emitSignal(
+        'org.freedesktop.Notifications', 'ActionInvoked',
+        'us', [['u', notificationId], ['s', 'default']]);
+
+      await clicked;
+    });
+  });
 });