#! /usr/bin/python2 # vim: set fileencoding=utf-8 # # Minimal GTK2 based daemon/consumer for FDO compliant desktop notifications # # Spec: http://developer.gnome.org/notification-spec/ # # Test suite compliance: # test-action-icons: 0% Action icons not supported # test-basic: 100% OK (long notifications are cut -- not strictly specification compliant, but acceptable in most cases) # test-default-action: 100% OK # test-error: 100% OK (since the test doesn't do anything but showing a notification) # test-image: 50% stock OK, URI not supported # test-markup: 100% OK # test-multiple-actions: 100% OK # test-persistence: 0% Persistence not supported # test-removal: 100% OK # test-replace: 100% OK # test-replace-widget: 100% OK # test-resident: 0% Persistence not supported # test-rtl: 100% OK # test-server-info: 100% OK # test-size-change: 0% other notifications not adjusted for new size # test-transient: (100%) Persistence not supported, therefore automatically transient # test-urgency: 0% Urgency not supported # test-xy 100% # test-xy-actions: 100% # text-xy-stress: 100% # total: 73% # # © Samuel Vincent Creshal 2011 # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import dbus, dbus.service, argparse, gobject from dbus.mainloop.glib import DBusGMainLoop from collections import OrderedDict as odict from os.path import isfile argp = argparse.ArgumentParser (description="Minimal notification handler implementation. Works both as GTK ui client and cli listener.") argp.add_argument ("-q", "--quiet", action="store_true", help="Disable printing to stdout [Default: enabled]") argp.add_argument ("-g", "--gtk", default=True, help="Enable/disable GTK UI [Default: True]") argp.add_argument ("-v", "--verbose", action="store_true", help="Debugging mode [default: not active]") argp.add_argument ("-t", "--timeout", default=5000, help="Timeout for pop ups [default: 5000]", type=int) argp.add_argument ("-x", default="right", help="X position for the popups. Allowed values: unsigned integer, left or right [default: right]") argp.add_argument ("-y", default="top", help="Y position. Allowed: unsigned integer, top or bottom [default: top]") argp.add_argument ("-s", default=64, help="Icon size in pixel [default: 64]", type=int) argp.add_argument ("-n", default=5, help="Maximum number of active notifications [default: 5]", type=int) argp.add_argument ("-d", "--force-tile-down", action="store_true", help="Force notifications to appear below each other [Default: tile down when y<256]") ARGV = argp.parse_args() if ARGV.gtk: import gtk icon_theme = gtk.icon_theme_get_default() icon_size = gtk.icon_size_register("notification", ARGV.s, ARGV.s) class GtkNotification (gtk.Window): def __init__ (self, daemon, app_name, id, icon, summary, body, actions, hints, argv): gtk.Window.__init__(self) self.y = 0 self.notifier = daemon self.argv = argv self.id = id self.hints = hints self.timeout = gobject.timeout_add (daemon.timeout, self.do_destroy, 1) #why write readable code when you can have short code? for attr in ["resizable","decorated"]: getattr(self,"set_"+attr)(False) for attr in ["keep_above","skip_pager_hint","skip_taskbar_hint"]: getattr(self,"set_"+attr)(True) self.stick() self.evbox = gtk.EventBox() self.hbox = gtk.HBox() self.evbox.add (self.hbox) self.add (self.evbox) self.evbox.connect ("button-release-event", self.do_destroy) self.connect ("configure-event", self.automove) self.set_border_width (4) self.update (app_name, id, icon, summary, body, actions, hints, "initial") def do_destroy (self, reason, unused=None): self.notifier.NotificationClosed (self.id, reason if type(reason) != gtk.EventBox else 2) gobject.source_remove (self.timeout) self.destroy() def update (self, app_name, id, icon, summary, body, actions, hints, updatetype="update"): gobject.source_remove (self.timeout) self.timeout = gobject.timeout_add (self.notifier.timeout, self.do_destroy, 1) self.evbox.remove (self.hbox) (w, h) = gtk.icon_size_lookup (icon_size) path = icon if isfile(icon) else hints["image-path"] if "image-path" in hints else hints["image_path"] if "image_path" in hints else None if not path and icon_theme.has_icon (icon) or ("desktop-entry" in hints and icon_theme.has_icon (hints["desktop-entry"])): info = icon_theme.lookup_icon (icon if icon_theme.has_icon (icon) else hints["desktop-entry"],w ,0) path = info.get_filename() info.free() pixbuf = gtk.gdk.pixbuf_new_from_file (path) if path else gtk.gdk.pixbuf_new_from_data ("".join([chr (x) for x in hints["image-data"][6]]), gtk.gdk.COLORSPACE_RGB, hints["image-data"][3], hints["image-data"][4], hints["image-data"][0], hints["image-data"][1], hints["image-data"][2]) if "image-data" in hints else None #whoever made the spec deserves being shot if pixbuf: if pixbuf.get_width() > w or pixbuf.get_height() > h: pixbuf = pixbuf.scale_simple (w, h, gtk.gdk.INTERP_HYPER) icon = gtk.image_new_from_pixbuf (pixbuf) if pixbuf.get_width() < w or pixbuf.get_height() < h: icon.set_size_request (w, h) else: icon = gtk.image_new_from_stock(gtk.STOCK_DIALOG_INFO,icon_size) title = gtk.Label ('%s' % summary) content = gtk.Label (body) self.hbox = gtk.HBox(False) self.hbox.add (icon) vbox = gtk.VBox(True) for label in [title, content]: vbox.pack_start (label) label.set_use_markup (True) if len (body) > 80: content.set_max_width_chars (80) #the dreaded 80 char limit... it's going to haunt us forever, isn't it? self.hbox.pack_start (vbox, padding=8) for i in range(len(actions))[::2]: button = gtk.Button (actions[i+1]) button.connect ("clicked", self.action, actions[i]) self.hbox.pack_start(button) self.evbox.add (self.hbox) self.modify_bg (gtk.STATE_NORMAL, self.get_style().bg[gtk.STATE_SELECTED]) self.show_all() self.needsmove = updatetype def action (self, widget, action): self.notifier.ActionInvoked (self.id, action) def automove (self, widget=None, event=None, force=False,excluded=0,precedessor=None): """ Move window to the user-supplied position. Thanks, GTK, for not making it any easier.""" if not self.needsmove and type(force) is bool or self.id is excluded: return self.set_gravity(gtk.gdk.GRAVITY_SOUTH_EAST) width, height = (event.width, event.height) if event else self.get_size() x,y = (event.x, event.y) if event else self.get_position() #I dare you to find an example of worse abuse of inline if x = self.hints["x"] if "x" in self.hints else (0 if self.argv.x == "left" else gtk.gdk.screen_width() - width if self.argv.x == "right" else int(self.argv.x)) if self.needsmove is not "update": #recalc height only when not updating content alone y = self.hints["y"] if "y" in self.hints else (0 if self.argv.y == "top" else gtk.gdk.screen_height() - height if self.argv.y == "bottom" else int(self.argv.y)) iop = None if "y" not in self.hints and (y < 256 or self.argv.force_tile_down): iop = y.__add__ elif "y" not in self.hints: iop == y.__sub__ if iop and precedessor: y = iop (height + precedessor.y) elif iop: y = iop (height * max((force if type(force) is int else (len(self.notifier.notifications)-1)) - self.notifier.absolute_notes, 0)) self.move(x, y) self.y = y self.needsmove = "" class YSNotifier (dbus.service.Object): """Daemon component. Listens for notifications over DBus.""" def __init__ (self, argv): bus_name = dbus.service.BusName ("org.freedesktop.Notifications", bus=dbus.SessionBus()) self.max_id = 1 #spec doesn't think 0 is a valid id self.notifications = odict() self.argv = argv self.timeout = argv.timeout self.absolute_notes = 0 dbus.service.Object.__init__(self, bus_name, "/org/freedesktop/Notifications") @dbus.service.method ("org.freedesktop.Notifications") def Notify (self, app_name, id, icon, summary, body, actions, hints, timeout): for note in self.notifications.copy(): if len(self.notifications) < self.argv.n: break self.notifications[note].do_destroy (4) id = int(id) summary = summary[:40] #truncate summary as per spec if self.argv.verbose: print ("|".join ([app_name, str(id), icon, summary, body, " ".join(actions), " ".join(hints), str(timeout)])) try: #update notification if requested and existing assert (id > 0) self.notifications[id].update (app_name, id, icon, summary, body, actions, hints) except: id = self.max_id if not id else id self.max_id = self.max_id +1 if id <= self.max_id else id+1 if self.argv.gtk == True: if "x" in hints: self.absolute_notes += 1 self.notifications[id] = GtkNotification (self, app_name, id, icon, summary, body, actions, hints, self.argv) elif not self.argv.quiet: print ("[%s]: %s" % (app_name, body if body else summary)) self.reposition_notes (id) return dbus.UInt32(id) def reposition_notes (self, excluded): last = None for note in enumerate (self.notifications): if "x" in self.notifications[note[1]].hints: continue self.notifications[note[1]].automove(force=note[0], precedessor=last, excluded=excluded) last = self.notifications[note[1]] @dbus.service.method ("org.freedesktop.Notifications") def GetCapabilities (self): return dbus.Array (["body","body-markup","icon-static","actions","body-hyperlinks"]) @dbus.service.method ("org.freedesktop.Notifications") def CloseNotification (self, id): try: self.notifications[id].do_destroy (3) finally: return @dbus.service.method ("org.freedesktop.Notifications", out_signature="ssss") def GetServerInformation (self): return ("YSNotifier", "The Yaki Syndicate", "0.5", "1.2") @dbus.service.signal (dbus_interface="org.freedesktop.Notifications", signature="uu") def NotificationClosed (self, id, reason): if "x" in self.notifications[id].hints: self.absolute_notes -= 1 del(self.notifications[id]) self.reposition_notes(id) if self.argv.verbose: print ("Removing notification %s, reason: %s" % (id, ["Expired","User request","Client request","Undefined/internal"][reason])) @dbus.service.signal (dbus_interface="org.freedesktop.Notifications", signature="us") def ActionInvoked (self, id, action): if self.argv.verbose: print ("Triggering action %s for notification %i" % (action, id)) DBusGMainLoop (set_as_default=True) ysnotifier = YSNotifier(ARGV) if ARGV.gtk: gtk.main() else: gobject.MainLoop().run()