Files
pallectrum/electrum/gui/qt/plugins_dialog.py
ThomasV 2607a0d9f6 Plugins dialog: remove direct download option.
Since users have to trust plugin publishers, we should expect
the plugin to be signed by its author, and the user to verify
the signature before installing.
2025-06-21 10:10:31 +02:00

367 lines
14 KiB
Python

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.gui import messages
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('Install')
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):
if not self.plugins.is_available(self.name):
msg = "\n".join([
_('This plugin requires installation of additional dependencies.'),
_('For Electrum to recognize external packages, you need to run it from source.')
])
self.window.show_message(msg)
return
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)
d = self.plugins.get_metadata(self.name)
if details := d.get('registers_keystore'):
self.plugins.register_keystore(self.name, details)
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
add_button.clicked.connect(self.add_plugin_dialog)
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=messages.MSG_THIRD_PARTY_PLUGIN_WARNING)
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 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)