// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect // // SPDX-License-Identifier: GPL-2.0-or-later import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GjsPrivate from 'gi://GjsPrivate'; import GObject from 'gi://GObject'; import * as DBus from '../utils/dbus.js'; const _nodeInfo = Gio.DBusNodeInfo.new_for_xml(` `); const FDO_IFACE = _nodeInfo.lookup_interface('org.freedesktop.Notifications'); const FDO_MATCH = "interface='org.freedesktop.Notifications',member='Notify',type='method_call'"; const GTK_IFACE = _nodeInfo.lookup_interface('org.gtk.Notifications'); const GTK_MATCH = "interface='org.gtk.Notifications',member='AddNotification',type='method_call'"; /** * A class for snooping Freedesktop (libnotify) and Gtk (GNotification) * notifications and forwarding them to supporting devices. */ const Listener = GObject.registerClass({ GTypeName: 'GSConnectNotificationListener', Signals: { 'notification-added': { flags: GObject.SignalFlags.RUN_LAST, param_types: [GLib.Variant.$gtype], }, }, }, class Listener extends GObject.Object { _init() { super._init(); // Respect desktop notification settings this._settings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications', }); // Watch for new application policies this._settingsId = this._settings.connect( 'changed::application-children', this._onSettingsChanged.bind(this) ); // Cache for appName->desktop-id lookups this._names = {}; // Asynchronous setup this._init_async(); } get applications() { if (this._applications === undefined) this._onSettingsChanged(); return this._applications; } /** * Update application notification settings */ _onSettingsChanged() { this._applications = {}; for (const app of this._settings.get_strv('application-children')) { const appSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications.application', path: `/org/gnome/desktop/notifications/application/${app}/`, }); const appInfo = Gio.DesktopAppInfo.new( appSettings.get_string('application-id') ); if (appInfo !== null) this._applications[appInfo.get_name()] = appSettings; } } async _listNames() { const reply = await this._session.call( 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'ListNames', null, null, Gio.DBusCallFlags.NONE, -1, null); return reply.deepUnpack()[0]; } async _getNameOwner(name) { const reply = await this._session.call( 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus', 'GetNameOwner', new GLib.Variant('(s)', [name]), null, Gio.DBusCallFlags.NONE, -1, null); return reply.deepUnpack()[0]; } /** * Try and find a well-known name for @sender on the session bus * * @param {string} sender - A DBus unique name (eg. :1.2282) * @param {string} appName - @appName passed to Notify() (Optional) * @return {string} A well-known name or %null */ async _getAppId(sender, appName) { try { // Get a list of well-known names, ignoring @sender const names = await this._listNames(); names.splice(names.indexOf(sender), 1); // Make a short list for substring matches (fractal/org.gnome.Fractal) const appLower = appName.toLowerCase(); const shortList = names.filter(name => { return name.toLowerCase().includes(appLower); }); // Run the short list first for (const name of shortList) { const nameOwner = await this._getNameOwner(name); if (nameOwner === sender) return name; names.splice(names.indexOf(name), 1); } // Run the full list for (const name of names) { const nameOwner = await this._getNameOwner(name); if (nameOwner === sender) return name; } return null; } catch (e) { debug(e); return null; } } /** * Try and find the application name for @sender * * @param {string} sender - A DBus unique name * @param {string} [appName] - `appName` supplied by Notify() * @return {string} A well-known name or %null */ async _getAppName(sender, appName = null) { // Check the cache first if (appName && this._names.hasOwnProperty(appName)) return this._names[appName]; try { const appId = await this._getAppId(sender, appName); const appInfo = Gio.DesktopAppInfo.new(`${appId}.desktop`); this._names[appName] = appInfo.get_name(); appName = appInfo.get_name(); } catch (e) { // Silence errors } return appName; } /** * Callback for AddNotification()/Notify() * * @param {DBus.Interface} iface - The DBus interface * @param {string} name - The DBus method name * @param {GLib.Variant} parameters - The method parameters * @param {Gio.DBusMethodInvocation} invocation - The method invocation info */ async _onHandleMethodCall(iface, name, parameters, invocation) { try { // Check if notifications are disabled in desktop settings if (!this._settings.get_boolean('show-banners')) return; parameters = parameters.full_unpack(); // GNotification if (name === 'AddNotification') { this.AddNotification(...parameters); // libnotify } else if (name === 'Notify') { const message = invocation.get_message(); const destination = message.get_destination(); // Deduplicate notifications; only accept messages // directed to the notification bus, or its owner. if (destination !== 'org.freedesktop.Notifications') { if (this._fdoNameOwner === undefined) { this._fdoNameOwner = await this._getNameOwner( 'org.freedesktop.Notifications'); } if (this._fdoNameOwner !== destination) return; } // Try to brute-force an application name using DBus if (!this.applications.hasOwnProperty(parameters[0])) { const sender = message.get_sender(); parameters[0] = await this._getAppName(sender, parameters[0]); } this.Notify(...parameters); } } catch (e) { debug(e); } } /** * Export interfaces for proxying notifications and become a monitor * * @return {Promise} A promise for the operation */ _monitorConnection() { // libnotify Interface this._fdoNotifications = new GjsPrivate.DBusImplementation({ g_interface_info: FDO_IFACE, }); this._fdoMethodCallId = this._fdoNotifications.connect( 'handle-method-call', this._onHandleMethodCall.bind(this)); this._fdoNotifications.export(this._monitor, '/org/freedesktop/Notifications'); this._fdoNameOwnerChangedId = this._session.signal_subscribe( 'org.freedesktop.DBus', 'org.freedesktop.DBus', 'NameOwnerChanged', '/org/freedesktop/DBus', 'org.freedesktop.Notifications', Gio.DBusSignalFlags.MATCH_ARG0_NAMESPACE, this._onFdoNameOwnerChanged.bind(this) ); // GNotification Interface this._gtkNotifications = new GjsPrivate.DBusImplementation({ g_interface_info: GTK_IFACE, }); this._gtkMethodCallId = this._gtkNotifications.connect( 'handle-method-call', this._onHandleMethodCall.bind(this)); this._gtkNotifications.export(this._monitor, '/org/gtk/Notifications'); // Become a monitor for Fdo & Gtk notifications return this._monitor.call( 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus.Monitoring', 'BecomeMonitor', new GLib.Variant('(asu)', [[FDO_MATCH, GTK_MATCH], 0]), null, Gio.DBusCallFlags.NONE, -1, null); } async _init_async() { try { this._session = Gio.DBus.session; this._monitor = await DBus.newConnection(); await this._monitorConnection(); } catch (e) { const service = Gio.Application.get_default(); if (service !== null) service.notify_error(e); else logError(e); } } _onFdoNameOwnerChanged(connection, sender, object, iface, signal, parameters) { this._fdoNameOwner = parameters.deepUnpack()[2]; } _sendNotification(notif) { // Check if this application is disabled in desktop settings const appSettings = this.applications[notif.appName]; if (appSettings && !appSettings.get_boolean('enable')) return; // Send the notification to each supporting device // TODO: avoid the overhead of the GAction framework with a signal? const variant = GLib.Variant.full_pack(notif); this.emit('notification-added', variant); } Notify(appName, replacesId, iconName, summary, body, actions, hints, timeout) { // Ignore notifications without an appName if (!appName) return; this._sendNotification({ appName: appName, id: `fdo|null|${replacesId}`, title: summary, text: body, ticker: `${summary}: ${body}`, isClearable: (replacesId !== 0), icon: iconName, }); } AddNotification(application, id, notification) { // Ignore our own notifications or we'll cause a notification loop if (application === 'org.gnome.Shell.Extensions.GSConnect') return; const appInfo = Gio.DesktopAppInfo.new(`${application}.desktop`); // Try to get an icon for the notification if (!notification.hasOwnProperty('icon')) notification.icon = appInfo.get_icon() || undefined; this._sendNotification({ appName: appInfo.get_name(), id: `gtk|${application}|${id}`, title: notification.title, text: notification.body, ticker: `${notification.title}: ${notification.body}`, isClearable: true, icon: notification.icon, }); } destroy() { try { if (this._fdoNotifications) { this._fdoNotifications.disconnect(this._fdoMethodCallId); this._fdoNotifications.unexport(); this._session.signal_unsubscribe(this._fdoNameOwnerChangedId); } if (this._gtkNotifications) { this._gtkNotifications.disconnect(this._gtkMethodCallId); this._gtkNotifications.unexport(); } if (this._settings) { this._settings.disconnect(this._settingsId); this._settings.run_dispose(); } // TODO: Gio.IOErrorEnum: The connection is closed // this._monitor.close_sync(null); GObject.signal_handlers_destroy(this); } catch (e) { debug(e); } } }); /** * The service class for this component */ export default Listener;