This commit is contained in:
2024-07-08 22:46:35 +02:00
parent 02f44c49d2
commit 27254d817a
56249 changed files with 808097 additions and 1 deletions

View File

@@ -0,0 +1,965 @@
// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
//
// 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 2
// 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, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import GdkPixbuf from 'gi://GdkPixbuf';
import Gio from 'gi://Gio';
import St from 'gi://St';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import * as Signals from 'resource:///org/gnome/shell/misc/signals.js';
import * as DBusInterfaces from './interfaces.js';
import * as PromiseUtils from './promiseUtils.js';
import * as Util from './util.js';
import {DBusProxy} from './dbusProxy.js';
Gio._promisify(GdkPixbuf.Pixbuf, 'new_from_stream_async');
// ////////////////////////////////////////////////////////////////////////
// PART ONE: "ViewModel" backend implementation.
// Both code and design are inspired by libdbusmenu
// ////////////////////////////////////////////////////////////////////////
/**
* Saves menu property values and handles type checking and defaults
*/
export class PropertyStore {
constructor(initialProperties) {
this._props = new Map();
if (initialProperties) {
for (const [prop, value] of Object.entries(initialProperties))
this.set(prop, value);
}
}
set(name, value) {
if (name in PropertyStore.MandatedTypes && value &&
!value.is_of_type(PropertyStore.MandatedTypes[name]))
Util.Logger.warn(`Cannot set property ${name}: type mismatch!`);
else if (value)
this._props.set(name, value);
else
this._props.delete(name);
}
get(name) {
const prop = this._props.get(name);
if (prop)
return prop;
else if (name in PropertyStore.DefaultValues)
return PropertyStore.DefaultValues[name];
else
return null;
}
}
// we list all the properties we know and use here, so we won' have to deal with unexpected type mismatches
PropertyStore.MandatedTypes = {
'visible': GLib.VariantType.new('b'),
'enabled': GLib.VariantType.new('b'),
'label': GLib.VariantType.new('s'),
'type': GLib.VariantType.new('s'),
'children-display': GLib.VariantType.new('s'),
'icon-name': GLib.VariantType.new('s'),
'icon-data': GLib.VariantType.new('ay'),
'toggle-type': GLib.VariantType.new('s'),
'toggle-state': GLib.VariantType.new('i'),
};
PropertyStore.DefaultValues = {
'visible': GLib.Variant.new_boolean(true),
'enabled': GLib.Variant.new_boolean(true),
'label': GLib.Variant.new_string(''),
'type': GLib.Variant.new_string('standard'),
// elements not in here must return null
};
/**
* Represents a single menu item
*/
export class DbusMenuItem extends Signals.EventEmitter {
// will steal the properties object
constructor(client, id, properties, childrenIds) {
super();
this._client = client;
this._id = id;
this._propStore = new PropertyStore(properties);
this._children_ids = childrenIds;
}
propertyGet(propName) {
const prop = this.propertyGetVariant(propName);
return prop ? prop.get_string()[0] : null;
}
propertyGetVariant(propName) {
return this._propStore.get(propName);
}
propertyGetBool(propName) {
const prop = this.propertyGetVariant(propName);
return prop ? prop.get_boolean() : false;
}
propertyGetInt(propName) {
const prop = this.propertyGetVariant(propName);
return prop ? prop.get_int32() : 0;
}
propertySet(prop, value) {
this._propStore.set(prop, value);
this.emit('property-changed', prop, this.propertyGetVariant(prop));
}
resetProperties() {
Object.entries(PropertyStore.DefaultValues).forEach(([prop, value]) =>
this.propertySet(prop, value));
}
getChildrenIds() {
return this._children_ids.concat(); // clone it!
}
addChild(pos, childId) {
this._children_ids.splice(pos, 0, childId);
this.emit('child-added', this._client.getItem(childId), pos);
}
removeChild(childId) {
// find it
let pos = -1;
for (let i = 0; i < this._children_ids.length; ++i) {
if (this._children_ids[i] === childId) {
pos = i;
break;
}
}
if (pos < 0) {
Util.Logger.critical("Trying to remove child which doesn't exist");
} else {
this._children_ids.splice(pos, 1);
this.emit('child-removed', this._client.getItem(childId));
}
}
moveChild(childId, newPos) {
// find the old position
let oldPos = -1;
for (let i = 0; i < this._children_ids.length; ++i) {
if (this._children_ids[i] === childId) {
oldPos = i;
break;
}
}
if (oldPos < 0) {
Util.Logger.critical("tried to move child which wasn't in the list");
return;
}
if (oldPos !== newPos) {
this._children_ids.splice(oldPos, 1);
this._children_ids.splice(newPos, 0, childId);
this.emit('child-moved', oldPos, newPos, this._client.getItem(childId));
}
}
getChildren() {
return this._children_ids.map(el => this._client.getItem(el));
}
handleEvent(event, data, timestamp) {
if (!data)
data = GLib.Variant.new_int32(0);
this._client.sendEvent(this._id, event, data, timestamp);
}
getId() {
return this._id;
}
sendAboutToShow() {
this._client.sendAboutToShow(this._id);
}
}
/**
* The client does the heavy lifting of actually reading layouts and distributing events
*/
export const DBusClient = GObject.registerClass({
Signals: {'ready-changed': {}},
}, class AppIndicatorsDBusClient extends DBusProxy {
static get interfaceInfo() {
if (!this._interfaceInfo) {
this._interfaceInfo = Gio.DBusInterfaceInfo.new_for_xml(
DBusInterfaces.DBusMenu);
}
return this._interfaceInfo;
}
static get baseItems() {
if (!this._baseItems) {
this._baseItems = {
'children-display': GLib.Variant.new_string('submenu'),
};
}
return this._baseItems;
}
static destroy() {
delete this._interfaceInfo;
}
_init(busName, objectPath) {
const {interfaceInfo} = AppIndicatorsDBusClient;
super._init(busName, objectPath, interfaceInfo,
Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES);
this._items = new Map();
this._items.set(0, new DbusMenuItem(this, 0, DBusClient.baseItems, []));
this._flagItemsUpdateRequired = false;
// will be set to true if a layout update is needed once active
this._flagLayoutUpdateRequired = false;
// property requests are queued
this._propertiesRequestedFor = new Set(/* ids */);
this._layoutUpdated = false;
this._active = false;
}
async initAsync(cancellable) {
await super.initAsync(cancellable);
this._requestLayoutUpdate();
}
_onNameOwnerChanged() {
if (this.isReady)
this._requestLayoutUpdate();
}
get isReady() {
return this._layoutUpdated && !!this.gNameOwner;
}
get cancellable() {
return this._cancellable;
}
getRoot() {
return this._items.get(0);
}
_requestLayoutUpdate() {
const cancellable = new Util.CancellableChild(this._cancellable);
this._beginLayoutUpdate(cancellable);
}
async _requestProperties(propertyId, cancellable) {
this._propertiesRequestedFor.add(propertyId);
if (this._propertiesRequest && this._propertiesRequest.pending())
return;
// if we don't have any requests queued, we'll need to add one
this._propertiesRequest = new PromiseUtils.IdlePromise(
GLib.PRIORITY_DEFAULT_IDLE, cancellable);
await this._propertiesRequest;
const requestedProperties = Array.from(this._propertiesRequestedFor);
this._propertiesRequestedFor.clear();
const [result] = await this.GetGroupPropertiesAsync(requestedProperties,
[], cancellable);
result.forEach(([id, properties]) => {
const item = this._items.get(id);
if (!item)
return;
item.resetProperties();
for (const [prop, value] of Object.entries(properties))
item.propertySet(prop, value);
});
}
// Traverses the list of cached menu items and removes everyone that is not in the list
// so we don't keep alive unused items
_gcItems() {
const tag = new Date().getTime();
const toTraverse = [0];
while (toTraverse.length > 0) {
const item = this.getItem(toTraverse.shift());
item._dbusClientGcTag = tag;
Array.prototype.push.apply(toTraverse, item.getChildrenIds());
}
this._items.forEach((i, id) => {
if (i._dbusClientGcTag !== tag)
this._items.delete(id);
});
}
// the original implementation will only request partial layouts if somehow possible
// we try to save us from multiple kinds of race conditions by always requesting a full layout
_beginLayoutUpdate(cancellable) {
this._layoutUpdateUpdateAsync(cancellable).catch(e => {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
});
}
// the original implementation will only request partial layouts if somehow possible
// we try to save us from multiple kinds of race conditions by always requesting a full layout
async _layoutUpdateUpdateAsync(cancellable) {
// we only read the type property, because if the type changes after reading all properties,
// the view would have to replace the item completely which we try to avoid
if (this._layoutUpdateCancellable)
this._layoutUpdateCancellable.cancel();
this._layoutUpdateCancellable = cancellable;
try {
const [revision_, root] = await this.GetLayoutAsync(0, -1,
['type', 'children-display'], cancellable);
this._updateLayoutState(true);
this._doLayoutUpdate(root, cancellable);
this._gcItems();
this._flagLayoutUpdateRequired = false;
this._flagItemsUpdateRequired = false;
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
this._updateLayoutState(false);
throw e;
} finally {
if (this._layoutUpdateCancellable === cancellable)
this._layoutUpdateCancellable = null;
}
}
_updateLayoutState(state) {
const wasReady = this.isReady;
this._layoutUpdated = state;
if (this.isReady !== wasReady)
this.emit('ready-changed');
}
_doLayoutUpdate(item, cancellable) {
const [id, properties, children] = item;
const childrenUnpacked = children.map(c => c.deep_unpack());
const childrenIds = childrenUnpacked.map(([c]) => c);
// make sure all our children exist
childrenUnpacked.forEach(c => this._doLayoutUpdate(c, cancellable));
// make sure we exist
const menuItem = this._items.get(id);
if (menuItem) {
// we do, update our properties if necessary
for (const [prop, value] of Object.entries(properties))
menuItem.propertySet(prop, value);
// make sure our children are all at the right place, and exist
const oldChildrenIds = menuItem.getChildrenIds();
for (let i = 0; i < childrenIds.length; ++i) {
// try to recycle an old child
let oldChild = -1;
for (let j = 0; j < oldChildrenIds.length; ++j) {
if (oldChildrenIds[j] === childrenIds[i]) {
[oldChild] = oldChildrenIds.splice(j, 1);
break;
}
}
if (oldChild < 0) {
// no old child found, so create a new one!
menuItem.addChild(i, childrenIds[i]);
} else {
// old child found, reuse it!
menuItem.moveChild(childrenIds[i], i);
}
}
// remove any old children that weren't reused
oldChildrenIds.forEach(c => menuItem.removeChild(c));
if (!this._flagItemsUpdateRequired)
return id;
}
// we don't, so let's create us
let newMenuItem = menuItem;
if (!newMenuItem) {
newMenuItem = new DbusMenuItem(this, id, properties, childrenIds);
this._items.set(id, newMenuItem);
}
this._requestProperties(id, cancellable).catch(e => {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
Util.Logger.warn(`Could not get menu properties menu proxy: ${e}`);
});
return id;
}
async _doPropertiesUpdateAsync(cancellable) {
if (this._propertiesUpdateCancellable)
this._propertiesUpdateCancellable.cancel();
this._propertiesUpdateCancellable = cancellable;
try {
const requests = [];
this._items.forEach((_, id) =>
requests.push(this._requestProperties(id, cancellable)));
await Promise.all(requests);
} finally {
if (this._propertiesUpdateCancellable === cancellable)
this._propertiesUpdateCancellable = null;
}
}
_doPropertiesUpdate() {
const cancellable = new Util.CancellableChild(this._cancellable);
this._doPropertiesUpdateAsync(cancellable).catch(e => {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
Util.Logger.warn(`Could not get menu properties menu proxy: ${e}`);
});
}
set active(active) {
const wasActive = this._active;
this._active = active;
if (active && wasActive !== active) {
if (this._flagLayoutUpdateRequired) {
this._requestLayoutUpdate();
} else if (this._flagItemsUpdateRequired) {
this._doPropertiesUpdate();
this._flagItemsUpdateRequired = false;
}
}
}
_onSignal(_sender, signal, params) {
if (signal === 'LayoutUpdated') {
if (!this._active) {
this._flagLayoutUpdateRequired = true;
return;
}
this._requestLayoutUpdate();
} else if (signal === 'ItemsPropertiesUpdated') {
if (!this._active) {
this._flagItemsUpdateRequired = true;
return;
}
this._onPropertiesUpdated(params.deep_unpack());
}
}
getItem(id) {
const item = this._items.get(id);
if (!item)
Util.Logger.warn(`trying to retrieve item for non-existing id ${id} !?`);
return item || null;
}
// we don't need to cache and burst-send that since it will not happen that frequently
async sendAboutToShow(id) {
if (this._hasAboutToShow === false)
return;
/* Some indicators (you, dropbox!) don't use the right signature
* and don't return a boolean, so we need to support both cases */
try {
const ret = await this.gConnection.call(this.gName, this.gObjectPath,
this.gInterfaceName, 'AboutToShow', new GLib.Variant('(i)', [id]),
null, Gio.DBusCallFlags.NONE, -1, this._cancellable);
if ((ret.is_of_type(new GLib.VariantType('(b)')) &&
ret.get_child_value(0).get_boolean()) ||
ret.is_of_type(new GLib.VariantType('()')))
this._requestLayoutUpdate();
} catch (e) {
if (e.matches(Gio.DBusError, Gio.DBusError.UNKNOWN_METHOD) ||
e.matches(Gio.DBusError, Gio.DBusError.FAILED)) {
this._hasAboutToShow = false;
return;
}
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
}
}
sendEvent(id, event, params, timestamp) {
if (!this.gNameOwner)
return;
this.EventAsync(id, event, params, timestamp, this._cancellable).catch(e => {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
});
}
_onPropertiesUpdated([changed, removed]) {
changed.forEach(([id, props]) => {
const item = this._items.get(id);
if (!item)
return;
for (const [prop, value] of Object.entries(props))
item.propertySet(prop, value);
});
removed.forEach(([id, propNames]) => {
const item = this._items.get(id);
if (!item)
return;
propNames.forEach(propName => item.propertySet(propName, null));
});
}
});
// ////////////////////////////////////////////////////////////////////////
// PART TWO: "View" frontend implementation.
// ////////////////////////////////////////////////////////////////////////
// https://bugzilla.gnome.org/show_bug.cgi?id=731514
// GNOME 3.10 and 3.12 can't open a nested submenu.
// Patches have been written, but it's not clear when (if?) they will be applied.
// We also don't know whether they will be backported to 3.10, so we will work around
// it in the meantime. Offending versions can be clearly identified:
const NEED_NESTED_SUBMENU_FIX = '_setOpenedSubMenu' in PopupMenu.PopupMenu.prototype;
/**
* Creates new wrapper menu items and injects methods for managing them at runtime.
*
* Many functions in this object will be bound to the created item and executed as event
* handlers, so any `this` will refer to a menu item create in createItem
*/
const MenuItemFactory = {
createItem(client, dbusItem) {
// first, decide whether it's a submenu or not
let shellItem;
if (dbusItem.propertyGet('children-display') === 'submenu')
shellItem = new PopupMenu.PopupSubMenuMenuItem('FIXME');
else if (dbusItem.propertyGet('type') === 'separator')
shellItem = new PopupMenu.PopupSeparatorMenuItem('');
else
shellItem = new PopupMenu.PopupMenuItem('FIXME');
shellItem._dbusItem = dbusItem;
shellItem._dbusClient = client;
if (shellItem instanceof PopupMenu.PopupMenuItem) {
shellItem._icon = new St.Icon({
style_class: 'popup-menu-icon',
xAlign: Clutter.ActorAlign.END,
});
shellItem.add_child(shellItem._icon);
shellItem.label.x_expand = true;
}
// initialize our state
MenuItemFactory._updateLabel.call(shellItem);
MenuItemFactory._updateOrnament.call(shellItem);
MenuItemFactory._updateImage.call(shellItem);
MenuItemFactory._updateVisible.call(shellItem);
MenuItemFactory._updateSensitive.call(shellItem);
// initially create children
if (shellItem instanceof PopupMenu.PopupSubMenuMenuItem) {
dbusItem.getChildren().forEach(c =>
shellItem.menu.addMenuItem(MenuItemFactory.createItem(client, c)));
}
// now, connect various events
Util.connectSmart(dbusItem, 'property-changed',
shellItem, MenuItemFactory._onPropertyChanged);
Util.connectSmart(dbusItem, 'child-added',
shellItem, MenuItemFactory._onChildAdded);
Util.connectSmart(dbusItem, 'child-removed',
shellItem, MenuItemFactory._onChildRemoved);
Util.connectSmart(dbusItem, 'child-moved',
shellItem, MenuItemFactory._onChildMoved);
Util.connectSmart(shellItem, 'activate',
shellItem, MenuItemFactory._onActivate);
shellItem.connect('destroy', () => {
shellItem._dbusItem = null;
shellItem._dbusClient = null;
shellItem._icon = null;
});
if (shellItem.menu) {
Util.connectSmart(shellItem.menu, 'open-state-changed',
shellItem, MenuItemFactory._onOpenStateChanged);
}
return shellItem;
},
_onOpenStateChanged(menu, open) {
if (open) {
if (NEED_NESTED_SUBMENU_FIX) {
// close our own submenus
if (menu._openedSubMenu)
menu._openedSubMenu.close(false);
// register ourselves and close sibling submenus
if (menu._parent._openedSubMenu && menu._parent._openedSubMenu !== menu)
menu._parent._openedSubMenu.close(true);
menu._parent._openedSubMenu = menu;
}
this._dbusItem.handleEvent('opened', null, 0);
this._dbusItem.sendAboutToShow();
} else {
if (NEED_NESTED_SUBMENU_FIX) {
// close our own submenus
if (menu._openedSubMenu)
menu._openedSubMenu.close(false);
}
this._dbusItem.handleEvent('closed', null, 0);
}
},
_onActivate(_item, event) {
const timestamp = event.get_time();
if (timestamp && this._dbusClient.indicator)
this._dbusClient.indicator.provideActivationToken(timestamp);
this._dbusItem.handleEvent('clicked', GLib.Variant.new('i', 0),
timestamp);
},
_onPropertyChanged(dbusItem, prop, _value) {
if (prop === 'toggle-type' || prop === 'toggle-state')
MenuItemFactory._updateOrnament.call(this);
else if (prop === 'label')
MenuItemFactory._updateLabel.call(this);
else if (prop === 'enabled')
MenuItemFactory._updateSensitive.call(this);
else if (prop === 'visible')
MenuItemFactory._updateVisible.call(this);
else if (prop === 'icon-name' || prop === 'icon-data')
MenuItemFactory._updateImage.call(this);
else if (prop === 'type' || prop === 'children-display')
MenuItemFactory._replaceSelf.call(this);
else
Util.Logger.debug(`Unhandled property change: ${prop}`);
},
_onChildAdded(dbusItem, child, position) {
if (!(this instanceof PopupMenu.PopupSubMenuMenuItem)) {
Util.Logger.warn('Tried to add a child to non-submenu item. Better recreate it as whole');
MenuItemFactory._replaceSelf.call(this);
} else {
this.menu.addMenuItem(MenuItemFactory.createItem(this._dbusClient, child), position);
}
},
_onChildRemoved(dbusItem, child) {
if (!(this instanceof PopupMenu.PopupSubMenuMenuItem)) {
Util.Logger.warn('Tried to remove a child from non-submenu item. Better recreate it as whole');
MenuItemFactory._replaceSelf.call(this);
} else {
// find it!
this.menu._getMenuItems().forEach(item => {
if (item._dbusItem === child)
item.destroy();
});
}
},
_onChildMoved(dbusItem, child, oldpos, newpos) {
if (!(this instanceof PopupMenu.PopupSubMenuMenuItem)) {
Util.Logger.warn('Tried to move a child in non-submenu item. Better recreate it as whole');
MenuItemFactory._replaceSelf.call(this);
} else {
MenuUtils.moveItemInMenu(this.menu, child, newpos);
}
},
_updateLabel() {
const label = this._dbusItem.propertyGet('label').replace(/_([^_])/, '$1');
if (this.label) // especially on GS3.8, the separator item might not even have a hidden label
this.label.set_text(label);
},
_updateOrnament() {
if (!this.setOrnament)
return; // separators and alike might not have gotten the polyfill
if (this._dbusItem.propertyGet('toggle-type') === 'checkmark' &&
this._dbusItem.propertyGetInt('toggle-state'))
this.setOrnament(PopupMenu.Ornament.CHECK);
else if (this._dbusItem.propertyGet('toggle-type') === 'radio' &&
this._dbusItem.propertyGetInt('toggle-state'))
this.setOrnament(PopupMenu.Ornament.DOT);
else
this.setOrnament(PopupMenu.Ornament.NONE);
},
async _updateImage() {
if (!this._icon)
return; // might be missing on submenus / separators
const iconName = this._dbusItem.propertyGet('icon-name');
const iconData = this._dbusItem.propertyGetVariant('icon-data');
if (iconName) {
this._icon.icon_name = iconName;
} else if (iconData) {
try {
const inputStream = Gio.MemoryInputStream.new_from_bytes(
iconData.get_data_as_bytes());
this._icon.gicon = await GdkPixbuf.Pixbuf.new_from_stream_async(
inputStream, this._dbusClient.cancellable);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
}
}
},
_updateVisible() {
this.visible = this._dbusItem.propertyGetBool('visible');
},
_updateSensitive() {
this.setSensitive(this._dbusItem.propertyGetBool('enabled'));
},
_replaceSelf(newSelf) {
// create our new self if needed
if (!newSelf)
newSelf = MenuItemFactory.createItem(this._dbusClient, this._dbusItem);
// first, we need to find our old position
let pos = -1;
const family = this._parent._getMenuItems();
for (let i = 0; i < family.length; ++i) {
if (family[i] === this)
pos = i;
}
if (pos < 0)
throw new Error("DBusMenu: can't replace non existing menu item");
// add our new self while we're still alive
this._parent.addMenuItem(newSelf, pos);
// now destroy our old self
this.destroy();
},
};
/**
* Utility functions not necessarily belonging into the item factory
*/
const MenuUtils = {
moveItemInMenu(menu, dbusItem, newpos) {
// HACK: we're really getting into the internals of the PopupMenu implementation
// First, find our wrapper. Children tend to lie. We do not trust the old positioning.
const family = menu._getMenuItems();
for (let i = 0; i < family.length; ++i) {
if (family[i]._dbusItem === dbusItem) {
// now, remove it
menu.box.remove_child(family[i]);
// and add it again somewhere else
if (newpos < family.length && family[newpos] !== family[i])
menu.box.insert_child_below(family[i], family[newpos]);
else
menu.box.add(family[i]);
// skip the rest
return;
}
}
},
};
/**
* Processes DBus events, creates the menu items and handles the actions
*
* Something like a mini-god-object
*/
export class Client extends Signals.EventEmitter {
constructor(busName, path, indicator) {
super();
this._busName = busName;
this._busPath = path;
this._client = new DBusClient(busName, path);
this._rootMenu = null; // the shell menu
this._rootItem = null; // the DbusMenuItem for the root
this.indicator = indicator;
this.cancellable = new Util.CancellableChild(this.indicator.cancellable);
this._client.initAsync(this.cancellable).catch(e => {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
});
Util.connectSmart(this._client, 'ready-changed', this,
() => this.emit('ready-changed'));
}
get isReady() {
return this._client.isReady;
}
// this will attach the client to an already existing menu that will be used as the root menu.
// it will also connect the client to be automatically destroyed when the menu dies.
attachToMenu(menu) {
this._rootMenu = menu;
this._rootItem = this._client.getRoot();
this._itemsBeingAdded = new Set();
// cleanup: remove existing children (just in case)
this._rootMenu.removeAll();
if (NEED_NESTED_SUBMENU_FIX)
menu._setOpenedSubMenu = this._setOpenedSubmenu.bind(this);
// connect handlers
Util.connectSmart(menu, 'open-state-changed', this, this._onMenuOpened);
Util.connectSmart(menu, 'destroy', this, this.destroy);
Util.connectSmart(this._rootItem, 'child-added', this, this._onRootChildAdded);
Util.connectSmart(this._rootItem, 'child-removed', this, this._onRootChildRemoved);
Util.connectSmart(this._rootItem, 'child-moved', this, this._onRootChildMoved);
// Dropbox requires us to call AboutToShow(0) first
this._rootItem.sendAboutToShow();
// fill the menu for the first time
const children = this._rootItem.getChildren();
children.forEach(child =>
this._onRootChildAdded(this._rootItem, child));
}
_setOpenedSubmenu(submenu) {
if (!submenu)
return;
if (submenu._parent !== this._rootMenu)
return;
if (submenu === this._openedSubMenu)
return;
if (this._openedSubMenu && this._openedSubMenu.isOpen)
this._openedSubMenu.close(true);
this._openedSubMenu = submenu;
}
_onRootChildAdded(dbusItem, child, position) {
// Menu additions can be expensive, so let's do it in different chunks
const basePriority = this.isOpen ? GLib.PRIORITY_DEFAULT : GLib.PRIORITY_LOW;
const idlePromise = new PromiseUtils.IdlePromise(
basePriority + this._itemsBeingAdded.size, this.cancellable);
this._itemsBeingAdded.add(child);
idlePromise.then(() => {
if (!this._itemsBeingAdded.has(child))
return;
this._rootMenu.addMenuItem(
MenuItemFactory.createItem(this, child), position);
}).catch(e => {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
}).finally(() => this._itemsBeingAdded.delete(child));
}
_onRootChildRemoved(dbusItem, child) {
// children like to play hide and seek
// but we know how to find it for sure!
const item = this._rootMenu._getMenuItems().find(it =>
it._dbusItem === child);
if (item)
item.destroy();
else
this._itemsBeingAdded.delete(child);
}
_onRootChildMoved(dbusItem, child, oldpos, newpos) {
MenuUtils.moveItemInMenu(this._rootMenu, dbusItem, newpos);
}
_onMenuOpened(menu, state) {
if (!this._rootItem)
return;
this._client.active = state;
if (state) {
if (this._openedSubMenu && this._openedSubMenu.isOpen)
this._openedSubMenu.close();
this._rootItem.handleEvent('opened', null, 0);
this._rootItem.sendAboutToShow();
} else {
this._rootItem.handleEvent('closed', null, 0);
}
}
destroy() {
this.emit('destroy');
if (this._client)
this._client.destroy();
this._client = null;
this._rootItem = null;
this._rootMenu = null;
this.indicator = null;
this._itemsBeingAdded = null;
}
}

