notification.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. import Gio from 'gi://Gio';
  5. import GLib from 'gi://GLib';
  6. import GjsPrivate from 'gi://GjsPrivate';
  7. import GObject from 'gi://GObject';
  8. import * as DBus from '../utils/dbus.js';
  9. const _nodeInfo = Gio.DBusNodeInfo.new_for_xml(`
  10. <node>
  11. <interface name="org.freedesktop.Notifications">
  12. <method name="Notify">
  13. <arg name="appName" type="s" direction="in"/>
  14. <arg name="replacesId" type="u" direction="in"/>
  15. <arg name="iconName" type="s" direction="in"/>
  16. <arg name="summary" type="s" direction="in"/>
  17. <arg name="body" type="s" direction="in"/>
  18. <arg name="actions" type="as" direction="in"/>
  19. <arg name="hints" type="a{sv}" direction="in"/>
  20. <arg name="timeout" type="i" direction="in"/>
  21. </method>
  22. </interface>
  23. <interface name="org.gtk.Notifications">
  24. <method name="AddNotification">
  25. <arg type="s" direction="in"/>
  26. <arg type="s" direction="in"/>
  27. <arg type="a{sv}" direction="in"/>
  28. </method>
  29. <method name="RemoveNotification">
  30. <arg type="s" direction="in"/>
  31. <arg type="s" direction="in"/>
  32. </method>
  33. </interface>
  34. </node>
  35. `);
  36. const FDO_IFACE = _nodeInfo.lookup_interface('org.freedesktop.Notifications');
  37. const FDO_MATCH = "interface='org.freedesktop.Notifications',member='Notify',type='method_call'";
  38. const GTK_IFACE = _nodeInfo.lookup_interface('org.gtk.Notifications');
  39. const GTK_MATCH = "interface='org.gtk.Notifications',member='AddNotification',type='method_call'";
  40. /**
  41. * A class for snooping Freedesktop (libnotify) and Gtk (GNotification)
  42. * notifications and forwarding them to supporting devices.
  43. */
  44. const Listener = GObject.registerClass({
  45. GTypeName: 'GSConnectNotificationListener',
  46. Signals: {
  47. 'notification-added': {
  48. flags: GObject.SignalFlags.RUN_LAST,
  49. param_types: [GLib.Variant.$gtype],
  50. },
  51. },
  52. }, class Listener extends GObject.Object {
  53. _init() {
  54. super._init();
  55. // Respect desktop notification settings
  56. this._settings = new Gio.Settings({
  57. schema_id: 'org.gnome.desktop.notifications',
  58. });
  59. // Watch for new application policies
  60. this._settingsId = this._settings.connect(
  61. 'changed::application-children',
  62. this._onSettingsChanged.bind(this)
  63. );
  64. // Cache for appName->desktop-id lookups
  65. this._names = {};
  66. // Asynchronous setup
  67. this._init_async();
  68. }
  69. get applications() {
  70. if (this._applications === undefined)
  71. this._onSettingsChanged();
  72. return this._applications;
  73. }
  74. /**
  75. * Update application notification settings
  76. */
  77. _onSettingsChanged() {
  78. this._applications = {};
  79. for (const app of this._settings.get_strv('application-children')) {
  80. const appSettings = new Gio.Settings({
  81. schema_id: 'org.gnome.desktop.notifications.application',
  82. path: `/org/gnome/desktop/notifications/application/${app}/`,
  83. });
  84. const appInfo = Gio.DesktopAppInfo.new(
  85. appSettings.get_string('application-id')
  86. );
  87. if (appInfo !== null)
  88. this._applications[appInfo.get_name()] = appSettings;
  89. }
  90. }
  91. async _listNames() {
  92. const reply = await this._session.call(
  93. 'org.freedesktop.DBus',
  94. '/org/freedesktop/DBus',
  95. 'org.freedesktop.DBus',
  96. 'ListNames',
  97. null,
  98. null,
  99. Gio.DBusCallFlags.NONE,
  100. -1,
  101. null);
  102. return reply.deepUnpack()[0];
  103. }
  104. async _getNameOwner(name) {
  105. const reply = await this._session.call(
  106. 'org.freedesktop.DBus',
  107. '/org/freedesktop/DBus',
  108. 'org.freedesktop.DBus',
  109. 'GetNameOwner',
  110. new GLib.Variant('(s)', [name]),
  111. null,
  112. Gio.DBusCallFlags.NONE,
  113. -1,
  114. null);
  115. return reply.deepUnpack()[0];
  116. }
  117. /**
  118. * Try and find a well-known name for @sender on the session bus
  119. *
  120. * @param {string} sender - A DBus unique name (eg. :1.2282)
  121. * @param {string} appName - @appName passed to Notify() (Optional)
  122. * @return {string} A well-known name or %null
  123. */
  124. async _getAppId(sender, appName) {
  125. try {
  126. // Get a list of well-known names, ignoring @sender
  127. const names = await this._listNames();
  128. names.splice(names.indexOf(sender), 1);
  129. // Make a short list for substring matches (fractal/org.gnome.Fractal)
  130. const appLower = appName.toLowerCase();
  131. const shortList = names.filter(name => {
  132. return name.toLowerCase().includes(appLower);
  133. });
  134. // Run the short list first
  135. for (const name of shortList) {
  136. const nameOwner = await this._getNameOwner(name);
  137. if (nameOwner === sender)
  138. return name;
  139. names.splice(names.indexOf(name), 1);
  140. }
  141. // Run the full list
  142. for (const name of names) {
  143. const nameOwner = await this._getNameOwner(name);
  144. if (nameOwner === sender)
  145. return name;
  146. }
  147. return null;
  148. } catch (e) {
  149. debug(e);
  150. return null;
  151. }
  152. }
  153. /**
  154. * Try and find the application name for @sender
  155. *
  156. * @param {string} sender - A DBus unique name
  157. * @param {string} [appName] - `appName` supplied by Notify()
  158. * @return {string} A well-known name or %null
  159. */
  160. async _getAppName(sender, appName = null) {
  161. // Check the cache first
  162. if (appName && this._names.hasOwnProperty(appName))
  163. return this._names[appName];
  164. try {
  165. const appId = await this._getAppId(sender, appName);
  166. const appInfo = Gio.DesktopAppInfo.new(`${appId}.desktop`);
  167. this._names[appName] = appInfo.get_name();
  168. appName = appInfo.get_name();
  169. } catch (e) {
  170. // Silence errors
  171. }
  172. return appName;
  173. }
  174. /**
  175. * Callback for AddNotification()/Notify()
  176. *
  177. * @param {DBus.Interface} iface - The DBus interface
  178. * @param {string} name - The DBus method name
  179. * @param {GLib.Variant} parameters - The method parameters
  180. * @param {Gio.DBusMethodInvocation} invocation - The method invocation info
  181. */
  182. async _onHandleMethodCall(iface, name, parameters, invocation) {
  183. try {
  184. // Check if notifications are disabled in desktop settings
  185. if (!this._settings.get_boolean('show-banners'))
  186. return;
  187. parameters = parameters.full_unpack();
  188. // GNotification
  189. if (name === 'AddNotification') {
  190. this.AddNotification(...parameters);
  191. // libnotify
  192. } else if (name === 'Notify') {
  193. const message = invocation.get_message();
  194. const destination = message.get_destination();
  195. // Deduplicate notifications; only accept messages
  196. // directed to the notification bus, or its owner.
  197. if (destination !== 'org.freedesktop.Notifications') {
  198. if (this._fdoNameOwner === undefined) {
  199. this._fdoNameOwner = await this._getNameOwner(
  200. 'org.freedesktop.Notifications');
  201. }
  202. if (this._fdoNameOwner !== destination)
  203. return;
  204. }
  205. // Try to brute-force an application name using DBus
  206. if (!this.applications.hasOwnProperty(parameters[0])) {
  207. const sender = message.get_sender();
  208. parameters[0] = await this._getAppName(sender, parameters[0]);
  209. }
  210. this.Notify(...parameters);
  211. }
  212. } catch (e) {
  213. debug(e);
  214. }
  215. }
  216. /**
  217. * Export interfaces for proxying notifications and become a monitor
  218. *
  219. * @return {Promise} A promise for the operation
  220. */
  221. _monitorConnection() {
  222. // libnotify Interface
  223. this._fdoNotifications = new GjsPrivate.DBusImplementation({
  224. g_interface_info: FDO_IFACE,
  225. });
  226. this._fdoMethodCallId = this._fdoNotifications.connect(
  227. 'handle-method-call', this._onHandleMethodCall.bind(this));
  228. this._fdoNotifications.export(this._monitor,
  229. '/org/freedesktop/Notifications');
  230. this._fdoNameOwnerChangedId = this._session.signal_subscribe(
  231. 'org.freedesktop.DBus',
  232. 'org.freedesktop.DBus',
  233. 'NameOwnerChanged',
  234. '/org/freedesktop/DBus',
  235. 'org.freedesktop.Notifications',
  236. Gio.DBusSignalFlags.MATCH_ARG0_NAMESPACE,
  237. this._onFdoNameOwnerChanged.bind(this)
  238. );
  239. // GNotification Interface
  240. this._gtkNotifications = new GjsPrivate.DBusImplementation({
  241. g_interface_info: GTK_IFACE,
  242. });
  243. this._gtkMethodCallId = this._gtkNotifications.connect(
  244. 'handle-method-call', this._onHandleMethodCall.bind(this));
  245. this._gtkNotifications.export(this._monitor, '/org/gtk/Notifications');
  246. // Become a monitor for Fdo & Gtk notifications
  247. return this._monitor.call(
  248. 'org.freedesktop.DBus',
  249. '/org/freedesktop/DBus',
  250. 'org.freedesktop.DBus.Monitoring',
  251. 'BecomeMonitor',
  252. new GLib.Variant('(asu)', [[FDO_MATCH, GTK_MATCH], 0]),
  253. null,
  254. Gio.DBusCallFlags.NONE,
  255. -1,
  256. null);
  257. }
  258. async _init_async() {
  259. try {
  260. this._session = Gio.DBus.session;
  261. this._monitor = await DBus.newConnection();
  262. await this._monitorConnection();
  263. } catch (e) {
  264. const service = Gio.Application.get_default();
  265. if (service !== null)
  266. service.notify_error(e);
  267. else
  268. logError(e);
  269. }
  270. }
  271. _onFdoNameOwnerChanged(connection, sender, object, iface, signal, parameters) {
  272. this._fdoNameOwner = parameters.deepUnpack()[2];
  273. }
  274. _sendNotification(notif) {
  275. // Check if this application is disabled in desktop settings
  276. const appSettings = this.applications[notif.appName];
  277. if (appSettings && !appSettings.get_boolean('enable'))
  278. return;
  279. // Send the notification to each supporting device
  280. // TODO: avoid the overhead of the GAction framework with a signal?
  281. const variant = GLib.Variant.full_pack(notif);
  282. this.emit('notification-added', variant);
  283. }
  284. Notify(appName, replacesId, iconName, summary, body, actions, hints, timeout) {
  285. // Ignore notifications without an appName
  286. if (!appName)
  287. return;
  288. this._sendNotification({
  289. appName: appName,
  290. id: `fdo|null|${replacesId}`,
  291. title: summary,
  292. text: body,
  293. ticker: `${summary}: ${body}`,
  294. isClearable: (replacesId !== 0),
  295. icon: iconName,
  296. });
  297. }
  298. AddNotification(application, id, notification) {
  299. // Ignore our own notifications or we'll cause a notification loop
  300. if (application === 'org.gnome.Shell.Extensions.GSConnect')
  301. return;
  302. const appInfo = Gio.DesktopAppInfo.new(`${application}.desktop`);
  303. // Try to get an icon for the notification
  304. if (!notification.hasOwnProperty('icon'))
  305. notification.icon = appInfo.get_icon() || undefined;
  306. this._sendNotification({
  307. appName: appInfo.get_name(),
  308. id: `gtk|${application}|${id}`,
  309. title: notification.title,
  310. text: notification.body,
  311. ticker: `${notification.title}: ${notification.body}`,
  312. isClearable: true,
  313. icon: notification.icon,
  314. });
  315. }
  316. destroy() {
  317. try {
  318. if (this._fdoNotifications) {
  319. this._fdoNotifications.disconnect(this._fdoMethodCallId);
  320. this._fdoNotifications.unexport();
  321. this._session.signal_unsubscribe(this._fdoNameOwnerChangedId);
  322. }
  323. if (this._gtkNotifications) {
  324. this._gtkNotifications.disconnect(this._gtkMethodCallId);
  325. this._gtkNotifications.unexport();
  326. }
  327. if (this._settings) {
  328. this._settings.disconnect(this._settingsId);
  329. this._settings.run_dispose();
  330. }
  331. // TODO: Gio.IOErrorEnum: The connection is closed
  332. // this._monitor.close_sync(null);
  333. GObject.signal_handlers_destroy(this);
  334. } catch (e) {
  335. debug(e);
  336. }
  337. }
  338. });
  339. /**
  340. * The service class for this component
  341. */
  342. export default Listener;