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