From b2ea3e8f0dcf24b9245775d7f4cbc386f529c37b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 26 Oct 2014 22:07:20 +0530 Subject: [PATCH] Start work on Status Notifier Item support for linux --- src/calibre/gui2/sni.py | 265 ++++++++ src/calibre/utils/dbus_service.py | 1050 +++++++++++++++++++++++++++++ 2 files changed, 1315 insertions(+) create mode 100644 src/calibre/gui2/sni.py create mode 100644 src/calibre/utils/dbus_service.py diff --git a/src/calibre/gui2/sni.py b/src/calibre/gui2/sni.py new file mode 100644 index 0000000000..31c809fe79 --- /dev/null +++ b/src/calibre/gui2/sni.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2014, Kovid Goyal ' + +import os, sys + +import dbus +from PyQt5.Qt import ( + QApplication, QObject, pyqtSignal, Qt, QPoint) + +from calibre.utils.dbus_service import Object, method as dbus_method, BusName, dbus_property, signal as dbus_signal + +def log(*args, **kw): + kw['file'] = sys.stderr + print('StatusNotifier:', *args, **kw) + kw['file'].flush() + +class Factory(QObject): + + SERVICE = "org.kde.StatusNotifierWatcher" + PATH = "/StatusNotifierWatcher" + IFACE = "org.kde.StatusNotifierWatcher" + + available_changed = pyqtSignal(bool) + + def __init__(self, parent=None): + QObject.__init__(self, parent) + self.count = 0 + self.items = [] + self.is_available = False + self._bus = None + self.connect_to_snw() + + @property + def bus(self): + if self._bus is None: + self._bus = dbus.SessionBus() + return self._bus + + def bus_disconnected(self, conn): + self._bus = None + + def init_bus(self): + bus = self.bus + bus.call_on_disconnection(self.bus_disconnected) + bus.watch_name_owner(self.SERVICE, self.owner_changed) + self.connect_to_snw() + + def owner_changed(self, new_owner): + old = self.is_available + if new_owner: + self.connect_to_snw() + else: + self.is_available = False + if old != self.is_available: + self.available_changed.emit(self.is_available) + + def connect_to_snw(self): + self.is_available = False + try: + self.bus.add_signal_receiver(self.host_registered, 'StatusNotifierHostRegistered', self.IFACE, self.SERVICE, self.PATH) + except dbus.DBusException as err: + log('Failed to connect to StatusNotifierHostRegistered, with error:', str(err)) + + self.update_availability() + if self.is_available: + for item in self.items: + self.register(item) + + def update_availability(self): + try: + self.is_available = bool(self.bus.call_blocking( + self.SERVICE, self.PATH, dbus.PROPERTIES_IFACE, 'Get', 'ss', (self.IFACE, 'IsStatusNotifierHostRegistered'), timeout=0.1)) + except dbus.DBusException as err: + self.is_available = False + log('Failed to get StatusNotifier host availability with error:', str(err)) + + def host_registered(self, *args): + if not self.is_available: + self.is_available = True + self.available_changed.emit(self.is_available) + + def create_indicator(self, **kw): + if not self.is_available: + raise RuntimeError('StatusNotifier services are not available on this system') + self.count += 1 + kw['bus'] = self.bus + item = StatusNotifierItem(self.count, **kw) + self.items.append(item) + item.destroyed.connect(self.items.remove) + self.register(item) + + def register(self, item): + self.bus.call_blocking( + self.SERVICE, self.PATH, self.IFACE, 'RegisterStatusNotifierItem', 's', (item.dbus.name,), timeout=0.1) + +class StatusNotifierItem(QObject): + + IFACE = 'org.kde.StatusNotifierItem' + NewTitle = pyqtSignal() + NewIcon = pyqtSignal() + NewAttentionIcon = pyqtSignal() + NewOverlayIcon = pyqtSignal() + NewToolTip = pyqtSignal() + NewStatus = pyqtSignal(str) + activated = pyqtSignal() + show_menu = pyqtSignal(int, int) + + def __init__(self, num, **kw): + QObject.__init__(self, parent=kw.get('parent')) + self.is_visible = True + self.show_menu.connect(self._show_menu, type=Qt.QueuedConnection) + kw['num'] = num + self.dbus = StatusNotifierItemAPI(self, **kw) + + def _show_menu(self, x, y): + m = self.contextMenu() + if m is not None: + m.exec_(QPoint(x, y)) + + def isVisible(self): + return self.is_visible + + def setVisible(self, visible): + if self.is_visible != visible: + self.is_visible = visible + self.NewStatus.emit(self.dbus.Status) + + def show(self): + self.setVisible(True) + + def hide(self): + self.setVisible(False) + +class StatusNotifierItemAPI(Object): + + IFACE = 'org.kde.StatusNotifierItem' + + def __init__(self, notifier, **kw): + self.notifier = notifier + bus = kw.get('bus') + if bus is None: + bus = dbus.SessionBus() + self.name = '%s-%s-%s' % (self.IFACE, os.getpid(), kw.get('num', 1)) + self.dbus_name = BusName(self.name, bus=bus, do_not_queue=True) + self.app_id = kw.get('app_id', QApplication.instance().applicationName()) or 'unknown_application' + self.category = kw.get('category', 'ApplicationStatus') + self.title = kw.get('title', self.app_id) + Object.__init__(self, bus, '/' + self.IFACE.split('.')[-1]) + for name, val in vars(self.__class__).iteritems(): + if getattr(val, '_dbus_is_signal', False): + getattr(notifier, name).connect(getattr(self, name)) + + @dbus_property(IFACE, signature='s') + def IconName(self): + return 'klipper' + + @dbus_property(IFACE, signature='s') + def IconThemePath(self): + return '' + + @dbus_property(IFACE, signature='a(iiay)') + def IconPixmap(self): + return dbus.Array(signature='(iiay)') + + @dbus_property(IFACE, signature='s') + def OverlayIconName(self): + return '' + + @dbus_property(IFACE, signature='a(iiay)') + def OverlayIconPixmap(self): + return dbus.Array(signature='(iiay)') + + @dbus_property(IFACE, signature='s') + def AttentionIconName(self): + return '' + + @dbus_property(IFACE, signature='a(iiay)') + def AttentionIconPixmap(self): + return dbus.Array(signature='(iiay)') + + @dbus_property(IFACE, signature='s') + def Category(self): + return self.category + + @dbus_property(IFACE, signature='s') + def Id(self): + return self.app_id + + @dbus_property(IFACE, signature='s') + def Title(self): + return self.title + + @dbus_property(IFACE, signature='s') + def Status(self): + return 'Active' if self.notifier.isVisible() else 'Passive' + + @dbus_property(IFACE, signature='i') + def WindowId(self): + return 0 + + @dbus_method(IFACE, in_signature='ii', out_signature='') + def ContextMenu(self, x, y): + self.notifier.show_menu.emit(x, y) + + @dbus_method(IFACE, in_signature='ii', out_signature='') + def Activate(self, x, y): + self.notifier.activated.emit() + + @dbus_method(IFACE, in_signature='ii', out_signature='') + def SecondaryActivate(self, x, y): + self.notifier.activated.emit() + + @dbus_method(IFACE, in_signature='is', out_signature='') + def Scroll(self, delta, orientation): + pass + + @dbus_signal(IFACE, '') + def NewTitle(self): + pass + + @dbus_signal(IFACE, '') + def NewIcon(self): + pass + + @dbus_signal(IFACE, '') + def NewAttentionIcon(self): + pass + + @dbus_signal(IFACE, '') + def NewOverlayIcon(self): + pass + + @dbus_signal(IFACE, '') + def NewToolTip(self): + pass + + @dbus_signal(IFACE, 's') + def NewStatus(self, status): + pass + + +_factory = None +def factory(): + global _factory + if _factory is None: + _factory = Factory() + return _factory + +def test(): + import signal + from dbus.mainloop.glib import DBusGMainLoop, threads_init + DBusGMainLoop(set_as_default=True) + threads_init() + app = QApplication([]) + signal.signal(signal.SIGINT, signal.SIG_DFL) # quit on Ctrl-C + factory().create_indicator(title='Testing SNI Interface') + app.exec_() + +if __name__ == '__main__': + test() diff --git a/src/calibre/utils/dbus_service.py b/src/calibre/utils/dbus_service.py new file mode 100644 index 0000000000..20082dc1e6 --- /dev/null +++ b/src/calibre/utils/dbus_service.py @@ -0,0 +1,1050 @@ +# Copyright (C) 2003-2006 Red Hat Inc. +# Copyright (C) 2003 David Zeuthen +# Copyright (C) 2004 Rob Taylor +# Copyright (C) 2005-2006 Collabora Ltd. +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, copy, +# modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# + +from __future__ import absolute_import + +__all__ = ('BusName', 'Object', 'PropertiesInterface', 'method', 'dbus_property', 'signal') +__docformat__ = 'restructuredtext' + +import sys +import logging +import threading +import traceback +from collections import Sequence + +import _dbus_bindings +from dbus import ( + INTROSPECTABLE_IFACE, ObjectPath, PROPERTIES_IFACE, SessionBus, Signature, + Struct, validate_bus_name, validate_object_path) +from dbus.decorators import method, signal, validate_interface_name, validate_member_name +from dbus.exceptions import ( + DBusException, NameExistsException, UnknownMethodException) +from dbus.lowlevel import ErrorMessage, MethodReturnMessage, MethodCallMessage +from dbus.proxies import LOCAL_PATH +is_py2 = True + +class dbus_property(object): + """A decorator used to mark properties of a `dbus.service.Object`. + """ + + def __init__(self, dbus_interface=None, signature=None, + property_name=None, emits_changed_signal=None, + fget=None, fset=None, doc=None): + """Initialize the decorator used to mark properties of a + `dbus.service.Object`. + + :Parameters: + `dbus_interface` : str + The D-Bus interface owning the property + + `signature` : str + The signature of the property in the usual D-Bus notation. The + signature must be suitable to be carried in a variant. + + `property_name` : str + A name for the property. Defaults to the name of the getter or + setter function. + + `emits_changed_signal` : True, False, "invalidates", or None + Tells for introspection if the object emits PropertiesChanged + signal. + + `fget` : func + Getter function taking the instance from which to read the + property. + + `fset` : func + Setter function taking the instance to which set the property + and the property value. + + `doc` : str + Documentation string for the property. Defaults to documentation + string of getter function. + + :Since: 1.3.0 + """ + validate_interface_name(dbus_interface) + self._dbus_interface = dbus_interface + + self._init_property_name = property_name + if property_name is None: + if fget is not None: + property_name = fget.__name__ + elif fset is not None: + property_name = fset.__name__ + if property_name: + validate_member_name(property_name) + self.__name__ = property_name + + self._init_doc = doc + if doc is None and fget is not None: + doc = getattr(fget, "__doc__", None) + self.fget = fget + self.fset = fset + self.__doc__ = doc + + self._emits_changed_signal = emits_changed_signal + if len(tuple(Signature(signature))) != 1: + raise ValueError('signature must have only one item') + self._dbus_signature = signature + + def __get__(self, inst, type=None): + if inst is None: + return self + if self.fget is None: + raise AttributeError("unreadable attribute") + return self.fget(inst) + + def __set__(self, inst, value): + if self.fset is None: + raise AttributeError("can't set attribute") + self.fset(inst, value) + + def __call__(self, fget): + return self.getter(fget) + + def _copy(self, fget=None, fset=None): + return dbus_property(dbus_interface=self._dbus_interface, + signature=self._dbus_signature, + property_name=self._init_property_name, + emits_changed_signal=self._emits_changed_signal, + fget=fget or self.fget, fset=fset or self.fset, + doc=self._init_doc) + + def getter(self, fget): + return self._copy(fget=fget) + + def setter(self, fset): + return self._copy(fset=fset) + +_logger = logging.getLogger('dbus.service') + + +class _VariantSignature(object): + """A fake method signature which, when iterated, yields an endless stream + of 'v' characters representing variants (handy with zip()). + + It has no string representation. + """ + def __iter__(self): + """Return self.""" + return self + + def __next__(self): + """Return 'v' whenever called.""" + return 'v' + + if is_py2: + next = __next__ + + +class BusName(object): + """A base class for exporting your own Named Services across the Bus. + + When instantiated, objects of this class attempt to claim the given + well-known name on the given bus for the current process. The name is + released when the BusName object becomes unreferenced. + + If a well-known name is requested multiple times, multiple references + to the same BusName object will be returned. + + Caveats + ------- + - Assumes that named services are only ever requested using this class - + if you request names from the bus directly, confusion may occur. + - Does not handle queueing. + """ + def __new__(cls, name, bus=None, allow_replacement=False , replace_existing=False, do_not_queue=False): + """Constructor, which may either return an existing cached object + or a new object. + + :Parameters: + `name` : str + The well-known name to be advertised + `bus` : dbus.Bus + A Bus on which this service will be advertised. + + Omitting this parameter or setting it to None has been + deprecated since version 0.82.1. For backwards compatibility, + if this is done, the global shared connection to the session + bus will be used. + + `allow_replacement` : bool + If True, other processes trying to claim the same well-known + name will take precedence over this one. + `replace_existing` : bool + If True, this process can take over the well-known name + from other processes already holding it. + `do_not_queue` : bool + If True, this service will not be placed in the queue of + services waiting for the requested name if another service + already holds it. + """ + validate_bus_name(name, allow_well_known=True, allow_unique=False) + + # if necessary, get default bus (deprecated) + if bus is None: + import warnings + warnings.warn('Omitting the "bus" parameter to ' + 'dbus.service.BusName.__init__ is deprecated', + DeprecationWarning, stacklevel=2) + bus = SessionBus() + + # see if this name is already defined, return it if so + # FIXME: accessing internals of Bus + if name in bus._bus_names: + return bus._bus_names[name] + + # otherwise register the name + name_flags = ( + (allow_replacement and _dbus_bindings.NAME_FLAG_ALLOW_REPLACEMENT or 0) | + (replace_existing and _dbus_bindings.NAME_FLAG_REPLACE_EXISTING or 0) | + (do_not_queue and _dbus_bindings.NAME_FLAG_DO_NOT_QUEUE or 0)) + + retval = bus.request_name(name, name_flags) + + # TODO: more intelligent tracking of bus name states? + if retval == _dbus_bindings.REQUEST_NAME_REPLY_PRIMARY_OWNER: + pass + elif retval == _dbus_bindings.REQUEST_NAME_REPLY_IN_QUEUE: + # queueing can happen by default, maybe we should + # track this better or let the user know if they're + # queued or not? + pass + elif retval == _dbus_bindings.REQUEST_NAME_REPLY_EXISTS: + raise NameExistsException(name) + elif retval == _dbus_bindings.REQUEST_NAME_REPLY_ALREADY_OWNER: + # if this is a shared bus which is being used by someone + # else in this process, this can happen legitimately + pass + else: + raise RuntimeError('requesting bus name %s returned unexpected value %s' % (name, retval)) + + # and create the object + bus_name = object.__new__(cls) + bus_name._bus = bus + bus_name._name = name + + # cache instance (weak ref only) + # FIXME: accessing Bus internals again + bus._bus_names[name] = bus_name + + return bus_name + + # do nothing because this is called whether or not the bus name + # object was retrieved from the cache or created new + def __init__(self, *args, **keywords): + pass + + # we can delete the low-level name here because these objects + # are guaranteed to exist only once for each bus name + def __del__(self): + self._bus.release_name(self._name) + pass + + def get_bus(self): + """Get the Bus this Service is on""" + return self._bus + + def get_name(self): + """Get the name of this service""" + return self._name + + def __repr__(self): + return '' % (self._name, self._bus, id(self)) + __str__ = __repr__ + + +def _method_lookup(self, method_name, dbus_interface): + """Walks the Python MRO of the given class to find the method to invoke. + + Returns two methods, the one to call, and the one it inherits from which + defines its D-Bus interface name, signature, and attributes. + """ + parent_method = None + candidate_class = None + successful = False + + # split up the cases when we do and don't have an interface because the + # latter is much simpler + if dbus_interface: + # search through the class hierarchy in python MRO order + for cls in self.__class__.__mro__: + # if we haven't got a candidate class yet, and we find a class with a + # suitably named member, save this as a candidate class + if (not candidate_class and method_name in cls.__dict__): + if ("_dbus_is_method" in cls.__dict__[method_name].__dict__ + and "_dbus_interface" in cls.__dict__[method_name].__dict__): + # however if it is annotated for a different interface + # than we are looking for, it cannot be a candidate + if cls.__dict__[method_name]._dbus_interface == dbus_interface: + candidate_class = cls + parent_method = cls.__dict__[method_name] + successful = True + break + else: + pass + else: + candidate_class = cls + + # if we have a candidate class, carry on checking this and all + # superclasses for a method annoated as a dbus method + # on the correct interface + if (candidate_class and method_name in cls.__dict__ + and "_dbus_is_method" in cls.__dict__[method_name].__dict__ + and "_dbus_interface" in cls.__dict__[method_name].__dict__ + and cls.__dict__[method_name]._dbus_interface == dbus_interface): + # the candidate class has a dbus method on the correct interface, + # or overrides a method that is, success! + parent_method = cls.__dict__[method_name] + successful = True + break + + else: + # simpler version of above + for cls in self.__class__.__mro__: + if (not candidate_class and method_name in cls.__dict__): + candidate_class = cls + + if (candidate_class and method_name in cls.__dict__ + and "_dbus_is_method" in cls.__dict__[method_name].__dict__): + parent_method = cls.__dict__[method_name] + successful = True + break + + if successful: + return (candidate_class.__dict__[method_name], parent_method) + else: + if dbus_interface: + raise UnknownMethodException('%s is not a valid method of interface %s' % (method_name, dbus_interface)) + else: + raise UnknownMethodException('%s is not a valid method' % method_name) + + +def _method_reply_return(connection, message, method_name, signature, *retval): + reply = MethodReturnMessage(message) + try: + reply.append(signature=signature, *retval) + except Exception as e: + logging.basicConfig() + if signature is None: + try: + signature = reply.guess_signature(retval) + ' (guessed)' + except Exception as e: + _logger.error('Unable to guess signature for arguments %r: ' + '%s: %s', retval, e.__class__, e) + raise + _logger.error('Unable to append %r to message with signature %s: ' + '%s: %s', retval, signature, e.__class__, e) + raise + + connection.send_message(reply) + + +def _method_reply_error(connection, message, exception): + name = getattr(exception, '_dbus_error_name', None) + + if name is not None: + pass + elif getattr(exception, '__module__', '') in ('', '__main__'): + name = 'org.freedesktop.DBus.Python.%s' % exception.__class__.__name__ + else: + name = 'org.freedesktop.DBus.Python.%s.%s' % (exception.__module__, exception.__class__.__name__) + + et, ev, etb = sys.exc_info() + if isinstance(exception, DBusException) and not exception.include_traceback: + # We don't actually want the traceback anyway + contents = exception.get_dbus_message() + elif ev is exception: + # The exception was actually thrown, so we can get a traceback + contents = ''.join(traceback.format_exception(et, ev, etb)) + else: + # We don't have any traceback for it, e.g. + # async_err_cb(MyException('Failed to badger the mushroom')) + # see also https://bugs.freedesktop.org/show_bug.cgi?id=12403 + contents = ''.join(traceback.format_exception_only(exception.__class__, + exception)) + reply = ErrorMessage(message, name, contents) + + connection.send_message(reply) + + +class InterfaceType(type): + def __new__(cls, name, bases, dct): + # Properties require the PropertiesInterface base. + for func in dct.values(): + if isinstance(func, dbus_property): + for b in bases: + if issubclass(b, PropertiesInterface): + break + else: + bases += (PropertiesInterface,) + break + + interface_table = dct.setdefault('_dbus_interface_table', {}) + + # merge all the name -> method tables for all the interfaces + # implemented by our base classes into our own + for b in bases: + base_interface_table = getattr(b, '_dbus_interface_table', False) + if base_interface_table: + for (interface, method_table) in base_interface_table.items(): + our_method_table = interface_table.setdefault(interface, {}) + our_method_table.update(method_table) + + # add in all the name -> method entries for our own methods/signals + for func in dct.values(): + if getattr(func, '_dbus_interface', False): + method_table = interface_table.setdefault(func._dbus_interface, {}) + method_table[func.__name__] = func + + return type.__new__(cls, name, bases, dct) + + # methods are different to signals and properties, so we have three functions... :) + def _reflect_on_method(cls, func): + args = func._dbus_args + + if func._dbus_in_signature: + # convert signature into a tuple so length refers to number of + # types, not number of characters. the length is checked by + # the decorator to make sure it matches the length of args. + in_sig = tuple(Signature(func._dbus_in_signature)) + else: + # magic iterator which returns as many v's as we need + in_sig = _VariantSignature() + + if func._dbus_out_signature: + out_sig = Signature(func._dbus_out_signature) + else: + # its tempting to default to Signature('v'), but + # for methods that return nothing, providing incorrect + # introspection data is worse than providing none at all + out_sig = [] + + reflection_data = ' \n' % (func.__name__) + for pair in zip(in_sig, args): + reflection_data += ' \n' % pair + for type in out_sig: + reflection_data += ' \n' % type + reflection_data += ' \n' + + return reflection_data + + def _reflect_on_signal(cls, func): + args = func._dbus_args + + if func._dbus_signature: + # convert signature into a tuple so length refers to number of + # types, not number of characters + sig = tuple(Signature(func._dbus_signature)) + else: + # magic iterator which returns as many v's as we need + sig = _VariantSignature() + + reflection_data = ' \n' % (func.__name__) + for pair in zip(sig, args): + reflection_data = reflection_data + ' \n' % pair + reflection_data = reflection_data + ' \n' + + return reflection_data + + def _reflect_on_property(cls, descriptor): + signature = descriptor._dbus_signature + if signature is None: + signature = 'v' + + if descriptor.fget: + if descriptor.fset: + access = "readwrite" + else: + access = "read" + elif descriptor.fset: + access = "write" + else: + return "" + reflection_data = ' ' % (self.__class__.__module__, + self.__class__.__name__, where, + id(self)) + __str__ = __repr__ + +class FallbackObject(Object): + """An object that implements an entire subtree of the object-path + tree. + + :Since: 0.82.0 + """ + + SUPPORTS_MULTIPLE_OBJECT_PATHS = True + + def __init__(self, conn=None, object_path=None): + """Constructor. + + Note that the superclass' ``bus_name`` __init__ argument is not + supported here. + + :Parameters: + `conn` : dbus.connection.Connection or None + The connection on which to export this object. If this is not + None, an `object_path` must also be provided. + + If None, the object is not initially available on any + Connection. + + `object_path` : str or None + A D-Bus object path at which to make this Object available + immediately. If this is not None, a `conn` must also be + provided. + + This object will implements all object-paths in the subtree + starting at this object-path, except where a more specific + object has been added. + """ + super(FallbackObject, self).__init__() + self._fallback = True + + if conn is None: + if object_path is not None: + raise TypeError('If object_path is given, conn is required') + elif object_path is None: + raise TypeError('If conn is given, object_path is required') + else: + self.add_to_connection(conn, object_path)