View File

@@ -0,0 +1,103 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import {CancellableChild, Logger} from './util.js';
Gio._promisify(Gio.DBusProxy.prototype, 'init_async');
export const DBusProxy = GObject.registerClass({
Signals: {'destroy': {}},
}, class DBusProxy extends Gio.DBusProxy {
static get TUPLE_VARIANT_TYPE() {
if (!this._tupleVariantType)
this._tupleVariantType = new GLib.VariantType('(v)');
return this._tupleVariantType;
}
static destroy() {
delete this._tupleType;
}
_init(busName, objectPath, interfaceInfo, flags = Gio.DBusProxyFlags.NONE) {
if (interfaceInfo.signals.length)
Logger.warn('Avoid exposing signals to gjs!');
super._init({
gConnection: Gio.DBus.session,
gInterfaceName: interfaceInfo.name,
gInterfaceInfo: interfaceInfo,
gName: busName,
gObjectPath: objectPath,
gFlags: flags,
});
this._signalIds = [];
if (!(flags & Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS)) {
this._signalIds.push(this.connect('g-signal',
(_proxy, ...args) => this._onSignal(...args)));
}
this._signalIds.push(this.connect('notify::g-name-owner', () =>
this._onNameOwnerChanged()));
}
async initAsync(cancellable) {
cancellable = new CancellableChild(cancellable);
await this.init_async(GLib.PRIORITY_DEFAULT, cancellable);
this._cancellable = cancellable;
this.gInterfaceInfo.methods.map(m => m.name).forEach(method =>
this._ensureAsyncMethod(method));
}
destroy() {
this.emit('destroy');
this._signalIds.forEach(id => this.disconnect(id));
if (this._cancellable)
this._cancellable.cancel();
}
// This can be removed when we will have GNOME 43 as minimum version
_ensureAsyncMethod(method) {
if (this[`${method}Async`])
return;
if (!this[`${method}Remote`])
throw new Error(`Missing remote method '${method}'`);
this[`${method}Async`] = function (...args) {
return new Promise((resolve, reject) => {
this[`${method}Remote`](...args, (ret, e) => {
if (e)
reject(e);
else
resolve(ret);
});
});
};
}
_onSignal() {
}
getProperty(propertyName, cancellable) {
return this.gConnection.call(this.gName,
this.gObjectPath, 'org.freedesktop.DBus.Properties', 'Get',
GLib.Variant.new('(ss)', [this.gInterfaceName, propertyName]),
DBusProxy.TUPLE_VARIANT_TYPE, Gio.DBusCallFlags.NONE, -1,
cancellable);
}
getProperties(cancellable) {
return this.gConnection.call(this.gName,
this.gObjectPath, 'org.freedesktop.DBus.Properties', 'GetAll',
GLib.Variant.new('(s)', [this.gInterfaceName]),
GLib.VariantType.new('(a{sv})'), Gio.DBusCallFlags.NONE, -1,
cancellable);
}
});

