/* extension.js * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * * SPDX-License-Identifier: GPL-3.0-or-later */ /* Based on https://stackoverflow.com/questions/33001192/how-to-send-a-string-to-a-gnome-shell-extension */ /* Some useful documentation : https://github.com/bananenfisch/RecentItems/blob/master/extension.js https://github.com/julio641742/gnome-shell-extension-reference/blob/master/tutorials/POPUPMENU-EXTENSION.md https://gjs-docs.gnome.org/st10~1.0_api/st.widget https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/panelMenu.js https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/popupMenu.js https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/panel.js */ import * as Extension from 'resource:///org/gnome/shell/extensions/extension.js'; import St from 'gi://St'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Clutter from 'gi://Clutter'; import GObject from 'gi://GObject'; import Pixbuf from 'gi://GdkPixbuf'; import Cogl from 'gi://Cogl'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; function hashGet(hash, key, defaultValue) { if (hash.hasOwnProperty(key)) return hash[key]; return defaultValue; } function log(message) { console.error('[GenericMontior]', message); } class SignalMgt { constructor(item, name, group, dbus, buttonMenu) { this.name = name; this.group = group; this.fullname = this.name + '@' + this.group; this.dbus = dbus; this.buttonMenu = buttonMenu; this.signals = new WeakMap(); this.widgets = new Array(); this.timeouts = new Array(); this.menuOpen = false; this.nbClicks = 0; this.button = -1; this.onClick = hashGet(item, 'on-click', ''); this.onDblClick = hashGet(item, 'on-dblclick', ''); this.onRightClick = hashGet(item, 'on-rightclick', ''); this.onRightDblClick = hashGet(item, 'on-rightdblclick', ''); this.onEnter = hashGet(item, 'on-enter', ''); this.onLeave = hashGet(item, 'on-leave', ''); this.onScroll = hashGet(item, 'on-scroll', ''); } destroy() { for(let widgetIdx in this.widgets) this.disconnectWidgetSignals(this.widgets[widgetIdx]); for(let timeoutIdx in this.timeouts) GLib.Source.remove(this.timeouts[timeoutIdx]); } updateSignals(item) { this.onClick = hashGet(item, 'on-click', this.onClick); this.onDblClick = hashGet(item, 'on-dblclick', this.onDblClick); this.onRightClick = hashGet(item, 'on-rightclick', this.onRightClick); this.onRightDblClick = hashGet(item, 'on-rightdblclick', this.onRightDblClick); this.onEnter = hashGet(item, 'on-enter', this.onEnter); this.onLeave = hashGet(item, 'on-leave', this.onLeave); this.onScroll = hashGet(item, 'on-scroll', this.onScroll); } connectWidgetSignals(widget) { this.widgets.push(widget); let array = new Array(); this.signals.set(widget, array); let id; id = widget.connect('enter-event', this._onEnter.bind(this)); array.push(id); id = widget.connect('leave-event', this._onLeave.bind(this)); array.push(id); id = widget.connect('scroll-event', this._onScroll.bind(this)); array.push(id); widget.set_reactive(true); id = widget.connect('button-release-event', this._clicked.bind(this)); array.push(id); } disconnectWidgetSignals(widget) { let array = this.signals.get(widget); for(let idx in array) widget.disconnect(array[idx]); this.signals.set(widget, null); } toggleMenu() { if (this.menuOpen) { this.buttonMenu.menu.close(); this.menuOpen = false; } else { this.buttonMenu.menu.open(true); this.menuOpen = true; } } _manageEventAction(action, signalName) { if (action === 'open-popup') { this.buttonMenu.menu.open(true); this.menuOpen = true; } else if (action === 'close-popup') { this.buttonMenu.menu.close(); this.menuOpen = false; } else if (action === 'toggle-popup') { this.toggleMenu(); } else if (action === 'delete') this.dbus.deleteItem(this, this.group); else if (action === 'signal') this.dbus.emitSignal(signalName, this.fullname); return Clutter.EVENT_STOP; } _doClickCallback() { let right = ''; let nbClicks = ''; if (this.button == 3) right = 'Right'; if (this.nbClicks > 1) nbClicks = 'Dbl'; const signalName = 'on' + right + nbClicks + 'Click'; let action = 'signal'; switch(signalName) { case 'onClick': action = this.onClick; break; case 'onDblClick': action = this.onDblClick; break; case 'onRightClick': action = this.onRightClick; break; case 'onRightDblClick': action = this.onRightDblClick; break; } this._manageEventAction(action, signalName); this.nbClicks = 0; this.button = -1; return false; } _clicked(actor, event) { if (event.get_button() == this.button) { this.nbClicks++; } else { this.button = event.get_button(); this.nbClicks = 1; let sourceId = GLib.timeout_add(GLib.G_PRIORITY_DEFAULT, this.dbus.ClutterSettings['double-click-time'], this._doClickCallback.bind(this)); this.timeouts.push(sourceId); } return Clutter.EVENT_PROPAGATE; } _onEnter(/*actor, event*/) { return this._manageEventAction(this.onEnter, 'onEnter'); } _onLeave(/*actor, event*/) { return this._manageEventAction(this.onLeave, 'onLeave'); } _onScroll(actor, event) { let signalName = ''; const direction = event.get_scroll_direction (); if (direction == Clutter.ScrollDirection.UP) signalName = 'onScrollUp'; else if (direction == Clutter.ScrollDirection.DOWN) signalName = 'onScrollDown'; return this._manageEventAction(this.onScroll, signalName); } } var MyPopupMenuItem = GObject.registerClass({ GTypeName: 'MyPopupMenuItem' }, class MyPopupMenuItem extends PopupMenu.PopupBaseMenuItem { _init(widgets, params) { super._init(params); this.box = new St.BoxLayout({ style_class: 'popup-combobox-item' }); this.box.set_vertical(true); for (let widgetIndex in widgets) this.box.add_child(widgets[widgetIndex]); this.add_child(this.box); } }); var MonitorWidget = GObject.registerClass({ GTypeName: 'MonitorWidget' }, class MonitorWidget extends PanelMenu.Button { _init(item, group, dbus, position) { super._init(0.0); this.name = item['name']; this.group = group; this.fullname = this.name + '@' + this.group; this.dbus = dbus; this.menuItem = null; this.signalManager = new SignalMgt(item, this.name, group, dbus, this); this.popup_signals = null; this.popup_widgets = null; if (item.hasOwnProperty('icon')) { if (typeof(item['icon']) === 'string') this.icon = this._createIconOld(item); else this.icon = this._createIcon(item['icon']); if (this.icon !== null) { this.signalManager.connectWidgetSignals(this.icon); this.add_child(this.icon); } } else this.icon = null; if (item.hasOwnProperty('text')) { if (typeof(item['text']) === 'string') this.widget = this._createTextOld(item); else this.widget = this._createText(item['text']); if (this.widget !== null) { this.signalManager.connectWidgetSignals(this.widget); this.add_child(this.widget); } } else this.widget = null; if (item.hasOwnProperty('popup')) this._createPopup(item['popup']); const box = hashGet(item, 'box', 'center'); if (box === 'right' && position == -1) position = 0; this.connect('style-changed', this._onStyleChanged.bind(this)); Main.panel.addToStatusArea(this.fullname, this, position, box); } update(item) { const prevWidget = this.widget; const prevIcon = this.icon; if (item.hasOwnProperty('text')) { let text = ''; let style = ''; if (typeof(item['text']) === 'string') { text = hashGet(item, 'text', ''); style = hashGet(item, 'style', ''); } else { const textValues = item['text']; text = hashGet(textValues, 'text', ''); style = hashGet(textValues, 'style', ''); } if (text !== '') { if (!this.widget) { if (typeof(item['text']) === 'string') this.widget = this._createTextOld(item); else this.widget = this._createText(item['text']); this.insert_child_above(this.widget, this.icon); } else { this.widget.label = text; } } if (style !== '' && this.widget) { this.widget.set_style(style); } } if (item.hasOwnProperty('icon')) { let icon = ''; let style = ''; if (typeof(item['icon']) === 'string') { icon = hashGet(item, 'icon', ''); style = hashGet(item, 'iconStyle', ''); } else { const iconValues = item['icon']; icon = hashGet(iconValues, 'path', ''); style = hashGet(iconValues, 'style', ''); } if (icon !== '') { if (typeof(item['icon']) === 'string') this.icon = this._createIconOld(item); else this.icon = this._createIcon(item['icon']); } if (prevIcon) { this.signalManager.disconnectWidgetSignals(prevIcon); this.insert_child_above(this.icon, prevIcon); this.remove_child(prevIcon); //delete prevIcon; } else this.insert_child_before(this.icon, prevWidget); if (style !== '' && this.icon) { this.icon.set_style(style); } } if (item.hasOwnProperty('popup')) { const menuOpen = this.menu.isOpen; if (this.menuItem) { if (menuOpen) this.menu.close(); for(let widgetIdx in this.popup_widgets) this.signalManager.disconnectWidgetSignals(this.popup_widgets[widgetIdx]); this.menu.removeAll(); //delete this.menuItem; } const popup = this._createPopup(item['popup']); if (popup !== null && menuOpen) this.menu.open(true); } this.signalManager.updateSignals(item); } openPopup() { this.menu.open(true); } closePopup() { this.menu.close(); } togglePopup() { this.menu.toggle(); } destroy() { this.menu.close(); this.signalManager.destroy(); super.destroy(); } _onStyleChanged() { // Force these values to avoid big spaces between each widgets this._minHPadding = 1; this._natHPadding = 1; } _createPopup(item) { if (!item.hasOwnProperty('items')) { return null; } let widgets = []; this.popup_signals = new WeakMap(); this.popup_widgets = new Array(); for (let itemIndex in item['items']) { let widget = null; let widgetDict = item['items'][itemIndex]; let nestedItem = null; if (widgetDict.hasOwnProperty('text')) { nestedItem = widgetDict['text']; widget = this._createLabel(nestedItem); } else if (widgetDict.hasOwnProperty('picture')) { nestedItem = widgetDict['picture']; widget = this._createPicture(nestedItem); } else { log('No known widget defined in popup'); } if (nestedItem === null) { continue; } widgets.push(widget); const name = hashGet(nestedItem, 'name', ''); this.popup_signals[widget] = new SignalMgt(nestedItem, name, this.fullname, this.dbus, this); this.popup_signals[widget].connectWidgetSignals(widget); this.popup_widgets.push(widget); } if (widgets.length > 0) { this.menuItem = new MyPopupMenuItem(widgets, {}); this.menu.addMenuItem(this.menuItem); this.menu.setSensitive(false); return this.menuItem; } return null; } _createTextOld(item) { var itemValues = {}; itemValues = { 'text':item['text'] }; if (item.hasOwnProperty('style')) itemValues['style'] = item['style']; return this._createText(itemValues); } __createText(item, isLabel) { if (!item.hasOwnProperty('text')) { log('Text must have a \'text\' value'); return null; } const style = hashGet(item, 'style', ''); this.textProperties = item; if (item['text'] === '') { return null; } else { let widget = null; if (isLabel) widget = new St.Label({ text: item['text'] }); else widget = new St.Button({ label: item['text'] }); widget.set_style(style); return widget; } } _createText(item) { return this.__createText(item, false); } _createLabel(item) { return this.__createText(item, true); } _createIconOld(item) { var itemValues = {}; itemValues = { 'path':item['icon'] }; if (item.hasOwnProperty('iconStyle')) itemValues['style'] = item['iconStyle']; return this._createIcon(itemValues); } _createIcon(item) { if (!item.hasOwnProperty('path')) { log('Icon must have a \'path\' value'); return null; } const style = hashGet(item, 'style', ''); this.iconProperties = item; if (item['path'] === '') { return null; } else { let gicon = Gio.icon_new_for_string(item['path']); gicon = new St.Icon({ gicon }); gicon.set_style(style); return gicon; } } _createPicture(item) { if (!item.hasOwnProperty('path')) { log('Picture must have a \'path\' value'); return null; } let width = hashGet(item, 'width', -1); let height = hashGet(item, 'height', -1); if (typeof(width) === 'string') width = parseInt(width, 10); if (typeof(height) === 'string') height = parseInt(height, 10); const img = new Clutter.Image(); const initialPixbuf = Pixbuf.Pixbuf.new_from_file(item['path']); img.set_data(initialPixbuf.get_pixels(), initialPixbuf.get_has_alpha() ? Cogl.PixelFormat.RGBA_8888 : Cogl.PixelFormat.RGB_888, initialPixbuf.get_width(), initialPixbuf.get_height(), initialPixbuf.get_rowstride()); const picture = new Clutter.Actor(); picture.set_content(img); picture.set_size((width != -1)?width:initialPixbuf.get_width(), (height != -1)?height:initialPixbuf.get_height()); // Pack it in a box to avoid picture resize const box = new St.BoxLayout({}); box.add_child(picture); return box; } }); // From https://github.com/ubuntu/gnome-shell-extension-appindicator/blob/master/interfaces.js // loads a xml file into an in-memory string function loadInterfaceXml(extension, filename) { const interfacesDir = extension.dir.get_child('.'); const file = interfacesDir.get_child(filename); let [result, contents] = GLib.file_get_contents(file.get_path()); if (result) { // HACK: The "" + trick is important as hell because file_get_contents returns // an object (WTF?) but Gio.makeProxyWrapper requires `typeof() == "string"` // Otherwise, it will try to check `instanceof XML` and fail miserably because there // is no `XML` on very recent SpiderMonkey releases (or, if SpiderMonkey is old enough, // will spit out a TypeError soon). if (contents instanceof Uint8Array) { const decoder = new TextDecoder(); contents = decoder.decode(contents); } const res = `${contents}`; return res; } else { throw new Error(`Generic monitor: Could not load file: ${filename}`); } } class GenericMonitorDBUS { constructor(extension) { this.monitor_groups = {}; this.actor_clicked = {}; this.ClutterSettings = Clutter.Settings.get_default(); this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(loadInterfaceXml(extension, 'dbus.xml'), this); this._dbusImpl.export(Gio.DBus.session, '/com/soutade/GenericMonitor'); this._dbusImpl.emit_signal('onActivate', null); } emitSignal(name, value) { this._dbusImpl.emit_signal(name, GLib.Variant.new('(s)',[value])); } _checkParameters(parameters) { if (!parameters.hasOwnProperty('group')) { log('No group defined'); return false; } if (!parameters.hasOwnProperty('items')) { log('No items defined'); return false; } for (let itemIndex in parameters['items']) { const item = parameters['items'][itemIndex]; if (!item.hasOwnProperty('name')) { log('No name defined for item'); return false; } } return true; } _getItemFromGroup(group, name) { for (let groupItemIndex in group) { const groupItem = group[groupItemIndex]; if (groupItem.name === name) return groupItem; } return null; } _getItemFromFullName(fullname) { const splitName = fullname.split('@'); if (splitName.length !== 2) { log(`Invalid name ${fullname}`); return null; } if (!this.monitor_groups.hasOwnProperty(splitName[1])) return null; return this._getItemFromGroup(this.monitor_groups[splitName[1]], splitName[0]); } _removeFromArray(array, value) { for(let i=0; i= childrens.length) position = -1; } monitorWidget = new MonitorWidget(item, groupName, this, position); group.push(monitorWidget); } else { monitorWidget.update(item); } } } deleteItem(item, groupName) { let group = this.monitor_groups[groupName]; group = this._removeFromArray(group, item); item.destroy(); if (group.length === 0) delete this.monitor_groups[groupName]; else this.monitor_groups = group; } deleteItems(str) { const parameters = JSON.parse(str); if (!parameters.hasOwnProperty('items')) { log('No items defined'); return false; } for (let itemIndex in parameters['items']) { let itemName = parameters['items'][itemIndex]; const splitName = itemName.split('@'); if (splitName.length !== 2) { log(`Invalid name ${itemName}`); return false; } itemName = splitName[0]; let groupName = splitName[1]; if (!this.monitor_groups.hasOwnProperty(groupName)) continue; let group = this.monitor_groups[groupName]; let item = this._getItemFromGroup(group, itemName); if (item !== null) { this.deleteItem(item, groupName); } } } deleteGroups(str) { const parameters = JSON.parse(str); if (!parameters.hasOwnProperty('groups')) { log('No groups defined'); return false; } let groupsToDelete = []; for (let groupIndex in parameters['groups']) { const groupName = parameters['groups'][groupIndex]; if (!this.monitor_groups.hasOwnProperty(groupName)) continue; const group = this.monitor_groups[groupName]; for (let itemIndex in group) group[itemIndex].destroy(); groupsToDelete.push(groupName); } for (let groupDeleteIndex in groupsToDelete) { const groupName = groupsToDelete[groupDeleteIndex]; delete this.monitor_groups[groupName]; } } _popupFunction(str) { const parameters = JSON.parse(str); if (!parameters.hasOwnProperty('item')) { log('No item defined'); return false; } const monitorWidget = this._getItemFromFullName(parameters['item']); if (monitorWidget !== null) return monitorWidget; else log(`Item ${str} not found`); return null; } openPopup(str) { const monitorWidget = this._popupFunction(str) if (monitorWidget !== null) monitorWidget.openPopup(); } closePopup(str) { const monitorWidget = this._popupFunction(str) if (monitorWidget !== null) monitorWidget.closePopup(); } togglePopup(str) { const monitorWidget = this._popupFunction(str) if (monitorWidget !== null) monitorWidget.togglePopup(); } destroy() { this._dbusImpl.emit_signal('onDeactivate', null); for (let groupIndex in this.monitor_groups) { const group = this.monitor_groups[groupIndex]; for (let itemIndex in group) group[itemIndex].destroy(); } this.monitor_groups = {}; this._dbusImpl.unexport(); } } export default class GenericMonitorExtension extends Extension.Extension { constructor(...args) { super(...args); this.textDBusService = null; } enable() { this.textDBusService = new GenericMonitorDBUS(this); } disable() { if (this.textDBusService !== null) { this.textDBusService.destroy(); delete this.textDBusService; this.textDBusService = null; } } }