comparison libervia/frontends/quick_frontend/quick_app.py @ 4074:26b7ed2817da

refactoring: rename `sat_frontends` to `libervia.frontends`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 14:12:38 +0200
parents sat_frontends/quick_frontend/quick_app.py@4b842c1fb686
children 6b581d4c249f
comparison
equal deleted inserted replaced
4073:7c5654c54fed 4074:26b7ed2817da
1 #!/usr/bin/env python3
2
3 # helper class for making a SAT frontend
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 from libervia.backend.core.log import getLogger
20 from libervia.backend.core.i18n import _
21 from libervia.backend.core import exceptions
22 from libervia.backend.tools import trigger
23 from libervia.backend.tools.common import data_format
24
25 from libervia.frontends.tools import jid
26 from libervia.frontends.quick_frontend import quick_widgets
27 from libervia.frontends.quick_frontend import quick_menus
28 from libervia.frontends.quick_frontend import quick_blog
29 from libervia.frontends.quick_frontend import quick_chat, quick_games
30 from libervia.frontends.quick_frontend import quick_contact_list
31 from libervia.frontends.quick_frontend.constants import Const as C
32
33 import sys
34 import time
35
36
37 log = getLogger(__name__)
38
39
40 class ProfileManager(object):
41 """Class managing all data relative to one profile, and plugging in mechanism"""
42
43 # TODO: handle waiting XMLUI requests: getWaitingConf doesn't exist anymore
44 # and a way to keep some XMLUI request between sessions is expected in backend
45 host = None
46 bridge = None
47 cache_keys_to_get = ['avatar', 'nicknames']
48
49 def __init__(self, profile):
50 self.profile = profile
51 self.connected = False
52 self.whoami = None
53 self.notifications = {} # key: bare jid or '' for general, value: notif data
54
55 @property
56 def autodisconnect(self):
57 try:
58 autodisconnect = self._autodisconnect
59 except AttributeError:
60 autodisconnect = False
61 return autodisconnect
62
63 def plug(self):
64 """Plug the profile to the host"""
65 # first of all we create the contact lists
66 self.host.contact_lists.add_profile(self.profile)
67
68 # we get the essential params
69 self.bridge.param_get_a_async(
70 "JabberID",
71 "Connection",
72 profile_key=self.profile,
73 callback=self._plug_profile_jid,
74 errback=self._get_param_error,
75 )
76
77 def _plug_profile_jid(self, jid_s):
78 self.whoami = jid.JID(jid_s) # resource might change after the connection
79 log.info(f"Our current jid is: {self.whoami}")
80 self.bridge.is_connected(self.profile, callback=self._plug_profile_isconnected)
81
82 def _autodisconnect_eb(self, failure_):
83 # XXX: we ignore error on this parameter, as Libervia can't access it
84 log.warning(
85 _("Error while trying to get autodisconnect param, ignoring: {}").format(
86 failure_
87 )
88 )
89 self._plug_profile_autodisconnect("false")
90
91 def _plug_profile_isconnected(self, connected):
92 self.connected = connected
93 if connected:
94 self.host.profile_connected(self.profile)
95 self.bridge.param_get_a_async(
96 "autodisconnect",
97 "Connection",
98 profile_key=self.profile,
99 callback=self._plug_profile_autodisconnect,
100 errback=self._autodisconnect_eb,
101 )
102
103 def _plug_profile_autodisconnect(self, autodisconnect):
104 if C.bool(autodisconnect):
105 self._autodisconnect = True
106 self.bridge.param_get_a_async(
107 "autoconnect",
108 "Connection",
109 profile_key=self.profile,
110 callback=self._plug_profile_autoconnect,
111 errback=self._get_param_error,
112 )
113
114 def _plug_profile_autoconnect(self, value_str):
115 autoconnect = C.bool(value_str)
116 if autoconnect and not self.connected:
117 self.host.connect(
118 self.profile, callback=lambda __: self._plug_profile_afterconnect()
119 )
120 else:
121 self._plug_profile_afterconnect()
122
123 def _plug_profile_afterconnect(self):
124 # Profile can be connected or not
125 # we get cached data
126 self.connected = True
127 self.host.bridge.features_get(
128 profile_key=self.profile,
129 callback=self._plug_profile_get_features_cb,
130 errback=self._plug_profile_get_features_eb,
131 )
132
133 def _plug_profile_get_features_eb(self, failure):
134 log.error("Couldn't get features: {}".format(failure))
135 self._plug_profile_get_features_cb({})
136
137 def _plug_profile_get_features_cb(self, features):
138 self.host.features = features
139 self.host.bridge.entities_data_get([], ProfileManager.cache_keys_to_get,
140 profile=self.profile,
141 callback=self._plug_profile_got_cached_values,
142 errback=self._plug_profile_failed_cached_values)
143
144 def _plug_profile_failed_cached_values(self, failure):
145 log.error("Couldn't get cached values: {}".format(failure))
146 self._plug_profile_got_cached_values({})
147
148 def _plug_profile_got_cached_values(self, cached_values):
149 contact_list = self.host.contact_lists[self.profile]
150 # add the contact list and its listener
151 for entity_s, data in cached_values.items():
152 for key, value in data.items():
153 self.host.entity_data_updated_handler(entity_s, key, value, self.profile)
154
155 if not self.connected:
156 self.host.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=self.profile)
157 else:
158
159 contact_list.fill()
160 self.host.set_presence_status(profile=self.profile)
161
162 # The waiting subscription requests
163 self.bridge.sub_waiting_get(
164 self.profile, callback=self._plug_profile_got_waiting_sub
165 )
166
167 def _plug_profile_got_waiting_sub(self, waiting_sub):
168 for sub in waiting_sub:
169 self.host.subscribe_handler(waiting_sub[sub], sub, self.profile)
170
171 self.bridge.muc_get_rooms_joined(
172 self.profile, callback=self._plug_profile_got_rooms_joined
173 )
174
175 def _plug_profile_got_rooms_joined(self, rooms_args):
176 # Now we open the MUC window where we already are:
177 for room_args in rooms_args:
178 self.host.muc_room_joined_handler(*room_args, profile=self.profile)
179 # Presence must be requested after rooms are filled
180 self.host.bridge.presence_statuses_get(
181 self.profile, callback=self._plug_profile_got_presences
182 )
183
184 def _plug_profile_got_presences(self, presences):
185 for contact in presences:
186 for res in presences[contact]:
187 jabber_id = ("%s/%s" % (jid.JID(contact).bare, res)) if res else contact
188 show = presences[contact][res][0]
189 priority = presences[contact][res][1]
190 statuses = presences[contact][res][2]
191 self.host.presence_update_handler(
192 jabber_id, show, priority, statuses, self.profile
193 )
194
195 # At this point, profile should be fully plugged
196 # and we launch frontend specific method
197 self.host.profile_plugged(self.profile)
198
199 def _get_param_error(self, failure):
200 log.error(_("Can't get profile parameter: {msg}").format(msg=failure))
201
202
203 class ProfilesManager(object):
204 """Class managing collection of profiles"""
205
206 def __init__(self):
207 self._profiles = {}
208
209 def __contains__(self, profile):
210 return profile in self._profiles
211
212 def __iter__(self):
213 return iter(self._profiles.keys())
214
215 def __getitem__(self, profile):
216 return self._profiles[profile]
217
218 def __len__(self):
219 return len(self._profiles)
220
221 def items(self):
222 return self._profiles.items()
223
224 def values(self):
225 return self._profiles.values()
226
227 def plug(self, profile):
228 if profile in self._profiles:
229 raise exceptions.ConflictError(
230 "A profile of the name [{}] is already plugged".format(profile)
231 )
232 self._profiles[profile] = ProfileManager(profile)
233 self._profiles[profile].plug()
234
235 def unplug(self, profile):
236 if profile not in self._profiles:
237 raise ValueError("The profile [{}] is not plugged".format(profile))
238
239 # remove the contact list and its listener
240 host = self._profiles[profile].host
241 host.contact_lists[profile].unplug()
242
243 del self._profiles[profile]
244
245 def choose_one_profile(self):
246 return list(self._profiles.keys())[0]
247
248
249 class QuickApp(object):
250 """This class contain the main methods needed for the frontend"""
251
252 MB_HANDLER = True #: Set to False if the frontend doesn't manage microblog
253 AVATARS_HANDLER = True #: set to False if avatars are not used
254 ENCRYPTION_HANDLERS = True #: set to False if encryption is handled separatly
255 #: if True, QuickApp will call resync itself, on all widgets at the same time
256 #: if False, frontend must call resync itself when suitable (e.g. widget is being
257 #: visible)
258 AUTO_RESYNC = True
259
260 def __init__(self, bridge_factory, xmlui, check_options=None, connect_bridge=True):
261 """Create a frontend application
262
263 @param bridge_factory: method to use to create the bridge
264 @param xmlui: xmlui module
265 @param check_options: method to call to check options (usually command line
266 arguments)
267 """
268 self.xmlui = xmlui
269 self.menus = quick_menus.QuickMenusManager(self)
270 ProfileManager.host = self
271 self.profiles = ProfilesManager()
272 # profiles currently being plugged, used to (un)lock contact list updates
273 self._plugs_in_progress = set()
274 self.ready_profiles = set() # profiles which are connected and ready
275 self.signals_cache = {} # used to keep signal received between start of
276 # plug_profile and when the profile is actualy ready
277 self.contact_lists = quick_contact_list.QuickContactListHandler(self)
278 self.widgets = quick_widgets.QuickWidgetsManager(self)
279 if check_options is not None:
280 self.options = check_options()
281 else:
282 self.options = None
283
284 # see selected_widget setter and getter
285 self._selected_widget = None
286
287 # listeners are callable watching events
288 self._listeners = {} # key: listener type ("avatar", "selected", etc),
289 # value: list of callbacks
290
291 # triggers
292 self.trigger = (
293 trigger.TriggerManager()
294 ) # trigger are used to change the default behaviour
295
296 ## bridge ##
297 self.bridge = bridge_factory()
298 ProfileManager.bridge = self.bridge
299 if connect_bridge:
300 self.connect_bridge()
301
302 # frontend notifications
303 self._notif_id = 0
304 self._notifications = {}
305 # watched progresses and associated callbacks
306 self._progress_ids = {}
307 # available features
308 # FIXME: features are profile specific, to be checked
309 self.features = None
310 #: map of short name to namespaces
311 self.ns_map = {}
312 #: available encryptions
313 self.encryption_plugins = []
314 # state of synchronisation with backend
315 self._sync = True
316
317 def connect_bridge(self):
318 self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb)
319
320 def _namespaces_get_cb(self, ns_map):
321 self.ns_map = ns_map
322
323 def _namespaces_get_eb(self, failure_):
324 log.error(_("Can't get namespaces map: {msg}").format(msg=failure_))
325
326 def _encryption_plugins_get_cb(self, plugins_ser):
327 self.encryption_plugins = data_format.deserialise(plugins_ser, type_check=list)
328
329 def _encryption_plugins_get_eb(self, failure_):
330 log.warning(_("Can't retrieve encryption plugins: {msg}").format(msg=failure_))
331
332 def on_bridge_connected(self):
333 self.bridge.ready_get(self.on_backend_ready)
334
335 def _bridge_cb(self):
336 self.register_signal("connected")
337 self.register_signal("disconnected")
338 self.register_signal("action_new")
339 self.register_signal("contact_new")
340 self.register_signal("message_new")
341 if self.ENCRYPTION_HANDLERS:
342 self.register_signal("message_encryption_started")
343 self.register_signal("message_encryption_stopped")
344 self.register_signal("presence_update")
345 self.register_signal("subscribe")
346 self.register_signal("param_update")
347 self.register_signal("contact_deleted")
348 self.register_signal("entity_data_updated")
349 self.register_signal("progress_started")
350 self.register_signal("progress_finished")
351 self.register_signal("progress_error")
352 self.register_signal("muc_room_joined", iface="plugin")
353 self.register_signal("muc_room_left", iface="plugin")
354 self.register_signal("muc_room_user_changed_nick", iface="plugin")
355 self.register_signal("muc_room_new_subject", iface="plugin")
356 self.register_signal("chat_state_received", iface="plugin")
357 self.register_signal("message_state", iface="plugin")
358 self.register_signal("ps_event", iface="plugin")
359 # useful for debugging
360 self.register_signal("_debug", iface="core")
361
362 # FIXME: do it dynamically
363 quick_games.Tarot.register_signals(self)
364 quick_games.Quiz.register_signals(self)
365 quick_games.Radiocol.register_signals(self)
366 self.on_bridge_connected()
367
368 def _bridge_eb(self, failure):
369 if isinstance(failure, exceptions.BridgeExceptionNoService):
370 print((_("Can't connect to SàT backend, are you sure it's launched ?")))
371 sys.exit(C.EXIT_BACKEND_NOT_FOUND)
372 elif isinstance(failure, exceptions.BridgeInitError):
373 print((_("Can't init bridge")))
374 sys.exit(C.EXIT_BRIDGE_ERROR)
375 else:
376 print((_("Error while initialising bridge: {}".format(failure))))
377
378 def on_backend_ready(self):
379 log.info("backend is ready")
380 self.bridge.namespaces_get(
381 callback=self._namespaces_get_cb, errback=self._namespaces_get_eb)
382 # we cache available encryption plugins, as we'll use them on each
383 # new chat widget
384 self.bridge.encryption_plugins_get(
385 callback=self._encryption_plugins_get_cb,
386 errback=self._encryption_plugins_get_eb)
387
388
389 @property
390 def current_profile(self):
391 """Profile that a user would expect to use"""
392 try:
393 return self.selected_widget.profile
394 except (TypeError, AttributeError):
395 return self.profiles.choose_one_profile()
396
397 @property
398 def visible_widgets(self):
399 """Widgets currently visible
400
401 This must be implemented by frontend
402 @return (iter[object]): iterable on visible widgets
403 widgets can be QuickWidgets or not
404 """
405 raise NotImplementedError
406
407 @property
408 def visible_quick_widgets(self):
409 """QuickWidgets currently visible
410
411 This generator iterate only on QuickWidgets, discarding other kinds of
412 widget the frontend may have.
413 @return (iter[object]): iterable on visible widgets
414 """
415 for w in self.visisble_widgets:
416 if isinstance(w, quick_widgets.QuickWidget):
417 return w
418
419 @property
420 def selected_widget(self):
421 """widget currently selected
422
423 This must be set by frontend using setter.
424 """
425 return self._selected_widget
426
427 @selected_widget.setter
428 def selected_widget(self, wid):
429 """Set the currently selected widget
430
431 Must be set by frontend
432 """
433 if self._selected_widget == wid:
434 return
435 self._selected_widget = wid
436 try:
437 on_selected = wid.on_selected
438 except AttributeError:
439 pass
440 else:
441 on_selected()
442
443 self.call_listeners("selected", wid)
444
445 # backend state management
446
447 @property
448 def sync(self):
449 """Synchronization flag
450
451 True if this frontend is synchronised with backend
452 """
453 return self._sync
454
455 @sync.setter
456 def sync(self, state):
457 """Called when backend is desynchronised or resynchronising
458
459 @param state(bool): True: if the backend is resynchronising
460 False when we lose synchronisation, for instance if frontend is going to sleep
461 or if connection has been lost and a reconnection is needed
462 """
463 if state:
464 log.debug("we are synchronised with server")
465 if self.AUTO_RESYNC:
466 # we are resynchronising all widgets
467 log.debug("doing a full widgets resynchronisation")
468 for w in self.widgets:
469 try:
470 resync = w.resync
471 except AttributeError:
472 pass
473 else:
474 resync()
475 self.contact_lists.fill()
476
477 self._sync = state
478 else:
479 log.debug("we have lost synchronisation with server")
480 self._sync = state
481 # we've lost synchronisation, all widgets must be notified
482 # note: this is always called independently of AUTO_RESYNC
483 for w in self.widgets:
484 try:
485 w.sync = False
486 except AttributeError:
487 pass
488
489 def register_signal(
490 self, function_name, handler=None, iface="core", with_profile=True
491 ):
492 """Register a handler for a signal
493
494 @param function_name (str): name of the signal to handle
495 @param handler (instancemethod): method to call when the signal arrive,
496 None for calling an automatically named handler (function_name + 'Handler')
497 @param iface (str): interface of the bridge to use ('core' or 'plugin')
498 @param with_profile (boolean): True if the signal concerns a specific profile,
499 in that case the profile name has to be passed by the caller
500 """
501 log.debug("registering signal {name}".format(name=function_name))
502 if handler is None:
503 handler = getattr(self, "{}{}".format(function_name, "_handler"))
504 if not with_profile:
505 self.bridge.register_signal(function_name, handler, iface)
506 return
507
508 def signal_received(*args, **kwargs):
509 profile = kwargs.get("profile")
510 if profile is None:
511 if not args:
512 raise exceptions.ProfileNotSetError
513 profile = args[-1]
514 if profile is not None:
515 if not self.check_profile(profile):
516 if profile in self.profiles:
517 # profile is not ready but is in self.profiles, that's mean that
518 # it's being connecting and we need to cache the signal
519 self.signals_cache.setdefault(profile, []).append(
520 (function_name, handler, args, kwargs)
521 )
522 return # we ignore signal for profiles we don't manage
523 handler(*args, **kwargs)
524
525 self.bridge.register_signal(function_name, signal_received, iface)
526
527 def addListener(self, type_, callback, profiles_filter=None):
528 """Add a listener for an event
529
530 /!\ don't forget to remove listener when not used anymore (e.g. if you delete a
531 widget)
532 @param type_: type of event, can be:
533 - contactsFilled: called when contact have been fully filled for a profiles
534 kwargs: profile
535 - avatar: called when avatar data is updated
536 args: (entity, avatar_data, profile)
537 - nicknames: called when nicknames data is updated
538 args: (entity, nicknames, profile)
539 - presence: called when a presence is received
540 args: (entity, show, priority, statuses, profile)
541 - selected: called when a widget is selected
542 args: (selected_widget,)
543 - notification: called when a new notification is emited
544 args: (entity, notification_data, profile)
545 - notificationsClear: called when notifications are cleared
546 args: (entity, type_, profile)
547 - widgetNew: a new QuickWidget has been created
548 args: (widget,)
549 - widgetDeleted: all instances of a widget with specific hash have been
550 deleted
551 args: (widget_deleted,)
552 - menu: called when a menu item is added or removed
553 args: (type_, path, path_i18n, item) were values are:
554 type_: same as in [sat.core.sat_main.SAT.import_menu]
555 path: same as in [sat.core.sat_main.SAT.import_menu]
556 path_i18n: translated path (or None if the item is removed)
557 item: instance of quick_menus.MenuItemBase or None if the item is
558 removed
559 - gotMenus: called only once when menu are available (no arg)
560 - progress_finished: called when a progressing action has just finished
561 args: (progress_id, metadata, profile)
562 - progress_error: called when a progressing action failed
563 args: (progress_id, error_msg, profile):
564 @param callback: method to call on event
565 @param profiles_filter (set[unicode]): if set and not empty, the
566 listener will be callable only by one of the given profiles.
567 """
568 assert type_ in C.LISTENERS
569 self._listeners.setdefault(type_, {})[callback] = profiles_filter
570
571 def removeListener(self, type_, callback, ignore_missing=False):
572 """Remove a callback from listeners
573
574 @param type_(str): same as for [addListener]
575 @param callback(callable): callback to remove
576 @param ignore_missing(bool): if True, don't log error if the listener doesn't
577 exist
578 """
579 assert type_ in C.LISTENERS
580 try:
581 self._listeners[type_].pop(callback)
582 except KeyError:
583 if not ignore_missing:
584 log.error(
585 f"Trying to remove an inexisting listener (type = {type_}): "
586 f"{callback}")
587
588 def call_listeners(self, type_, *args, **kwargs):
589 """Call the methods which listen type_ event. If a profiles filter has
590 been register with a listener and profile argument is not None, the
591 listener will be called only if profile is in the profiles filter list.
592
593 @param type_: same as for [addListener]
594 @param *args: arguments sent to callback
595 @param **kwargs: keywords argument, mainly used to pass "profile" when needed
596 """
597 assert type_ in C.LISTENERS
598 try:
599 listeners = self._listeners[type_]
600 except KeyError:
601 pass
602 else:
603 profile = kwargs.get("profile")
604 for listener, profiles_filter in list(listeners.items()):
605 if profile is None or not profiles_filter or profile in profiles_filter:
606 listener(*args, **kwargs)
607
608 def check_profile(self, profile):
609 """Tell if the profile is currently followed by the application, and ready"""
610 return profile in self.ready_profiles
611
612 def post_init(self, profile_manager):
613 """Must be called after initialization is done, do all automatic task
614
615 (auto plug profile)
616 @param profile_manager: instance of a subclass of
617 Quick_frontend.QuickProfileManager
618 """
619 if self.options and self.options.profile:
620 profile_manager.autoconnect([self.options.profile])
621
622 def profile_plugged(self, profile):
623 """Method called when the profile is fully plugged
624
625 This will launch frontend specific workflow
626
627 /!\ if you override the method and don't call the parent, be sure to add the
628 profile to ready_profiles ! if you don't, all signals will stay in cache
629
630 @param profile(unicode): %(doc_profile)s
631 """
632 self._plugs_in_progress.remove(profile)
633 self.ready_profiles.add(profile)
634
635 # profile is ready, we can call send signals that where is cache
636 cached_signals = self.signals_cache.pop(profile, [])
637 for function_name, handler, args, kwargs in cached_signals:
638 log.debug(
639 "Calling cached signal [%s] with args %s and kwargs %s"
640 % (function_name, args, kwargs)
641 )
642 handler(*args, **kwargs)
643
644 self.call_listeners("profile_plugged", profile=profile)
645 if not self._plugs_in_progress:
646 self.contact_lists.lock_update(False)
647
648 def profile_connected(self, profile):
649 """Called when a plugged profile is connected
650
651 it is called independently of profile_plugged (may be called before or after
652 profile_plugged)
653 """
654 pass
655
656 def connect(self, profile, callback=None, errback=None):
657 if not callback:
658 callback = lambda __: None
659 if not errback:
660
661 def errback(failure):
662 log.error(_("Can't connect profile [%s]") % failure)
663 try:
664 module = failure.module
665 except AttributeError:
666 module = ""
667 try:
668 message = failure.message
669 except AttributeError:
670 message = "error"
671 try:
672 fullname = failure.fullname
673 except AttributeError:
674 fullname = "error"
675 if (
676 module.startswith("twisted.words.protocols.jabber")
677 and failure.condition == "not-authorized"
678 ):
679 self.action_launch(C.CHANGE_XMPP_PASSWD_ID, {}, profile=profile)
680 else:
681 self.show_dialog(message, fullname, "error")
682
683 self.bridge.connect(profile, callback=callback, errback=errback)
684
685 def plug_profiles(self, profiles):
686 """Tell application which profiles must be used
687
688 @param profiles: list of valid profile names
689 """
690 self.contact_lists.lock_update()
691 self._plugs_in_progress.update(profiles)
692 self.plugging_profiles()
693 for profile in profiles:
694 self.profiles.plug(profile)
695
696 def plugging_profiles(self):
697 """Method to subclass to manage frontend specific things to do
698
699 will be called when profiles are choosen and are to be plugged soon
700 """
701 pass
702
703 def unplug_profile(self, profile):
704 """Tell the application to not follow anymore the profile"""
705 if not profile in self.profiles:
706 raise ValueError("The profile [{}] is not plugged".format(profile))
707 self.profiles.unplug(profile)
708
709 def clear_profile(self):
710 self.profiles.clear()
711
712 def new_widget(self, widget):
713 raise NotImplementedError
714
715 # bridge signals hanlers
716
717 def connected_handler(self, jid_s, profile):
718 """Called when the connection is made.
719
720 @param jid_s (unicode): the JID that we were assigned by the server,
721 as the resource might differ from the JID we asked for.
722 """
723 log.debug(_("Connected"))
724 self.profiles[profile].whoami = jid.JID(jid_s)
725 self.set_presence_status(profile=profile)
726 # FIXME: fill() is already called for all profiles when doing self.sync = True
727 # a per-profile fill() should be done once, see below note
728 self.contact_lists[profile].fill()
729 # if we were already displaying widgets, they must be resynchronized
730 # FIXME: self.sync is for all profiles
731 # while (dis)connection is per-profile.
732 # A mechanism similar to sync should be available
733 # on a per-profile basis
734 self.sync = True
735 self.profile_connected(profile)
736
737 def disconnected_handler(self, profile):
738 """called when the connection is closed"""
739 log.debug(_("Disconnected"))
740 self.contact_lists[profile].disconnect()
741 # FIXME: see note on connected_handler
742 self.sync = False
743 self.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=profile)
744
745 def action_new_handler(self, action_data_s, id_, security_limit, profile):
746 self.action_manager(
747 data_format.deserialise(action_data_s), user_action=False, profile=profile
748 )
749
750 def contact_new_handler(self, jid_s, attributes, groups, profile):
751 entity = jid.JID(jid_s)
752 groups = list(groups)
753 self.contact_lists[profile].set_contact(entity, groups, attributes, in_roster=True)
754
755 def message_new_handler(
756 self, uid, timestamp, from_jid_s, to_jid_s, msg, subject, type_, extra_s,
757 profile):
758 from_jid = jid.JID(from_jid_s)
759 to_jid = jid.JID(to_jid_s)
760 extra = data_format.deserialise(extra_s)
761 if not self.trigger.point(
762 "messageNewTrigger", uid, timestamp, from_jid, to_jid, msg, subject, type_,
763 extra, profile=profile,):
764 return
765
766 from_me = from_jid.bare == self.profiles[profile].whoami.bare
767 mess_to_jid = to_jid if from_me else from_jid
768 target = mess_to_jid.bare
769 contact_list = self.contact_lists[profile]
770
771 try:
772 is_room = contact_list.is_room(target)
773 except exceptions.NotFound:
774 is_room = False
775
776 if target.resource and not is_room:
777 # we avoid resource locking, but we must keep resource for private MUC
778 # messages
779 target = target
780 # we want to be sure to have at least one QuickChat instance
781 self.widgets.get_or_create_widget(
782 quick_chat.QuickChat,
783 target,
784 type_ = C.CHAT_GROUP if is_room else C.CHAT_ONE2ONE,
785 on_new_widget = None,
786 profile = profile,
787 )
788
789 if (
790 not from_jid in contact_list
791 and from_jid.bare != self.profiles[profile].whoami.bare
792 ):
793 # XXX: needed to show entities which haven't sent any
794 # presence information and which are not in roster
795 contact_list.set_contact(from_jid)
796
797 # we dispatch the message in the widgets
798 for widget in self.widgets.get_widgets(
799 quick_chat.QuickChat, target=target, profiles=(profile,)
800 ):
801 widget.message_new(
802 uid, timestamp, from_jid, mess_to_jid, msg, subject, type_, extra, profile
803 )
804
805 def message_encryption_started_handler(self, destinee_jid_s, plugin_data, profile):
806 destinee_jid = jid.JID(destinee_jid_s)
807 plugin_data = data_format.deserialise(plugin_data)
808 for widget in self.widgets.get_widgets(quick_chat.QuickChat,
809 target=destinee_jid.bare,
810 profiles=(profile,)):
811 widget.message_encryption_started(plugin_data)
812
813 def message_encryption_stopped_handler(self, destinee_jid_s, plugin_data, profile):
814 destinee_jid = jid.JID(destinee_jid_s)
815 for widget in self.widgets.get_widgets(quick_chat.QuickChat,
816 target=destinee_jid.bare,
817 profiles=(profile,)):
818 widget.message_encryption_stopped(plugin_data)
819
820 def message_state_handler(self, uid, status, profile):
821 for widget in self.widgets.get_widgets(quick_chat.QuickChat, profiles=(profile,)):
822 widget.on_message_state(uid, status, profile)
823
824 def message_send(self, to_jid, message, subject=None, mess_type="auto", extra=None, callback=None, errback=None, profile_key=C.PROF_KEY_NONE):
825 if not subject and not extra and (not message or message == {'': ''}):
826 log.debug("Not sending empty message")
827 return
828
829 if subject is None:
830 subject = {}
831 if extra is None:
832 extra = {}
833 if callback is None:
834 callback = (
835 lambda __=None: None
836 ) # FIXME: optional argument is here because pyjamas doesn't support callback
837 # without arg with json proxy
838 if errback is None:
839 errback = lambda failure: self.show_dialog(
840 message=failure.message, title=failure.fullname, type="error"
841 )
842
843 if not self.trigger.point("messageSendTrigger", to_jid, message, subject, mess_type, extra, callback, errback, profile_key=profile_key):
844 return
845
846 self.bridge.message_send(
847 str(to_jid),
848 message,
849 subject,
850 mess_type,
851 data_format.serialise(extra),
852 profile_key,
853 callback=callback,
854 errback=errback,
855 )
856
857 def set_presence_status(self, show="", status=None, profile=C.PROF_KEY_NONE):
858 raise NotImplementedError
859
860 def presence_update_handler(self, entity_s, show, priority, statuses, profile):
861 # XXX: this log is commented because it's really too verbose even for DEBUG logs
862 # but it is kept here as it may still be useful for troubleshooting
863 # log.debug(
864 # _(
865 # u"presence update for %(entity)s (show=%(show)s, priority=%(priority)s, "
866 # u"statuses=%(statuses)s) [profile:%(profile)s]"
867 # )
868 # % {
869 # "entity": entity_s,
870 # C.PRESENCE_SHOW: show,
871 # C.PRESENCE_PRIORITY: priority,
872 # C.PRESENCE_STATUSES: statuses,
873 # "profile": profile,
874 # }
875 # )
876 entity = jid.JID(entity_s)
877
878 if entity == self.profiles[profile].whoami:
879 if show == C.PRESENCE_UNAVAILABLE:
880 self.set_presence_status(C.PRESENCE_UNAVAILABLE, "", profile=profile)
881 else:
882 # FIXME: try to retrieve user language status before fallback to default
883 status = statuses.get(C.PRESENCE_STATUSES_DEFAULT, None)
884 self.set_presence_status(show, status, profile=profile)
885 return
886
887 self.call_listeners("presence", entity, show, priority, statuses, profile=profile)
888
889 def muc_room_joined_handler(
890 self, room_jid_s, occupants, user_nick, subject, statuses, profile):
891 """Called when a MUC room is joined"""
892 log.debug(
893 "Room [{room_jid}] joined by {profile}, users presents:{users}".format(
894 room_jid=room_jid_s, profile=profile, users=list(occupants.keys())
895 )
896 )
897 room_jid = jid.JID(room_jid_s)
898 self.contact_lists[profile].set_special(room_jid, C.CONTACT_SPECIAL_GROUP)
899 self.widgets.get_or_create_widget(
900 quick_chat.QuickChat,
901 room_jid,
902 type_=C.CHAT_GROUP,
903 nick=user_nick,
904 occupants=occupants,
905 subject=subject,
906 statuses=statuses,
907 profile=profile,
908 )
909
910 def muc_room_left_handler(self, room_jid_s, profile):
911 """Called when a MUC room is left"""
912 log.debug(
913 "Room [%(room_jid)s] left by %(profile)s"
914 % {"room_jid": room_jid_s, "profile": profile}
915 )
916 room_jid = jid.JID(room_jid_s)
917 chat_widget = self.widgets.get_widget(quick_chat.QuickChat, room_jid, profile)
918 if chat_widget:
919 self.widgets.delete_widget(
920 chat_widget, all_instances=True, explicit_close=True)
921 self.contact_lists[profile].remove_contact(room_jid)
922
923 def muc_room_user_changed_nick_handler(self, room_jid_s, old_nick, new_nick, profile):
924 """Called when an user joined a MUC room"""
925 room_jid = jid.JID(room_jid_s)
926 chat_widget = self.widgets.get_or_create_widget(
927 quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
928 )
929 chat_widget.change_user_nick(old_nick, new_nick)
930 log.debug(
931 "user [%(old_nick)s] is now known as [%(new_nick)s] in room [%(room_jid)s]"
932 % {"old_nick": old_nick, "new_nick": new_nick, "room_jid": room_jid}
933 )
934
935 def muc_room_new_subject_handler(self, room_jid_s, subject, profile):
936 """Called when subject of MUC room change"""
937 room_jid = jid.JID(room_jid_s)
938 chat_widget = self.widgets.get_or_create_widget(
939 quick_chat.QuickChat, room_jid, type_=C.CHAT_GROUP, profile=profile
940 )
941 chat_widget.set_subject(subject)
942 log.debug(
943 "new subject for room [%(room_jid)s]: %(subject)s"
944 % {"room_jid": room_jid, "subject": subject}
945 )
946
947 def chat_state_received_handler(self, from_jid_s, state, profile):
948 """Called when a new chat state (XEP-0085) is received.
949
950 @param from_jid_s (unicode): JID of a contact or C.ENTITY_ALL
951 @param state (unicode): new state
952 @param profile (unicode): current profile
953 """
954 from_jid = jid.JID(from_jid_s)
955 for widget in self.widgets.get_widgets(quick_chat.QuickChat, target=from_jid.bare,
956 profiles=(profile,)):
957 widget.on_chat_state(from_jid, state, profile)
958
959 def notify(self, type_, entity=None, message=None, subject=None, callback=None,
960 cb_args=None, widget=None, profile=C.PROF_KEY_NONE):
961 """Trigger an event notification
962
963 @param type_(unicode): notifation kind,
964 one of C.NOTIFY_* constant or any custom type specific to frontend
965 @param entity(jid.JID, None): entity involved in the notification
966 if entity is in contact list, a indicator may be added in front of it
967 @param message(unicode, None): message of the notification
968 @param subject(unicode, None): subject of the notification
969 @param callback(callable, None): method to call when notification is selected
970 @param cb_args(list, None): list of args for callback
971 @param widget(object, None): widget where the notification happened
972 """
973 assert type_ in C.NOTIFY_ALL
974 notif_dict = self.profiles[profile].notifications
975 key = "" if entity is None else entity.bare
976 type_notifs = notif_dict.setdefault(key, {}).setdefault(type_, [])
977 notif_data = {
978 "id": self._notif_id,
979 "time": time.time(),
980 "entity": entity,
981 "callback": callback,
982 "cb_args": cb_args,
983 "message": message,
984 "subject": subject,
985 }
986 if widget is not None:
987 notif_data[widget] = widget
988 type_notifs.append(notif_data)
989 self._notifications[self._notif_id] = notif_data
990 self._notif_id += 1
991 self.call_listeners("notification", entity, notif_data, profile=profile)
992
993 def get_notifs(self, entity=None, type_=None, exact_jid=None, profile=C.PROF_KEY_NONE):
994 """return notifications for given entity
995
996 @param entity(jid.JID, None, C.ENTITY_ALL): jid of the entity to check
997 bare jid to get all notifications, full jid to filter on resource
998 None to get general notifications
999 C.ENTITY_ALL to get all notifications
1000 @param type_(unicode, None): notification type to filter
1001 None to get all notifications
1002 @param exact_jid(bool, None): if True, only return notifications from
1003 exact entity jid (i.e. not including other resources)
1004 None for automatic selection (True for full jid, False else)
1005 False to get resources notifications
1006 False doesn't do anything if entity is not a bare jid
1007 @return (iter[dict]): notifications
1008 """
1009 main_notif_dict = self.profiles[profile].notifications
1010
1011 if entity is C.ENTITY_ALL:
1012 selected_notifs = iter(main_notif_dict.values())
1013 exact_jid = False
1014 else:
1015 if entity is None:
1016 key = ""
1017 exact_jid = False
1018 else:
1019 key = entity.bare
1020 if exact_jid is None:
1021 exact_jid = bool(entity.resource)
1022 selected_notifs = (main_notif_dict.setdefault(key, {}),)
1023
1024 for notifs_from_select in selected_notifs:
1025
1026 if type_ is None:
1027 type_notifs = iter(notifs_from_select.values())
1028 else:
1029 type_notifs = (notifs_from_select.get(type_, []),)
1030
1031 for notifs in type_notifs:
1032 for notif in notifs:
1033 if exact_jid and notif["entity"] != entity:
1034 continue
1035 yield notif
1036
1037 def clear_notifs(self, entity, type_=None, profile=C.PROF_KEY_NONE):
1038 """return notifications for given entity
1039
1040 @param entity(jid.JID, None): bare jid of the entity to check
1041 None to clear general notifications (but keep entities ones)
1042 @param type_(unicode, None): notification type to filter
1043 None to clear all notifications
1044 @return (list[dict]): list of notifications
1045 """
1046 notif_dict = self.profiles[profile].notifications
1047 key = "" if entity is None else entity.bare
1048 try:
1049 if type_ is None:
1050 del notif_dict[key]
1051 else:
1052 del notif_dict[key][type_]
1053 except KeyError:
1054 return
1055 self.call_listeners("notificationsClear", entity, type_, profile=profile)
1056
1057 def ps_event_handler(self, category, service_s, node, event_type, data, profile):
1058 """Called when a PubSub event is received.
1059
1060 @param category(unicode): event category (e.g. "PEP", "MICROBLOG")
1061 @param service_s (unicode): pubsub service
1062 @param node (unicode): pubsub node
1063 @param event_type (unicode): event type (one of C.PUBLISH, C.RETRACT, C.DELETE)
1064 @param data (serialised_dict): event data
1065 """
1066 data = data_format.deserialise(data)
1067 service_s = jid.JID(service_s)
1068
1069 if category == C.PS_MICROBLOG and self.MB_HANDLER:
1070 if event_type == C.PS_PUBLISH:
1071 if not "content" in data:
1072 log.warning("No content found in microblog data")
1073 return
1074
1075 # FIXME: check if [] make sense (instead of None)
1076 _groups = data.get("group")
1077
1078 for wid in self.widgets.get_widgets(quick_blog.QuickBlog):
1079 wid.add_entry_if_accepted(service_s, node, data, _groups, profile)
1080
1081 try:
1082 comments_node, comments_service = (
1083 data["comments_node"],
1084 data["comments_service"],
1085 )
1086 except KeyError:
1087 pass
1088 else:
1089 self.bridge.mb_get(
1090 comments_service,
1091 comments_node,
1092 C.NO_LIMIT,
1093 [],
1094 {"subscribe": C.BOOL_TRUE},
1095 profile=profile,
1096 )
1097 elif event_type == C.PS_RETRACT:
1098 for wid in self.widgets.get_widgets(quick_blog.QuickBlog):
1099 wid.delete_entry_if_present(service_s, node, data["id"], profile)
1100 pass
1101 else:
1102 log.warning("Unmanaged PubSub event type {}".format(event_type))
1103
1104 def register_progress_cbs(self, progress_id, callback, errback):
1105 """Register progression callbacks
1106
1107 @param progress_id(unicode): id of the progression to check
1108 @param callback(callable, None): method to call when progressing action
1109 successfuly finished.
1110 None to ignore
1111 @param errback(callable, None): method to call when progressions action failed
1112 None to ignore
1113 """
1114 callbacks = self._progress_ids.setdefault(progress_id, [])
1115 callbacks.append((callback, errback))
1116
1117 def progress_started_handler(self, pid, metadata, profile):
1118 log.info("Progress {} started".format(pid))
1119
1120 def progress_finished_handler(self, pid, metadata, profile):
1121 log.info("Progress {} finished".format(pid))
1122 try:
1123 callbacks = self._progress_ids.pop(pid)
1124 except KeyError:
1125 pass
1126 else:
1127 for callback, __ in callbacks:
1128 if callback is not None:
1129 callback(metadata, profile=profile)
1130 self.call_listeners("progress_finished", pid, metadata, profile=profile)
1131
1132 def progress_error_handler(self, pid, err_msg, profile):
1133 log.warning("Progress {pid} error: {err_msg}".format(pid=pid, err_msg=err_msg))
1134 try:
1135 callbacks = self._progress_ids.pop(pid)
1136 except KeyError:
1137 pass
1138 else:
1139 for __, errback in callbacks:
1140 if errback is not None:
1141 errback(err_msg, profile=profile)
1142 self.call_listeners("progress_error", pid, err_msg, profile=profile)
1143
1144 def _subscribe_cb(self, answer, data):
1145 entity, profile = data
1146 type_ = "subscribed" if answer else "unsubscribed"
1147 self.bridge.subscription(type_, str(entity.bare), profile_key=profile)
1148
1149 def subscribe_handler(self, type, raw_jid, profile):
1150 """Called when a subsciption management signal is received"""
1151 entity = jid.JID(raw_jid)
1152 if type == "subscribed":
1153 # this is a subscription confirmation, we just have to inform user
1154 # TODO: call self.getEntityMBlog to add the new contact blogs
1155 self.show_dialog(
1156 _("The contact {contact} has accepted your subscription").format(
1157 contact=entity.bare
1158 ),
1159 _("Subscription confirmation"),
1160 )
1161 elif type == "unsubscribed":
1162 # this is a subscription refusal, we just have to inform user
1163 self.show_dialog(
1164 _("The contact {contact} has refused your subscription").format(
1165 contact=entity.bare
1166 ),
1167 _("Subscription refusal"),
1168 "error",
1169 )
1170 elif type == "subscribe":
1171 # this is a subscriptionn request, we have to ask for user confirmation
1172 # TODO: use sat.stdui.ui_contact_list to display the groups selector
1173 self.show_dialog(
1174 _(
1175 "The contact {contact} wants to subscribe to your presence"
1176 ".\nDo you accept ?"
1177 ).format(contact=entity.bare),
1178 _("Subscription confirmation"),
1179 "yes/no",
1180 answer_cb=self._subscribe_cb,
1181 answer_data=(entity, profile),
1182 )
1183
1184 def _debug_handler(self, action, parameters, profile):
1185 if action == "widgets_dump":
1186 from pprint import pformat
1187 log.info("Widgets dump:\n{data}".format(data=pformat(self.widgets._widgets)))
1188 else:
1189 log.warning("Unknown debug action: {action}".format(action=action))
1190
1191
1192 def show_dialog(self, message, title, type="info", answer_cb=None, answer_data=None):
1193 """Show a dialog to user
1194
1195 Frontends must override this method
1196 @param message(unicode): body of the dialog
1197 @param title(unicode): title of the dialog
1198 @param type(unicode): one of:
1199 - "info": information dialog (callbacks not used)
1200 - "warning": important information to notice (callbacks not used)
1201 - "error": something went wrong (callbacks not used)
1202 - "yes/no": a dialog with 2 choices (yes and no)
1203 @param answer_cb(callable): method to call on answer.
1204 Arguments depend on dialog type:
1205 - "yes/no": argument is a boolean (True for yes)
1206 @param answer_data(object): data to link on callback
1207 """
1208 # FIXME: misnamed method + types are not well chosen. Need to be rethought
1209 raise NotImplementedError
1210
1211 def show_alert(self, message):
1212 # FIXME: doesn't seems used anymore, to remove?
1213 pass # FIXME
1214
1215 def dialog_failure(self, failure):
1216 log.warning("Failure: {}".format(failure))
1217
1218 def progress_id_handler(self, progress_id, profile):
1219 """Callback used when an action result in a progress id"""
1220 log.info("Progress ID received: {}".format(progress_id))
1221
1222 def is_hidden(self):
1223 """Tells if the frontend window is hidden.
1224
1225 @return bool
1226 """
1227 raise NotImplementedError
1228
1229 def param_update_handler(self, name, value, namespace, profile):
1230 log.debug(
1231 _("param update: [%(namespace)s] %(name)s = %(value)s")
1232 % {"namespace": namespace, "name": name, "value": value}
1233 )
1234 if (namespace, name) == ("Connection", "JabberID"):
1235 log.debug(_("Changing JID to %s") % value)
1236 self.profiles[profile].whoami = jid.JID(value)
1237 elif (namespace, name) == ("General", C.SHOW_OFFLINE_CONTACTS):
1238 self.contact_lists[profile].show_offline_contacts(C.bool(value))
1239 elif (namespace, name) == ("General", C.SHOW_EMPTY_GROUPS):
1240 self.contact_lists[profile].show_empty_groups(C.bool(value))
1241
1242 def contact_deleted_handler(self, jid_s, profile):
1243 target = jid.JID(jid_s)
1244 self.contact_lists[profile].remove_contact(target)
1245
1246 def entity_data_updated_handler(self, entity_s, key, value_raw, profile):
1247 entity = jid.JID(entity_s)
1248 value = data_format.deserialise(value_raw, type_check=None)
1249 if key == "nicknames":
1250 assert isinstance(value, list) or value is None
1251 if entity in self.contact_lists[profile]:
1252 self.contact_lists[profile].set_cache(entity, "nicknames", value)
1253 self.call_listeners("nicknames", entity, value, profile=profile)
1254 elif key == "avatar" and self.AVATARS_HANDLER:
1255 assert isinstance(value, dict) or value is None
1256 self.contact_lists[profile].set_cache(entity, "avatar", value)
1257 self.call_listeners("avatar", entity, value, profile=profile)
1258
1259 def action_manager(self, action_data, callback=None, ui_show_cb=None, user_action=True,
1260 progress_cb=None, progress_eb=None, profile=C.PROF_KEY_NONE):
1261 """Handle backend action
1262
1263 @param action_data(dict): action dict as sent by action_launch or returned by an
1264 UI action
1265 @param callback(None, callback): if not None, callback to use on XMLUI answer
1266 @param ui_show_cb(None, callback): if not None, method to call to show the XMLUI
1267 @param user_action(bool): if True, the action is a result of a user interaction
1268 else the action come from backend direclty (i.e. action_new).
1269 This is useful to know if the frontend can display a popup immediately (if
1270 True) or if it should add it to a queue that the user can activate later.
1271 @param progress_cb(None, callable): method to call when progression is finished.
1272 Only make sense if a progress is expected in this action
1273 @param progress_eb(None, callable): method to call when something went wrong
1274 during progression.
1275 Only make sense if a progress is expected in this action
1276 """
1277 try:
1278 xmlui = action_data.pop("xmlui")
1279 except KeyError:
1280 pass
1281 else:
1282 ui = self.xmlui.create(
1283 self,
1284 xml_data=xmlui,
1285 flags=("FROM_BACKEND",) if not user_action else None,
1286 callback=callback,
1287 profile=profile,
1288 )
1289 if ui_show_cb is None:
1290 ui.show()
1291 else:
1292 ui_show_cb(ui)
1293
1294 try:
1295 progress_id = action_data.pop("progress")
1296 except KeyError:
1297 pass
1298 else:
1299 if progress_cb or progress_eb:
1300 self.register_progress_cbs(progress_id, progress_cb, progress_eb)
1301 self.progress_id_handler(progress_id, profile)
1302
1303 def _action_cb(self, data, callback, callback_id, profile):
1304 if callback is None:
1305 self.action_manager(data, profile=profile)
1306 else:
1307 callback(data=data, cb_id=callback_id, profile=profile)
1308
1309 def action_launch(
1310 self, callback_id, data=None, callback=None, profile=C.PROF_KEY_NONE
1311 ):
1312 """Launch a dynamic action
1313
1314 @param callback_id: id of the action to launch
1315 @param data: data needed only for certain actions
1316 @param callback(callable, None): will be called with the resut
1317 if None, self.action_manager will be called
1318 else the callable will be called with the following kw parameters:
1319 - data: action_data
1320 - cb_id: callback id
1321 - profile: %(doc_profile)s
1322 @param profile: %(doc_profile)s
1323
1324 """
1325 if data is None:
1326 data = dict()
1327 action_cb = lambda data: self._action_cb(
1328 data_format.deserialise(data), callback, callback_id, profile
1329 )
1330 self.bridge.action_launch(
1331 callback_id, data_format.serialise(data), profile, callback=action_cb,
1332 errback=self.dialog_failure
1333 )
1334
1335 def launch_menu(
1336 self,
1337 menu_type,
1338 path,
1339 data=None,
1340 callback=None,
1341 security_limit=C.SECURITY_LIMIT_MAX,
1342 profile=C.PROF_KEY_NONE,
1343 ):
1344 """Launch a menu manually
1345
1346 @param menu_type(unicode): type of the menu to launch
1347 @param path(iterable[unicode]): path to the menu
1348 @param data: data needed only for certain actions
1349 @param callback(callable, None): will be called with the resut
1350 if None, self.action_manager will be called
1351 else the callable will be called with the following kw parameters:
1352 - data: action_data
1353 - cb_id: (menu_type, path) tuple
1354 - profile: %(doc_profile)s
1355 @param profile: %(doc_profile)s
1356
1357 """
1358 if data is None:
1359 data = dict()
1360 action_cb = lambda data: self._action_cb(
1361 data, callback, (menu_type, path), profile
1362 )
1363 self.bridge.menu_launch(
1364 menu_type,
1365 path,
1366 data,
1367 security_limit,
1368 profile,
1369 callback=action_cb,
1370 errback=self.dialog_failure,
1371 )
1372
1373 def disconnect(self, profile):
1374 log.info("disconnecting")
1375 self.call_listeners("disconnect", profile=profile)
1376 self.bridge.disconnect(profile)
1377
1378 def on_exit(self):
1379 """Must be called when the frontend is terminating"""
1380 to_unplug = []
1381 for profile, profile_manager in self.profiles.items():
1382 if profile_manager.connected and profile_manager.autodisconnect:
1383 # The user wants autodisconnection
1384 self.disconnect(profile)
1385 to_unplug.append(profile)
1386 for profile in to_unplug:
1387 self.unplug_profile(profile)