View File

@@ -0,0 +1,89 @@
// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
//
// 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 2
// 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, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import * as Extension from 'resource:///org/gnome/shell/extensions/extension.js';
import * as StatusNotifierWatcher from './statusNotifierWatcher.js';
import * as Interfaces from './interfaces.js';
import * as TrayIconsManager from './trayIconsManager.js';
import * as Util from './util.js';
import {SettingsManager} from './settingsManager.js';
export default class AppIndicatorExtension extends Extension.Extension {
constructor(...args) {
super(...args);
Util.Logger.init(this);
Interfaces.initialize(this);
this._isEnabled = false;
this._statusNotifierWatcher = null;
this._watchDog = new Util.NameWatcher(StatusNotifierWatcher.WATCHER_BUS_NAME);
this._watchDog.connect('vanished', () => this._maybeEnableAfterNameAvailable());
// HACK: we want to leave the watchdog alive when disabling the extension,
// but if we are being reloaded, we destroy it since it could be considered
// a leak and spams our log, too.
/* eslint-disable no-undef */
if (typeof global['--appindicator-extension-on-reload'] === 'function')
global['--appindicator-extension-on-reload']();
global['--appindicator-extension-on-reload'] = () => {
Util.Logger.debug('Reload detected, destroying old watchdog');
this._watchDog.destroy();
this._watchDog = null;
};
/* eslint-enable no-undef */
}
enable() {
this._isEnabled = true;
SettingsManager.initialize(this);
Util.tryCleanupOldIndicators();
this._maybeEnableAfterNameAvailable();
TrayIconsManager.TrayIconsManager.initialize();
}
disable() {
this._isEnabled = false;
TrayIconsManager.TrayIconsManager.destroy();
if (this._statusNotifierWatcher !== null) {
this._statusNotifierWatcher.destroy();
this._statusNotifierWatcher = null;
}
SettingsManager.destroy();
}
// FIXME: when entering/leaving the lock screen, the extension might be
// enabled/disabled rapidly.
// This will create very bad side effects in case we were not done unowning
// the name while trying to own it again. Since g_bus_unown_name doesn't
// fire any callback when it's done, we need to monitor the bus manually
// to find out when the name vanished so we can reclaim it again.
_maybeEnableAfterNameAvailable() {
// by the time we get called whe might not be enabled
if (!this._isEnabled || this._statusNotifierWatcher)
return;
if (this._watchDog.nameAcquired && this._watchDog.nameOnBus)
return;
this._statusNotifierWatcher = new StatusNotifierWatcher.StatusNotifierWatcher(
this._watchDog);
}
}

View File

@@ -0,0 +1,179 @@
// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
//
// 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 2
// 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, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import * as PromiseUtils from './promiseUtils.js';
import * as Util from './util.js';
// The icon cache caches icon objects in case they're reused shortly aftwerwards.
// This is necessary for some indicators like skype which rapidly switch between serveral icons.
// Without caching, the garbage collection would never be able to handle the amount of new icon data.
// If the lifetime of an icon is over, the cache will destroy the icon. (!)
// The presence of active icons will extend the lifetime.
const GC_INTERVAL = 100; // seconds
const LIFETIME_TIMESPAN = 120; // seconds
// how to use: see IconCache.add, IconCache.get
export class IconCache {
constructor() {
this._cache = new Map();
this._activeIcons = Object.create(null);
this._lifetime = new Map(); // we don't want to attach lifetime to the object
}
add(id, icon) {
if (!(icon instanceof Gio.Icon)) {
Util.Logger.critical('IconCache: Only Gio.Icons are supported');
return null;
}
if (!id) {
Util.Logger.critical('IconCache: Invalid ID provided');
return null;
}
const oldIcon = this._cache.get(id);
if (!oldIcon || !oldIcon.equal(icon)) {
Util.Logger.debug(`IconCache: adding ${id}: ${icon}`);
this._cache.set(id, icon);
} else {
icon = oldIcon;
}
this._renewLifetime(id);
this._checkGC();
return icon;
}
updateActive(iconType, gicon, isActive) {
if (!gicon)
return;
const previousActive = this._activeIcons[iconType];
if (isActive && [...this._cache.values()].some(icon => icon === gicon))
this._activeIcons[iconType] = gicon;
else if (previousActive === gicon)
delete this._activeIcons[iconType];
else
return;
if (previousActive) {
this._cache.forEach((icon, id) => {
if (icon === previousActive)
this._renewLifetime(id);
});
}
}
_remove(id) {
Util.Logger.debug(`IconCache: removing ${id}`);
this._cache.delete(id);
this._lifetime.delete(id);
}
_renewLifetime(id) {
this._lifetime.set(id, new Date().getTime() + LIFETIME_TIMESPAN * 1000);
}
forceDestroy(id) {
const gicon = this._cache.has(id);
if (gicon) {
Object.keys(this._activeIcons).forEach(iconType =>
this.updateActive(iconType, gicon, false));
this._remove(id);
this._checkGC();
}
}
// marks all the icons as removable, if something doesn't claim them before
weakClear() {
this._activeIcons = Object.create(null);
this._checkGC();
}
// removes everything from the cache
clear() {
this._activeIcons = Object.create(null);
this._cache.forEach((_icon, id) => this._remove(id));
this._checkGC();
}
// returns an object from the cache, or null if it can't be found.
get(id) {
const icon = this._cache.get(id);
if (icon) {
Util.Logger.debug(`IconCache: retrieving ${id}: ${icon}`);
this._renewLifetime(id);
return icon;
}
return null;
}
async _checkGC() {
const cacheIsEmpty = this._cache.size === 0;
if (!cacheIsEmpty && !this._gcTimeout) {
Util.Logger.debug('IconCache: garbage collector started');
let anyUnusedInCache = false;
this._gcTimeout = new PromiseUtils.TimeoutSecondsPromise(GC_INTERVAL,
GLib.PRIORITY_LOW);
try {
await this._gcTimeout;
anyUnusedInCache = this._gc();
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e, 'IconCache: garbage collector');
} finally {
delete this._gcTimeout;
}
if (anyUnusedInCache)
this._checkGC();
} else if (cacheIsEmpty && this._gcTimeout) {
Util.Logger.debug('IconCache: garbage collector stopped');
this._gcTimeout.cancel();
}
}
_gc() {
const time = new Date().getTime();
let anyUnused = false;
this._cache.forEach((icon, id) => {
if (Object.values(this._activeIcons).includes(icon)) {
Util.Logger.debug(`IconCache: ${id} is in use.`);
} else if (this._lifetime.get(id) < time) {
this._remove(id);
} else {
anyUnused = true;
Util.Logger.debug(`IconCache: ${id} survived this round.`);
}
});
return anyUnused;
}
destroy() {
this.clear();
}
}

View File

