root/trunk/src/disco.py

Revision 10658, 66.2 kB (checked in by steve-e, 2 weeks ago)

Prevent possible traceback when selecting a row while ServiceBrowser? is still loading.

Line 
1# -*- coding: utf-8 -*-
2## src/disco.py
3##
4## Copyright (C) 2005-2006 Stéphan Kochen <stephan AT kochen.nl>
5## Copyright (C) 2005-2007 Nikos Kouremenos <kourem AT gmail.com>
6## Copyright (C) 2005-2008 Yann Leboulanger <asterix AT lagaule.org>
7## Copyright (C) 2006 Dimitur Kirov <dkirov AT gmail.com>
8## Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org>
9## Copyright (C) 2007 Stephan Erb <steve-e AT h3c.de>
10##
11## This file is part of Gajim.
12##
13## Gajim is free software; you can redistribute it and/or modify
14## it under the terms of the GNU General Public License as published
15## by the Free Software Foundation; version 3 only.
16##
17## Gajim is distributed in the hope that it will be useful,
18## but WITHOUT ANY WARRANTY; without even the implied warranty of
19## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20## GNU General Public License for more details.
21##
22## You should have received a copy of the GNU General Public License
23## along with Gajim. If not, see <http://www.gnu.org/licenses/>.
24##
25
26# The appearance of the treeview, and parts of the dialog, are controlled by
27# AgentBrowser (sub-)classes. Methods that probably should be overridden when
28# subclassing are: (look at the docstrings and source for additional info)
29# - def cleanup(self) *
30# - def _create_treemodel(self) *
31# - def _add_actions(self)
32# - def _clean_actions(self)
33# - def update_theme(self) *
34# - def update_actions(self)
35# - def default_action(self)
36# - def _find_item(self, jid, node)
37# - def _add_item(self, jid, node, item, force)
38# - def _update_item(self, iter, jid, node, item)
39# - def _update_info(self, iter, jid, node, identities, features, data)
40# - def _update_error(self, iter, jid, node)
41#
42# * Should call the super class for this method.
43# All others do not have to call back to the super class. (but can if they want
44# the functionality)
45# There are more methods, of course, but this is a basic set.
46
47import os
48import types
49import weakref
50import gobject
51import gtk
52import pango
53
54import dialogs
55import tooltips
56import gtkgui_helpers
57import groups
58import adhoc_commands
59import search_window
60
61from common import gajim
62from common import xmpp
63from common.exceptions import GajimGeneralException
64from common import helpers
65
66# Dictionary mapping category, type pairs to browser class, image pairs.
67# This is a function, so we can call it after the classes are declared.
68# For the browser class, None means that the service will only be browsable
69# when it advertises disco as it's feature, False means it's never browsable.
70def _gen_agent_type_info():
71        return {
72                # Defaults
73                (0, 0):                                                 (None, None),
74
75                # Jabber server
76                ('server', 'im'):                               (ToplevelAgentBrowser, 'jabber.png'),
77                ('services', 'jabber'):         (ToplevelAgentBrowser, 'jabber.png'),
78                ('hierarchy', 'branch'):        (AgentBrowser, 'jabber.png'),
79
80                # Services
81                ('conference', 'text'):         (MucBrowser, 'conference.png'),
82                ('headline', 'rss'):                    (AgentBrowser, 'rss.png'),
83                ('headline', 'weather'):        (False, 'weather.png'),
84                ('gateway', 'weather'):         (False, 'weather.png'),
85                ('_jid', 'weather'):                    (False, 'weather.png'),
86                ('gateway', 'sip'):                     (False, 'sip.png'),
87                ('directory', 'user'):          (None, 'jud.png'),
88                ('pubsub', 'generic'):          (PubSubBrowser, 'pubsub.png'),
89                ('pubsub', 'service'):          (PubSubBrowser, 'pubsub.png'),
90                ('proxy', 'bytestreams'):       (None, 'bytestreams.png'), # Socks5 FT proxy
91                ('headline', 'newmail'):        (ToplevelAgentBrowser, 'mail.png'),
92
93                # Transports
94                ('conference', 'irc'):          (ToplevelAgentBrowser, 'irc.png'),
95                ('_jid', 'irc'):                                (False, 'irc.png'),
96                ('gateway', 'aim'):                     (False, 'aim.png'),
97                ('_jid', 'aim'):                                (False, 'aim.png'),
98                ('gateway', 'gadu-gadu'):       (False, 'gadu-gadu.png'),
99                ('_jid', 'gadugadu'):           (False, 'gadu-gadu.png'),
100                ('gateway', 'http-ws'):         (False, 'http-ws.png'),
101                ('gateway', 'icq'):                     (False, 'icq.png'),
102                ('_jid', 'icq'):                                (False, 'icq.png'),
103                ('gateway', 'msn'):                     (False, 'msn.png'),
104                ('_jid', 'msn'):                                (False, 'msn.png'),
105                ('gateway', 'sms'):                     (False, 'sms.png'),
106                ('_jid', 'sms'):                                (False, 'sms.png'),
107                ('gateway', 'smtp'):                    (False, 'mail.png'),
108                ('gateway', 'yahoo'):           (False, 'yahoo.png'),
109                ('_jid', 'yahoo'):                      (False, 'yahoo.png'),
110                ('gateway', 'mrim'):                    (False, 'mrim.png'),
111                ('_jid', 'mrim'):                               (False, 'mrim.png'),
112        }
113
114# Category type to "human-readable" description string, and sort priority
115_cat_to_descr = {
116        'other':                        (_('Others'),   2),
117        'gateway':              (_('Transports'),       0),
118        '_jid':                 (_('Transports'),       0),
119        #conference is a category for listing mostly groupchats in service discovery
120        'conference':   (_('Conference'),       1),
121}
122
123
124class CacheDictionary:
125        '''A dictionary that keeps items around for only a specific time.
126        Lifetime is in minutes. Getrefresh specifies whether to refresh when
127        an item is merely accessed instead of set aswell.'''
128        def __init__(self, lifetime, getrefresh = True):
129                self.lifetime = lifetime * 1000 * 60
130                self.getrefresh = getrefresh
131                self.cache = {}
132
133        class CacheItem:
134                '''An object to store cache items and their timeouts.'''
135                def __init__(self, value):
136                        self.value = value
137                        self.source = None
138
139                def __call__(self):
140                        return self.value
141
142        def cleanup(self):
143                for key in self.cache.keys():
144                        item = self.cache[key]
145                        if item.source:
146                                gobject.source_remove(item.source)
147                        del self.cache[key]
148
149        def _expire_timeout(self, key):
150                '''The timeout has expired, remove the object.'''
151                if key in self.cache:
152                        del self.cache[key]
153                return False
154
155        def _refresh_timeout(self, key):
156                '''The object was accessed, refresh the timeout.'''
157                item = self.cache[key]
158                if item.source:
159                        gobject.source_remove(item.source)
160                if self.lifetime:
161                        source = gobject.timeout_add_seconds(self.lifetime/1000, self._expire_timeout, key)
162                        item.source = source
163
164        def __getitem__(self, key):
165                item = self.cache[key]
166                if self.getrefresh:
167                        self._refresh_timeout(key)
168                return item()
169
170        def __setitem__(self, key, value):
171                item = self.CacheItem(value)
172                self.cache[key] = item
173                self._refresh_timeout(key)
174
175        def __delitem__(self, key):
176                item = self.cache[key]
177                if item.source:
178                        gobject.source_remove(item.source)
179                del self.cache[key]
180
181        def __contains__(self, key):
182                return key in self.cache
183        has_key = __contains__
184
185_icon_cache = CacheDictionary(15)
186
187def get_agent_address(jid, node = None):
188        '''Returns an agent's address for displaying in the GUI.'''
189        if node:
190                return '%s@%s' % (node, str(jid))
191        else:
192                return str(jid)
193
194class Closure(object):
195        '''A weak reference to a callback with arguments as an object.
196
197        Weak references to methods immediatly die, even if the object is still
198        alive. Besides a handy way to store a callback, this provides a workaround
199        that keeps a reference to the object instead.
200
201        Userargs and removeargs must be tuples.'''
202        def __init__(self, cb, userargs = (), remove = None, removeargs = ()):
203                self.userargs = userargs
204                self.remove = remove
205                self.removeargs = removeargs
206                if isinstance(cb, types.MethodType):
207                        self.meth_self = weakref.ref(cb.im_self, self._remove)
208                        self.meth_name = cb.func_name
209                elif callable(cb):
210                        self.meth_self = None
211                        self.cb = weakref.ref(cb, self._remove)
212                else:
213                        raise TypeError('Object is not callable')
214
215        def _remove(self, ref):
216                if self.remove:
217                        self.remove(self, *self.removeargs)
218
219        def __call__(self, *args, **kwargs):
220                if self.meth_self:
221                        obj = self.meth_self()
222                        cb = getattr(obj, self.meth_name)
223                else:
224                        cb = self.cb()
225                args = args + self.userargs
226                return cb(*args, **kwargs)
227
228
229class ServicesCache:
230        '''Class that caches our query results. Each connection will have it's own
231        ServiceCache instance.'''
232        def __init__(self, account):
233                self.account = account
234                self._items = CacheDictionary(0, getrefresh = False)
235                self._info = CacheDictionary(0, getrefresh = False)
236                self._subscriptions = CacheDictionary(5, getrefresh=False)
237                self._cbs = {}
238
239        def cleanup(self):
240                self._items.cleanup()
241                self._info.cleanup()
242
243        def _clean_closure(self, cb, type_, addr):
244                # A closure died, clean up
245                cbkey = (type_, addr)
246                try:
247                        self._cbs[cbkey].remove(cb)
248                except KeyError:
249                        return
250                except ValueError:
251                        return
252                # Clean an empty list
253                if not self._cbs[cbkey]:
254                        del self._cbs[cbkey]
255
256        def get_icon(self, identities = []):
257                '''Return the icon for an agent.'''
258                # Grab the first identity with an icon
259                for identity in identities:
260                        try:
261                                cat, type = identity['category'], identity['type']
262                                info = _agent_type_info[(cat, type)]
263                        except KeyError:
264                                continue
265                        filename = info[1]
266                        if filename:
267                                break
268                else:
269                        # Loop fell through, default to unknown
270                        cat = type = 0
271                        info = _agent_type_info[(0, 0)]
272                        filename = info[1]
273                if not filename: # we don't have an image to show for this type
274                        filename = 'jabber.png'
275                # Use the cache if possible
276                if filename in _icon_cache:
277                        return _icon_cache[filename]
278                # Or load it
279                filepath = os.path.join(gajim.DATA_DIR, 'pixmaps', 'agents', filename)
280                pix = gtk.gdk.pixbuf_new_from_file(filepath)
281                # Store in cache
282                _icon_cache[filename] = pix
283                return pix
284
285        def get_browser(self, identities=[], features=[]):
286                '''Return the browser class for an agent.'''
287                # First pass, we try to find a ToplevelAgentBrowser
288                for identity in identities:
289                        try:
290                                cat, type_ = identity['category'], identity['type']
291                                info = _agent_type_info[(cat, type_)]
292                        except KeyError:
293                                continue
294                        browser = info[0]
295                        if browser and browser == ToplevelAgentBrowser:
296                                return browser
297
298                # second pass, we haven't found a ToplevelAgentBrowser
299                for identity in identities:
300                        try:
301                                cat, type_ = identity['category'], identity['type']
302                                info = _agent_type_info[(cat, type_)]
303                        except KeyError:
304                                continue
305                        browser = info[0]
306                        if browser:
307                                return browser
308                # NS_BROWSE is deprecated, but we check for it anyways.
309                # Some services list it in features and respond to
310                # NS_DISCO_ITEMS anyways.
311                # Allow browsing for unknown types aswell.
312                if (not features and not identities) or \
313                xmpp.NS_DISCO_ITEMS in features or xmpp.NS_BROWSE in features:
314                        return ToplevelAgentBrowser
315                return None
316
317        def get_info(self, jid, node, cb, force = False, nofetch = False, args = ()):
318                '''Get info for an agent.'''
319                addr = get_agent_address(jid, node)
320                # Check the cache
321                if addr in self._info:
322                        args = self._info[addr] + args
323                        cb(jid, node, *args)
324                        return
325                if nofetch:
326                        return
327
328                # Create a closure object
329                cbkey = ('info', addr)
330                cb = Closure(cb, userargs = args, remove = self._clean_closure,
331                                removeargs = cbkey)
332                # Are we already fetching this?
333                if cbkey in self._cbs:
334                        self._cbs[cbkey].append(cb)
335                else:
336                        self._cbs[cbkey] = [cb]
337                        gajim.connections[self.account].discoverInfo(jid, node)
338
339        def get_items(self, jid, node, cb, force = False, nofetch = False, args = ()):
340                '''Get a list of items in an agent.'''
341                addr = get_agent_address(jid, node)
342                # Check the cache
343                if addr in self._items:
344                        args = (self._items[addr],) + args
345                        cb(jid, node, *args)
346                        return
347                if nofetch:
348                        return
349
350                # Create a closure object
351                cbkey = ('items', addr)
352                cb = Closure(cb, userargs = args, remove = self._clean_closure,
353                                removeargs = cbkey)
354                # Are we already fetching this?
355                if cbkey in self._cbs:
356                        self._cbs[cbkey].append(cb)
357                else:
358                        self._cbs[cbkey] = [cb]
359                        gajim.connections[self.account].discoverItems(jid, node)
360
361        def agent_info(self, jid, node, identities, features, data):
362                '''Callback for when we receive an agent's info.'''
363                addr = get_agent_address(jid, node)
364
365                # Store in cache
366                self._info[addr] = (identities, features, data)
367
368                # Call callbacks
369                cbkey = ('info', addr)
370                if cbkey in self._cbs:
371                        for cb in self._cbs[cbkey]:
372                                cb(jid, node, identities, features, data)
373                        # clean_closure may have beaten us to it
374                        if cbkey in self._cbs:
375                                del self._cbs[cbkey]
376
377        def agent_items(self, jid, node, items):
378                '''Callback for when we receive an agent's items.'''
379                addr = get_agent_address(jid, node)
380
381                # Store in cache
382                self._items[addr] = items
383
384                # Call callbacks
385                cbkey = ('items', addr)
386                if cbkey in self._cbs:
387                        for cb in self._cbs[cbkey]:
388                                cb(jid, node, items)
389                        # clean_closure may have beaten us to it
390                        if cbkey in self._cbs:
391                                del self._cbs[cbkey]
392
393        def agent_info_error(self, jid):
394                '''Callback for when a query fails. (even after the browse and agents
395                namespaces)'''
396                addr = get_agent_address(jid)
397
398                # Call callbacks
399                cbkey = ('info', addr)
400                if cbkey in self._cbs:
401                        for cb in self._cbs[cbkey]:
402                                cb(jid, '', 0, 0, 0)
403                        # clean_closure may have beaten us to it
404                        if cbkey in self._cbs:
405                                del self._cbs[cbkey]
406
407        def agent_items_error(self, jid):
408                '''Callback for when a query fails. (even after the browse and agents
409                namespaces)'''
410                addr = get_agent_address(jid)
411
412                # Call callbacks
413                cbkey = ('items', addr)
414                if cbkey in self._cbs:
415                        for cb in self._cbs[cbkey]:
416                                cb(jid, '', 0)
417                        # clean_closure may have beaten us to it
418                        if cbkey in self._cbs:
419                                del self._cbs[cbkey]
420
421# object is needed so that @property works
422class ServiceDiscoveryWindow(object):
423        '''Class that represents the Services Discovery window.'''
424        def __init__(self, account, jid = '', node = '',
425                        address_entry = False, parent = None):
426                self.account = account
427                self.parent = parent
428                if not jid:
429                        jid = gajim.config.get_per('accounts', account, 'hostname')
430                        node = ''
431
432                self.jid = None
433                self.browser = None
434                self.children = []
435                self.dying = False
436
437                # Check connection
438                if gajim.connections[account].connected < 2:
439                        dialogs.ErrorDialog(_('You are not connected to the server'),
440_('Without a connection, you can not browse available services'))
441                        raise RuntimeError, 'You must be connected to browse services'
442
443                # Get a ServicesCache object.
444                try:
445                        self.cache = gajim.connections[account].services_cache
446                except AttributeError:
447                        self.cache = ServicesCache(account)
448                        gajim.connections[account].services_cache = self.cache
449
450                self.xml = gtkgui_helpers.get_glade('service_discovery_window.glade')
451                self.window = self.xml.get_widget('service_discovery_window')
452                self.services_treeview = self.xml.get_widget('services_treeview')
453                self.model = None
454                # This is more reliable than the cursor-changed signal.
455                selection = self.services_treeview.get_selection()
456                selection.connect_after('changed',
457                        self.on_services_treeview_selection_changed)
458                self.services_scrollwin = self.xml.get_widget('services_scrollwin')
459                self.progressbar = self.xml.get_widget('services_progressbar')
460                self.banner = self.xml.get_widget('banner_agent_label')
461                self.banner_icon = self.xml.get_widget('banner_agent_icon')
462                self.banner_eventbox = self.xml.get_widget('banner_agent_eventbox')
463                self.style_event_id = 0
464                self.banner.realize()
465                self.paint_banner()
466                self.action_buttonbox = self.xml.get_widget('action_buttonbox')
467
468                # Address combobox
469                self.address_comboboxentry = None
470                address_table = self.xml.get_widget('address_table')
471                if address_entry:
472                        self.address_comboboxentry = self.xml.get_widget(
473                                'address_comboboxentry')
474                        self.address_comboboxentry_entry = self.address_comboboxentry.child
475                        self.address_comboboxentry_entry.set_activates_default(True)
476
477                        liststore = gtk.ListStore(str)
478                        self.address_comboboxentry.set_model(liststore)
479                        self.latest_addresses = gajim.config.get(
480                                'latest_disco_addresses').split()
481                        if jid in self.latest_addresses:
482                                self.latest_addresses.remove(jid)
483                        self.latest_addresses.insert(0, jid)
484                        if len(self.latest_addresses) > 10:
485                                self.latest_addresses = self.latest_addresses[0:10]
486                        for j in self.latest_addresses:
487                                self.address_comboboxentry.append_text(j)
488                        self.address_comboboxentry.child.set_text(jid)
489                else:
490                        # Don't show it at all if we didn't ask for it
491                        address_table.set_no_show_all(True)
492                        address_table.hide()
493
494                self._initial_state()
495                self.xml.signal_autoconnect(self)
496                self.travel(jid, node)
497                self.window.show_all()
498
499        @property
500        def _get_account(self):
501                return self.account
502
503        @property
504        def _set_account(self, value):
505                self.account = value
506                self.cache.account = value
507                if self.browser:
508                        self.browser.account = value
509
510        def _initial_state(self):
511                '''Set some initial state on the window. Separated in a method because
512                it's handy to use within browser's cleanup method.'''
513                self.progressbar.hide()
514                title_text = _('Service Discovery using account %s') % self.account
515                self.window.set_title(title_text)
516                self._set_window_banner_text(_('Service Discovery'))
517                self.banner_icon.clear()
518                self.banner_icon.hide() # Just clearing it doesn't work
519
520        def _set_window_banner_text(self, text, text_after = None):
521                theme = gajim.config.get('roster_theme')
522                bannerfont = gajim.config.get_per('themes', theme, 'bannerfont')
523                bannerfontattrs = gajim.config.get_per('themes', theme,
524                        'bannerfontattrs')
525               
526                if bannerfont:
527                        font = pango.FontDescription(bannerfont)
528                else:
529                        font = pango.FontDescription('Normal')
530                if bannerfontattrs:
531                        # B is attribute set by default
532                        if 'B' in bannerfontattrs:
533                                font.set_weight(pango.WEIGHT_HEAVY)
534                        if 'I' in bannerfontattrs:
535                                font.set_style(pango.STYLE_ITALIC)
536               
537                font_attrs = 'font_desc="%s"' % font.to_string()
538                font_size = font.get_size()
539               
540                # in case there is no font specified we use x-large font size
541                if font_size == 0:
542                        font_attrs = '%s size="large"' % font_attrs
543                markup = '<span %s>%s</span>' % (font_attrs, text)
544                if text_after:
545                        font.set_weight(pango.WEIGHT_NORMAL)
546                        markup = '%s\n<span font_desc="%s" size="small">%s</span>' % \
547                                                                        (markup, font.to_string(), text_after)
548                self.banner.set_markup(markup)
549       
550        def paint_banner(self):
551                '''Repaint the banner with theme color'''
552                theme = gajim.config.get('roster_theme')
553                bgcolor = gajim.config.get_per('themes', theme, 'bannerbgcolor')
554                textcolor = gajim.config.get_per('themes', theme, 'bannertextcolor')
555                self.disconnect_style_event()
556                if bgcolor:
557                        color = gtk.gdk.color_parse(bgcolor)
558                        self.banner_eventbox.modify_bg(gtk.STATE_NORMAL, color)
559                        default_bg = False
560                else:
561                        default_bg = True
562               
563                if textcolor:
564                        color = gtk.gdk.color_parse(textcolor)
565                        self.banner.modify_fg(gtk.STATE_NORMAL, color)
566                        default_fg = False
567                else:
568                        default_fg = True
569                if default_fg or default_bg:
570                        self._on_style_set_event(self.banner, None, default_fg, default_bg)
571                if self.browser:
572                        self.browser.update_theme()
573       
574        def disconnect_style_event(self):
575                if self.style_event_id:
576                        self.banner.disconnect(self.style_event_id)
577                        self.style_event_id = 0
578       
579        def connect_style_event(self, set_fg = False, set_bg = False):
580                self.disconnect_style_event()
581                self.style_event_id = self.banner.connect('style-set', 
582                                        self._on_style_set_event, set_fg, set_bg)
583       
584        def _on_style_set_event(self, widget, style, *opts):
585                ''' set style of widget from style class *.Frame.Eventbox
586                        opts[0] == True -> set fg color
587                        opts[1] == True -> set bg color '''
588               
589                self.disconnect_style_event()
590                if opts[1]:
591                        bg_color = widget.style.bg[gtk.STATE_SELECTED]
592                        self.banner_eventbox.modify_bg(gtk.STATE_NORMAL, bg_color)
593                if opts[0]:
594                        fg_color = widget.style.fg[gtk.STATE_SELECTED]
595                        self.banner.modify_fg(gtk.STATE_NORMAL, fg_color)
596                self.banner.ensure_style()
597                self.connect_style_event(opts[0], opts[1])
598       
599        def destroy(self, chain = False):
600                '''Close the browser. This can optionally close its children and
601                propagate to the parent. This should happen on actions like register,
602                or join to kill off the entire browser chain.'''
603                if self.dying:
604                        return
605                self.dying = True
606
607                # self.browser._get_agent_address() would break when no browser.
608                addr = get_agent_address(self.jid, self.node)
609                del gajim.interface.instances[self.account]['disco'][addr]
610
611                if self.browser:
612                        self.window.hide()
613                        self.browser.cleanup()
614                        self.browser = None
615                self.window.destroy()