From 8f0cb38af27890445b965c76ac0f61389df65d48 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 11 Jul 2023 21:32:30 +0200 Subject: [PATCH] qt: add initial wizard classes for desktop client --- electrum/gui/qt/wizard/__init__.py | 0 electrum/gui/qt/wizard/server_connect.py | 82 +++++++++++ electrum/gui/qt/wizard/wallet.py | 90 ++++++++++++ electrum/gui/qt/wizard/wizard.py | 174 +++++++++++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 electrum/gui/qt/wizard/__init__.py create mode 100644 electrum/gui/qt/wizard/server_connect.py create mode 100644 electrum/gui/qt/wizard/wallet.py create mode 100644 electrum/gui/qt/wizard/wizard.py diff --git a/electrum/gui/qt/wizard/__init__.py b/electrum/gui/qt/wizard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/electrum/gui/qt/wizard/server_connect.py b/electrum/gui/qt/wizard/server_connect.py new file mode 100644 index 000000000..bd637a244 --- /dev/null +++ b/electrum/gui/qt/wizard/server_connect.py @@ -0,0 +1,82 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from PyQt5.QtWidgets import QApplication, QVBoxLayout, QWidget + +from electrum.i18n import _ +from .wizard import QEAbstractWizard, WizardComponent +from electrum.logging import get_logger +from electrum import mnemonic +from electrum.wizard import ServerConnectWizard +from ..util import ChoicesLayout + + +class QEServerConnectWizard(ServerConnectWizard, QEAbstractWizard): + + def __init__(self, config: 'SimpleConfig', app: QApplication, daemon, parent=None): + ServerConnectWizard.__init__(self, daemon) + QEAbstractWizard.__init__(self, config, app, parent) + self._daemon = daemon + + # attach view names + self.navmap_merge({ + 'autoconnect': { 'gui': WCAutoConnect }, + 'proxy_ask': { 'gui': WCProxyAsk }, + 'proxy_config': { 'gui': WCProxyConfig }, + 'server_config': { 'gui': WCServerConfig }, + }) + + +class WCAutoConnect(WizardComponent): + def __init__(self, parent=None): + WizardComponent.__init__(self, parent, title=_("How do you want to connect to a server? ")) + message = _("Electrum communicates with remote servers to get " + "information about your transactions and addresses. The " + "servers all fulfill the same purpose only differing in " + "hardware. In most cases you simply want to let Electrum " + "pick one at random. However if you prefer feel free to " + "select a server manually.") + choices = [_("Auto connect"), _("Select server manually")] + self.clayout = ChoicesLayout(message, choices) + self.clayout.group.buttonClicked.connect(self.on_updated) + self.layout().addLayout(self.clayout.layout()) + self._valid = True + + def apply(self): + r = self.clayout.selected_index() + self.wizard_data['autoconnect'] = (r == 0) + # if r == 1: + # nlayout = NetworkChoiceLayout(network, self.config, wizard=True) + # if self.exec_layout(nlayout.layout()): + # nlayout.accept() + # self.config.NETWORK_AUTO_CONNECT = network.auto_connect + # else: + # network.auto_connect = True + # self.config.NETWORK_AUTO_CONNECT = True + + +class WCProxyAsk(WizardComponent): + def __init__(self, parent=None): + WizardComponent.__init__(self, parent, title=_("Proxy")) + message = _("Do you use a local proxy service such as TOR to reach the internet?") + choices = [_("Yes"), _("No")] + self.clayout = ChoicesLayout(message, choices) + self.layout().addLayout(self.clayout.layout()) + + def apply(self): + r = self.clayout.selected_index() + self.wizard_data['want_proxy'] = (r == 0) + + +class WCProxyConfig(WizardComponent): + def __init__(self, parent=None): + WizardComponent.__init__(self, parent, title=_("Proxy")) + + def apply(self): + pass + + +class WCServerConfig(WizardComponent): + def __init__(self, parent=None): + WizardComponent.__init__(self, parent, title=_("Server")) + + def apply(self): + pass diff --git a/electrum/gui/qt/wizard/wallet.py b/electrum/gui/qt/wizard/wallet.py new file mode 100644 index 000000000..dc94d9421 --- /dev/null +++ b/electrum/gui/qt/wizard/wallet.py @@ -0,0 +1,90 @@ +import os + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from PyQt5.QtQml import QQmlApplicationEngine + +from electrum.logging import get_logger +from electrum import mnemonic +from electrum.wizard import NewWalletWizard, ServerConnectWizard + + +class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): + + createError = pyqtSignal([str], arguments=["error"]) + createSuccess = pyqtSignal() + + def __init__(self, daemon, parent = None): + NewWalletWizard.__init__(self, daemon) + QEAbstractWizard.__init__(self, parent) + self._daemon = daemon + + # attach view names and accept handlers + self.navmap_merge({ + 'wallet_name': { 'gui': 'WCWalletName' }, + 'wallet_type': { 'gui': 'WCWalletType' }, + 'keystore_type': { 'gui': 'WCKeystoreType' }, + 'create_seed': { 'gui': 'WCCreateSeed' }, + 'confirm_seed': { 'gui': 'WCConfirmSeed' }, + 'have_seed': { 'gui': 'WCHaveSeed' }, + 'bip39_refine': { 'gui': 'WCBIP39Refine' }, + 'have_master_key': { 'gui': 'WCHaveMasterKey' }, + 'multisig': { 'gui': 'WCMultisig' }, + 'multisig_cosigner_keystore': { 'gui': 'WCCosignerKeystore' }, + 'multisig_cosigner_key': { 'gui': 'WCHaveMasterKey' }, + 'multisig_cosigner_seed': { 'gui': 'WCHaveSeed' }, + 'multisig_cosigner_bip39_refine': { 'gui': 'WCBIP39Refine' }, + 'imported': { 'gui': 'WCImport' }, + 'wallet_password': { 'gui': 'WCWalletPassword' } + }) + + pathChanged = pyqtSignal() + @pyqtProperty(str, notify=pathChanged) + def path(self): + return self._path + + @path.setter + def path(self, path): + self._path = path + self.pathChanged.emit() + + def is_single_password(self): + return self._daemon.singlePasswordEnabled + + @pyqtSlot('QJSValue', result=bool) + def hasDuplicateMasterKeys(self, js_data): + self._logger.info('Checking for duplicate masterkeys') + data = js_data.toVariant() + return self.has_duplicate_masterkeys(data) + + @pyqtSlot('QJSValue', result=bool) + def hasHeterogeneousMasterKeys(self, js_data): + self._logger.info('Checking for heterogeneous masterkeys') + data = js_data.toVariant() + return self.has_heterogeneous_masterkeys(data) + + @pyqtSlot(str, str, result=bool) + def isMatchingSeed(self, seed, seed_again): + return mnemonic.is_matching_seed(seed=seed, seed_again=seed_again) + + @pyqtSlot('QJSValue', bool, str) + def createStorage(self, js_data, single_password_enabled, single_password): + self._logger.info('Creating wallet from wizard data') + data = js_data.toVariant() + + if single_password_enabled and single_password: + data['encrypt'] = True + data['password'] = single_password + + path = os.path.join(os.path.dirname(self._daemon.daemon.config.get_wallet_path()), data['wallet_name']) + + try: + self.create_storage(path, data) + + # minimally populate self after create + self._password = data['password'] + self.path = path + + self.createSuccess.emit() + except Exception as e: + self._logger.error(f"createStorage errored: {e!r}") + self.createError.emit(str(e)) diff --git a/electrum/gui/qt/wizard/wizard.py b/electrum/gui/qt/wizard/wizard.py new file mode 100644 index 000000000..80e398731 --- /dev/null +++ b/electrum/gui/qt/wizard/wizard.py @@ -0,0 +1,174 @@ +from abc import abstractmethod + +from PyQt5.QtCore import Qt, QVariant, QTimer, pyqtSignal, pyqtSlot +from PyQt5.QtGui import QPixmap +from PyQt5.QtWidgets import (QDialog, QApplication, QPushButton, QWidget, QLabel, QVBoxLayout, QScrollArea, + QHBoxLayout, QLayout, QStackedWidget) + +from electrum.i18n import _ +from ..util import Buttons, icon_path +from electrum.logging import get_logger + + +class QEAbstractWizard(QDialog): + _logger = get_logger(__name__) + + # def __init__(self, config: 'SimpleConfig', app: QApplication, plugins: 'Plugins', *, gui_object: 'ElectrumGui'): + def __init__(self, config: 'SimpleConfig', app: QApplication, daemon): + QDialog.__init__(self, None) + self.app = app + self.config = config + # self.gui_thread = gui_object.gui_thread + self.setMinimumSize(600, 400) + self.title = QLabel() + self.main_widget = QStackedWidget(self) + self.back_button = QPushButton(_("Back"), self) + self.back_button.clicked.connect(self.on_back_button_clicked) + self.next_button = QPushButton(_("Next"), self) + self.next_button.clicked.connect(self.on_next_button_clicked) + self.next_button.setDefault(True) + self.logo = QLabel() + self.please_wait = QLabel(_("Please wait...")) + self.please_wait.setAlignment(Qt.AlignCenter) + self.please_wait.setVisible(False) + self.icon_filename = None + + outer_vbox = QVBoxLayout(self) + inner_vbox = QVBoxLayout() + inner_vbox.addWidget(self.title) + inner_vbox.addWidget(self.main_widget) + inner_vbox.addStretch(1) + inner_vbox.addWidget(self.please_wait) + inner_vbox.addStretch(1) + scroll_widget = QWidget() + scroll_widget.setLayout(inner_vbox) + scroll = QScrollArea() + scroll.setFocusPolicy(Qt.NoFocus) + scroll.setWidget(scroll_widget) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setWidgetResizable(True) + icon_vbox = QVBoxLayout() + icon_vbox.addWidget(self.logo) + icon_vbox.addStretch(1) + hbox = QHBoxLayout() + hbox.addLayout(icon_vbox) + hbox.addSpacing(5) + hbox.addWidget(scroll) + hbox.setStretchFactor(scroll, 1) + outer_vbox.addLayout(hbox) + outer_vbox.addLayout(Buttons(self.back_button, self.next_button)) + self.set_icon('electrum.png') + self.show() + self.raise_() + + QTimer.singleShot(40, self.strt) + + # TODO: re-test if needed on macOS + # self.refresh_gui() # Need for QT on MacOSX. Lame. + + # def refresh_gui(self): + # # For some reason, to refresh the GUI this needs to be called twice + # self.app.processEvents() + # self.app.processEvents() + + def strt(self): + view = self.start_wizard() + self.load_next_component(view) + + def load_next_component(self, view, wdata={}): + comp = self.view_to_component(view) + page = comp(self.main_widget) + page.wizard_data = wdata + page.updated.connect(self.on_page_updated) + self._logger.debug(f'{page!r}') + self.main_widget.setCurrentIndex(self.main_widget.addWidget(page)) + page.apply() + self.update(page.wizard_data) + + @pyqtSlot(object) + def on_page_updated(self, page): + page.apply() + self.update(page.wizard_data) + + def set_icon(self, filename): + prior_filename, self.icon_filename = self.icon_filename, filename + self.logo.setPixmap(QPixmap(icon_path(filename)) + .scaledToWidth(60, mode=Qt.SmoothTransformation)) + return prior_filename + + def can_go_back(self): + return len(self._stack) > 0 + + def update(self, wdata: dict): + self.back_button.setText(_('Back') if self.can_go_back() else _('Cancel')) + self.next_button.setText(_('Next') if not self.is_last(wdata) else _('Finish')) + + def on_back_button_clicked(self): + if self.can_go_back(): + wdata = self.prev() + self.main_widget.removeWidget(self.main_widget.currentWidget()) + self.update(wdata) + else: + self.close() + + def on_next_button_clicked(self): + wc = self.main_widget.currentWidget() + wc.apply() + wd = wc.wizard_data.copy() + if self.is_last(wd): + self.finished(wd) + self.close() + else: + next = self.submit(wd) + self.load_next_component(next['view'], wd) + + def start_wizard(self) -> str: + self.start() + return self._current.view + + def view_to_component(self, view) -> QWidget: + return self.navmap[view]['gui'] + + def submit(self, wizard_data) -> dict: + wdata = wizard_data.copy() + self.log_state(wdata) + view = self.resolve_next(self._current.view, wdata) + return { + 'view': view.view, + 'wizard_data': view.wizard_data + } + + def prev(self) -> dict: + viewstate = self.resolve_prev() + return viewstate.wizard_data + + def is_last(self, wizard_data: dict) -> bool: + wdata = wizard_data.copy() + return self.is_last_view(self._current.view, wdata) + + +### support classes + + +class WizardComponent(QWidget): + updated = pyqtSignal(object) + + def __init__(self, parent: QWidget = None, *, title: str = None, layout: QLayout = None): + super().__init__(parent) + self.setLayout(layout if layout else QVBoxLayout(self)) + self.wizard_data = {} + self.title = title if title is not None else 'No title' + self._valid = False + + @property + def valid(self): + return self._valid + + @abstractmethod + def apply(self): + pass + + @pyqtSlot() + def on_updated(self, *args): + self.updated.emit(self) +