@@ -0,0 +1,585 @@
// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
//
// 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 2
// 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, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import St from 'gi://St';
import * as AppDisplay from 'resource:///org/gnome/shell/ui/appDisplay.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as Panel from 'resource:///org/gnome/shell/ui/panel.js';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as AppIndicator from './appIndicator.js';
import * as PromiseUtils from './promiseUtils.js';
import * as SettingsManager from './settingsManager.js';
import * as Util from './util.js';
import * as DBusMenu from './dbusMenu.js';
const DEFAULT_ICON_SIZE = Panel.PANEL_ICON_SIZE || 16;
export function addIconToPanel(statusIcon) {
if (!(statusIcon instanceof BaseStatusIcon))
throw TypeError(`Unexpected icon type: ${statusIcon}`);
const settings = SettingsManager.getDefaultGSettings();
const indicatorId = `appindicator-${statusIcon.uniqueId}`;
const currentIcon = Main.panel.statusArea[indicatorId];
if (currentIcon) {
if (currentIcon !== statusIcon)
currentIcon.destroy();
Main.panel.statusArea[indicatorId] = null;
}
Main.panel.addToStatusArea(indicatorId, statusIcon, 1,
settings.get_string('tray-pos'));
Util.connectSmart(settings, 'changed::tray-pos', statusIcon, () =>
addIconToPanel(statusIcon));
}
export function getTrayIcons() {
return Object.values(Main.panel.statusArea).filter(
i => i instanceof IndicatorStatusTrayIcon);
}
export function getAppIndicatorIcons() {
return Object.values(Main.panel.statusArea).filter(
i => i instanceof IndicatorStatusIcon);
}
export const BaseStatusIcon = GObject.registerClass(
class IndicatorBaseStatusIcon extends PanelMenu.Button {
_init(menuAlignment, nameText, iconActor, dontCreateMenu) {
super._init(menuAlignment, nameText, dontCreateMenu);
const settings = SettingsManager.getDefaultGSettings();
Util.connectSmart(settings, 'changed::icon-opacity', this, this._updateOpacity);
this.connect('notify::hover', () => this._onHoverChanged());
if (!super._onDestroy)
this.connect('destroy', () => this._onDestroy());
this._box = new St.BoxLayout({style_class: 'panel-status-indicators-box'});
this.add_child(this._box);
this._setIconActor(iconActor);
this._showIfReady();
}
_setIconActor(icon) {
if (!(icon instanceof Clutter.Actor))
throw new Error(`${icon} is not a valid actor`);
if (this._icon && this._icon !== icon)
this._icon.destroy();
this._icon = icon;
this._updateEffects();
this._monitorIconEffects();
if (this._icon) {
this._box.add_child(this._icon);
const id = this._icon.connect('destroy', () => {
this._icon.disconnect(id);
this._icon = null;
this._monitorIconEffects();
});
}
}
_onDestroy() {
if (this._icon)
this._icon.destroy();
if (super._onDestroy)
super._onDestroy();
}
isReady() {
throw new GObject.NotImplementedError('isReady() in %s'.format(this.constructor.name));
}
get icon() {
return this._icon;
}
get uniqueId() {
throw new GObject.NotImplementedError('uniqueId in %s'.format(this.constructor.name));
}
_showIfReady() {
this.visible = this.isReady();
}
_onHoverChanged() {
if (this.hover) {
this.opacity = 255;
if (this._icon)
this._icon.remove_effect_by_name('desaturate');
} else {
this._updateEffects();
}
}
_updateOpacity() {
const settings = SettingsManager.getDefaultGSettings();
const userValue = settings.get_user_value('icon-opacity');
if (userValue)
this.opacity = userValue.unpack();
else
this.opacity = 255;
}
_updateEffects() {
this._updateOpacity();
if (this._icon) {
this._updateSaturation();
this._updateBrightnessContrast();
}
}
_monitorIconEffects() {
const settings = SettingsManager.getDefaultGSettings();
const monitoring = !!this._iconSaturationIds;
if (!this._icon && monitoring) {
Util.disconnectSmart(settings, this, this._iconSaturationIds);
delete this._iconSaturationIds;
Util.disconnectSmart(settings, this, this._iconBrightnessIds);
delete this._iconBrightnessIds;
Util.disconnectSmart(settings, this, this._iconContrastIds);
delete this._iconContrastIds;
} else if (this._icon && !monitoring) {
this._iconSaturationIds =
Util.connectSmart(settings, 'changed::icon-saturation', this,
this._updateSaturation);
this._iconBrightnessIds =
Util.connectSmart(settings, 'changed::icon-brightness', this,
this._updateBrightnessContrast);
this._iconContrastIds =
Util.connectSmart(settings, 'changed::icon-contrast', this,
this._updateBrightnessContrast);
}
}
_updateSaturation() {
const settings = SettingsManager.getDefaultGSettings();
const desaturationValue = settings.get_double('icon-saturation');
let desaturateEffect = this._icon.get_effect('desaturate');
if (desaturationValue > 0) {
if (!desaturateEffect) {
desaturateEffect = new Clutter.DesaturateEffect();
this._icon.add_effect_with_name('desaturate', desaturateEffect);
}
desaturateEffect.set_factor(desaturationValue);
} else if (desaturateEffect) {
this._icon.remove_effect(desaturateEffect);
}
}
_updateBrightnessContrast() {
const settings = SettingsManager.getDefaultGSettings();
const brightnessValue = settings.get_double('icon-brightness');
const contrastValue = settings.get_double('icon-contrast');
let brightnessContrastEffect = this._icon.get_effect('brightness-contrast');
if (brightnessValue !== 0 | contrastValue !== 0) {
if (!brightnessContrastEffect) {
brightnessContrastEffect = new Clutter.BrightnessContrastEffect();
this._icon.add_effect_with_name('brightness-contrast', brightnessContrastEffect);
}
brightnessContrastEffect.set_brightness(brightnessValue);
brightnessContrastEffect.set_contrast(contrastValue);
} else if (brightnessContrastEffect) {
this._icon.remove_effect(brightnessContrastEffect);
}
}
});
/*
* IndicatorStatusIcon implements an icon in the system status area
*/
export const IndicatorStatusIcon = GObject.registerClass(
class IndicatorStatusIcon extends BaseStatusIcon {
_init(indicator) {
super._init(0.5, indicator.accessibleName,
new AppIndicator.IconActor(indicator, DEFAULT_ICON_SIZE));
this._indicator = indicator;
this._lastClickTime = -1;
this._lastClickX = -1;
this._lastClickY = -1;
this._box.add_style_class_name('appindicator-box');
Util.connectSmart(this._indicator, 'ready', this, this._showIfReady);
Util.connectSmart(this._indicator, 'menu', this, this._updateMenu);
Util.connectSmart(this._indicator, 'label', this, this._updateLabel);
Util.connectSmart(this._indicator, 'status', this, this._updateStatus);
Util.connectSmart(this._indicator, 'reset', this, () => {
this._updateStatus();
this._updateLabel();
});
Util.connectSmart(this._indicator, 'accessible-name', this, () =>
this.set_accessible_name(this._indicator.accessibleName));
Util.connectSmart(this._indicator, 'destroy', this, () => this.destroy());
this.connect('notify::visible', () => this._updateMenu());
this._showIfReady();
}
_onDestroy() {
if (this._menuClient) {
this._menuClient.disconnect(this._menuReadyId);
this._menuClient.destroy();
this._menuClient = null;
}
super._onDestroy();
}
get uniqueId() {
return this._indicator.uniqueId;
}
isReady() {
return this._indicator && this._indicator.isReady;
}
_updateLabel() {
const {label} = this._indicator;
if (label) {
if (!this._label || !this._labelBin) {
this._labelBin = new St.Bin({
yAlign: Clutter.ActorAlign.CENTER,
});
this._label = new St.Label();
Util.addActor(this._labelBin, this._label);
Util.addActor(this._box, this._labelBin);
}
this._label.set_text(label);
if (!this._box.contains(this._labelBin))
Util.addActor(this._box, this._labelBin); // FIXME: why is it suddenly necessary?
} else if (this._label) {
this._labelBin.destroy_all_children();
Util.removeActor(this._box, this._labelBin);
this._labelBin.destroy();
delete this._labelBin;
delete this._label;
}
}
_updateStatus() {
const wasVisible = this.visible;
this.visible = this._indicator.status !== AppIndicator.SNIStatus.PASSIVE;
if (this.visible !== wasVisible)
this._indicator.checkAlive().catch(logError);
}
_updateMenu() {
if (this._menuClient) {
this._menuClient.disconnect(this._menuReadyId);
this._menuClient.destroy();
this._menuClient = null;
this.menu.removeAll();
}
if (this.visible && this._indicator.menuPath) {
this._menuClient = new DBusMenu.Client(this._indicator.busName,
this._indicator.menuPath, this._indicator);
if (this._menuClient.isReady)
this._menuClient.attachToMenu(this.menu);
this._menuReadyId = this._menuClient.connect('ready-changed', () => {
if (this._menuClient.isReady)
this._menuClient.attachToMenu(this.menu);
else
this._updateMenu();
});
}
}
_showIfReady() {
if (!this.isReady())
return;
this._updateLabel();
this._updateStatus();
this._updateMenu();
}
_updateClickCount(event) {
const [x, y] = event.get_coords();
const time = event.get_time();
const {doubleClickDistance, doubleClickTime} =
Clutter.Settings.get_default();
if (time > (this._lastClickTime + doubleClickTime) ||
(Math.abs(x - this._lastClickX) > doubleClickDistance) ||
(Math.abs(y - this._lastClickY) > doubleClickDistance))
this._clickCount = 0;
this._lastClickTime = time;
this._lastClickX = x;
this._lastClickY = y;
this._clickCount = (this._clickCount % 2) + 1;
return this._clickCount;
}
_maybeHandleDoubleClick(event) {
if (this._indicator.supportsActivation === false)
return Clutter.EVENT_PROPAGATE;
if (event.get_button() !== Clutter.BUTTON_PRIMARY)
return Clutter.EVENT_PROPAGATE;
if (this._updateClickCount(event) === 2) {
this._indicator.open(...event.get_coords(), event.get_time());
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
}
async _waitForDoubleClick() {
const {doubleClickTime} = Clutter.Settings.get_default();
this._waitDoubleClickPromise = new PromiseUtils.TimeoutPromise(
doubleClickTime);
try {
await this._waitDoubleClickPromise;
this.menu.toggle();
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
throw e;
} finally {
delete this._waitDoubleClickPromise;
}
}
vfunc_event(event) {
if (this.menu.numMenuItems && event.type() === Clutter.EventType.TOUCH_BEGIN)
this.menu.toggle();
return Clutter.EVENT_PROPAGATE;
}
vfunc_button_press_event(event) {
if (this._waitDoubleClickPromise)
this._waitDoubleClickPromise.cancel();
// if middle mouse button clicked send SecondaryActivate dbus event and do not show appindicator menu
if (event.get_button() === Clutter.BUTTON_MIDDLE) {
if (Main.panel.menuManager.activeMenu)
Main.panel.menuManager._closeMenu(true, Main.panel.menuManager.activeMenu);
this._indicator.secondaryActivate(event.get_time(), ...event.get_coords());
return Clutter.EVENT_STOP;
}
if (event.get_button() === Clutter.BUTTON_SECONDARY) {
this.menu.toggle();
return Clutter.EVENT_PROPAGATE;
}
const doubleClickHandled = this._maybeHandleDoubleClick(event);
if (doubleClickHandled === Clutter.EVENT_PROPAGATE &&
event.get_button() === Clutter.BUTTON_PRIMARY &&
this.menu.numMenuItems) {
if (this._indicator.supportsActivation !== false)
this._waitForDoubleClick().catch(logError);
else
this.menu.toggle();
}
return Clutter.EVENT_PROPAGATE;
}
vfunc_scroll_event(event) {
// Since Clutter 1.10, clutter will always send a smooth scrolling event
// with explicit deltas, no matter what input device is used
// In fact, for every scroll there will be a smooth and non-smooth scroll
// event, and we can choose which one we interpret.
if (event.get_scroll_direction() === Clutter.ScrollDirection.SMOOTH) {
const [dx, dy] = event.get_scroll_delta();
this._indicator.scroll(dx, dy);
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
}
});
export const IndicatorStatusTrayIcon = GObject.registerClass(
class IndicatorTrayIcon extends BaseStatusIcon {
_init(icon) {
super._init(0.5, icon.wm_class, icon, {dontCreateMenu: true});
Util.Logger.debug(`Adding legacy tray icon ${this.uniqueId}`);
this._box.add_style_class_name('appindicator-trayicons-box');
this.add_style_class_name('appindicator-icon');
this.add_style_class_name('tray-icon');
this.connect('button-press-event', (_actor, _event) => {
this.add_style_pseudo_class('active');
return Clutter.EVENT_PROPAGATE;
});
this.connect('button-release-event', (_actor, event) => {
this._icon.click(event);
this.remove_style_pseudo_class('active');
return Clutter.EVENT_PROPAGATE;
});
this.connect('key-press-event', (_actor, event) => {
this.add_style_pseudo_class('active');
this._icon.click(event);
return Clutter.EVENT_PROPAGATE;
});
this.connect('key-release-event', (_actor, event) => {
this._icon.click(event);
this.remove_style_pseudo_class('active');
return Clutter.EVENT_PROPAGATE;
});
Util.connectSmart(this._icon, 'destroy', this, () => {
icon.clear_effects();
this.destroy();
});
const settings = SettingsManager.getDefaultGSettings();
Util.connectSmart(settings, 'changed::icon-size', this, this._updateIconSize);
const themeContext = St.ThemeContext.get_for_stage(global.stage);
Util.connectSmart(themeContext, 'notify::scale-factor', this, () =>
this._updateIconSize());
this._updateIconSize();
}
_onDestroy() {
Util.Logger.debug(`Destroying legacy tray icon ${this.uniqueId}`);
if (this._waitDoubleClickPromise)
this._waitDoubleClickPromise.cancel();
super._onDestroy();
}
isReady() {
return !!this._icon;
}
get uniqueId() {
return `legacy:${this._icon.wm_class}:${this._icon.pid}`;
}
vfunc_navigate_focus(from, direction) {
this.grab_key_focus();
return super.vfunc_navigate_focus(from, direction);
}
_getSimulatedButtonEvent(touchEvent) {
const event = Clutter.Event.new(Clutter.EventType.BUTTON_RELEASE);
event.set_button(1);
event.set_time(touchEvent.get_time());
event.set_flags(touchEvent.get_flags());
event.set_stage(global.stage);
event.set_source(touchEvent.get_source());
event.set_coords(...touchEvent.get_coords());
event.set_state(touchEvent.get_state());
return event;
}
vfunc_touch_event(event) {
// Under X11 we rely on emulated pointer events
if (!imports.gi.Meta.is_wayland_compositor())
return Clutter.EVENT_PROPAGATE;
const slot = event.get_event_sequence().get_slot();
if (!this._touchPressSlot &&
event.get_type() === Clutter.EventType.TOUCH_BEGIN) {
this.add_style_pseudo_class('active');
this._touchButtonEvent = this._getSimulatedButtonEvent(event);
this._touchPressSlot = slot;
this._touchDelayPromise = new PromiseUtils.TimeoutPromise(
AppDisplay.MENU_POPUP_TIMEOUT);
this._touchDelayPromise.then(() => {
delete this._touchDelayPromise;
delete this._touchPressSlot;
this._touchButtonEvent.set_button(3);
this._icon.click(this._touchButtonEvent);
this.remove_style_pseudo_class('active');
});
} else if (event.get_type() === Clutter.EventType.TOUCH_END &&
this._touchPressSlot === slot) {
delete this._touchPressSlot;
delete this._touchButtonEvent;
if (this._touchDelayPromise) {
this._touchDelayPromise.cancel();
delete this._touchDelayPromise;
}
this._icon.click(this._getSimulatedButtonEvent(event));
this.remove_style_pseudo_class('active');
} else if (event.get_type() === Clutter.EventType.TOUCH_UPDATE &&
this._touchPressSlot === slot) {
this.add_style_pseudo_class('active');
this._touchButtonEvent = this._getSimulatedButtonEvent(event);
}
return Clutter.EVENT_PROPAGATE;
}
vfunc_leave_event(event) {
this.remove_style_pseudo_class('active');
if (this._touchDelayPromise) {
this._touchDelayPromise.cancel();
delete this._touchDelayPromise;
}
return super.vfunc_leave_event(event);
}
_updateIconSize() {
const settings = SettingsManager.getDefaultGSettings();
const {scaleFactor} = St.ThemeContext.get_for_stage(global.stage);
let iconSize = settings.get_int('icon-size');
if (iconSize <= 0)
iconSize = DEFAULT_ICON_SIZE;
this.height = -1;
this._icon.set({
width: iconSize * scaleFactor,
height: iconSize * scaleFactor,
xAlign: Clutter.ActorAlign.CENTER,
yAlign: Clutter.ActorAlign.CENTER,
});
}
});

View File

@@ -0,0 +1,66 @@
<interface name="com.canonical.dbusmenu">
<!-- Properties -->
<property name="Version" type="u" access="read" />
<property name="TextDirection" type="s" access="read" />
<property name="Status" type="s" access="read" />
<property name="IconThemePath" type="as" access="read" />
<!-- Functions -->
<method name="GetLayout">
<arg type="i" name="parentId" direction="in" />
<arg type="i" name="recursionDepth" direction="in" />
<arg type="as" name="propertyNames" direction="in" />
<arg type="u" name="revision" direction="out" />
<arg type="(ia{sv}av)" name="layout" direction="out" />
</method>
<method name="GetGroupProperties">
<arg type="ai" name="ids" direction="in" />
<arg type="as" name="propertyNames" direction="in" />
<arg type="a(ia{sv})" name="properties" direction="out" />
</method>
<method name="GetProperty">
<arg type="i" name="id" direction="in" />
<arg type="s" name="name" direction="in" />
<arg type="v" name="value" direction="out" />
</method>
<method name="Event">
<arg type="i" name="id" direction="in" />
<arg type="s" name="eventId" direction="in" />
<arg type="v" name="data" direction="in" />
<arg type="u" name="timestamp" direction="in" />
</method>
<method name="EventGroup">
<arg type="a(isvu)" name="events" direction="in" />
<arg type="ai" name="idErrors" direction="out" />
</method>
<method name="AboutToShow">
<arg type="i" name="id" direction="in" />
<arg type="b" name="needUpdate" direction="out" />
</method>
<method name="AboutToShowGroup">
<arg type="ai" name="ids" direction="in" />
<arg type="ai" name="updatesNeeded" direction="out" />
<arg type="ai" name="idErrors" direction="out" />
</method>
<!-- Signals
<signal name="ItemsPropertiesUpdated">
<arg type="a(ia{sv})" name="updatedProps" direction="out" />
<arg type="a(ias)" name="removedProps" direction="out" />
</signal>
<signal name="LayoutUpdated">
<arg type="u" name="revision" direction="out" />
<arg type="i" name="parent" direction="out" />
</signal>
<signal name="ItemActivationRequested">
<arg type="i" name="id" direction="out" />
<arg type="u" name="timestamp" direction="out" />
</signal>
-->
</interface>

View File

@@ -0,0 +1,130 @@
<!-- Based on:
https://invent.kde.org/frameworks/knotifications/-/blob/master/src/org.kde.StatusNotifierItem.xml
-->
<interface name="org.kde.StatusNotifierItem">
<property name="Category" type="s" access="read"/>
<property name="Id" type="s" access="read"/>
<property name="Title" type="s" access="read"/>
<property name="Status" type="s" access="read"/>
<property name="WindowId" type="i" access="read"/>
<!-- An additional path to add to the theme search path to find the icons specified above. -->
<property name="IconThemePath" type="s" access="read"/>
<property name="Menu" type="o" access="read"/>
<property name="ItemIsMenu" type="b" access="read"/>
<!-- main icon -->
<!-- names are preferred over pixmaps -->
<property name="IconName" type="s" access="read"/>
<!--struct containing width, height and image data-->
<property name="IconPixmap" type="a(iiay)" access="read">
<annotation name="org.qtproject.QtDBus.QtTypeName" value="KDbusImageVector"/>
</property>
<property name="OverlayIconName" type="s" access="read"/>
<property name="OverlayIconPixmap" type="a(iiay)" access="read">
<annotation name="org.qtproject.QtDBus.QtTypeName" value="KDbusImageVector"/>
</property>
<!-- Requesting attention icon -->
<property name="AttentionIconName" type="s" access="read"/>
<!--same definition as image-->
<property name="AttentionIconPixmap" type="a(iiay)" access="read">
<annotation name="org.qtproject.QtDBus.QtTypeName" value="KDbusImageVector"/>
</property>
<property name="AttentionMovieName" type="s" access="read"/>
<!-- tooltip data -->
<!--(iiay) is an image-->
<!-- We disable this as we don't support tooltip, so no need to go through it
<property name="ToolTip" type="(sa(iiay)ss)" access="read">
<annotation name="org.qtproject.QtDBus.QtTypeName" value="KDbusToolTipStruct"/>
</property>
-->
<!-- interaction: the systemtray wants the application to do something -->
<method name="ContextMenu">
<!-- we're passing the coordinates of the icon, so the app knows where to put the popup window -->
<arg name="x" type="i" direction="in"/>
<arg name="y" type="i" direction="in"/>
</method>
<method name="Activate">
<arg name="x" type="i" direction="in"/>
<arg name="y" type="i" direction="in"/>
</method>
<method name="ProvideXdgActivationToken">
<arg name="token" type="s" direction="in"/>
</method>
<method name="SecondaryActivate">
<arg name="x" type="i" direction="in"/>
<arg name="y" type="i" direction="in"/>
</method>
<method name="XAyatanaSecondaryActivate">
<arg name="timestamp" type="u" direction="in"/>
</method>
<method name="Scroll">
<arg name="delta" type="i" direction="in"/>
<arg name="orientation" type="s" direction="in"/>
</method>
<!-- Signals: the client wants to change something in the status
<signal name="NewTitle">
</signal>
<signal name="NewIcon">
</signal>
<signal name="NewAttentionIcon">
</signal>
<signal name="NewOverlayIcon">
</signal>
-->
<!-- We disable this as we don't support tooltip, so no need to go through it
<signal name="NewToolTip">
</signal>
-->
<!--
<signal name="NewStatus">
<arg name="status" type="s"/>
</signal>
-->
<!-- The following items are not supported by specs, but widely used
<signal name="NewIconThemePath">
<arg type="s" name="icon_theme_path" direction="out" />
</signal>
<signal name="NewMenu"></signal>
-->
<!-- ayatana labels -->
<!-- These are commented out because GDBusProxy would otherwise require them,
but they are not available for KDE indicators
-->
<!--<signal name="XAyatanaNewLabel">
<arg type="s" name="label" direction="out" />
<arg type="s" name="guide" direction="out" />
</signal>
<property name="XAyatanaLabel" type="s" access="read" />
<property name="XAyatanaLabelGuide" type="s" access="read" />-->
</interface>

View File

@@ -0,0 +1,38 @@
<interface name="org.kde.StatusNotifierWatcher">
<!-- methods -->
<method name="RegisterStatusNotifierItem">
<arg name="service" type="s" direction="in"/>
</method>
<method name="RegisterStatusNotifierHost">
<arg name="service" type="s" direction="in"/>
</method>
<!-- properties -->
<property name="RegisteredStatusNotifierItems" type="as" access="read">
<annotation name="org.qtproject.QtDBus.QtTypeName.Out0" value="QStringList"/>
</property>
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
<property name="ProtocolVersion" type="i" access="read"/>
<!-- signals -->
<signal name="StatusNotifierItemRegistered">
<arg type="s"/>
</signal>
<signal name="StatusNotifierItemUnregistered">
<arg type="s"/>
</signal>
<signal name="StatusNotifierHostRegistered">
</signal>
<signal name="StatusNotifierHostUnregistered">
</signal>
</interface>

View File

@@ -0,0 +1,52 @@
// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
//
// 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 2
// 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, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
export let StatusNotifierItem = null;
export let StatusNotifierWatcher = null;
export let DBusMenu = null;
// loads a xml file into an in-memory string
function loadInterfaceXml(extension, filename) {
const interfacesDir = extension.dir.get_child('interfaces-xml');
const file = interfacesDir.get_child(filename);
const [result, contents] = imports.gi.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).
let nodeContents = contents;
if (contents instanceof Uint8Array)
nodeContents = imports.byteArray.toString(contents);
return `<node>${nodeContents}</node>`;
} else {
throw new Error(`AppIndicatorSupport: Could not load file: ${filename}`);
}
}
export function initialize(extension) {
StatusNotifierItem = loadInterfaceXml(extension, 'StatusNotifierItem.xml');
StatusNotifierWatcher = loadInterfaceXml(extension, 'StatusNotifierWatcher.xml');
DBusMenu = loadInterfaceXml(extension, 'DBusMenu.xml');
}
export function destroy() {
StatusNotifierItem = null;
StatusNotifierWatcher = null;
DBusMenu = null;
}

