454 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			454 lines
		
	
	
		
			15 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 GObject from 'gi://GObject';
 | |
| import St from 'gi://St';
 | |
| 
 | |
| import * as Main from 'resource:///org/gnome/shell/ui/main.js';
 | |
| import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js';
 | |
| import * as Calendar from 'resource:///org/gnome/shell/ui/calendar.js';
 | |
| import * as NotificationDaemon from 'resource:///org/gnome/shell/ui/notificationDaemon.js';
 | |
| 
 | |
| import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';
 | |
| import {getIcon} from './utils.js';
 | |
| 
 | |
| const APP_ID = 'org.gnome.Shell.Extensions.GSConnect';
 | |
| const APP_PATH = '/org/gnome/Shell/Extensions/GSConnect';
 | |
| 
 | |
| 
 | |
| // deviceId Pattern (<device-id>|<remote-id>)
 | |
| const DEVICE_REGEX = new RegExp(/^([^|]+)\|([\s\S]+)$/);
 | |
| 
 | |
| // requestReplyId Pattern (<device-id>|<remote-id>)|<reply-id>)
 | |
| const REPLY_REGEX = new RegExp(/^([^|]+)\|([\s\S]+)\|([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/, 'i');
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * Extracted from notificationDaemon.js, as it's no longer exported
 | |
|  * https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/notificationDaemon.js#L556
 | |
|  * @returns {{ 'desktop-startup-id': string }} Object with ID containing current time
 | |
|  */
 | |
| function getPlatformData() {
 | |
|     const startupId = GLib.Variant.new('s', `_TIME${global.get_current_time()}`);
 | |
|     return {'desktop-startup-id': startupId};
 | |
| }
 | |
| 
 | |
| // This is no longer directly exported, so we do this instead for now
 | |
| const GtkNotificationDaemon = Main.notificationDaemon._gtkNotificationDaemon.constructor;
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * A slightly modified Notification Banner with an entry field
 | |
|  */
 | |
| const NotificationBanner = GObject.registerClass({
 | |
|     GTypeName: 'GSConnectNotificationBanner',
 | |
| }, class NotificationBanner extends Calendar.NotificationMessage {
 | |
| 
 | |
|     constructor(notification) {
 | |
|         super(notification);
 | |
|         if (notification.requestReplyId !== undefined)
 | |
|             this._addReplyAction();
 | |
|     }
 | |
| 
 | |
|     _addReplyAction() {
 | |
|         if (!this._buttonBox) {
 | |
|             this._buttonBox = new St.BoxLayout({
 | |
|                 style_class: 'notification-buttons-bin',
 | |
|                 x_expand: true,
 | |
|             });
 | |
|             this.setActionArea(this._buttonBox);
 | |
|             global.focus_manager.add_group(this._buttonBox);
 | |
|         }
 | |
| 
 | |
|         // Reply Button
 | |
|         const button = new St.Button({
 | |
|             style_class: 'notification-button',
 | |
|             label: _('Reply'),
 | |
|             x_expand: true,
 | |
|             can_focus: true,
 | |
|         });
 | |
| 
 | |
|         button.connect(
 | |
|             'clicked',
 | |
|             this._onEntryRequested.bind(this)
 | |
|         );
 | |
| 
 | |
|         this._buttonBox.add_child(button);
 | |
| 
 | |
|         // Reply Entry
 | |
|         this._replyEntry = new St.Entry({
 | |
|             can_focus: true,
 | |
|             hint_text: _('Type a message'),
 | |
|             style_class: 'chat-response',
 | |
|             x_expand: true,
 | |
|             visible: false,
 | |
|         });
 | |
| 
 | |
|         this._buttonBox.add_child(this._replyEntry);
 | |
|     }
 | |
| 
 | |
|     _onEntryRequested(button) {
 | |
|         this.focused = true;
 | |
| 
 | |
|         for (const child of this._buttonBox.get_children())
 | |
|             child.visible = (child === this._replyEntry);
 | |
| 
 | |
|         // Release the notification focus with the entry focus
 | |
|         this._replyEntry.connect(
 | |
|             'key-focus-out',
 | |
|             this._onEntryDismissed.bind(this)
 | |
|         );
 | |
| 
 | |
|         this._replyEntry.clutter_text.connect(
 | |
|             'activate',
 | |
|             this._onEntryActivated.bind(this)
 | |
|         );
 | |
| 
 | |
|         this._replyEntry.grab_key_focus();
 | |
|     }
 | |
| 
 | |
|     _onEntryDismissed(entry) {
 | |
|         this.focused = false;
 | |
|         this.emit('unfocused');
 | |
|     }
 | |
| 
 | |
|     _onEntryActivated(clutter_text) {
 | |
|         // Refuse to send empty replies
 | |
|         if (clutter_text.text === '')
 | |
|             return;
 | |
| 
 | |
|         // Copy the text, then clear the entry
 | |
|         const text = clutter_text.text;
 | |
|         clutter_text.text = '';
 | |
| 
 | |
|         const {deviceId, requestReplyId} = this.notification;
 | |
| 
 | |
|         const target = new GLib.Variant('(ssbv)', [
 | |
|             deviceId,
 | |
|             'replyNotification',
 | |
|             true,
 | |
|             new GLib.Variant('(ssa{ss})', [requestReplyId, text, {}]),
 | |
|         ]);
 | |
|         const platformData = getPlatformData();
 | |
| 
 | |
|         Gio.DBus.session.call(
 | |
|             APP_ID,
 | |
|             APP_PATH,
 | |
|             'org.freedesktop.Application',
 | |
|             'ActivateAction',
 | |
|             GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
 | |
|             null,
 | |
|             Gio.DBusCallFlags.NO_AUTO_START,
 | |
|             -1,
 | |
|             null,
 | |
|             (connection, res) => {
 | |
|                 try {
 | |
|                     connection.call_finish(res);
 | |
|                 } catch (e) {
 | |
|                     // Silence errors
 | |
|                 }
 | |
|             }
 | |
|         );
 | |
| 
 | |
|         this.close();
 | |
|     }
 | |
| });
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * A custom notification source for spawning notifications and closing device
 | |
|  * notifications. This source isn't actually used, but it's methods are patched
 | |
|  * into existing sources.
 | |
|  */
 | |
| const Source = GObject.registerClass({
 | |
|     GTypeName: 'GSConnectNotificationSource',
 | |
| }, class Source extends NotificationDaemon.GtkNotificationDaemonAppSource {
 | |
| 
 | |
|     _closeGSConnectNotification(notification, reason) {
 | |
|         if (reason !== MessageTray.NotificationDestroyedReason.DISMISSED)
 | |
|             return;
 | |
| 
 | |
|         // Avoid sending the request multiple times
 | |
|         if (notification._remoteClosed || notification.remoteId === undefined)
 | |
|             return;
 | |
| 
 | |
|         notification._remoteClosed = true;
 | |
| 
 | |
|         const target = new GLib.Variant('(ssbv)', [
 | |
|             notification.deviceId,
 | |
|             'closeNotification',
 | |
|             true,
 | |
|             new GLib.Variant('s', notification.remoteId),
 | |
|         ]);
 | |
|         const platformData = getPlatformData();
 | |
| 
 | |
|         Gio.DBus.session.call(
 | |
|             APP_ID,
 | |
|             APP_PATH,
 | |
|             'org.freedesktop.Application',
 | |
|             'ActivateAction',
 | |
|             GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
 | |
|             null,
 | |
|             Gio.DBusCallFlags.NO_AUTO_START,
 | |
|             -1,
 | |
|             null,
 | |
|             (connection, res) => {
 | |
|                 try {
 | |
|                     connection.call_finish(res);
 | |
|                 } catch (e) {
 | |
|                     // If we fail, reset in case we can try again
 | |
|                     notification._remoteClosed = false;
 | |
|                 }
 | |
|             }
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     /*
 | |
|      * Parse the id to determine if it's a repliable notification, device
 | |
|      * notification or a regular local notification
 | |
|      */
 | |
|     _parseNotificationId(notificationId) {
 | |
|         let idMatch, deviceId, requestReplyId, remoteId, localId;
 | |
| 
 | |
|         if ((idMatch = REPLY_REGEX.exec(notificationId))) {
 | |
|             [, deviceId, remoteId, requestReplyId] = idMatch;
 | |
|             localId = `${deviceId}|${remoteId}`;
 | |
| 
 | |
|         } else if ((idMatch = DEVICE_REGEX.exec(notificationId))) {
 | |
|             [, deviceId, remoteId] = idMatch;
 | |
|             localId = `${deviceId}|${remoteId}`;
 | |
| 
 | |
|         } else {
 | |
|             localId = notificationId;
 | |
|         }
 | |
|         return [idMatch, deviceId, requestReplyId, remoteId, localId];
 | |
|     }
 | |
| 
 | |
|     /*
 | |
|      * Add notification to source or update existing notification with extra
 | |
|      * GsConnect information
 | |
|      */
 | |
|     _createNotification(notification) {
 | |
|         const [idMatch, deviceId, requestReplyId, remoteId, localId] = this._parseNotificationId(notification.id);
 | |
|         const cachedNotification = this._notifications[localId];
 | |
| 
 | |
|         // Check if this is a repeat
 | |
|         if (cachedNotification) {
 | |
|             cachedNotification.requestReplyId = requestReplyId;
 | |
| 
 | |
|             // Bail early If @notificationParams represents an exact repeat
 | |
|             const title = notification.title;
 | |
|             const body = notification.body
 | |
|                 ? notification.body
 | |
|                 : null;
 | |
| 
 | |
|             if (cachedNotification.title === title &&
 | |
|                 cachedNotification.body === body)
 | |
|                 return cachedNotification;
 | |
| 
 | |
|             cachedNotification.title = title;
 | |
|             cachedNotification.body = body;
 | |
| 
 | |
|             return cachedNotification;
 | |
|         }
 | |
| 
 | |
|         // Device Notification
 | |
|         if (idMatch) {
 | |
|             notification.deviceId = deviceId;
 | |
|             notification.remoteId = remoteId;
 | |
|             notification.requestReplyId = requestReplyId;
 | |
| 
 | |
|             notification.connect('destroy', (notification, reason) => {
 | |
|                 this._closeGSConnectNotification(notification, reason);
 | |
|                 delete this._notifications[localId];
 | |
|             });
 | |
| 
 | |
|         // Service Notification
 | |
|         } else {
 | |
|             notification.connect('destroy', (notification, reason) => {
 | |
|                 delete this._notifications[localId];
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         this._notifications[localId] = notification;
 | |
|         return notification;
 | |
|     }
 | |
| 
 | |
|     /*
 | |
|      * Override to control notification spawning
 | |
|      */
 | |
|     addNotification(notification) {
 | |
|         this._notificationPending = true;
 | |
| 
 | |
|         // Fix themed icons
 | |
|         if (notification.icon) {
 | |
|             let gicon = notification.icon;
 | |
| 
 | |
|             if (gicon instanceof Gio.ThemedIcon) {
 | |
|                 gicon = getIcon(gicon.names[0]);
 | |
|                 notification.icon = gicon.serialize();
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         const createdNotification = this._createNotification(notification);
 | |
|         this._addNotificationToMessageTray(createdNotification);
 | |
| 
 | |
|         this._notificationPending = false;
 | |
|     }
 | |
| 
 | |
|     /*
 | |
|      * Reimplementation of MessageTray.addNotification to raise the usual
 | |
|      * notification limit (3)
 | |
|      */
 | |
|     _addNotificationToMessageTray(notification) {
 | |
|         if (this.notifications.includes(notification)) {
 | |
|             notification.acknowledged = false;
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         while (this.notifications.length >= 10) {
 | |
|             const [oldest] = this.notifications;
 | |
|             oldest.destroy(MessageTray.NotificationDestroyedReason.EXPIRED);
 | |
|         }
 | |
| 
 | |
|         notification.connect('destroy', this._onNotificationDestroy.bind(this));
 | |
|         notification.connect('notify::acknowledged', () => {
 | |
|             this.countUpdated();
 | |
| 
 | |
|             // If acknowledged was set to false try to show the notification again
 | |
|             if (!notification.acknowledged)
 | |
|                 this.emit('notification-request-banner', notification);
 | |
|         });
 | |
|         this.notifications.push(notification);
 | |
| 
 | |
|         this.emit('notification-added', notification);
 | |
|         this.emit('notification-request-banner', notification);
 | |
|     }
 | |
| 
 | |
|     createBanner(notification) {
 | |
|         return new NotificationBanner(notification);
 | |
|     }
 | |
| });
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * If there is an active GtkNotificationDaemonAppSource for GSConnect when the
 | |
|  * extension is loaded, it has to be patched in place.
 | |
|  */
 | |
| export function patchGSConnectNotificationSource() {
 | |
|     const source = Main.notificationDaemon._gtkNotificationDaemon._sources[APP_ID];
 | |
| 
 | |
|     if (source !== undefined) {
 | |
|         // Patch in the subclassed methods
 | |
|         source._closeGSConnectNotification = Source.prototype._closeGSConnectNotification;
 | |
|         source._parseNotificationId = Source.prototype._parseNotificationId;
 | |
|         source._createNotification = Source.prototype._createNotification;
 | |
|         source.addNotification = Source.prototype.addNotification;
 | |
|         source._addNotificationToMessageTray = Source.prototype._addNotificationToMessageTray;
 | |
|         source.createBanner = Source.prototype.createBanner;
 | |
| 
 | |
|         // Connect to existing notifications
 | |
|         for (const notification of Object.values(source._notifications)) {
 | |
| 
 | |
|             const _id = notification.connect('destroy', (notification, reason) => {
 | |
|                 source._closeGSConnectNotification(notification, reason);
 | |
|                 notification.disconnect(_id);
 | |
|             });
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * Wrap GtkNotificationDaemon._ensureAppSource() to patch GSConnect's app source
 | |
|  * https://gitlab.gnome.org/GNOME/gnome-shell/blob/master/js/ui/notificationDaemon.js#L742-755
 | |
|  */
 | |
| const __ensureAppSource = GtkNotificationDaemon.prototype._ensureAppSource;
 | |
| 
 | |
| // eslint-disable-next-line func-style
 | |
| const _ensureAppSource = function (appId) {
 | |
|     const source = __ensureAppSource.call(this, appId);
 | |
| 
 | |
|     if (source._appId === APP_ID) {
 | |
|         source._closeGSConnectNotification = Source.prototype._closeGSConnectNotification;
 | |
|         source._parseNotificationId = Source.prototype._parseNotificationId;
 | |
|         source._createNotification = Source.prototype._createNotification;
 | |
|         source.addNotification = Source.prototype.addNotification;
 | |
|         source._addNotificationToMessageTray = Source.prototype._addNotificationToMessageTray;
 | |
|         source.createBanner = Source.prototype.createBanner;
 | |
|     }
 | |
| 
 | |
|     return source;
 | |
| };
 | |
| 
 | |
| 
 | |
| export function patchGtkNotificationDaemon() {
 | |
|     GtkNotificationDaemon.prototype._ensureAppSource = _ensureAppSource;
 | |
| }
 | |
| 
 | |
| 
 | |
| export function unpatchGtkNotificationDaemon() {
 | |
|     GtkNotificationDaemon.prototype._ensureAppSource = __ensureAppSource;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * We patch other Gtk notification sources so we can notify remote devices when
 | |
|  * notifications have been closed locally.
 | |
|  */
 | |
| const _addNotification = NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification;
 | |
| 
 | |
| export function patchGtkNotificationSources() {
 | |
|     // eslint-disable-next-line func-style
 | |
|     const _withdrawGSConnectNotification = function (id, notification, reason) {
 | |
|         if (reason !== MessageTray.NotificationDestroyedReason.DISMISSED)
 | |
|             return;
 | |
| 
 | |
|         // Avoid sending the request multiple times
 | |
|         if (notification._remoteWithdrawn)
 | |
|             return;
 | |
| 
 | |
|         notification._remoteWithdrawn = true;
 | |
| 
 | |
|         // Recreate the notification id as it would've been sent
 | |
|         const target = new GLib.Variant('(ssbv)', [
 | |
|             '*',
 | |
|             'withdrawNotification',
 | |
|             true,
 | |
|             new GLib.Variant('s', `gtk|${this._appId}|${id}`),
 | |
|         ]);
 | |
|         const platformData = getPlatformData();
 | |
| 
 | |
|         Gio.DBus.session.call(
 | |
|             APP_ID,
 | |
|             APP_PATH,
 | |
|             'org.freedesktop.Application',
 | |
|             'ActivateAction',
 | |
|             GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
 | |
|             null,
 | |
|             Gio.DBusCallFlags.NO_AUTO_START,
 | |
|             -1,
 | |
|             null,
 | |
|             (connection, res) => {
 | |
|                 try {
 | |
|                     connection.call_finish(res);
 | |
|                 } catch (e) {
 | |
|                     // If we fail, reset in case we can try again
 | |
|                     notification._remoteWithdrawn = false;
 | |
|                 }
 | |
|             }
 | |
|         );
 | |
|     };
 | |
| 
 | |
|     NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification = _withdrawGSConnectNotification;
 | |
| }
 | |
| 
 | |
| 
 | |
| export function unpatchGtkNotificationSources() {
 | |
|     NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification = _addNotification;
 | |
|     delete NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification;
 | |
| }
 | |
| 
 |