410 lines
13 KiB
JavaScript
410 lines
13 KiB
JavaScript
|
// 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(`
|
||
|
<node>
|
||
|
<interface name="org.freedesktop.Notifications">
|
||
|
<method name="Notify">
|
||
|
<arg name="appName" type="s" direction="in"/>
|
||
|
<arg name="replacesId" type="u" direction="in"/>
|
||
|
<arg name="iconName" type="s" direction="in"/>
|
||
|
<arg name="summary" type="s" direction="in"/>
|
||
|
<arg name="body" type="s" direction="in"/>
|
||
|
<arg name="actions" type="as" direction="in"/>
|
||
|
<arg name="hints" type="a{sv}" direction="in"/>
|
||
|
<arg name="timeout" type="i" direction="in"/>
|
||
|
</method>
|
||
|
</interface>
|
||
|
<interface name="org.gtk.Notifications">
|
||
|
<method name="AddNotification">
|
||
|
<arg type="s" direction="in"/>
|
||
|
<arg type="s" direction="in"/>
|
||
|
<arg type="a{sv}" direction="in"/>
|
||
|
</method>
|
||
|
<method name="RemoveNotification">
|
||
|
<arg type="s" direction="in"/>
|
||
|
<arg type="s" direction="in"/>
|
||
|
</method>
|
||
|
</interface>
|
||
|
</node>
|
||
|
`);
|
||
|
|
||
|
|
||
|
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;
|