View File

@@ -0,0 +1,14 @@
{
"_generated": "Generated by SweetTooth, do not edit",
"description": "Adds AppIndicator, KStatusNotifierItem and legacy Tray icons support to the Shell",
"gettext-domain": "AppIndicatorExtension",
"name": "AppIndicator and KStatusNotifierItem Support",
"settings-schema": "org.gnome.shell.extensions.appindicator",
"shell-version": [
"45",
"46"
],
"url": "https://github.com/ubuntu/gnome-shell-extension-appindicator",
"uuid": "appindicatorsupport@rgcjonas.gmail.com",
"version": 58
}

View File

@@ -0,0 +1,68 @@
// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
//
// 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 2
// 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, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
export function argbToRgba(src) {
const dest = new Uint8Array(src.length);
for (let j = 0; j < src.length; j += 4) {
const srcAlpha = src[j];
dest[j] = src[j + 1]; /* red */
dest[j + 1] = src[j + 2]; /* green */
dest[j + 2] = src[j + 3]; /* blue */
dest[j + 3] = srcAlpha; /* alpha */
}
return dest;
}
export function getBestPixmap(pixmapsVariant, preferredSize) {
if (!pixmapsVariant)
throw new TypeError('null pixmapsVariant');
const pixmapsVariantsArray = new Array(pixmapsVariant.n_children());
if (!pixmapsVariantsArray.length)
throw TypeError('Empty Icon found');
for (let i = 0; i < pixmapsVariantsArray.length; ++i)
pixmapsVariantsArray[i] = pixmapsVariant.get_child_value(i);
const pixmapsSizedArray = pixmapsVariantsArray.map((pixmapVariant, index) => ({
width: pixmapVariant.get_child_value(0).unpack(),
height: pixmapVariant.get_child_value(1).unpack(),
index,
}));
const sortedIconPixmapArray = pixmapsSizedArray.sort(
({width: widthA, height: heightA}, {width: widthB, height: heightB}) => {
const areaA = widthA * heightA;
const areaB = widthB * heightB;
return areaA - areaB;
});
// we prefer any pixmap that is equal or bigger than our requested size
const qualifiedIconPixmapArray = sortedIconPixmapArray.filter(({width, height}) =>
width >= preferredSize && height >= preferredSize);
const {width, height, index} = qualifiedIconPixmapArray.length > 0
? qualifiedIconPixmapArray[0] : sortedIconPixmapArray.pop();
const pixmapVariant = pixmapsVariantsArray[index].get_child_value(2);
const rowStride = width * 4; // hopefully this is correct
return {pixmapVariant, width, height, rowStride};
}

View File

