fix: forward activation token from libnotify on notification click (#50669)

* feat: forward activation token from libnotify notification clicks

When a notification action is clicked on Linux, retrieve the activation
token from libnotify (if available) via dlsym and set it using
`base::nix::SetActivationToken()`. This enables proper window focus
handling under Wayland, where the compositor requires a valid activation
token to grant focus to the application.

The `notify_notification_get_activation_token` symbol is resolved at
runtime to maintain compatibility with older libnotify versions that
do not expose this API.

Co-authored-by: Bohdan Tkachenko <bohdan@tkachenko.dev>

* refactor: simplify libnotify soname loading and activation token lookup

Replace the chained Load() calls with a loop over a constexpr array of
sonames, and inline the lazy EnsureActivationTokenFunc() into
Initialize() since it is only called once and the library handle is
already known at that point.

Co-authored-by: Bohdan Tkachenko <bohdan@tkachenko.dev>

---------

Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Bohdan Tkachenko <bohdan@tkachenko.dev>
This commit is contained in:
trop[bot]
2026-04-03 15:45:34 -05:00
committed by GitHub
parent d73eaf83f5
commit cd495e20a7
2 changed files with 80 additions and 6 deletions

View File

@@ -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();
}

View File

@@ -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;
});
});
});