1#!/usr/local/bin/python3.8
2#
3# Electrum - lightweight Bitcoin client
4# Copyright (C) 2012 thomasv@gitorious
5#
6# Permission is hereby granted, free of charge, to any person
7# obtaining a copy of this software and associated documentation files
8# (the "Software"), to deal in the Software without restriction,
9# including without limitation the rights to use, copy, modify, merge,
10# publish, distribute, sublicense, and/or sell copies of the Software,
11# and to permit persons to whom the Software is furnished to do so,
12# subject to the following conditions:
13#
14# The above copyright notice and this permission notice shall be
15# included in all copies or substantial portions of the Software.
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
21# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
22# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
23# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24# SOFTWARE.
25
26import os
27import signal
28import sys
29import traceback
30import threading
31from typing import Optional, TYPE_CHECKING, List
32
33
34try:
35    import PyQt5
36except Exception:
37    sys.exit("Error: Could not import PyQt5 on Linux systems, you may try 'sudo apt-get install python3-pyqt5'")
38
39from PyQt5.QtGui import QGuiApplication
40from PyQt5.QtWidgets import (QApplication, QSystemTrayIcon, QWidget, QMenu,
41                             QMessageBox)
42from PyQt5.QtCore import QObject, pyqtSignal, QTimer, Qt
43import PyQt5.QtCore as QtCore
44
45from electrum.i18n import _, set_language
46from electrum.plugin import run_hook
47from electrum.base_wizard import GoBack
48from electrum.util import (UserCancelled, profiler, send_exception_to_crash_reporter,
49                           WalletFileException, BitcoinException, get_new_wallet_name)
50from electrum.wallet import Wallet, Abstract_Wallet
51from electrum.wallet_db import WalletDB
52from electrum.logging import Logger
53
54from .installwizard import InstallWizard, WalletAlreadyOpenInMemory
55from .util import get_default_language, read_QIcon, ColorScheme, custom_message_box, MessageBoxMixin
56from .main_window import ElectrumWindow
57from .network_dialog import NetworkDialog
58from .stylesheet_patcher import patch_qt_stylesheet
59from .lightning_dialog import LightningDialog
60from .watchtower_dialog import WatchtowerDialog
61from .exception_window import Exception_Hook
62
63if TYPE_CHECKING:
64    from electrum.daemon import Daemon
65    from electrum.simple_config import SimpleConfig
66    from electrum.plugin import Plugins
67
68
69class OpenFileEventFilter(QObject):
70    def __init__(self, windows):
71        self.windows = windows
72        super(OpenFileEventFilter, self).__init__()
73
74    def eventFilter(self, obj, event):
75        if event.type() == QtCore.QEvent.FileOpen:
76            if len(self.windows) >= 1:
77                self.windows[0].pay_to_URI(event.url().toString())
78                return True
79        return False
80
81
82class QElectrumApplication(QApplication):
83    new_window_signal = pyqtSignal(str, object)
84
85
86class QNetworkUpdatedSignalObject(QObject):
87    network_updated_signal = pyqtSignal(str, object)
88
89
90class ElectrumGui(Logger):
91
92    network_dialog: Optional['NetworkDialog']
93    lightning_dialog: Optional['LightningDialog']
94    watchtower_dialog: Optional['WatchtowerDialog']
95
96    @profiler
97    def __init__(self, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
98        set_language(config.get('language', get_default_language()))
99        Logger.__init__(self)
100        self.logger.info(f"Qt GUI starting up... Qt={QtCore.QT_VERSION_STR}, PyQt={QtCore.PYQT_VERSION_STR}")
101        # Uncomment this call to verify objects are being properly
102        # GC-ed when windows are closed
103        #network.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer,
104        #                            ElectrumWindow], interval=5)])
105        QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_X11InitThreads)
106        if hasattr(QtCore.Qt, "AA_ShareOpenGLContexts"):
107            QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts)
108        if hasattr(QGuiApplication, 'setDesktopFileName'):
109            QGuiApplication.setDesktopFileName('electrum.desktop')
110        self.gui_thread = threading.current_thread()
111        self.config = config
112        self.daemon = daemon
113        self.plugins = plugins
114        self.windows = []  # type: List[ElectrumWindow]
115        self.efilter = OpenFileEventFilter(self.windows)
116        self.app = QElectrumApplication(sys.argv)
117        self.app.installEventFilter(self.efilter)
118        self.app.setWindowIcon(read_QIcon("electrum.png"))
119        self._cleaned_up = False
120        # timer
121        self.timer = QTimer(self.app)
122        self.timer.setSingleShot(False)
123        self.timer.setInterval(500)  # msec
124
125        self.network_dialog = None
126        self.lightning_dialog = None
127        self.watchtower_dialog = None
128        self.network_updated_signal_obj = QNetworkUpdatedSignalObject()
129        self._num_wizards_in_progress = 0
130        self._num_wizards_lock = threading.Lock()
131        self.dark_icon = self.config.get("dark_icon", False)
132        self.tray = None
133        self._init_tray()
134        self.app.new_window_signal.connect(self.start_new_window)
135        self.set_dark_theme_if_needed()
136        run_hook('init_qt', self)
137
138    def _init_tray(self):
139        self.tray = QSystemTrayIcon(self.tray_icon(), None)
140        self.tray.setToolTip('Electrum')
141        self.tray.activated.connect(self.tray_activated)
142        self.build_tray_menu()
143        self.tray.show()
144
145    def set_dark_theme_if_needed(self):
146        use_dark_theme = self.config.get('qt_gui_color_theme', 'default') == 'dark'
147        if use_dark_theme:
148            try:
149                import qdarkstyle
150                self.app.setStyleSheet(qdarkstyle.load_stylesheet_pyqt5())
151            except BaseException as e:
152                use_dark_theme = False
153                self.logger.warning(f'Error setting dark theme: {repr(e)}')
154        # Apply any necessary stylesheet patches
155        patch_qt_stylesheet(use_dark_theme=use_dark_theme)
156        # Even if we ourselves don't set the dark theme,
157        # the OS/window manager/etc might set *a dark theme*.
158        # Hence, try to choose colors accordingly:
159        ColorScheme.update_from_widget(QWidget(), force_dark=use_dark_theme)
160
161    def build_tray_menu(self):
162        if not self.tray:
163            return
164        # Avoid immediate GC of old menu when window closed via its action
165        if self.tray.contextMenu() is None:
166            m = QMenu()
167            self.tray.setContextMenu(m)
168        else:
169            m = self.tray.contextMenu()
170            m.clear()
171        network = self.daemon.network
172        m.addAction(_("Network"), self.show_network_dialog)
173        if network and network.lngossip:
174            m.addAction(_("Lightning Network"), self.show_lightning_dialog)
175        if network and network.local_watchtower:
176            m.addAction(_("Local Watchtower"), self.show_watchtower_dialog)
177        for window in self.windows:
178            name = window.wallet.basename()
179            submenu = m.addMenu(name)
180            submenu.addAction(_("Show/Hide"), window.show_or_hide)
181            submenu.addAction(_("Close"), window.close)
182        m.addAction(_("Dark/Light"), self.toggle_tray_icon)
183        m.addSeparator()
184        m.addAction(_("Exit Electrum"), self.app.quit)
185
186    def tray_icon(self):
187        if self.dark_icon:
188            return read_QIcon('electrum_dark_icon.png')
189        else:
190            return read_QIcon('electrum_light_icon.png')
191
192    def toggle_tray_icon(self):
193        if not self.tray:
194            return
195        self.dark_icon = not self.dark_icon
196        self.config.set_key("dark_icon", self.dark_icon, True)
197        self.tray.setIcon(self.tray_icon())
198
199    def tray_activated(self, reason):
200        if reason == QSystemTrayIcon.DoubleClick:
201            if all([w.is_hidden() for w in self.windows]):
202                for w in self.windows:
203                    w.bring_to_top()
204            else:
205                for w in self.windows:
206                    w.hide()
207
208    def _cleanup_before_exit(self):
209        if self._cleaned_up:
210            return
211        self._cleaned_up = True
212        self.app.new_window_signal.disconnect()
213        self.efilter = None
214        # If there are still some open windows, try to clean them up.
215        for window in list(self.windows):
216            window.close()
217            window.clean_up()
218        if self.network_dialog:
219            self.network_dialog.close()
220            self.network_dialog.clean_up()
221            self.network_dialog = None
222        self.network_updated_signal_obj = None
223        if self.lightning_dialog:
224            self.lightning_dialog.close()
225            self.lightning_dialog = None
226        if self.watchtower_dialog:
227            self.watchtower_dialog.close()
228            self.watchtower_dialog = None
229        # Shut down the timer cleanly
230        self.timer.stop()
231        self.timer = None
232        # clipboard persistence. see http://www.mail-archive.com/pyqt@riverbankcomputing.com/msg17328.html
233        event = QtCore.QEvent(QtCore.QEvent.Clipboard)
234        self.app.sendEvent(self.app.clipboard(), event)
235        if self.tray:
236            self.tray.hide()
237            self.tray.deleteLater()
238            self.tray = None
239
240    def _maybe_quit_if_no_windows_open(self) -> None:
241        """Check if there are any open windows and decide whether we should quit."""
242        # keep daemon running after close
243        if self.config.get('daemon'):
244            return
245        # check if a wizard is in progress
246        with self._num_wizards_lock:
247            if self._num_wizards_in_progress > 0 or len(self.windows) > 0:
248                return
249        self.app.quit()
250
251    def new_window(self, path, uri=None):
252        # Use a signal as can be called from daemon thread
253        self.app.new_window_signal.emit(path, uri)
254
255    def show_lightning_dialog(self):
256        if not self.daemon.network.has_channel_db():
257            return
258        if not self.lightning_dialog:
259            self.lightning_dialog = LightningDialog(self)
260        self.lightning_dialog.bring_to_top()
261
262    def show_watchtower_dialog(self):
263        if not self.watchtower_dialog:
264            self.watchtower_dialog = WatchtowerDialog(self)
265        self.watchtower_dialog.bring_to_top()
266
267    def show_network_dialog(self):
268        if self.network_dialog:
269            self.network_dialog.on_update()
270            self.network_dialog.show()
271            self.network_dialog.raise_()
272            return
273        self.network_dialog = NetworkDialog(
274            network=self.daemon.network,
275            config=self.config,
276            network_updated_signal_obj=self.network_updated_signal_obj)
277        self.network_dialog.show()
278
279    def _create_window_for_wallet(self, wallet):
280        w = ElectrumWindow(self, wallet)
281        self.windows.append(w)
282        self.build_tray_menu()
283        w.warn_if_testnet()
284        w.warn_if_watching_only()
285        return w
286
287    def count_wizards_in_progress(func):
288        def wrapper(self: 'ElectrumGui', *args, **kwargs):
289            with self._num_wizards_lock:
290                self._num_wizards_in_progress += 1
291            try:
292                return func(self, *args, **kwargs)
293            finally:
294                with self._num_wizards_lock:
295                    self._num_wizards_in_progress -= 1
296                self._maybe_quit_if_no_windows_open()
297        return wrapper
298
299    @count_wizards_in_progress
300    def start_new_window(self, path, uri, *, app_is_starting=False) -> Optional[ElectrumWindow]:
301        '''Raises the window for the wallet if it is open.  Otherwise
302        opens the wallet and creates a new window for it'''
303        wallet = None
304        try:
305            wallet = self.daemon.load_wallet(path, None)
306        except Exception as e:
307            self.logger.exception('')
308            custom_message_box(icon=QMessageBox.Warning,
309                               parent=None,
310                               title=_('Error'),
311                               text=_('Cannot load wallet') + ' (1):\n' + repr(e))
312            # if app is starting, still let wizard to appear
313            if not app_is_starting:
314                return
315        if not wallet:
316            try:
317                wallet = self._start_wizard_to_select_or_create_wallet(path)
318            except (WalletFileException, BitcoinException) as e:
319                self.logger.exception('')
320                custom_message_box(icon=QMessageBox.Warning,
321                                   parent=None,
322                                   title=_('Error'),
323                                   text=_('Cannot load wallet') + ' (2):\n' + repr(e))
324        if not wallet:
325            return
326        # create or raise window
327        try:
328            for window in self.windows:
329                if window.wallet.storage.path == wallet.storage.path:
330                    break
331            else:
332                window = self._create_window_for_wallet(wallet)
333        except Exception as e:
334            self.logger.exception('')
335            custom_message_box(icon=QMessageBox.Warning,
336                               parent=None,
337                               title=_('Error'),
338                               text=_('Cannot create window for wallet') + ':\n' + repr(e))
339            if app_is_starting:
340                wallet_dir = os.path.dirname(path)
341                path = os.path.join(wallet_dir, get_new_wallet_name(wallet_dir))
342                self.start_new_window(path, uri)
343            return
344        if uri:
345            window.pay_to_URI(uri)
346        window.bring_to_top()
347        window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive)
348
349        window.activateWindow()
350        return window
351
352    def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]:
353        wizard = InstallWizard(self.config, self.app, self.plugins, gui_object=self)
354        try:
355            path, storage = wizard.select_storage(path, self.daemon.get_wallet)
356            # storage is None if file does not exist
357            if storage is None:
358                wizard.path = path  # needed by trustedcoin plugin
359                wizard.run('new')
360                storage, db = wizard.create_storage(path)
361            else:
362                db = WalletDB(storage.read(), manual_upgrades=False)
363                wizard.run_upgrades(storage, db)
364        except (UserCancelled, GoBack):
365            return
366        except WalletAlreadyOpenInMemory as e:
367            return e.wallet
368        finally:
369            wizard.terminate()
370        # return if wallet creation is not complete
371        if storage is None or db.get_action():
372            return
373        wallet = Wallet(db, storage, config=self.config)
374        wallet.start_network(self.daemon.network)
375        self.daemon.add_wallet(wallet)
376        return wallet
377
378    def close_window(self, window: ElectrumWindow):
379        if window in self.windows:
380            self.windows.remove(window)
381        self.build_tray_menu()
382        # save wallet path of last open window
383        if not self.windows:
384            self.config.save_last_wallet(window.wallet)
385        run_hook('on_close_window', window)
386        self.daemon.stop_wallet(window.wallet.storage.path)
387
388    def init_network(self):
389        # Show network dialog if config does not exist
390        if self.daemon.network:
391            if self.config.get('auto_connect') is None:
392                wizard = InstallWizard(self.config, self.app, self.plugins, gui_object=self)
393                wizard.init_network(self.daemon.network)
394                wizard.terminate()
395
396    def main(self):
397        # setup Ctrl-C handling and tear-down code first, so that user can easily exit whenever
398        self.app.setQuitOnLastWindowClosed(False)  # so _we_ can decide whether to quit
399        self.app.lastWindowClosed.connect(self._maybe_quit_if_no_windows_open)
400        self.app.aboutToQuit.connect(self._cleanup_before_exit)
401        signal.signal(signal.SIGINT, lambda *args: self.app.quit())
402        # hook for crash reporter
403        Exception_Hook.maybe_setup(config=self.config)
404        # first-start network-setup
405        try:
406            self.init_network()
407        except UserCancelled:
408            return
409        except GoBack:
410            return
411        except Exception as e:
412            self.logger.exception('')
413            return
414        # start wizard to select/create wallet
415        self.timer.start()
416        path = self.config.get_wallet_path(use_gui_last_wallet=True)
417        try:
418            if not self.start_new_window(path, self.config.get('url'), app_is_starting=True):
419                return
420        except Exception as e:
421            self.logger.error("error loading wallet (or creating window for it)")
422            send_exception_to_crash_reporter(e)
423            # Let Qt event loop start properly so that crash reporter window can appear.
424            # We will shutdown when the user closes that window, via lastWindowClosed signal.
425        # main loop
426        self.logger.info("starting Qt main loop")
427        self.app.exec_()
428        # on some platforms the exec_ call may not return, so use _cleanup_before_exit
429
430    def stop(self):
431        self.logger.info('closing GUI')
432        self.app.quit()
433