@@ -0,0 +1,326 @@
// -*- mode: js2; indent-tabs-mode: nil; js2-basic-offset: 4 -*-
/* exported init, buildPrefsWidget */
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gio from 'gi://Gio';
import Gtk from 'gi://Gtk';
import {
ExtensionPreferences,
gettext as _
} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
const AppIndicatorPreferences = GObject.registerClass(
class AppIndicatorPreferences extends Gtk.Box {
_init(extension) {
super._init({orientation: Gtk.Orientation.VERTICAL, spacing: 30});
this._settings = extension.getSettings();
let label = null;
let widget = null;
this.preferences_vbox = new Gtk.Box({
orientation: Gtk.Orientation.VERTICAL,
spacing: 8,
margin_start: 30,
margin_end: 30,
margin_top: 30,
margin_bottom: 30,
});
this.custom_icons_vbox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
spacing: 10,
margin_start: 10,
margin_end: 10,
margin_top: 10,
margin_bottom: 10,
});
label = new Gtk.Label({
label: _('Enable Legacy Tray Icons support'),
hexpand: true,
halign: Gtk.Align.START,
});
widget = new Gtk.Switch({halign: Gtk.Align.END});
this._settings.bind('legacy-tray-enabled', widget, 'active',
Gio.SettingsBindFlags.DEFAULT);
this.legacy_tray_hbox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
spacing: 10,
margin_start: 10,
margin_end: 10,
margin_top: 10,
margin_bottom: 10,
});
this.legacy_tray_hbox.append(label);
this.legacy_tray_hbox.append(widget);
// Icon opacity
this.opacity_hbox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
spacing: 10,
margin_start: 10,
margin_end: 10,
margin_top: 10,
margin_bottom: 10,
});
label = new Gtk.Label({
label: _('Opacity (min: 0, max: 255)'),
hexpand: true,
halign: Gtk.Align.START,
});
widget = new Gtk.SpinButton({halign: Gtk.Align.END});
widget.set_sensitive(true);
widget.set_range(0, 255);
widget.set_value(this._settings.get_int('icon-opacity'));
widget.set_increments(1, 2);
widget.connect('value-changed', w => {
this._settings.set_int('icon-opacity', w.get_value_as_int());
});
this.opacity_hbox.append(label);
this.opacity_hbox.append(widget);
// Icon saturation
this.saturation_hbox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
spacing: 10,
margin_start: 10,
margin_end: 10,
margin_top: 10,
margin_bottom: 10,
});
label = new Gtk.Label({
label: _('Desaturation (min: 0.0, max: 1.0)'),
hexpand: true,
halign: Gtk.Align.START,
});
widget = new Gtk.SpinButton({halign: Gtk.Align.END, digits: 1});
widget.set_sensitive(true);
widget.set_range(0.0, 1.0);
widget.set_value(this._settings.get_double('icon-saturation'));
widget.set_increments(0.1, 0.2);
widget.connect('value-changed', w => {
this._settings.set_double('icon-saturation', w.get_value());
});
this.saturation_hbox.append(label);
this.saturation_hbox.append(widget);
// Icon brightness
this.brightness_hbox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
spacing: 10,
margin_start: 10,
margin_end: 10,
margin_top: 10,
margin_bottom: 10,
});
label = new Gtk.Label({
label: _('Brightness (min: -1.0, max: 1.0)'),
hexpand: true,
halign: Gtk.Align.START,
});
widget = new Gtk.SpinButton({halign: Gtk.Align.END, digits: 1});
widget.set_sensitive(true);
widget.set_range(-1.0, 1.0);
widget.set_value(this._settings.get_double('icon-brightness'));
widget.set_increments(0.1, 0.2);
widget.connect('value-changed', w => {
this._settings.set_double('icon-brightness', w.get_value());
});
this.brightness_hbox.append(label);
this.brightness_hbox.append(widget);
// Icon contrast
this.contrast_hbox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
spacing: 10,
margin_start: 10,
margin_end: 10,
margin_top: 10,
margin_bottom: 10,
});
label = new Gtk.Label({
label: _('Contrast (min: -1.0, max: 1.0)'),
hexpand: true,
halign: Gtk.Align.START,
});
widget = new Gtk.SpinButton({halign: Gtk.Align.END, digits: 1});
widget.set_sensitive(true);
widget.set_range(-1.0, 1.0);
widget.set_value(this._settings.get_double('icon-contrast'));
widget.set_increments(0.1, 0.2);
widget.connect('value-changed', w => {
this._settings.set_double('icon-contrast', w.get_value());
});
this.contrast_hbox.append(label);
this.contrast_hbox.append(widget);
// Icon size
this.icon_size_hbox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
spacing: 10,
margin_start: 10,
margin_end: 10,
margin_top: 10,
margin_bottom: 10,
});
label = new Gtk.Label({
label: _('Icon size (min: 0, max: 96)'),
hexpand: true,
halign: Gtk.Align.START,
});
widget = new Gtk.SpinButton({halign: Gtk.Align.END});
widget.set_sensitive(true);
widget.set_range(0, 96);
widget.set_value(this._settings.get_int('icon-size'));
widget.set_increments(1, 2);
widget.connect('value-changed', w => {
this._settings.set_int('icon-size', w.get_value_as_int());
});
this.icon_size_hbox.append(label);
this.icon_size_hbox.append(widget);
// Tray position in panel
this.tray_position_hbox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
spacing: 10,
margin_start: 10,
margin_end: 10,
margin_top: 10,
margin_bottom: 10,
});
label = new Gtk.Label({
label: _('Tray horizontal alignment'),
hexpand: true,
halign: Gtk.Align.START,
});
widget = new Gtk.ComboBoxText();
widget.append('center', _('Center'));
widget.append('left', _('Left'));
widget.append('right', _('Right'));
this._settings.bind('tray-pos', widget, 'active-id',
Gio.SettingsBindFlags.DEFAULT);
this.tray_position_hbox.append(label);
this.tray_position_hbox.append(widget);
this.preferences_vbox.append(this.legacy_tray_hbox);
this.preferences_vbox.append(this.opacity_hbox);
this.preferences_vbox.append(this.saturation_hbox);
this.preferences_vbox.append(this.brightness_hbox);
this.preferences_vbox.append(this.contrast_hbox);
this.preferences_vbox.append(this.icon_size_hbox);
this.preferences_vbox.append(this.tray_position_hbox);
// Custom icons section
const customListStore = new Gtk.ListStore();
customListStore.set_column_types([
GObject.TYPE_STRING,
GObject.TYPE_STRING,
GObject.TYPE_STRING,
]);
const customInitArray = this._settings.get_value('custom-icons').deep_unpack();
customInitArray.forEach(pair => {
customListStore.set(customListStore.append(), [0, 1, 2], pair);
});
customListStore.append();
const customTreeView = new Gtk.TreeView({
model: customListStore,
hexpand: true,
vexpand: true,
});
const customTitles = [
_('Indicator ID'),
_('Icon Name'),
_('Attention Icon Name'),
];
const indicatorIdColumn = new Gtk.TreeViewColumn({
title: customTitles[0],
sizing: Gtk.TreeViewColumnSizing.AUTOSIZE,
});
const customIconColumn = new Gtk.TreeViewColumn({
title: customTitles[1],
sizing: Gtk.TreeViewColumnSizing.AUTOSIZE,
});
const customAttentionIconColumn = new Gtk.TreeViewColumn({
title: customTitles[2],
sizing: Gtk.TreeViewColumnSizing.AUTOSIZE,
});
const cellrenderer = new Gtk.CellRendererText({editable: true});
indicatorIdColumn.pack_start(cellrenderer, true);
customIconColumn.pack_start(cellrenderer, true);
customAttentionIconColumn.pack_start(cellrenderer, true);
indicatorIdColumn.add_attribute(cellrenderer, 'text', 0);
customIconColumn.add_attribute(cellrenderer, 'text', 1);
customAttentionIconColumn.add_attribute(cellrenderer, 'text', 2);
customTreeView.insert_column(indicatorIdColumn, 0);
customTreeView.insert_column(customIconColumn, 1);
customTreeView.insert_column(customAttentionIconColumn, 2);
customTreeView.set_grid_lines(Gtk.TreeViewGridLines.BOTH);
this.custom_icons_vbox.append(customTreeView);
cellrenderer.connect('edited', (w, path, text) => {
this.selection = customTreeView.get_selection();
const title = customTreeView.get_cursor()[1].get_title();
const columnIndex = customTitles.indexOf(title);
const selection = this.selection.get_selected();
const iter = selection.at(2);
const text2 = customListStore.get_value(iter, columnIndex ? 0 : 1);
customListStore.set(iter, [columnIndex], [text]);
const storeLength = customListStore.iter_n_children(null);
const customIconArray = [];
for (let i = 0; i < storeLength; i++) {
const returnIter = customListStore.iter_nth_child(null, i);
const [success, iterList] = returnIter;
if (!success)
break;
if (iterList) {
const id = customListStore.get_value(iterList, 0);
const customIcon = customListStore.get_value(iterList, 1);
const customAttentionIcon = customListStore.get_value(iterList, 2);
if (id && customIcon)
customIconArray.push([id, customIcon, customAttentionIcon || '']);
} else {
break;
}
}
this._settings.set_value('custom-icons', new GLib.Variant(
'a(sss)', customIconArray));
if (storeLength === 1 && (text || text2))
customListStore.append();
if (storeLength > 1) {
if ((!text && !text2) && (storeLength - 1 > path))
customListStore.remove(iter);
if ((text || text2) && storeLength - 1 <= path)
customListStore.append();
}
});
this.notebook = new Gtk.Notebook();
this.notebook.append_page(this.preferences_vbox,
new Gtk.Label({label: _('Preferences')}));
this.notebook.append_page(this.custom_icons_vbox,
new Gtk.Label({label: _('Custom Icons')}));
this.append(this.notebook);
}
});
export default class DockPreferences extends ExtensionPreferences {
getPreferencesWidget() {
return new AppIndicatorPreferences(this);
}
}

View File

@@ -0,0 +1,324 @@
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Meta from 'gi://GdkPixbuf';
import * as Signals from 'resource:///org/gnome/shell/misc/signals.js';
export class CancellablePromise extends Promise {
constructor(executor, cancellable) {
if (!(executor instanceof Function))
throw TypeError('executor is not a function');
if (cancellable && !(cancellable instanceof Gio.Cancellable))
throw TypeError('cancellable parameter is not a Gio.Cancellable');
let rejector;
let resolver;
super((resolve, reject) => {
resolver = resolve;
rejector = reject;
});
const {stack: promiseStack} = new Error();
this._promiseStack = promiseStack;
this._resolver = (...args) => {
resolver(...args);
this._resolved = true;
this._cleanup();
};
this._rejector = (...args) => {
rejector(...args);
this._rejected = true;
this._cleanup();
};
if (!cancellable) {
executor(this._resolver, this._rejector);
return;
}
this._cancellable = cancellable;
this._cancelled = cancellable.is_cancelled();
if (this._cancelled) {
this._rejector(new GLib.Error(Gio.IOErrorEnum,
Gio.IOErrorEnum.CANCELLED, 'Promise cancelled'));
return;
}
this._cancellationId = cancellable.connect(() => {
const id = this._cancellationId;
this._cancellationId = 0;
GLib.idle_add(GLib.PRIORITY_DEFAULT, () => cancellable.disconnect(id));
this.cancel();
});
executor(this._resolver, this._rejector);
}
_cleanup() {
if (this._cancellationId)
this._cancellable.disconnect(this._cancellationId);
}
get cancellable() {
return this._chainRoot._cancellable || null;
}
get _chainRoot() {
return this._root ? this._root : this;
}
then(...args) {
const ret = super.then(...args);
/* Every time we call then() on this promise we'd get a new
* CancellablePromise however that won't have the properties that the
* root one has set, and then it won't be possible to cancel a promise
* chain from the last one.
* To allow this we keep track of the root promise, make sure that
* the same method on the root object is called during cancellation
* or any destruction method if you want this to work. */
if (ret instanceof CancellablePromise)
ret._root = this._chainRoot;
return ret;
}
resolved() {
return !!this._chainRoot._resolved;
}
rejected() {
return !!this._chainRoot._rejected;
}
cancelled() {
return !!this._chainRoot._cancelled;
}
pending() {
return !this.resolved() && !this.rejected();
}
cancel() {
if (this._root) {
this._root.cancel();
return this;
}
if (!this.pending())
return this;
this._cancelled = true;
const error = new GLib.Error(Gio.IOErrorEnum,
Gio.IOErrorEnum.CANCELLED, 'Promise cancelled');
error.stack += `## Promise created at:\n${this._promiseStack}`;
this._rejector(error);
return this;
}
}
export class SignalConnectionPromise extends CancellablePromise {
constructor(object, signal, cancellable) {
if (arguments.length === 1 && object instanceof Function) {
super(object);
return;
}
if (!(object.connect instanceof Function))
throw new TypeError('Not a valid object');
if (object instanceof GObject.Object &&
!GObject.signal_lookup(signal.split(':')[0], object.constructor.$gtype))
throw new TypeError(`Signal ${signal} not found on object ${object}`);
let id;
let destroyId;
super(resolve => {
let connectSignal;
if (object instanceof GObject.Object)
connectSignal = (sig, cb) => GObject.signal_connect(object, sig, cb);
else
connectSignal = (sig, cb) => object.connect(sig, cb);
id = connectSignal(signal, (_obj, ...args) => {
if (!args.length)
resolve();
else
resolve(args.length === 1 ? args[0] : args);
});
if (signal !== 'destroy' &&
(!(object instanceof GObject.Object) ||
GObject.signal_lookup('destroy', object.constructor.$gtype)))
destroyId = connectSignal('destroy', () => this.cancel());
}, cancellable);
this._object = object;
this._id = id;
this._destroyId = destroyId;
}
_cleanup() {
if (this._id) {
let disconnectSignal;
if (this._object instanceof GObject.Object)
disconnectSignal = id => GObject.signal_handler_disconnect(this._object, id);
else
disconnectSignal = id => this._object.disconnect(id);
disconnectSignal(this._id);
if (this._destroyId) {
disconnectSignal(this._destroyId);
this._destroyId = 0;
}
this._object = null;
this._id = 0;
}
super._cleanup();
}
get object() {
return this._chainRoot._object;
}
}
export class GSourcePromise extends CancellablePromise {
constructor(gsource, priority, cancellable) {
if (arguments.length === 1 && gsource instanceof Function) {
super(gsource);
return;
}
if (gsource.constructor.$gtype !== GLib.Source.$gtype)
throw new TypeError(`gsource ${gsource} is not of type GLib.Source`);
if (priority === undefined)
priority = GLib.PRIORITY_DEFAULT;
else if (!Number.isInteger(priority))
throw TypeError('Invalid priority');
super(resolve => {
gsource.set_priority(priority);
gsource.set_callback(() => {
resolve();
return GLib.SOURCE_REMOVE;
});
gsource.attach(null);
}, cancellable);
this._gsource = gsource;
this._gsource.set_name(`[gnome-shell] ${this.constructor.name} ${
new Error().stack.split('\n').filter(line =>
!line.match(/misc\/promiseUtils\.js/))[0]}`);
if (this.rejected())
this._gsource.destroy();
}
get gsource() {
return this._chainRoot._gsource;
}
_cleanup() {
if (this._gsource) {
this._gsource.destroy();
this._gsource = null;
}
super._cleanup();
}
}
export class IdlePromise extends GSourcePromise {
constructor(priority, cancellable) {
if (arguments.length === 1 && priority instanceof Function) {
super(priority);
return;
}
if (priority === undefined)
priority = GLib.PRIORITY_DEFAULT_IDLE;
super(GLib.idle_source_new(), priority, cancellable);
}
}
export class TimeoutPromise extends GSourcePromise {
constructor(interval, priority, cancellable) {
if (arguments.length === 1 && interval instanceof Function) {
super(interval);
return;
}
if (!Number.isInteger(interval) || interval < 0)
throw TypeError('Invalid interval');
super(GLib.timeout_source_new(interval), priority, cancellable);
}
}
export class TimeoutSecondsPromise extends GSourcePromise {
constructor(interval, priority, cancellable) {
if (arguments.length === 1 && interval instanceof Function) {
super(interval);
return;
}
if (!Number.isInteger(interval) || interval < 0)
throw TypeError('Invalid interval');
super(GLib.timeout_source_new_seconds(interval), priority, cancellable);
}
}
export class MetaLaterPromise extends CancellablePromise {
constructor(laterType, cancellable) {
if (arguments.length === 1 && laterType instanceof Function) {
super(laterType);
return;
}
if (laterType && laterType.constructor.$gtype !== Meta.LaterType.$gtype)
throw new TypeError(`laterType ${laterType} is not of type Meta.LaterType`);
else if (!laterType)
laterType = Meta.LaterType.BEFORE_REDRAW;
let id;
super(resolve => {
id = Meta.later_add(laterType, () => {
this.remove();
resolve();
return GLib.SOURCE_REMOVE;
});
}, cancellable);
this._id = id;
}
_cleanup() {
if (this._id) {
Meta.later_remove(this._id);
this._id = 0;
}
super._cleanup();
}
}
export function _promisifySignals(proto) {
if (proto.connect_once)
return;
proto.connect_once = function (signal, cancellable) {
return new SignalConnectionPromise(this, signal, cancellable);
};
}
_promisifySignals(GObject.Object.prototype);
_promisifySignals(Signals.EventEmitter.prototype);

