You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

537 lines
22 KiB

#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2012 thomasv@gitorious
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
import signal
import sys
import threading
from typing import Optional, TYPE_CHECKING, List, Sequence
try:
import PyQt5
import PyQt5.QtGui
except Exception as e:
from electrum import GuiImportError
raise GuiImportError(
"Error: Could not import PyQt5. On Linux systems, "
"you may try 'sudo apt-get install python3-pyqt5'") from e
from PyQt5.QtGui import QGuiApplication
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QWidget, QMenu, QMessageBox
from PyQt5.QtCore import QObject, pyqtSignal, QTimer, Qt
import PyQt5.QtCore as QtCore
try:
# Preload QtMultimedia at app start, if available.
# We use QtMultimedia on some platforms for camera-handling, and
# lazy-loading it later led to some crashes. Maybe due to bugs in PyQt5. (see #7725)
from PyQt5.QtMultimedia import QCameraInfo; del QCameraInfo
except ImportError as e:
pass # failure is ok; it is an optional dependency.
from electrum.i18n import _, set_language
from electrum.plugin import run_hook
from electrum.util import (UserCancelled, profiler, send_exception_to_crash_reporter,
WalletFileException, BitcoinException, get_new_wallet_name, InvalidPassword)
from electrum.wallet import Wallet, Abstract_Wallet
from electrum.wallet_db import WalletDB, WalletRequiresSplit, WalletRequiresUpgrade, WalletUnfinished
from electrum.logging import Logger
from electrum.gui import BaseElectrumGui
from electrum.simple_config import SimpleConfig
from electrum.storage import WalletStorage
from electrum.wizard import WizardViewState
from electrum.keystore import load_keystore
from .util import read_QIcon, ColorScheme, custom_message_box, MessageBoxMixin, WWLabel
from .main_window import ElectrumWindow
from .network_dialog import NetworkDialog
from .stylesheet_patcher import patch_qt_stylesheet
from .lightning_dialog import LightningDialog
from .watchtower_dialog import WatchtowerDialog
from .exception_window import Exception_Hook
from .wizard.server_connect import QEServerConnectWizard
from .wizard.wallet import QENewWalletWizard
if TYPE_CHECKING:
from electrum.daemon import Daemon
from electrum.plugin import Plugins
class OpenFileEventFilter(QObject):
def __init__(self, windows: Sequence[ElectrumWindow]):
self.windows = windows
super(OpenFileEventFilter, self).__init__()
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.FileOpen:
if len(self.windows) >= 1:
self.windows[0].set_payment_identifier(event.url().toString())
return True
return False
class QElectrumApplication(QApplication):
new_window_signal = pyqtSignal(str, object)
quit_signal = pyqtSignal()
refresh_tabs_signal = pyqtSignal()
refresh_amount_edits_signal = pyqtSignal()
update_status_signal = pyqtSignal()
update_fiat_signal = pyqtSignal()
alias_received_signal = pyqtSignal()
class ElectrumGui(BaseElectrumGui, Logger):
network_dialog: Optional['NetworkDialog']
lightning_dialog: Optional['LightningDialog']
watchtower_dialog: Optional['WatchtowerDialog']
@profiler
def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
BaseElectrumGui.__init__(self, config=config, daemon=daemon, plugins=plugins)
Logger.__init__(self)
self.logger.info(f"Qt GUI starting up... Qt={QtCore.QT_VERSION_STR}, PyQt={QtCore.PYQT_VERSION_STR}")
# Uncomment this call to verify objects are being properly
# GC-ed when windows are closed
#network.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer,
# ElectrumWindow], interval=5)])
if hasattr(QtCore.Qt, "AA_ShareOpenGLContexts"):
QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts)
if hasattr(QGuiApplication, 'setDesktopFileName'):
QGuiApplication.setDesktopFileName('electrum.desktop')
self.gui_thread = threading.current_thread()
self.windows = [] # type: List[ElectrumWindow]
self.efilter = OpenFileEventFilter(self.windows)
self.app = QElectrumApplication(sys.argv)
self.app.installEventFilter(self.efilter)
self.app.setWindowIcon(read_QIcon("electrum.png"))
self._cleaned_up = False
# timer
self.timer = QTimer(self.app)
self.timer.setSingleShot(False)
self.timer.setInterval(500) # msec
self.network_dialog = None
self.lightning_dialog = None
self.watchtower_dialog = None
self._num_wizards_in_progress = 0
self._num_wizards_lock = threading.Lock()
self.dark_icon = self.config.GUI_QT_DARK_TRAY_ICON
self.tray = None
self._init_tray()
self.app.new_window_signal.connect(self.start_new_window)
self.app.quit_signal.connect(self.app.quit, Qt.QueuedConnection)
# maybe set dark theme
self._default_qtstylesheet = self.app.styleSheet()
self.reload_app_stylesheet()
# always load 2fa
self.plugins.load_plugin('trustedcoin')
run_hook('init_qt', self)
def _init_tray(self):
self.tray = QSystemTrayIcon(self.tray_icon(), None)
self.tray.setToolTip('Electrum')
self.tray.activated.connect(self.tray_activated)
self.build_tray_menu()
self.tray.show()
def reload_app_stylesheet(self):
"""Set the Qt stylesheet and custom colors according to the user-selected
light/dark theme.
TODO this can ~almost be used to change the theme at runtime (without app restart),
except for util.ColorScheme... widgets already created with colors set using
ColorSchemeItem.as_stylesheet() and similar will not get recolored.
See e.g.
- in Coins tab, the color for "frozen" UTXOs, or
- in TxDialog, the receiving/change address colors
"""
use_dark_theme = self.config.GUI_QT_COLOR_THEME == 'dark'
if use_dark_theme:
try:
import qdarkstyle
self.app.setStyleSheet(qdarkstyle.load_stylesheet_pyqt5())
except BaseException as e:
use_dark_theme = False
self.logger.warning(f'Error setting dark theme: {repr(e)}')
else:
self.app.setStyleSheet(self._default_qtstylesheet)
# Apply any necessary stylesheet patches
patch_qt_stylesheet(use_dark_theme=use_dark_theme)
# Even if we ourselves don't set the dark theme,
# the OS/window manager/etc might set *a dark theme*.
# Hence, try to choose colors accordingly:
ColorScheme.update_from_widget(QWidget(), force_dark=use_dark_theme)
def build_tray_menu(self):
if not self.tray:
return
# Avoid immediate GC of old menu when window closed via its action
if self.tray.contextMenu() is None:
m = QMenu()
self.tray.setContextMenu(m)
else:
m = self.tray.contextMenu()
m.clear()
network = self.daemon.network
m.addAction(_("Network"), self.show_network_dialog)
if network and network.lngossip:
m.addAction(_("Lightning Network"), self.show_lightning_dialog)
if network and network.local_watchtower:
m.addAction(_("Local Watchtower"), self.show_watchtower_dialog)
for window in self.windows:
name = window.wallet.basename()
submenu = m.addMenu(name)
submenu.addAction(_("Show/Hide"), window.show_or_hide)
submenu.addAction(_("Close"), window.close)
m.addAction(_("Dark/Light"), self.toggle_tray_icon)
m.addSeparator()
m.addAction(_("Exit Electrum"), self.app.quit)
def tray_icon(self):
if self.dark_icon:
return read_QIcon('electrum_dark_icon.png')
else:
return read_QIcon('electrum_light_icon.png')
def toggle_tray_icon(self):
if not self.tray:
return
self.dark_icon = not self.dark_icon
self.config.GUI_QT_DARK_TRAY_ICON = self.dark_icon
self.tray.setIcon(self.tray_icon())
def tray_activated(self, reason):
if reason == QSystemTrayIcon.DoubleClick:
if all([w.is_hidden() for w in self.windows]):
for w in self.windows:
w.bring_to_top()
else:
for w in self.windows:
w.hide()
def _cleanup_before_exit(self):
if self._cleaned_up:
return
self._cleaned_up = True
self.app.new_window_signal.disconnect()
self.efilter = None
# If there are still some open windows, try to clean them up.
for window in list(self.windows):
window.close()
window.clean_up()
if self.network_dialog:
self.network_dialog.close()
self.network_dialog.clean_up()
self.network_dialog = None
if self.lightning_dialog:
self.lightning_dialog.close()
self.lightning_dialog = None
if self.watchtower_dialog:
self.watchtower_dialog.close()
self.watchtower_dialog = None
# Shut down the timer cleanly
self.timer.stop()
self.timer = None
# clipboard persistence. see http://www.mail-archive.com/pyqt@riverbankcomputing.com/msg17328.html
event = QtCore.QEvent(QtCore.QEvent.Clipboard)
self.app.sendEvent(self.app.clipboard(), event)
if self.tray:
self.tray.hide()
self.tray.deleteLater()
self.tray = None
def _maybe_quit_if_no_windows_open(self) -> None:
"""Check if there are any open windows and decide whether we should quit."""
# keep daemon running after close
if self.config.get('daemon'):
return
# check if a wizard is in progress
with self._num_wizards_lock:
if self._num_wizards_in_progress > 0 or len(self.windows) > 0:
return
self.app.quit()
def new_window(self, path, uri=None):
# Use a signal as can be called from daemon thread
self.app.new_window_signal.emit(path, uri)
def show_lightning_dialog(self):
if not self.daemon.network.has_channel_db():
return
if not self.lightning_dialog:
self.lightning_dialog = LightningDialog(self)
self.lightning_dialog.bring_to_top()
def show_watchtower_dialog(self):
if not self.watchtower_dialog:
self.watchtower_dialog = WatchtowerDialog(self)
self.watchtower_dialog.bring_to_top()
def show_network_dialog(self):
if self.network_dialog:
self.network_dialog.on_event_network_updated()
self.network_dialog.show()
self.network_dialog.raise_()
return
self.network_dialog = NetworkDialog(
network=self.daemon.network,
config=self.config)
self.network_dialog.show()
def _create_window_for_wallet(self, wallet):
w = ElectrumWindow(self, wallet)
self.windows.append(w)
self.build_tray_menu()
w.warn_if_testnet()
w.warn_if_watching_only()
return w
def count_wizards_in_progress(func):
def wrapper(self: 'ElectrumGui', *args, **kwargs):
with self._num_wizards_lock:
self._num_wizards_in_progress += 1
try:
return func(self, *args, **kwargs)
finally:
with self._num_wizards_lock:
self._num_wizards_in_progress -= 1
self._maybe_quit_if_no_windows_open()
return wrapper
@count_wizards_in_progress
def start_new_window(
self,
path,
uri: Optional[str],
*,
app_is_starting: bool = False,
force_wizard: bool = False,
) -> Optional[ElectrumWindow]:
"""Raises the window for the wallet if it is open.
Otherwise, opens the wallet and creates a new window for it.
Warning: the returned window might be for a completely different wallet
than the provided path, as we allow user interaction to change the path.
"""
wallet = None
# Try to open with daemon first. If this succeeds, there won't be a wizard at all
# (the wallet main window will appear directly).
if not force_wizard:
try:
wallet = self.daemon.load_wallet(path, None)
except FileNotFoundError:
pass # open with wizard below
except InvalidPassword:
pass # open with wizard below
except WalletRequiresSplit:
pass # open with wizard below
except WalletRequiresUpgrade:
pass # open with wizard below
except Exception as e:
self.logger.exception('')
err_text = str(e) if isinstance(e, WalletFileException) else repr(e)
custom_message_box(icon=QMessageBox.Warning,
parent=None,
title=_('Error'),
text=_('Cannot load wallet') + ' (1):\n' + err_text)
if isinstance(e, WalletFileException) and e.should_report_crash:
send_exception_to_crash_reporter(e)
# if app is starting, still let wizard appear
if not app_is_starting:
return
# Open a wizard window. This lets the user e.g. enter a password, or select
# a different wallet.
try:
if not wallet:
wallet = self._start_wizard_to_select_or_create_wallet(path)
if not wallet:
return
# create or raise window
for window in self.windows:
if window.wallet.storage.path == wallet.storage.path:
break
else:
window = self._create_window_for_wallet(wallet)
except Exception as e:
self.logger.exception('')
err_text = str(e) if isinstance(e, WalletFileException) else repr(e)
custom_message_box(icon=QMessageBox.Warning,
parent=None,
title=_('Error'),
text=_('Cannot load wallet') + '(2) :\n' + err_text)
if isinstance(e, WalletFileException) and e.should_report_crash:
send_exception_to_crash_reporter(e)
if app_is_starting:
# If we raise in this context, there are no more fallbacks, we will shut down.
# Worst case scenario, we might have gotten here without user interaction,
# in which case, if we raise now without user interaction, the same sequence of
# events is likely to repeat when the user restarts the process.
# So we play it safe: clear path, clear uri, force a wizard to appear.
try:
wallet_dir = os.path.dirname(path)
filename = get_new_wallet_name(wallet_dir)
except OSError:
path = self.config.get_fallback_wallet_path()
else:
path = os.path.join(wallet_dir, filename)
return self.start_new_window(path, uri=None, force_wizard=True)
return
window.bring_to_top()
window.setWindowState(window.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive)
window.activateWindow()
if uri:
window.show_send_tab()
window.send_tab.set_payment_identifier(uri)
return window
def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]:
wizard = QENewWalletWizard(self.config, self.app, self.plugins, self.daemon, path)
result = wizard.exec()
# TODO: use dialog.open() instead to avoid new event loop spawn?
self.logger.info(f'{result}')
if result == QENewWalletWizard.Rejected:
self.logger.info('ok bye bye')
return
d = wizard.get_wizard_data()
if d['wallet_is_open']:
for window in self.windows:
if window.wallet.storage.path == d['wallet_name']:
return window.wallet
raise Exception('found by wizard but not here?!')
if not d['wallet_exists']:
self.logger.info('about to create wallet')
wizard.create_storage()
if d['wallet_type'] == '2fa' and 'x3' not in d:
return
wallet_file = wizard.path
else:
wallet_file = d['wallet_name']
try:
wallet = self.daemon.load_wallet(wallet_file, d['password'], upgrade=True)
return wallet
except WalletRequiresSplit as e:
wizard.run_split(wallet_file, e._split_data)
return
except WalletUnfinished as e:
# wallet creation is not complete, 2fa online phase
db = e._wallet_db
action = db.get_action()
assert action[1] == 'accept_terms_of_use', 'only support for resuming trustedcoin split setup'
k1 = load_keystore(db, 'x1')
if 'password' in d and d['password']:
xprv = k1.get_master_private_key(d['password'])
else:
xprv = db.get('x1')['xprv']
data = {
'wallet_name': os.path.basename(wallet_file),
'xprv1': xprv,
'xpub1': db.get('x1')['xpub'],
'xpub2': db.get('x2')['xpub'],
}
wizard = QENewWalletWizard(self.config, self.app, self.plugins, self.daemon, path,
start_viewstate=WizardViewState('trustedcoin_tos_email', data, {}))
result = wizard.exec()
if result == QENewWalletWizard.Rejected:
self.logger.info('ok bye bye')
return
db.put('x3', wizard.get_wizard_data()['x3'])
db.write()
wallet = Wallet(db, config=self.config)
wallet.start_network(self.daemon.network)
self.daemon.add_wallet(wallet)
return wallet
def close_window(self, window: ElectrumWindow):
if window in self.windows:
self.windows.remove(window)
self.build_tray_menu()
# save wallet path of last open window
if not self.windows:
self.config.save_last_wallet(window.wallet)
run_hook('on_close_window', window)
self.daemon.stop_wallet(window.wallet.storage.path)
def init_network(self):
"""Start the network, including showing a first-start network dialog if config does not exist."""
if self.daemon.network:
# first-start network-setup
if not self.config.cv.NETWORK_AUTO_CONNECT.is_set():
dialog = QEServerConnectWizard(self.config, self.app, self.plugins, self.daemon)
dialog.exec()
# start network
self.daemon.start_network()
def main(self):
# setup Ctrl-C handling and tear-down code first, so that user can easily exit whenever
self.app.setQuitOnLastWindowClosed(False) # so _we_ can decide whether to quit
self.app.lastWindowClosed.connect(self._maybe_quit_if_no_windows_open)
self.app.aboutToQuit.connect(self._cleanup_before_exit)
signal.signal(signal.SIGINT, lambda *args: self.app.quit())
# hook for crash reporter
Exception_Hook.maybe_setup(config=self.config)
# start network, and maybe show first-start network-setup
try:
self.init_network()
except UserCancelled:
return
except Exception as e:
self.logger.exception('')
return
# start wizard to select/create wallet
self.timer.start()
path = self.config.get_wallet_path(use_gui_last_wallet=True)
try:
if not self.start_new_window(path, self.config.get('url'), app_is_starting=True):
return
except Exception as e:
self.logger.error("error loading wallet (or creating window for it)")
send_exception_to_crash_reporter(e)
# Let Qt event loop start properly so that crash reporter window can appear.
# We will shutdown when the user closes that window, via lastWindowClosed signal.
# main loop
self.logger.info("starting Qt main loop")
self.app.exec_()
# on some platforms the exec_ call may not return, so use _cleanup_before_exit
def stop(self):
self.logger.info('closing GUI')
self.app.quit_signal.emit()
@classmethod
def version_info(cls):
ret = {
"qt.version": QtCore.QT_VERSION_STR,
"pyqt.version": QtCore.PYQT_VERSION_STR,
}
if hasattr(PyQt5, "__path__"):
ret["pyqt.path"] = ", ".join(PyQt5.__path__ or [])
return ret