2024-07-08 22:46:35 +02:00

410 lines
13 KiB
JavaScript
Executable File

// 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;