View File

@@ -0,0 +1,49 @@
<schemalist gettext-domain="AppIndicatorExtension">
<schema id="org.gnome.shell.extensions.appindicator" path="/org/gnome/shell/extensions/appindicator/">
<key name="legacy-tray-enabled" type="b">
<default>true</default>
<summary>Enable legacy tray icons support</summary>
</key>
<key name="icon-saturation" type="d">
<default>0.0</default>
<summary>Saturation</summary>
</key>
<key name="icon-brightness" type="d">
<default>0.0</default>
<summary>Brightness</summary>
</key>
<key name="icon-contrast" type="d">
<default>0.0</default>
<summary>Contrast</summary>
</key>
<key name="icon-opacity" type="i">
<default>240</default>
<summary>Opacity</summary>
</key>
<key name="icon-size" type="i">
<default>0</default>
<summary>Icon size</summary>
<description>Icon size in pixel</description>
</key>
<key name="icon-spacing" type="i">
<default>12</default>
<summary>Icon spacing</summary>
<description>Icon spacing within the tray</description>
</key>
<key name="tray-pos" type="s">
<default>"right"</default>
<summary>Position in tray</summary>
<description>Set where the Icon tray should appear in Gnome tray</description>
</key>
<key name="tray-order" type="i">
<default>1</default>
<summary>Order in tray</summary>
<description>Set where the Icon tray should appear among other trays</description>
</key>
<key name="custom-icons" type="a(sss)">
<default>[]</default>
<summary>Custom icons</summary>
<description>Replace any icons with custom icons from themes</description>
</key>
</schema>
</schemalist>

View File

@@ -0,0 +1,55 @@
// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
//
// 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 2
// 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, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
let settingsManager;
export class SettingsManager {
static initialize(extension) {
SettingsManager._settingsManager = new SettingsManager(extension);
}
static destroy() {
SettingsManager._settingsManager.destroy();
SettingsManager._settingsManager = null;
}
static getDefault() {
return this._settingsManager;
}
get gsettings() {
return this._gsettings;
}
constructor(extension) {
if (settingsManager)
throw new Error('SettingsManager is already constructed');
this._gsettings = extension.getSettings();
}
destroy() {
this._gsettings = null;
}
}
export function getDefault() {
return SettingsManager.getDefault();
}
export function getDefaultGSettings() {
return SettingsManager.getDefault().gsettings;
}

View File

@@ -0,0 +1,287 @@
// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
//
// 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 2
// 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, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import * as AppIndicator from './appIndicator.js';
import * as IndicatorStatusIcon from './indicatorStatusIcon.js';
import * as Interfaces from './interfaces.js';
import * as PromiseUtils from './promiseUtils.js';
import * as Util from './util.js';
import * as DBusMenu from './dbusMenu.js';
import {DBusProxy} from './dbusProxy.js';
// TODO: replace with org.freedesktop and /org/freedesktop when approved
const KDE_PREFIX = 'org.kde';
export const WATCHER_BUS_NAME = `${KDE_PREFIX}.StatusNotifierWatcher`;
const WATCHER_OBJECT = '/StatusNotifierWatcher';
const DEFAULT_ITEM_OBJECT_PATH = '/StatusNotifierItem';
/*
* The StatusNotifierWatcher class implements the StatusNotifierWatcher dbus object
*/
export class StatusNotifierWatcher {
constructor(watchDog) {
this._watchDog = watchDog;
this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(Interfaces.StatusNotifierWatcher, this);
try {
this._dbusImpl.export(Gio.DBus.session, WATCHER_OBJECT);
} catch (e) {
Util.Logger.warn(`Failed to export ${WATCHER_OBJECT}`);
logError(e);
}
this._cancellable = new Gio.Cancellable();
this._everAcquiredName = false;
this._ownName = Gio.DBus.session.own_name(WATCHER_BUS_NAME,
Gio.BusNameOwnerFlags.NONE,
this._acquiredName.bind(this),
this._lostName.bind(this));
this._items = new Map();
try {
this._dbusImpl.emit_signal('StatusNotifierHostRegistered', null);
} catch (e) {
Util.Logger.warn(`Failed to notify registered host ${WATCHER_OBJECT}`);
}
this._seekStatusNotifierItems().catch(e => {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e, 'Looking for StatusNotifierItem\'s');
});
}
_acquiredName() {
this._everAcquiredName = true;
this._watchDog.nameAcquired = true;
}
_lostName() {
if (this._everAcquiredName)
Util.Logger.debug(`Lost name${WATCHER_BUS_NAME}`);
else
Util.Logger.warn(`Failed to acquire ${WATCHER_BUS_NAME}`);
this._watchDog.nameAcquired = false;
}
async _registerItem(service, busName, objPath) {
const id = Util.indicatorId(service, busName, objPath);
if (this._items.has(id)) {
Util.Logger.warn(`Item ${id} is already registered`);
return;
}
Util.Logger.debug(`Registering StatusNotifierItem ${id}`);
try {
const indicator = new AppIndicator.AppIndicator(service, busName, objPath);
this._items.set(id, indicator);
indicator.connect('destroy', () => this._onIndicatorDestroyed(indicator));
indicator.connect('name-owner-changed', async () => {
if (!indicator.hasNameOwner) {
try {
await new PromiseUtils.TimeoutPromise(500,
GLib.PRIORITY_DEFAULT, this._cancellable);
if (this._items.has(id) && !indicator.hasNameOwner)
indicator.destroy();
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
}
}
});
// if the desktop is not ready delay the icon creation and signal emissions
await Util.waitForStartupCompletion(indicator.cancellable);
const statusIcon = new IndicatorStatusIcon.IndicatorStatusIcon(indicator);
IndicatorStatusIcon.addIconToPanel(statusIcon);
this._dbusImpl.emit_signal('StatusNotifierItemRegistered',
GLib.Variant.new('(s)', [indicator.uniqueId]));
this._dbusImpl.emit_property_changed('RegisteredStatusNotifierItems',
GLib.Variant.new('as', this.RegisteredStatusNotifierItems));
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
throw e;
}
}
async _ensureItemRegistered(service, busName, objPath) {
const id = Util.indicatorId(service, busName, objPath);
const item = this._items.get(id);
if (item) {
// delete the old one and add the new indicator
Util.Logger.debug(`Attempting to re-register ${id}; resetting instead`);
item.reset();
return;
}
await this._registerItem(service, busName, objPath);
}
async _seekStatusNotifierItems() {
// Some indicators (*coff*, dropbox, *coff*) do not re-register again
// when the plugin is enabled/disabled, thus we need to manually look
// for the objects in the session bus that implements the
// StatusNotifierItem interface... However let's do it after a low
// priority idle, so that it won't affect startup.
const cancellable = this._cancellable;
const bus = Gio.DBus.session;
const uniqueNames = await Util.getBusNames(bus, cancellable);
const introspectName = async name => {
const nodes = Util.introspectBusObject(bus, name, cancellable,
['org.kde.StatusNotifierItem']);
const services = [...uniqueNames.get(name)];
for await (const node of nodes) {
const {path} = node;
const ids = services.map(s => Util.indicatorId(s, name, path));
if (ids.every(id => !this._items.has(id))) {
const service = services.find(s =>
s && s.startsWith('org.kde.StatusNotifierItem')) || services[0];
const id = Util.indicatorId(
path === DEFAULT_ITEM_OBJECT_PATH ? service : null,
name, path);
Util.Logger.warn(`Using Brute-force mode for StatusNotifierItem ${id}`);
this._registerItem(service, name, path);
}
}
};
await Promise.allSettled([...uniqueNames.keys()].map(n => introspectName(n)));
}
async RegisterStatusNotifierItemAsync(params, invocation) {
// it would be too easy if all application behaved the same
// instead, ayatana patched gnome apps to send a path
// while kde apps send a bus name
const [service] = params;
let busName, objPath;
if (service.charAt(0) === '/') { // looks like a path
busName = invocation.get_sender();
objPath = service;
} else if (service.match(Util.BUS_ADDRESS_REGEX)) {
try {
busName = await Util.getUniqueBusName(invocation.get_connection(),
service, this._cancellable);
} catch (e) {
logError(e);
}
objPath = DEFAULT_ITEM_OBJECT_PATH;
}
if (!busName || !objPath) {
const error = `Impossible to register an indicator for parameters '${
service.toString()}'`;
Util.Logger.warn(error);
invocation.return_dbus_error('org.gnome.gjs.JSError.ValueError',
error);
return;
}
try {
await this._ensureItemRegistered(service, busName, objPath);
invocation.return_value(null);
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
logError(e);
invocation.return_dbus_error('org.gnome.gjs.JSError.ValueError',
e.message);
}
}
_onIndicatorDestroyed(indicator) {
const {uniqueId} = indicator;
this._items.delete(uniqueId);
try {
this._dbusImpl.emit_signal('StatusNotifierItemUnregistered',
GLib.Variant.new('(s)', [uniqueId]));
this._dbusImpl.emit_property_changed('RegisteredStatusNotifierItems',
GLib.Variant.new('as', this.RegisteredStatusNotifierItems));
} catch (e) {
Util.Logger.warn(`Failed to emit signals: ${e}`);
}
}
RegisterStatusNotifierHostAsync(_service, invocation) {
invocation.return_error_literal(
Gio.DBusError,
Gio.DBusError.NOT_SUPPORTED,
'Registering additional notification hosts is not supported');
}
IsNotificationHostRegistered() {
return true;
}
get RegisteredStatusNotifierItems() {
return Array.from(this._items.values()).map(i => i.uniqueId);
}
get IsStatusNotifierHostRegistered() {
return true;
}
get ProtocolVersion() {
return 0;
}
destroy() {
if (this._isDestroyed)
return;
// this doesn't do any sync operation and doesn't allow us to hook up
// the event of being finished which results in our unholy debounce hack
// (see extension.js)
this._items.forEach(indicator => indicator.destroy());
this._cancellable.cancel();
try {
this._dbusImpl.emit_signal('StatusNotifierHostUnregistered', null);
} catch (e) {
Util.Logger.warn(`Failed to emit uinregistered signal: ${e}`);
}
Gio.DBus.session.unown_name(this._ownName);
try {
this._dbusImpl.unexport();
} catch (e) {
Util.Logger.warn(`Failed to unexport watcher object: ${e}`);
}
DBusMenu.DBusClient.destroy();
AppIndicator.AppIndicatorProxy.destroy();
DBusProxy.destroy();
Util.destroyDefaultTheme();
this._dbusImpl.run_dispose();
delete this._dbusImpl;
delete this._items;
this._isDestroyed = true;
}
}

View File

@@ -0,0 +1,104 @@
// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
//
// 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 2
// 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, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
import Shell from 'gi://Shell';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as Signals from 'resource:///org/gnome/shell/misc/signals.js';
import * as IndicatorStatusIcon from './indicatorStatusIcon.js';
import * as Util from './util.js';
import * as SettingsManager from './settingsManager.js';
let trayIconsManager;
export class TrayIconsManager extends Signals.EventEmitter {
static initialize() {
if (!trayIconsManager)
trayIconsManager = new TrayIconsManager();
return trayIconsManager;
}
static destroy() {
trayIconsManager.destroy();
}
constructor() {
super();
if (trayIconsManager)
throw new Error('TrayIconsManager is already constructed');
this._changedId = SettingsManager.getDefaultGSettings().connect(
'changed::legacy-tray-enabled', () => this._toggle());
this._toggle();
}
_toggle() {
if (SettingsManager.getDefaultGSettings().get_boolean('legacy-tray-enabled'))
this._enable();
else
this._disable();
}
_enable() {
if (this._tray)
return;
this._tray = new Shell.TrayManager();
Util.connectSmart(this._tray, 'tray-icon-added', this, this.onTrayIconAdded);
Util.connectSmart(this._tray, 'tray-icon-removed', this, this.onTrayIconRemoved);
this._tray.manage_screen(Main.panel);
}
_disable() {
if (!this._tray)
return;
IndicatorStatusIcon.getTrayIcons().forEach(i => i.destroy());
if (this._tray.unmanage_screen) {
this._tray.unmanage_screen();
this._tray = null;
} else {
// FIXME: This is very ugly, but it's needed by old shell versions
this._tray = null;
imports.system.gc(); // force finalizing tray to unmanage screen
}
}
onTrayIconAdded(_tray, icon) {
const trayIcon = new IndicatorStatusIcon.IndicatorStatusTrayIcon(icon);
IndicatorStatusIcon.addIconToPanel(trayIcon);
}
onTrayIconRemoved(_tray, icon) {
try {
const [trayIcon] = IndicatorStatusIcon.getTrayIcons().filter(i => i.icon === icon);
trayIcon.destroy();
} catch (e) {
Util.Logger.warning(`No icon container found for ${icon.title} (${icon})`);
}
}
destroy() {
this.emit('destroy');
SettingsManager.getDefaultGSettings().disconnect(this._changedId);
this._disable();
trayIconsManager = null;
}
}

