notification.js 13 KB

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