1# Copyright (C) 2003-2017 Yann Leboulanger <asterix AT lagaule.org> 2# Copyright (C) 2004-2005 Vincent Hanquez <tab AT snarc.org> 3# Copyright (C) 2005 Alex Podaras <bigpod AT gmail.com> 4# Norman Rasmussen <norman AT rasmussen.co.za> 5# Stéphan Kochen <stephan AT kochen.nl> 6# Copyright (C) 2005-2006 Dimitur Kirov <dkirov AT gmail.com> 7# Alex Mauer <hawke AT hawkesnest.net> 8# Copyright (C) 2005-2007 Travis Shirk <travis AT pobox.com> 9# Nikos Kouremenos <kourem AT gmail.com> 10# Copyright (C) 2006 Junglecow J <junglecow AT gmail.com> 11# Stefan Bethge <stefan AT lanpartei.de> 12# Copyright (C) 2006-2008 Jean-Marie Traissard <jim AT lapin.org> 13# Copyright (C) 2007 Lukas Petrovicky <lukas AT petrovicky.net> 14# James Newton <redshodan AT gmail.com> 15# Copyright (C) 2007-2008 Brendan Taylor <whateley AT gmail.com> 16# Julien Pivotto <roidelapluie AT gmail.com> 17# Stephan Erb <steve-e AT h3c.de> 18# Copyright (C) 2008 Jonathan Schleifer <js-gajim AT webkeks.org> 19# Copyright (C) 2016-2017 Emmanuel Gil Peyrot <linkmauve AT linkmauve.fr> 20# Philipp Hörist <philipp AT hoerist.com> 21# 22# This file is part of Gajim. 23# 24# Gajim is free software; you can redistribute it and/or modify 25# it under the terms of the GNU General Public License as published 26# by the Free Software Foundation; version 3 only. 27# 28# Gajim is distributed in the hope that it will be useful, 29# but WITHOUT ANY WARRANTY; without even the implied warranty of 30# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 31# GNU General Public License for more details. 32# 33# You should have received a copy of the GNU General Public License 34# along with Gajim. If not, see <http://www.gnu.org/licenses/>. 35 36import os 37import sys 38from urllib.parse import unquote 39 40from nbxmpp.namespaces import Namespace 41from nbxmpp import JID 42from nbxmpp.protocol import InvalidJid 43from gi.repository import Gio 44from gi.repository import GLib 45from gi.repository import Gtk 46 47import gajim 48from gajim.common import app 49from gajim.common import ged 50from gajim.common import configpaths 51from gajim.common import logging_helpers 52from gajim.common import exceptions 53from gajim.common.i18n import _ 54from gajim.common.contacts import LegacyContactsAPI 55from gajim.common.task_manager import TaskManager 56from gajim.common.storage.cache import CacheStorage 57from gajim.common.storage.archive import MessageArchiveStorage 58from gajim.common.settings import Settings 59from gajim.common.settings import LegacyConfig 60 61 62class GajimApplication(Gtk.Application): 63 '''Main class handling activation and command line.''' 64 65 def __init__(self): 66 flags = (Gio.ApplicationFlags.HANDLES_COMMAND_LINE | 67 Gio.ApplicationFlags.CAN_OVERRIDE_APP_ID) 68 Gtk.Application.__init__(self, 69 application_id='org.gajim.Gajim', 70 flags=flags) 71 72 # required to track screensaver state 73 self.props.register_session = True 74 75 self.add_main_option( 76 'version', 77 ord('V'), 78 GLib.OptionFlags.NONE, 79 GLib.OptionArg.NONE, 80 _('Show the application\'s version')) 81 82 self.add_main_option( 83 'quiet', 84 ord('q'), 85 GLib.OptionFlags.NONE, 86 GLib.OptionArg.NONE, 87 _('Show only critical errors')) 88 89 self.add_main_option( 90 'separate', 91 ord('s'), 92 GLib.OptionFlags.NONE, 93 GLib.OptionArg.NONE, 94 _('Separate profile files completely ' 95 '(even history database and plugins)')) 96 97 self.add_main_option( 98 'verbose', 99 ord('v'), 100 GLib.OptionFlags.NONE, 101 GLib.OptionArg.NONE, 102 _('Print XML stanzas and other debug information')) 103 104 self.add_main_option( 105 'profile', 106 ord('p'), 107 GLib.OptionFlags.NONE, 108 GLib.OptionArg.STRING, 109 _('Use defined profile in configuration directory'), 110 'NAME') 111 112 self.add_main_option( 113 'config-path', 114 ord('c'), 115 GLib.OptionFlags.NONE, 116 GLib.OptionArg.STRING, 117 _('Set configuration directory'), 118 'PATH') 119 120 self.add_main_option( 121 'loglevel', 122 ord('l'), 123 GLib.OptionFlags.NONE, 124 GLib.OptionArg.STRING, 125 _('Configure logging system'), 126 'LEVEL') 127 128 self.add_main_option( 129 'warnings', 130 ord('w'), 131 GLib.OptionFlags.NONE, 132 GLib.OptionArg.NONE, 133 _('Show all warnings')) 134 135 self.add_main_option( 136 'ipython', 137 ord('i'), 138 GLib.OptionFlags.NONE, 139 GLib.OptionArg.NONE, 140 _('Open IPython shell')) 141 142 self.add_main_option( 143 'gdebug', 144 0, 145 GLib.OptionFlags.NONE, 146 GLib.OptionArg.NONE, 147 _('Sets an environment variable so ' 148 'GLib debug messages are printed')) 149 150 self.add_main_option( 151 'show-next-pending-event', 152 0, 153 GLib.OptionFlags.NONE, 154 GLib.OptionArg.NONE, 155 _('Pops up a window with the next pending event')) 156 157 self.add_main_option( 158 'start-chat', 0, 159 GLib.OptionFlags.NONE, 160 GLib.OptionArg.NONE, 161 _('Start a new chat')) 162 163 self.add_main_option_entries(self._get_remaining_entry()) 164 165 self.connect('handle-local-options', self._handle_local_options) 166 self.connect('command-line', self._command_line) 167 self.connect('startup', self._startup) 168 169 self.interface = None 170 171 GLib.set_prgname('org.gajim.Gajim') 172 if GLib.get_application_name() != 'Gajim': 173 GLib.set_application_name('Gajim') 174 175 @staticmethod 176 def _get_remaining_entry(): 177 option = GLib.OptionEntry() 178 option.arg = GLib.OptionArg.STRING_ARRAY 179 option.arg_data = None 180 option.arg_description = ('[URI …]') 181 option.flags = GLib.OptionFlags.NONE 182 option.long_name = GLib.OPTION_REMAINING 183 option.short_name = 0 184 return [option] 185 186 def _startup(self, _application): 187 # Create and initialize Application Paths & Databases 188 app.print_version() 189 app.detect_dependencies() 190 configpaths.create_paths() 191 192 app.settings = Settings() 193 app.settings.init() 194 195 app.config = LegacyConfig() # type: ignore 196 197 app.storage.cache = CacheStorage() 198 app.storage.cache.init() 199 200 app.storage.archive = MessageArchiveStorage() 201 app.storage.archive.init() 202 203 try: 204 app.contacts = LegacyContactsAPI() 205 except exceptions.DatabaseMalformed as error: 206 dlg = Gtk.MessageDialog( 207 transient_for=None, 208 destroy_with_parent=True, 209 modal=True, 210 message_type=Gtk.MessageType.ERROR, 211 buttons=Gtk.ButtonsType.OK, 212 text=_('Database Error')) 213 dlg.format_secondary_text(str(error)) 214 dlg.run() 215 dlg.destroy() 216 sys.exit() 217 218 from gajim.gui.util import load_user_iconsets 219 load_user_iconsets() 220 221 from gajim.common.cert_store import CertificateStore 222 app.cert_store = CertificateStore() 223 app.task_manager = TaskManager() 224 225 # Set Application Menu 226 app.app = self 227 from gajim.gui.util import get_builder 228 builder = get_builder('application_menu.ui') 229 menubar = builder.get_object("menubar") 230 self.set_menubar(menubar) 231 232 from gajim.gui_interface import Interface 233 self.interface = Interface() 234 self.interface.run(self) 235 self.add_actions() 236 self._set_shortcuts() 237 from gajim import gui_menu_builder 238 gui_menu_builder.build_accounts_menu() 239 self.update_app_actions_state() 240 241 app.ged.register_event_handler('feature-discovered', 242 ged.CORE, 243 self._on_feature_discovered) 244 245 def _open_uris(self, uris): 246 accounts = list(app.connections.keys()) 247 if not accounts: 248 return 249 250 for uri in uris: 251 app.log('uri_handler').info('open %s', uri) 252 if not uri.startswith('xmpp:'): 253 continue 254 # remove xmpp: 255 uri = uri[5:] 256 try: 257 jid, cmd = uri.split('?') 258 except ValueError: 259 # No query argument 260 jid, cmd = uri, 'message' 261 262 try: 263 jid = JID.from_string(jid) 264 except InvalidJid as error: 265 app.log('uri_handler').warning('Invalid JID %s: %s', uri, error) 266 continue 267 268 if cmd == 'join' and jid.resource: 269 app.log('uri_handler').warning('Invalid MUC JID %s', uri) 270 continue 271 272 jid = str(jid) 273 274 if cmd == 'join': 275 if len(accounts) == 1: 276 self.activate_action( 277 'groupchat-join', 278 GLib.Variant('as', [accounts[0], jid])) 279 else: 280 self.activate_action('start-chat', GLib.Variant('s', jid)) 281 282 elif cmd == 'roster': 283 self.activate_action('add-contact', GLib.Variant('s', jid)) 284 285 elif cmd.startswith('message'): 286 attributes = cmd.split(';') 287 message = None 288 for key in attributes: 289 if not key.startswith('body'): 290 continue 291 try: 292 message = unquote(key.split('=')[1]) 293 except Exception: 294 app.log('uri_handler').error('Invalid URI: %s', cmd) 295 296 if len(accounts) == 1: 297 app.interface.new_chat_from_jid(accounts[0], jid, message) 298 else: 299 self.activate_action('start-chat', GLib.Variant('s', jid)) 300 301 def do_shutdown(self, *args): 302 Gtk.Application.do_shutdown(self) 303 # Shutdown GUI and save config 304 if hasattr(self.interface, 'roster') and self.interface.roster: 305 self.interface.roster.prepare_quit() 306 307 # Commit any outstanding SQL transactions 308 app.storage.cache.shutdown() 309 app.storage.archive.shutdown() 310 311 def _command_line(self, _application, command_line): 312 options = command_line.get_options_dict() 313 314 remote_commands = [ 315 ('ipython', None), 316 ('show-next-pending-event', None), 317 ('start-chat', GLib.Variant('s', '')), 318 ] 319 320 remaining = options.lookup_value(GLib.OPTION_REMAINING, 321 GLib.VariantType.new('as')) 322 323 for cmd, parameter in remote_commands: 324 if options.contains(cmd): 325 self.activate_action(cmd, parameter) 326 return 0 327 328 if remaining is not None: 329 self._open_uris(remaining.unpack()) 330 return 0 331 332 return 0 333 334 def _handle_local_options(self, 335 _application: Gtk.Application, 336 options: GLib.VariantDict) -> int: 337 # Parse all options that have to be executed before ::startup 338 if options.contains('version'): 339 print(gajim.__version__) 340 return 0 341 if options.contains('profile'): 342 # Incorporate profile name into application id 343 # to have a single app instance for each profile. 344 profile = options.lookup_value('profile').get_string() 345 app_id = '%s.%s' % (self.get_application_id(), profile) 346 self.set_application_id(app_id) 347 configpaths.set_profile(profile) 348 if options.contains('separate'): 349 configpaths.set_separation(True) 350 if options.contains('config-path'): 351 path = options.lookup_value('config-path').get_string() 352 configpaths.set_config_root(path) 353 354 configpaths.init() 355 356 if options.contains('gdebug'): 357 os.environ['G_MESSAGES_DEBUG'] = 'all' 358 359 logging_helpers.init() 360 361 if options.contains('quiet'): 362 logging_helpers.set_quiet() 363 if options.contains('verbose'): 364 logging_helpers.set_verbose() 365 if options.contains('loglevel'): 366 loglevel = options.lookup_value('loglevel').get_string() 367 logging_helpers.set_loglevels(loglevel) 368 if options.contains('warnings'): 369 self.show_warnings() 370 371 return -1 372 373 @staticmethod 374 def show_warnings(): 375 import traceback 376 import warnings 377 378 def warn_with_traceback(message, category, filename, lineno, 379 _file=None, line=None): 380 traceback.print_stack(file=sys.stderr) 381 sys.stderr.write(warnings.formatwarning(message, category, 382 filename, lineno, line)) 383 384 warnings.showwarning = warn_with_traceback 385 warnings.filterwarnings(action="always") 386 387 def add_actions(self): 388 ''' Build Application Actions ''' 389 from gajim import app_actions 390 391 # General Stateful Actions 392 393 act = Gio.SimpleAction.new_stateful( 394 'merge', None, 395 GLib.Variant.new_boolean(app.settings.get('mergeaccounts'))) 396 act.connect('change-state', app_actions.on_merge_accounts) 397 self.add_action(act) 398 399 actions = [ 400 ('quit', app_actions.on_quit), 401 ('add-account', app_actions.on_add_account), 402 ('manage-proxies', app_actions.on_manage_proxies), 403 ('history-manager', app_actions.on_history_manager), 404 ('preferences', app_actions.on_preferences), 405 ('plugins', app_actions.on_plugins), 406 ('xml-console', app_actions.on_xml_console), 407 ('file-transfer', app_actions.on_file_transfers), 408 ('history', app_actions.on_history), 409 ('shortcuts', app_actions.on_keyboard_shortcuts), 410 ('features', app_actions.on_features), 411 ('content', app_actions.on_contents), 412 ('about', app_actions.on_about), 413 ('faq', app_actions.on_faq), 414 ('ipython', app_actions.toggle_ipython), 415 ('show-next-pending-event', app_actions.show_next_pending_event), 416 ('start-chat', 's', app_actions.on_new_chat), 417 ('accounts', 's', app_actions.on_accounts), 418 ('add-contact', 's', app_actions.on_add_contact_jid), 419 ('copy-text', 's', app_actions.copy_text), 420 ('open-link', 'as', app_actions.open_link), 421 ('open-mail', 's', app_actions.open_mail), 422 ('create-groupchat', 's', app_actions.on_create_gc), 423 ('browse-history', 'a{sv}', app_actions.on_browse_history), 424 ('groupchat-join', 'as', app_actions.on_groupchat_join), 425 ] 426 427 for action in actions: 428 if len(action) == 2: 429 action_name, func = action 430 variant = None 431 else: 432 action_name, variant, func = action 433 variant = GLib.VariantType.new(variant) 434 435 act = Gio.SimpleAction.new(action_name, variant) 436 act.connect('activate', func) 437 self.add_action(act) 438 439 accounts_list = sorted(app.settings.get_accounts()) 440 if not accounts_list: 441 return 442 if len(accounts_list) > 1: 443 for acc in accounts_list: 444 self.add_account_actions(acc) 445 else: 446 self.add_account_actions(accounts_list[0]) 447 448 @staticmethod 449 def _get_account_actions(account): 450 from gajim import app_actions as a 451 452 if account == 'Local': 453 return [] 454 455 return [ 456 ('-bookmarks', a.on_bookmarks, 'online', 's'), 457 ('-start-single-chat', a.on_single_message, 'online', 's'), 458 ('-start-chat', a.start_chat, 'online', 'as'), 459 ('-add-contact', a.on_add_contact, 'online', 'as'), 460 ('-services', a.on_service_disco, 'online', 's'), 461 ('-profile', a.on_profile, 'online', 's'), 462 ('-server-info', a.on_server_info, 'online', 's'), 463 ('-archive', a.on_mam_preferences, 'feature', 's'), 464 ('-pep-config', a.on_pep_config, 'online', 's'), 465 ('-sync-history', a.on_history_sync, 'online', 's'), 466 ('-blocking', a.on_blocking_list, 'feature', 's'), 467 ('-send-server-message', a.on_send_server_message, 'online', 's'), 468 ('-set-motd', a.on_set_motd, 'online', 's'), 469 ('-update-motd', a.on_update_motd, 'online', 's'), 470 ('-delete-motd', a.on_delete_motd, 'online', 's'), 471 ('-open-event', a.on_open_event, 'always', 'a{sv}'), 472 ('-remove-event', a.on_remove_event, 'always', 'a{sv}'), 473 ('-import-contacts', a.on_import_contacts, 'online', 's'), 474 ] 475 476 def add_account_actions(self, account): 477 for action in self._get_account_actions(account): 478 action_name, func, state, type_ = action 479 action_name = account + action_name 480 if self.lookup_action(action_name): 481 # We already added this action 482 continue 483 act = Gio.SimpleAction.new( 484 action_name, GLib.VariantType.new(type_)) 485 act.connect("activate", func) 486 if state != 'always': 487 act.set_enabled(False) 488 self.add_action(act) 489 490 def remove_account_actions(self, account): 491 for action in self._get_account_actions(account): 492 action_name = account + action[0] 493 self.remove_action(action_name) 494 495 def set_account_actions_state(self, account, new_state=False): 496 for action in self._get_account_actions(account): 497 action_name, _, state, _ = action 498 if not new_state and state in ('online', 'feature'): 499 # We go offline 500 self.lookup_action(account + action_name).set_enabled(False) 501 elif new_state and state == 'online': 502 # We go online 503 self.lookup_action(account + action_name).set_enabled(True) 504 505 def update_app_actions_state(self): 506 active_accounts = bool(app.get_connected_accounts(exclude_local=True)) 507 self.lookup_action('create-groupchat').set_enabled(active_accounts) 508 509 enabled_accounts = app.contacts.get_accounts() 510 self.lookup_action('start-chat').set_enabled(enabled_accounts) 511 512 def _set_shortcuts(self): 513 shortcuts = { 514 'app.quit': ['<Primary>Q'], 515 'app.shortcuts': ['<Primary>question'], 516 'app.preferences': ['<Primary>P'], 517 'app.plugins': ['<Primary>E'], 518 'app.xml-console': ['<Primary><Shift>X'], 519 'app.file-transfer': ['<Primary>T'], 520 'app.ipython': ['<Primary><Alt>I'], 521 'app.start-chat::': ['<Primary>N'], 522 'app.create-groupchat::': ['<Primary>G'], 523 'win.show-roster': ['<Primary>R'], 524 'win.show-offline': ['<Primary>O'], 525 'win.show-active': ['<Primary>Y'], 526 'win.change-nickname': ['<Primary><Shift>N'], 527 'win.change-subject': ['<Primary><Shift>S'], 528 'win.escape': ['Escape'], 529 'win.browse-history': ['<Primary>H'], 530 'win.send-file': ['<Primary>F'], 531 'win.show-contact-info': ['<Primary>I'], 532 'win.show-emoji-chooser': ['<Primary><Shift>M'], 533 'win.clear-chat': ['<Primary>L'], 534 'win.delete-line': ['<Primary>U'], 535 'win.close-tab': ['<Primary>W'], 536 'win.move-tab-up': ['<Primary><Shift>Page_Up'], 537 'win.move-tab-down': ['<Primary><Shift>Page_Down'], 538 'win.switch-next-tab': ['<Primary>Page_Down'], 539 'win.switch-prev-tab': ['<Primary>Page_Up'], 540 'win.switch-next-unread-tab-right': ['<Primary>Tab'], 541 'win.switch-next-unread-tab-left': ['<Primary>ISO_Left_Tab'], 542 'win.switch-tab-1': ['<Alt>1', '<Alt>KP_1'], 543 'win.switch-tab-2': ['<Alt>2', '<Alt>KP_2'], 544 'win.switch-tab-3': ['<Alt>3', '<Alt>KP_3'], 545 'win.switch-tab-4': ['<Alt>4', '<Alt>KP_4'], 546 'win.switch-tab-5': ['<Alt>5', '<Alt>KP_5'], 547 'win.switch-tab-6': ['<Alt>6', '<Alt>KP_6'], 548 'win.switch-tab-7': ['<Alt>7', '<Alt>KP_7'], 549 'win.switch-tab-8': ['<Alt>8', '<Alt>KP_8'], 550 'win.switch-tab-9': ['<Alt>9', '<Alt>KP_9'], 551 } 552 553 for action, accels in shortcuts.items(): 554 self.set_accels_for_action(action, accels) 555 556 def _on_feature_discovered(self, event): 557 if event.feature == Namespace.MAM_2: 558 action = '%s-archive' % event.account 559 self.lookup_action(action).set_enabled(True) 560 elif event.feature == Namespace.BLOCKING: 561 action = '%s-blocking' % event.account 562 self.lookup_action(action).set_enabled(True) 563