from typing import TYPE_CHECKING, Optional from functools import partial import shutil import os from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton, QWidget, QScrollArea, \ QFormLayout, QFileDialog, QMenu, QApplication, QMessageBox from PyQt6.QtCore import QTimer from electrum.i18n import _ from electrum.logging import get_logger from .util import (WindowModalDialog, Buttons, CloseButton, WWLabel, insert_spaces, MessageBoxMixin, EnterButton, read_QIcon_from_bytes, IconLabel, RunCoroutineDialog) if TYPE_CHECKING: from . import ElectrumGui from electrum_ecc import ECPrivkey from electrum.simple_config import SimpleConfig from electrum.plugin import Plugins class PluginDialog(WindowModalDialog): def __init__(self, name, metadata, status_button: Optional['PluginStatusButton'], window: 'PluginsDialog'): display_name = metadata.get('fullname', '') author = metadata.get('author', '') description = metadata.get('description', '') requires = metadata.get('requires') version = metadata.get('version') zip_hash = metadata.get('zip_hash_sha256', None) icon_path = metadata.get('icon') WindowModalDialog.__init__(self, window, 'Plugin') self.setMinimumSize(400, 250) self.window = window self.metadata = metadata self.plugins = self.window.plugins self.name = name self.status_button = status_button p = self.plugins.get(name) # is enabled vbox = QVBoxLayout(self) name_label = IconLabel(text=display_name, reverse=True) if icon_path: name_label.icon_size = 64 icon = read_QIcon_from_bytes(self.plugins.read_file(name, icon_path)) name_label.setIcon(icon) vbox.addWidget(name_label) vbox.addStretch() vbox.addWidget(WWLabel(description)) vbox.addStretch() form = QFormLayout(None) if author: form.addRow(QLabel(_('Author') + ':'), QLabel(author)) if version: form.addRow(QLabel(_('Version') + ':'), QLabel(version)) if zip_hash: form.addRow(QLabel('Hash [sha256]:'), WWLabel(insert_spaces(zip_hash, 8))) if requires: msg = '\n'.join(map(lambda x: x[1], requires)) form.addRow(QLabel(_('Requires') + ':'), WWLabel(msg)) vbox.addLayout(form) vbox.addStretch() close_button = CloseButton(self) close_button.setText(_('Close')) buttons = [close_button] p = self.plugins.get(name) is_enabled = p and p.is_enabled() is_external = self.plugins.is_external(name) if is_external: is_authorized = self.plugins.is_authorized(name) if status_button is not None: # status_button is None when called from add_external_plugin remove_button = QPushButton('') remove_button.clicked.connect(self.do_remove) remove_button.setText(_('Remove')) buttons.insert(0, remove_button) if not is_authorized: auth_button = QPushButton('Authorize') auth_button.clicked.connect(self.do_authorize) buttons.insert(0, auth_button) else: toggle_button = QPushButton('') toggle_button.setText(_('Disable') if is_enabled else _('Enable')) toggle_button.clicked.connect(self.do_toggle) buttons.insert(0, toggle_button) # add settings button if p and p.requires_settings() and p.is_enabled(): settings_button = EnterButton( _('Settings'), partial(p.settings_dialog, self)) buttons.insert(1, settings_button) # add buttons vbox.addLayout(Buttons(*buttons)) def do_toggle(self): self.close() self.window.do_toggle(self.name, self.status_button) def do_remove(self): self.window.uninstall_plugin(self.name) self.close() def do_authorize(self): assert not self.plugins.is_authorized(self.name) privkey = self.window.get_plugins_privkey() if not privkey: return filename = self.plugins.zip_plugin_path(self.name) self.window.plugins.authorize_plugin(self.name, filename, privkey) self.window.plugins.enable(self.name) if self.status_button: self.status_button.update() self.accept() class PluginStatusButton(QPushButton): def __init__(self, window: 'PluginsDialog', name: str): QPushButton.__init__(self, '') self.window = window self.plugins = window.plugins self.name = name self.clicked.connect(self.show_plugin_dialog) self.update() def show_plugin_dialog(self): metadata = self.plugins.descriptions[self.name] d = PluginDialog(self.name, metadata, self, self.window) d.exec() def update(self): from .util import ColorScheme p = self.plugins.get(self.name) plugin_is_loaded = p is not None enabled = not plugin_is_loaded or (plugin_is_loaded and p.can_user_disable()) self.setEnabled(enabled) if p is not None and p.is_enabled(): text, color = _('Enabled'), ColorScheme.BLUE else: text, color = _('Disabled'), ColorScheme.RED self.setStyleSheet(color.as_stylesheet()) self.setText(text) class PluginsDialog(WindowModalDialog, MessageBoxMixin): _logger = get_logger(__name__) def __init__(self, config: 'SimpleConfig', plugins: 'Plugins', *, gui_object: Optional['ElectrumGui'] = None): WindowModalDialog.__init__(self, None, _('Electrum Plugins')) self.gui_object = gui_object self.config = config self.plugins = plugins vbox = QVBoxLayout(self) scroll = QScrollArea() scroll.setEnabled(True) scroll.setWidgetResizable(True) scroll.setMinimumSize(400, 250) scroll_w = QWidget() scroll.setWidget(scroll_w) self.grid = QGridLayout() self.grid.setColumnStretch(0, 1) scroll_w.setLayout(self.grid) vbox.addWidget(scroll) add_button = QPushButton(_('Add')) add_button.setMinimumWidth(40) # looks better on windows, no difference on linux menu = QMenu() menu.addAction(_('Local ZIP file'), self.add_plugin_dialog) menu.addAction(_('Download ZIP file'), self.download_plugin_dialog) add_button.setMenu(menu) vbox.addLayout(Buttons(add_button, CloseButton(self))) self.show_list() def get_plugins_privkey(self) -> Optional['ECPrivkey']: pubkey, salt = self.plugins.get_pubkey_bytes() if not pubkey: self.init_plugins_password() return None # ask for url and password, same window pw = self.password_dialog( msg=' '.join([ _('Warning: Third-party plugins are not endorsed by Electrum!'), '