View File

@@ -0,0 +1,447 @@
// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
//
// 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 2
// 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, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
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 Config from 'resource:///org/gnome/shell/misc/config.js';
import * as Signals from 'resource:///org/gnome/shell/misc/signals.js';
import {BaseStatusIcon} from './indicatorStatusIcon.js';
export const BUS_ADDRESS_REGEX = /([a-zA-Z0-9._-]+\.[a-zA-Z0-9.-]+)|(:[0-9]+\.[0-9]+)$/;
Gio._promisify(Gio.DBusConnection.prototype, 'call');
Gio._promisify(Gio._LocalFilePrototype, 'read');
Gio._promisify(Gio.InputStream.prototype, 'read_bytes_async');
export function indicatorId(service, busName, objectPath) {
if (service !== busName && service?.match(BUS_ADDRESS_REGEX))
return service;
return `${busName}@${objectPath}`;
}
export async function getUniqueBusName(bus, name, cancellable) {
if (name[0] === ':')
return name;
if (!bus)
bus = Gio.DBus.session;
const variantName = new GLib.Variant('(s)', [name]);
const [unique] = (await bus.call('org.freedesktop.DBus', '/', 'org.freedesktop.DBus',
'GetNameOwner', variantName, new GLib.VariantType('(s)'),
Gio.DBusCallFlags.NONE, -1, cancellable)).deep_unpack();
return unique;
}
export async function getBusNames(bus, cancellable) {
if (!bus)
bus = Gio.DBus.session;
const [names] = (await bus.call('org.freedesktop.DBus', '/', 'org.freedesktop.DBus',
'ListNames', null, new GLib.VariantType('(as)'), Gio.DBusCallFlags.NONE,
-1, cancellable)).deep_unpack();
const uniqueNames = new Map();
const requests = names.map(name => getUniqueBusName(bus, name, cancellable));
const results = await Promise.allSettled(requests);
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'fulfilled') {
let namesForBus = uniqueNames.get(result.value);
if (!namesForBus) {
namesForBus = new Set();
uniqueNames.set(result.value, namesForBus);
}
namesForBus.add(result.value !== names[i] ? names[i] : null);
} else if (!result.reason.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) {
Logger.debug(`Impossible to get the unique name of ${names[i]}: ${result.reason}`);
}
}
return uniqueNames;
}
async function getProcessId(connectionName, cancellable = null, bus = Gio.DBus.session) {
const res = await bus.call('org.freedesktop.DBus', '/',
'org.freedesktop.DBus', 'GetConnectionUnixProcessID',
new GLib.Variant('(s)', [connectionName]),
new GLib.VariantType('(u)'),
Gio.DBusCallFlags.NONE,
-1,
cancellable);
const [pid] = res.deepUnpack();
return pid;
}
export async function getProcessName(connectionName, cancellable = null,
priority = GLib.PRIORITY_DEFAULT, bus = Gio.DBus.session) {
const pid = await getProcessId(connectionName, cancellable, bus);
const cmdFile = Gio.File.new_for_path(`/proc/${pid}/cmdline`);
const inputStream = await cmdFile.read_async(priority, cancellable);
const bytes = await inputStream.read_bytes_async(2048, priority, cancellable);
const textDecoder = new TextDecoder();
return textDecoder.decode(bytes.toArray().map(v => !v ? 0x20 : v));
}
export async function* introspectBusObject(bus, name, cancellable,
interfaces = undefined, path = undefined) {
if (!path)
path = '/';
const [introspection] = (await bus.call(name, path, 'org.freedesktop.DBus.Introspectable',
'Introspect', null, new GLib.VariantType('(s)'), Gio.DBusCallFlags.NONE,
5000, cancellable)).deep_unpack();
const nodeInfo = Gio.DBusNodeInfo.new_for_xml(introspection);
if (!interfaces || dbusNodeImplementsInterfaces(nodeInfo, interfaces))
yield {nodeInfo, path};
if (path === '/')
path = '';
for (const subNodeInfo of nodeInfo.nodes) {
const subPath = `${path}/${subNodeInfo.path}`;
yield* introspectBusObject(bus, name, cancellable, interfaces, subPath);
}
}
function dbusNodeImplementsInterfaces(nodeInfo, interfaces) {
if (!(nodeInfo instanceof Gio.DBusNodeInfo) || !Array.isArray(interfaces))
return false;
return interfaces.some(iface => nodeInfo.lookup_interface(iface));
}
export class NameWatcher extends Signals.EventEmitter {
constructor(name) {
super();
this._watcherId = Gio.DBus.session.watch_name(name,
Gio.BusNameWatcherFlags.NONE, () => {
this._nameOnBus = true;
Logger.debug(`Name ${name} appeared`);
this.emit('changed');
this.emit('appeared');
}, () => {
this._nameOnBus = false;
Logger.debug(`Name ${name} vanished`);
this.emit('changed');
this.emit('vanished');
});
}
destroy() {
this.emit('destroy');
Gio.DBus.session.unwatch_name(this._watcherId);
delete this._watcherId;
}
get nameOnBus() {
return !!this._nameOnBus;
}
}
function connectSmart3A(src, signal, handler) {
const id = src.connect(signal, handler);
let destroyId = 0;
if (src.connect && (!(src instanceof GObject.Object) || GObject.signal_lookup('destroy', src))) {
destroyId = src.connect('destroy', () => {
src.disconnect(id);
src.disconnect(destroyId);
});
}
return [id, destroyId];
}
function connectSmart4A(src, signal, target, method) {
if (typeof method !== 'function')
throw new TypeError('Unsupported function');
method = method.bind(target);
const signalId = src.connect(signal, method);
const onDestroy = () => {
src.disconnect(signalId);
if (srcDestroyId)
src.disconnect(srcDestroyId);
if (tgtDestroyId)
target.disconnect(tgtDestroyId);
};
// GObject classes might or might not have a destroy signal
// JS Classes will not complain when connecting to non-existent signals
const srcDestroyId = src.connect && (!(src instanceof GObject.Object) ||
GObject.signal_lookup('destroy', src)) ? src.connect('destroy', onDestroy) : 0;
const tgtDestroyId = target.connect && (!(target instanceof GObject.Object) ||
GObject.signal_lookup('destroy', target)) ? target.connect('destroy', onDestroy) : 0;
return [signalId, srcDestroyId, tgtDestroyId];
}
// eslint-disable-next-line valid-jsdoc
/**
* Connect signals to slots, and remove the connection when either source or
* target are destroyed
*
* Usage:
* Util.connectSmart(srcOb, 'signal', tgtObj, 'handler')
* or
* Util.connectSmart(srcOb, 'signal', () => { ... })
*/
export function connectSmart(...args) {
if (arguments.length === 4)
return connectSmart4A(...args);
else
return connectSmart3A(...args);
}
function disconnectSmart3A(src, signalIds) {
const [id, destroyId] = signalIds;
src.disconnect(id);
if (destroyId)
src.disconnect(destroyId);
}
function disconnectSmart4A(src, tgt, signalIds) {
const [signalId, srcDestroyId, tgtDestroyId] = signalIds;
disconnectSmart3A(src, [signalId, srcDestroyId]);
if (tgtDestroyId)
tgt.disconnect(tgtDestroyId);
}
export function disconnectSmart(...args) {
if (arguments.length === 2)
return disconnectSmart3A(...args);
else if (arguments.length === 3)
return disconnectSmart4A(...args);
throw new TypeError('Unexpected number of arguments');
}
let _defaultTheme;
export function getDefaultTheme() {
if (_defaultTheme)
return _defaultTheme;
_defaultTheme = new St.IconTheme();
return _defaultTheme;
}
export function destroyDefaultTheme() {
_defaultTheme = null;
}
// eslint-disable-next-line valid-jsdoc
/**
* Helper function to wait for the system startup to be completed.
* Adding widgets before the desktop is ready to accept them can result in errors.
*/
export async function waitForStartupCompletion(cancellable) {
if (Main.layoutManager._startingUp)
await Main.layoutManager.connect_once('startup-complete', cancellable);
}
/**
* Helper class for logging stuff
*/
export class Logger {
static _logStructured(logLevel, message, extraFields = {}) {
if (!Object.values(GLib.LogLevelFlags).includes(logLevel)) {
Logger._logStructured(GLib.LogLevelFlags.LEVEL_WARNING,
'logLevel is not a valid GLib.LogLevelFlags');
return;
}
if (!Logger._levels.includes(logLevel))
return;
let fields = {
'SYSLOG_IDENTIFIER': this.uuid,
'MESSAGE': `${message}`,
};
let thisFile = null;
const {stack} = new Error();
for (let stackLine of stack.split('\n')) {
stackLine = stackLine.replace('resource:///org/gnome/Shell/', '');
const [code, line] = stackLine.split(':');
const [func, file] = code.split(/@(.+)/);
if (!thisFile || thisFile === file) {
thisFile = file;
continue;
}
fields = Object.assign(fields, {
'CODE_FILE': file || '',
'CODE_LINE': line || '',
'CODE_FUNC': func || '',
});
break;
}
GLib.log_structured(Logger._domain, logLevel, Object.assign(fields, extraFields));
}
static init(extension) {
if (Logger._domain)
return;
const allLevels = Object.values(GLib.LogLevelFlags);
const domains = GLib.getenv('G_MESSAGES_DEBUG');
const {name: domain} = extension.metadata;
this.uuid = extension.metadata.uuid;
Logger._domain = domain.replaceAll(' ', '-');
if (domains === 'all' || (domains && domains.split(' ').includes(Logger._domain))) {
Logger._levels = allLevels;
} else {
Logger._levels = allLevels.filter(
l => l <= GLib.LogLevelFlags.LEVEL_WARNING);
}
}
static debug(message) {
Logger._logStructured(GLib.LogLevelFlags.LEVEL_DEBUG, message);
}
static message(message) {
Logger._logStructured(GLib.LogLevelFlags.LEVEL_MESSAGE, message);
}
static warn(message) {
Logger._logStructured(GLib.LogLevelFlags.LEVEL_WARNING, message);
}
static error(message) {
Logger._logStructured(GLib.LogLevelFlags.LEVEL_ERROR, message);
}
static critical(message) {
Logger._logStructured(GLib.LogLevelFlags.LEVEL_CRITICAL, message);
}
}
export function versionCheck(required) {
const current = Config.PACKAGE_VERSION;
const currentArray = current.split('.');
const [major] = currentArray;
return major >= required;
}
export function tryCleanupOldIndicators() {
const indicatorType = BaseStatusIcon;
const indicators = Object.values(Main.panel.statusArea).filter(i => i instanceof indicatorType);
try {
const panelBoxes = [
Main.panel._leftBox, Main.panel._centerBox, Main.panel._rightBox,
];
panelBoxes.forEach(box =>
indicators.push(...box.get_children().filter(i => i instanceof indicatorType)));
} catch (e) {
logError(e);
}
new Set(indicators).forEach(i => i.destroy());
}
export function addActor(obj, actor) {
if (obj.add_actor)
obj.add_actor(actor);
else
obj.add_child(actor);
}
export function removeActor(obj, actor) {
if (obj.remove_actor)
obj.remove_actor(actor);
else
obj.remove_child(actor);
}
export const CancellableChild = GObject.registerClass({
Properties: {
'parent': GObject.ParamSpec.object(
'parent', 'parent', 'parent',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
Gio.Cancellable.$gtype),
},
},
class CancellableChild extends Gio.Cancellable {
_init(parent) {
if (parent && !(parent instanceof Gio.Cancellable))
throw TypeError('Not a valid cancellable');
super._init({parent});
if (parent) {
if (parent.is_cancelled()) {
this.cancel();
return;
}
this._connectToParent();
}
}
_connectToParent() {
this._connectId = this.parent.connect(() => {
this._realCancel();
if (this._disconnectIdle)
return;
this._disconnectIdle = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
delete this._disconnectIdle;
this._disconnectFromParent();
return GLib.SOURCE_REMOVE;
});
});
}
_disconnectFromParent() {
if (this._connectId && !this._disconnectIdle) {
this.parent.disconnect(this._connectId);
delete this._connectId;
}
}
_realCancel() {
Gio.Cancellable.prototype.cancel.call(this);
}
cancel() {
this._disconnectFromParent();
this._realCancel();
}
});