b1d2a5bac8
Actually link a notification source with an app instead of just to its app name and PID, which in many cases don't really identify an app. E.g. for portal applications the PID points to the xdg-desktop-portal. Use the app when ever possible but keep using the app name and PID as a fallback. Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3173>
677 lines
21 KiB
JavaScript
677 lines
21 KiB
JavaScript
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
|
||
|
||
import GdkPixbuf from 'gi://GdkPixbuf';
|
||
import Gio from 'gi://Gio';
|
||
import GLib from 'gi://GLib';
|
||
import GObject from 'gi://GObject';
|
||
import Shell from 'gi://Shell';
|
||
|
||
import * as Config from '../misc/config.js';
|
||
import * as Main from './main.js';
|
||
import * as MessageTray from './messageTray.js';
|
||
|
||
import {loadInterfaceXML} from '../misc/fileUtils.js';
|
||
import {NotificationErrors, NotificationError} from '../misc/dbusErrors.js';
|
||
|
||
const FdoNotificationsIface = loadInterfaceXML('org.freedesktop.Notifications');
|
||
|
||
/** @enum {number} */
|
||
const NotificationClosedReason = {
|
||
EXPIRED: 1,
|
||
DISMISSED: 2,
|
||
APP_CLOSED: 3,
|
||
UNDEFINED: 4,
|
||
};
|
||
|
||
/** @enum {number} */
|
||
const Urgency = {
|
||
LOW: 0,
|
||
NORMAL: 1,
|
||
CRITICAL: 2,
|
||
};
|
||
|
||
class FdoNotificationDaemon {
|
||
constructor() {
|
||
this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(FdoNotificationsIface, this);
|
||
this._dbusImpl.export(Gio.DBus.session, '/org/freedesktop/Notifications');
|
||
|
||
this._sourcesForApp = new Map();
|
||
this._sourceForPidAndName = new Map();
|
||
this._notifications = new Map();
|
||
|
||
this._nextNotificationId = 1;
|
||
}
|
||
|
||
_imageForNotificationData(hints) {
|
||
if (hints['image-data']) {
|
||
const [
|
||
width, height, rowStride, hasAlpha,
|
||
bitsPerSample, nChannels_, data,
|
||
] = hints['image-data'];
|
||
return Shell.util_create_pixbuf_from_data(data,
|
||
GdkPixbuf.Colorspace.RGB,
|
||
hasAlpha,
|
||
bitsPerSample,
|
||
width,
|
||
height,
|
||
rowStride);
|
||
} else if (hints['image-path']) {
|
||
return this._iconForNotificationData(hints['image-path']);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
_iconForNotificationData(icon) {
|
||
if (icon) {
|
||
if (icon.substr(0, 7) === 'file://')
|
||
return new Gio.FileIcon({file: Gio.File.new_for_uri(icon)});
|
||
else if (icon[0] === '/')
|
||
return new Gio.FileIcon({file: Gio.File.new_for_path(icon)});
|
||
else
|
||
return new Gio.ThemedIcon({name: icon});
|
||
}
|
||
return null;
|
||
}
|
||
|
||
_getApp(pid, appId, appName) {
|
||
const appSys = Shell.AppSystem.get_default();
|
||
let app;
|
||
|
||
app = Shell.WindowTracker.get_default().get_app_from_pid(pid);
|
||
if (!app && appId)
|
||
app = appSys.lookup_app(`${appId}.desktop`);
|
||
|
||
if (!app)
|
||
app = appSys.lookup_app(`${appName}.desktop`);
|
||
|
||
return app;
|
||
}
|
||
|
||
// Returns the source associated with an app.
|
||
//
|
||
// If no existing source is found a new one is created.
|
||
_getSourceForApp(sender, app) {
|
||
let source = this._sourcesForApp.get(app);
|
||
|
||
if (source)
|
||
return source;
|
||
|
||
source = new FdoNotificationDaemonSource(sender, app);
|
||
|
||
if (app) {
|
||
this._sourcesForApp.set(app, source);
|
||
source.connect('destroy', () => {
|
||
this._sourcesForApp.delete(app);
|
||
});
|
||
}
|
||
|
||
Main.messageTray.add(source);
|
||
return source;
|
||
}
|
||
|
||
// Returns the source associated with a pid and the app name.
|
||
//
|
||
// If no existing source is found, a new one is created.
|
||
_getSourceForPidAndName(sender, pid, appName) {
|
||
const key = `${pid}${appName}`;
|
||
let source = this._sourceForPidAndName.get(key);
|
||
|
||
if (source)
|
||
return source;
|
||
|
||
source = new FdoNotificationDaemonSource(sender, null);
|
||
|
||
// Only check whether we have a PID since it's enough to identify
|
||
// uniquely an app and "" is a valid app name.
|
||
if (pid) {
|
||
this._sourceForPidAndName.set(key, source);
|
||
source.connect('destroy', () => {
|
||
this._sourceForPidAndName.delete(key);
|
||
});
|
||
}
|
||
|
||
Main.messageTray.add(source);
|
||
return source;
|
||
}
|
||
|
||
NotifyAsync(params, invocation) {
|
||
let [appName, replacesId, appIcon, summary, body, actions, hints, timeout_] = params;
|
||
let id;
|
||
|
||
for (let hint in hints) {
|
||
// unpack the variants
|
||
hints[hint] = hints[hint].deepUnpack();
|
||
}
|
||
|
||
hints = {urgency: Urgency.NORMAL, ...hints};
|
||
|
||
// Be compatible with the various hints for image data and image path
|
||
// 'image-data' and 'image-path' are the latest name of these hints, introduced in 1.2
|
||
|
||
if (!hints['image-path'] && hints['image_path'])
|
||
hints['image-path'] = hints['image_path']; // version 1.1 of the spec
|
||
|
||
if (!hints['image-data']) {
|
||
if (hints['image_data'])
|
||
hints['image-data'] = hints['image_data']; // version 1.1 of the spec
|
||
else if (hints['icon_data'] && !hints['image-path'])
|
||
// early versions of the spec; 'icon_data' should only be used if 'image-path' is not available
|
||
hints['image-data'] = hints['icon_data'];
|
||
}
|
||
|
||
let source, notification;
|
||
if (replacesId !== 0 && this._notifications.has(replacesId)) {
|
||
notification = this._notifications.get(replacesId);
|
||
source = notification.source;
|
||
id = replacesId;
|
||
} else {
|
||
const sender = hints['x-shell-sender'];
|
||
const pid = hints['x-shell-sender-pid'];
|
||
const appId = hints['desktop-entry'];
|
||
const app = this._getApp(pid, appId, appName);
|
||
|
||
id = this._nextNotificationId++;
|
||
source = app
|
||
? this._getSourceForApp(sender, app)
|
||
: this._getSourceForPidAndName(sender, pid, appName);
|
||
|
||
notification = new MessageTray.Notification(source);
|
||
this._notifications.set(id, notification);
|
||
notification.connect('destroy', (n, reason) => {
|
||
this._notifications.delete(id);
|
||
let notificationClosedReason;
|
||
switch (reason) {
|
||
case MessageTray.NotificationDestroyedReason.EXPIRED:
|
||
notificationClosedReason = NotificationClosedReason.EXPIRED;
|
||
break;
|
||
case MessageTray.NotificationDestroyedReason.DISMISSED:
|
||
notificationClosedReason = NotificationClosedReason.DISMISSED;
|
||
break;
|
||
case MessageTray.NotificationDestroyedReason.SOURCE_CLOSED:
|
||
notificationClosedReason = NotificationClosedReason.APP_CLOSED;
|
||
break;
|
||
}
|
||
this._emitNotificationClosed(id, notificationClosedReason);
|
||
});
|
||
}
|
||
|
||
const gicon = this._imageForNotificationData(hints);
|
||
|
||
const soundFile = 'sound-file' in hints
|
||
? Gio.File.new_for_path(hints['sound-file']) : null;
|
||
|
||
notification.update(summary, body, {
|
||
gicon,
|
||
bannerMarkup: true,
|
||
clear: true,
|
||
sound: new MessageTray.Sound(soundFile, hints['sound-name']),
|
||
});
|
||
|
||
let hasDefaultAction = false;
|
||
|
||
if (actions.length) {
|
||
for (let i = 0; i < actions.length - 1; i += 2) {
|
||
let [actionId, label] = [actions[i], actions[i + 1]];
|
||
if (actionId === 'default') {
|
||
hasDefaultAction = true;
|
||
} else {
|
||
notification.addAction(label, () => {
|
||
this._emitActivationToken(source, id);
|
||
this._emitActionInvoked(id, actionId);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasDefaultAction) {
|
||
notification.connect('activated', () => {
|
||
this._emitActivationToken(source, id);
|
||
this._emitActionInvoked(id, 'default');
|
||
});
|
||
} else {
|
||
notification.connect('activated', () => {
|
||
source.open();
|
||
});
|
||
}
|
||
|
||
switch (hints.urgency) {
|
||
case Urgency.LOW:
|
||
notification.setUrgency(MessageTray.Urgency.LOW);
|
||
break;
|
||
case Urgency.NORMAL:
|
||
notification.setUrgency(MessageTray.Urgency.NORMAL);
|
||
break;
|
||
case Urgency.CRITICAL:
|
||
notification.setUrgency(MessageTray.Urgency.CRITICAL);
|
||
break;
|
||
}
|
||
notification.setResident(!!hints.resident);
|
||
// 'transient' is a reserved keyword in JS, so we have to retrieve the value
|
||
// of the 'transient' hint with hints['transient'] rather than hints.transient
|
||
notification.setTransient(!!hints['transient']);
|
||
|
||
let privacyScope = hints['x-gnome-privacy-scope'] || 'user';
|
||
notification.setPrivacyScope(privacyScope === 'system'
|
||
? MessageTray.PrivacyScope.SYSTEM
|
||
: MessageTray.PrivacyScope.USER);
|
||
|
||
// Only fallback to 'app-icon' when the source doesn't have a valid app
|
||
const sourceGIcon = source.app ? null : this._iconForNotificationData(appIcon);
|
||
source.processNotification(notification, appName, sourceGIcon);
|
||
|
||
return invocation.return_value(GLib.Variant.new('(u)', [id]));
|
||
}
|
||
|
||
CloseNotification(id) {
|
||
const notification = this._notifications.get(id);
|
||
notification?.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
|
||
}
|
||
|
||
GetCapabilities() {
|
||
return [
|
||
'actions',
|
||
// 'action-icons',
|
||
'body',
|
||
// 'body-hyperlinks',
|
||
// 'body-images',
|
||
'body-markup',
|
||
// 'icon-multi',
|
||
'icon-static',
|
||
'persistence',
|
||
'sound',
|
||
];
|
||
}
|
||
|
||
GetServerInformation() {
|
||
return [
|
||
Config.PACKAGE_NAME,
|
||
'GNOME',
|
||
Config.PACKAGE_VERSION,
|
||
'1.2',
|
||
];
|
||
}
|
||
|
||
_emitNotificationClosed(id, reason) {
|
||
this._dbusImpl.emit_signal('NotificationClosed',
|
||
GLib.Variant.new('(uu)', [id, reason]));
|
||
}
|
||
|
||
_emitActionInvoked(id, action) {
|
||
this._dbusImpl.emit_signal('ActionInvoked',
|
||
GLib.Variant.new('(us)', [id, action]));
|
||
}
|
||
|
||
_emitActivationToken(source, id) {
|
||
const context = global.create_app_launch_context(0, -1);
|
||
const info = source.app?.get_app_info();
|
||
if (info) {
|
||
const token = context.get_startup_notify_id(info, []);
|
||
this._dbusImpl.emit_signal('ActivationToken',
|
||
GLib.Variant.new('(us)', [id, token]));
|
||
}
|
||
}
|
||
}
|
||
|
||
export const FdoNotificationDaemonSource = GObject.registerClass(
|
||
class FdoNotificationDaemonSource extends MessageTray.Source {
|
||
constructor(sender, app) {
|
||
super({
|
||
policy: MessageTray.NotificationPolicy.newForApp(app),
|
||
});
|
||
|
||
this.app = app;
|
||
this._appName = null;
|
||
this._appIcon = null;
|
||
|
||
if (sender) {
|
||
this._nameWatcherId = Gio.DBus.session.watch_name(sender,
|
||
Gio.BusNameWatcherFlags.NONE,
|
||
null,
|
||
this._onNameVanished.bind(this));
|
||
} else {
|
||
this._nameWatcherId = 0;
|
||
}
|
||
}
|
||
|
||
_onNameVanished() {
|
||
// Destroy the notification source when its sender is removed from DBus.
|
||
// Only do so if this.app is set to avoid removing "notify-send" sources, senders
|
||
// of which аre removed from DBus immediately.
|
||
// Sender being removed from DBus would normally result in a tray icon being removed,
|
||
// so allow the code path that handles the tray icon being removed to handle that case.
|
||
if (this.app)
|
||
this.destroy();
|
||
}
|
||
|
||
processNotification(notification, appName, appIcon) {
|
||
if (!this.app && appName) {
|
||
this._appName = appName;
|
||
this.notify('title');
|
||
}
|
||
|
||
if (!this.app && appIcon) {
|
||
this._appIcon = appIcon;
|
||
this.notify('icon');
|
||
}
|
||
|
||
let tracker = Shell.WindowTracker.get_default();
|
||
if (notification.resident && this.app && tracker.focus_app === this.app)
|
||
this.pushNotification(notification);
|
||
else
|
||
this.showNotification(notification);
|
||
}
|
||
|
||
open() {
|
||
this.openApp();
|
||
this.destroyNonResidentNotifications();
|
||
}
|
||
|
||
openApp() {
|
||
if (this.app == null)
|
||
return;
|
||
|
||
this.app.activate();
|
||
Main.overview.hide();
|
||
Main.panel.closeCalendar();
|
||
}
|
||
|
||
destroy() {
|
||
if (this._nameWatcherId) {
|
||
Gio.DBus.session.unwatch_name(this._nameWatcherId);
|
||
this._nameWatcherId = 0;
|
||
}
|
||
|
||
super.destroy();
|
||
}
|
||
|
||
get title() {
|
||
return this.app?.get_name() ?? this._appName;
|
||
}
|
||
|
||
get icon() {
|
||
return this.app?.get_icon() ?? this._appIcon;
|
||
}
|
||
});
|
||
|
||
const PRIORITY_URGENCY_MAP = {
|
||
low: MessageTray.Urgency.LOW,
|
||
normal: MessageTray.Urgency.NORMAL,
|
||
high: MessageTray.Urgency.HIGH,
|
||
urgent: MessageTray.Urgency.CRITICAL,
|
||
};
|
||
|
||
const GtkNotificationDaemonNotification = GObject.registerClass(
|
||
class GtkNotificationDaemonNotification extends MessageTray.Notification {
|
||
_init(source, notification) {
|
||
super._init(source);
|
||
this._serialized = GLib.Variant.new('a{sv}', notification);
|
||
|
||
const {
|
||
title,
|
||
body,
|
||
icon: gicon,
|
||
urgent,
|
||
priority,
|
||
buttons,
|
||
'default-action': defaultAction,
|
||
'default-action-target': defaultActionTarget,
|
||
timestamp: time,
|
||
} = notification;
|
||
|
||
if (priority) {
|
||
let urgency = PRIORITY_URGENCY_MAP[priority.unpack()];
|
||
this.setUrgency(urgency !== undefined ? urgency : MessageTray.Urgency.NORMAL);
|
||
} else if (urgent) {
|
||
this.setUrgency(urgent.unpack()
|
||
? MessageTray.Urgency.CRITICAL
|
||
: MessageTray.Urgency.NORMAL);
|
||
} else {
|
||
this.setUrgency(MessageTray.Urgency.NORMAL);
|
||
}
|
||
|
||
if (buttons) {
|
||
buttons.deepUnpack().forEach(button => {
|
||
this.addAction(button.label.unpack(), () => {
|
||
this._onButtonClicked(button);
|
||
});
|
||
});
|
||
}
|
||
|
||
this._defaultAction = defaultAction?.unpack();
|
||
this._defaultActionTarget = defaultActionTarget;
|
||
|
||
this.update(title.unpack(), body?.unpack(), {
|
||
gicon: gicon
|
||
? Gio.icon_deserialize(gicon) : null,
|
||
datetime: time
|
||
? GLib.DateTime.new_from_unix_local(time.unpack()) : null,
|
||
});
|
||
}
|
||
|
||
_activateAction(namespacedActionId, target) {
|
||
if (namespacedActionId) {
|
||
if (namespacedActionId.startsWith('app.')) {
|
||
let actionId = namespacedActionId.slice('app.'.length);
|
||
this.source.activateAction(actionId, target);
|
||
}
|
||
} else {
|
||
this.source.open();
|
||
}
|
||
}
|
||
|
||
_onButtonClicked(button) {
|
||
let {action, target} = button;
|
||
this._activateAction(action.unpack(), target);
|
||
}
|
||
|
||
activate() {
|
||
this._activateAction(this._defaultAction, this._defaultActionTarget);
|
||
super.activate();
|
||
}
|
||
|
||
serialize() {
|
||
return this._serialized;
|
||
}
|
||
});
|
||
|
||
function InvalidAppError() {}
|
||
|
||
export const GtkNotificationDaemonAppSource = GObject.registerClass(
|
||
class GtkNotificationDaemonAppSource extends MessageTray.Source {
|
||
constructor(appId) {
|
||
if (!Gio.Application.id_is_valid(appId))
|
||
throw new InvalidAppError();
|
||
|
||
const app = Shell.AppSystem.get_default().lookup_app(`${appId}.desktop`);
|
||
if (!app)
|
||
throw new InvalidAppError();
|
||
|
||
super({
|
||
title: app.get_name(),
|
||
icon: app.get_icon(),
|
||
policy: new MessageTray.NotificationApplicationPolicy(appId),
|
||
});
|
||
|
||
this._appId = appId;
|
||
this._app = app;
|
||
|
||
this._notifications = {};
|
||
this._notificationPending = false;
|
||
}
|
||
|
||
_createNotification(params) {
|
||
return new GtkNotificationDaemonNotification(this, params);
|
||
}
|
||
|
||
activateAction(actionId, target) {
|
||
const params = target ? GLib.Variant.new('av', [target]) : null;
|
||
this._app.activate_action(actionId, params, 0, -1, null).catch(error => {
|
||
logError(error, `Failed to activate action for ${this._appId}`);
|
||
});
|
||
Main.overview.hide();
|
||
Main.panel.closeCalendar();
|
||
}
|
||
|
||
open() {
|
||
this._app.activate();
|
||
Main.overview.hide();
|
||
Main.panel.closeCalendar();
|
||
}
|
||
|
||
addNotification(notificationId, notificationParams, showBanner) {
|
||
this._notificationPending = true;
|
||
|
||
if (this._notifications[notificationId])
|
||
this._notifications[notificationId].destroy(MessageTray.NotificationDestroyedReason.REPLACED);
|
||
|
||
let notification = this._createNotification(notificationParams);
|
||
notification.connect('destroy', () => {
|
||
delete this._notifications[notificationId];
|
||
});
|
||
this._notifications[notificationId] = notification;
|
||
|
||
if (showBanner)
|
||
this.showNotification(notification);
|
||
else
|
||
this.pushNotification(notification);
|
||
|
||
this._notificationPending = false;
|
||
}
|
||
|
||
destroy(reason) {
|
||
if (this._notificationPending)
|
||
return;
|
||
super.destroy(reason);
|
||
}
|
||
|
||
removeNotification(notificationId) {
|
||
if (this._notifications[notificationId])
|
||
this._notifications[notificationId].destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
|
||
}
|
||
|
||
serialize() {
|
||
let notifications = [];
|
||
for (let notificationId in this._notifications) {
|
||
let notification = this._notifications[notificationId];
|
||
notifications.push([notificationId, notification.serialize()]);
|
||
}
|
||
return [this._appId, notifications];
|
||
}
|
||
});
|
||
|
||
const GtkNotificationsIface = loadInterfaceXML('org.gtk.Notifications');
|
||
|
||
class GtkNotificationDaemon {
|
||
constructor() {
|
||
this._sources = {};
|
||
|
||
this._loadNotifications();
|
||
|
||
this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GtkNotificationsIface, this);
|
||
this._dbusImpl.export(Gio.DBus.session, '/org/gtk/Notifications');
|
||
|
||
Gio.DBus.session.own_name('org.gtk.Notifications', Gio.BusNameOwnerFlags.REPLACE, null, null);
|
||
}
|
||
|
||
_ensureAppSource(appId) {
|
||
if (this._sources[appId])
|
||
return this._sources[appId];
|
||
|
||
let source = new GtkNotificationDaemonAppSource(appId);
|
||
|
||
source.connect('destroy', () => {
|
||
delete this._sources[appId];
|
||
this._saveNotifications();
|
||
});
|
||
source.connect('notify::count', this._saveNotifications.bind(this));
|
||
Main.messageTray.add(source);
|
||
this._sources[appId] = source;
|
||
return source;
|
||
}
|
||
|
||
_loadNotifications() {
|
||
this._isLoading = true;
|
||
|
||
try {
|
||
let value = global.get_persistent_state('a(sa(sv))', 'notifications');
|
||
if (value) {
|
||
let sources = value.deepUnpack();
|
||
sources.forEach(([appId, notifications]) => {
|
||
if (notifications.length === 0)
|
||
return;
|
||
|
||
let source;
|
||
try {
|
||
source = this._ensureAppSource(appId);
|
||
} catch (e) {
|
||
if (e instanceof InvalidAppError)
|
||
return;
|
||
throw e;
|
||
}
|
||
|
||
notifications.forEach(([notificationId, notification]) => {
|
||
source.addNotification(notificationId, notification.deepUnpack(), false);
|
||
});
|
||
});
|
||
}
|
||
} catch (e) {
|
||
logError(e, 'Failed to load saved notifications');
|
||
} finally {
|
||
this._isLoading = false;
|
||
}
|
||
}
|
||
|
||
_saveNotifications() {
|
||
if (this._isLoading)
|
||
return;
|
||
|
||
let sources = [];
|
||
for (let appId in this._sources) {
|
||
let source = this._sources[appId];
|
||
sources.push(source.serialize());
|
||
}
|
||
|
||
global.set_persistent_state('notifications', new GLib.Variant('a(sa(sv))', sources));
|
||
}
|
||
|
||
AddNotificationAsync(params, invocation) {
|
||
let [appId, notificationId, notification] = params;
|
||
|
||
let source;
|
||
try {
|
||
source = this._ensureAppSource(appId);
|
||
} catch (e) {
|
||
if (e instanceof InvalidAppError) {
|
||
invocation.return_error_literal(NotificationErrors,
|
||
NotificationError.INVALID_APP,
|
||
`The app by ID "${appId}" could not be found`);
|
||
return;
|
||
}
|
||
throw e;
|
||
}
|
||
|
||
let timestamp = GLib.DateTime.new_now_local().to_unix();
|
||
notification['timestamp'] = new GLib.Variant('x', timestamp);
|
||
|
||
source.addNotification(notificationId, notification, true);
|
||
|
||
invocation.return_value(null);
|
||
}
|
||
|
||
RemoveNotificationAsync(params, invocation) {
|
||
let [appId, notificationId] = params;
|
||
let source = this._sources[appId];
|
||
if (source)
|
||
source.removeNotification(notificationId);
|
||
|
||
invocation.return_value(null);
|
||
}
|
||
}
|
||
|
||
export class NotificationDaemon {
|
||
constructor() {
|
||
this._fdoNotificationDaemon = new FdoNotificationDaemon();
|
||
this._gtkNotificationDaemon = new GtkNotificationDaemon();
|
||
}
|
||
}
|