', _('If you install a third-party plugin, you trust the software not to be malicious.'), _('Electrum will not be responsible in case of theft, loss of funds or privacy that might result from third-party plugins.'), _('You should at minimum check who the author of the plugin is, and be careful of imposters.'), '

', _('Please enter your plugin authorization password') + ':' ]) ) if not pw: return None privkey = self.plugins.derive_privkey(pw, salt) if pubkey != privkey.get_public_key_bytes(): keyfile_path, _keyfile_help = self.plugins.get_keyfile_path(None) while True: exit_dialog = True auto_reset_btn = QPushButton(_('Try Auto-Reset')) def on_try_auto_reset_clicked(): nonlocal exit_dialog if not self.plugins.try_auto_key_reset(): self.show_error(_("Auto-Reset not possible. Delete the file manually.")) exit_dialog = False else: self.show_message(_("Auto-Reset successful. You can now setup a new password.")) auto_reset_btn.clicked.connect(on_try_auto_reset_clicked) buttons = [ QMessageBox.StandardButton.Ok, (auto_reset_btn, QMessageBox.ButtonRole.ActionRole, 0), ] if self.show_error( ''.join([ _('Incorrect password.'), '\n\n', _('Your plugin authorization password is required to install plugins.'), ' ', _('If you need to reset it, remove the following file:'), '\n\n', keyfile_path ]), buttons=buttons ) or exit_dialog: break return None return privkey def init_plugins_password(self): from .password_dialog import NewPasswordDialog msg = ' '.join([ _('In order to install third-party plugins, you need to choose a plugin authorization password.'), _('Its purpose is to prevent unauthorized users (or malware) from installing plugins.'), ]) d = NewPasswordDialog(self, msg=msg) pw = d.run() if not pw: return key_hex = self.plugins.create_new_key(pw) keyfile_path, keyfile_help = self.plugins.get_keyfile_path(key_hex) msg = '\n\n'.join([ _('Your plugins key is:'), key_hex, _('This key has been copied to your clipboard. Please save it in:'), keyfile_path, keyfile_help, '', ]) clipboard = QApplication.clipboard() clipboard.setText(key_hex) while True: exit_dialog = True # the button has to be recreated inside the loop, as qt destroys it when the dialog is closed auto_setup_btn = QPushButton(_('Try Auto-Setup')) def on_auto_setup_clicked(): nonlocal exit_dialog if not self.plugins.try_auto_key_setup(key_hex): self.show_error(_("Auto-Setup not possible. Try the manual setup.")) exit_dialog = False else: self.show_message(_("Auto-Setup successful. You can now install plugins.")) auto_setup_btn.clicked.connect(on_auto_setup_clicked) # on windows, the auto-setup button is shown right of the ok button, # apparently due to OS conventions buttons = [ (auto_setup_btn, QMessageBox.ButtonRole.ActionRole, 0), QMessageBox.StandardButton.Ok, ] if self.show_message(msg, buttons=buttons) or exit_dialog: break def download_plugin_dialog(self): from .util import line_dialog from electrum.util import UserCancelled pubkey, salt = self.plugins.get_pubkey_bytes() if not pubkey: self.init_plugins_password() return url = line_dialog(self, 'url', _('Enter plugin URL'), _('Download')) if not url: return coro = self.plugins.download_external_plugin(url) try: d = RunCoroutineDialog(self, _("Downloading plugin..."), coro) path = d.run() except UserCancelled: return except Exception as e: self._logger.exception("") self.show_error(f"{e}") return self._try_add_external_plugin_from_path(path) def add_plugin_dialog(self): pubkey, salt = self.plugins.get_pubkey_bytes() if not pubkey: self.init_plugins_password() return filename, __ = QFileDialog.getOpenFileName(self, _("Select your plugin zipfile"), "", "*.zip") if not filename: return plugins_dir = self.plugins.get_external_plugin_dir() path = os.path.join(plugins_dir, os.path.basename(filename)) if os.path.exists(path): self.show_warning(_('Plugin already installed.')) return shutil.copyfile(filename, path) self._try_add_external_plugin_from_path(path) def _try_add_external_plugin_from_path(self, path: str): try: success = self.add_external_plugin(path) except Exception as e: self._logger.exception("") self.show_error(f"{e}") success = False if not success: try: os.unlink(path) except FileNotFoundError: self._logger.debug("", exc_info=True) def add_external_plugin(self, path): manifest = self.plugins.read_manifest(path) name = manifest['name'] self.plugins.external_plugin_metadata[name] = manifest d = PluginDialog(name, manifest, None, self) if not d.exec(): self.plugins.external_plugin_metadata.pop(name) return False if self.gui_object: self.gui_object.reload_windows() self.show_list() return True def show_list(self): descriptions = self.plugins.descriptions descriptions = sorted(descriptions.items()) grid = self.grid # clear existing items for i in reversed(range(grid.count())): grid.itemAt(i).widget().setParent(None) # populate i = 0 for name, metadata in descriptions: i += 1 if self.plugins.is_internal(name) and self.plugins.is_auto_loaded(name): continue display_name = metadata.get('fullname') if not display_name: continue label = IconLabel(text=display_name, reverse=True) icon_path = metadata.get('icon') if icon_path: icon = read_QIcon_from_bytes(self.plugins.read_file(name, icon_path)) label.setIcon(icon) label.status_button = PluginStatusButton(self, name) grid.addWidget(label, i, 0) grid.addWidget(label.status_button, i, 1) # add stretch grid.setRowStretch(i + 1, 1) def do_toggle(self, name, status_button): p = self.plugins.get(name) is_enabled = p and p.is_enabled() if is_enabled: self.plugins.disable(name) else: self.plugins.enable(name) if status_button: status_button.update() if self.gui_object: self.gui_object.reload_windows() self.bring_to_front() def uninstall_plugin(self, name): if not self.question(_('Remove plugin \'{}\'?').format(name)): return self.plugins.uninstall(name) if self.gui_object: self.gui_object.reload_windows() self.show_list() self.bring_to_front() def bring_to_front(self): def _bring_self_to_front(): self.activateWindow() self.setFocus() QTimer.singleShot(100, _bring_self_to_front)