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