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