diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index 7f5729d3e..a358980d7 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -150,7 +150,7 @@ class ElectrumGui(BaseElectrumGui, Logger): self.reload_app_stylesheet() # always load 2fa - self.plugins.load_plugin('trustedcoin') + self.plugins.load_internal_plugin('trustedcoin') run_hook('init_qt', self) diff --git a/electrum/gui/qt/plugins_dialog.py b/electrum/gui/qt/plugins_dialog.py index 21f7070c0..09cb4888b 100644 --- a/electrum/gui/qt/plugins_dialog.py +++ b/electrum/gui/qt/plugins_dialog.py @@ -63,15 +63,15 @@ class PluginsDialog(WindowModalDialog): run_hook('init_qt', self.window.gui_object) def show_list(self): - descriptions = self.plugins.descriptions.values() - for i, descr in enumerate(descriptions): - full_name = descr['__name__'] - prefix, _separator, name = full_name.rpartition('.') + descriptions = sorted(self.plugins.descriptions.items()) + i = 0 + for name, descr in descriptions: + i += 1 p = self.plugins.get(name) if descr.get('registers_keystore'): continue try: - cb = QCheckBox(descr['fullname']) + cb = QCheckBox(descr['display_name']) plugin_is_loaded = p is not None cb_enabled = (not plugin_is_loaded and self.plugins.is_available(name, self.wallet) or plugin_is_loaded and p.can_user_disable()) diff --git a/electrum/plugin.py b/electrum/plugin.py index 32ff34757..ed1717844 100644 --- a/electrum/plugin.py +++ b/electrum/plugin.py @@ -27,12 +27,18 @@ import pkgutil import importlib.util import time import threading +import traceback import sys +import json from typing import (NamedTuple, Any, Union, TYPE_CHECKING, Optional, Tuple, Dict, Iterable, List, Sequence, Callable, TypeVar, Mapping, Set) import concurrent +import zipimport from concurrent import futures from functools import wraps, partial +from enum import IntEnum +from packaging.version import parse as parse_version +from electrum.version import ELECTRUM_VERSION from .i18n import _ from .util import (profiler, DaemonThread, UserCancelled, ThreadJob, UserFacingException) @@ -53,6 +59,7 @@ hook_names = set() hooks = {} + class Plugins(DaemonThread): LOGGING_SHORTCUT = 'p' @@ -66,16 +73,21 @@ class Plugins(DaemonThread): self.hw_wallets = {} self.plugins = {} # type: Dict[str, BasePlugin] self.internal_plugin_metadata = {} + self.external_plugin_metadata = {} self.gui_name = gui_name self.device_manager = DeviceMgr(config) + self.user_pkgpath = os.path.join(self.config.electrum_path_root(), 'plugins') + if not os.path.exists(self.user_pkgpath): + os.mkdir(self.user_pkgpath) self.find_internal_plugins() + self.find_external_plugins() self.load_plugins() self.add_jobs(self.device_manager.thread_jobs()) self.start() @property def descriptions(self): - return dict(list(self.internal_plugin_metadata.items())) + return dict(list(self.internal_plugin_metadata.items()) + list(self.external_plugin_metadata.items())) def find_internal_plugins(self) -> Mapping[str, dict]: """Populates self.internal_plugin_metadata @@ -122,15 +134,88 @@ class Plugins(DaemonThread): def load_plugins(self): self.load_internal_plugins() + self.load_external_plugins() def load_internal_plugins(self): for name, d in self.internal_plugin_metadata.items(): - if self.config.get('enable_plugin_' + name) is True: + if not d.get('requires_wallet_type') and self.config.get('enable_plugin_' + name): try: - self.load_plugin(name) + self.load_internal_plugin(name) except BaseException as e: self.logger.exception(f"cannot initialize plugin {name}: {e}") + def load_external_plugin(self, name): + if name in self.plugins: + return self.plugins[name] + # If we do not have the metadata, it was not detected by `load_external_plugins` + # on startup, or added by manual user installation after that point. + metadata = self.external_plugin_metadata.get(name, None) + if metadata is None: + self.logger.exception("attempted to load unknown external plugin %s" % name) + return + + from .crypto import sha256 + external_plugin_dir = self.get_external_plugin_dir() + plugin_file_path = os.path.join(external_plugin_dir, name + '.zip') + if not os.path.exists(plugin_file_path): + return + with open(plugin_file_path, 'rb') as f: + s = f.read() + if sha256(s).hex() != metadata['hash']: + self.logger.exception("wrong hash for plugin '%s'" % plugin_file_path) + return + + try: + zipfile = zipimport.zipimporter(plugin_file_path) + except zipimport.ZipImportError: + self.logger.exception("unable to load zip plugin '%s'" % plugin_file_path) + return + try: + module = zipfile.load_module(name) + except zipimport.ZipImportError as e: + self.logger.exception(f"unable to load zip plugin '{plugin_file_path}' package '{name}'") + return + sys.modules['electrum_external_plugins.'+ name] = module + full_name = f'electrum_external_plugins.{name}.{self.gui_name}' + spec = importlib.util.find_spec(full_name) + if spec is None: + raise RuntimeError("%s implementation for %s plugin not found" + % (self.gui_name, name)) + module = importlib.util.module_from_spec(spec) + self._register_module(spec, module) + if sys.version_info >= (3, 10): + spec.loader.exec_module(module) + else: + module = spec.loader.load_module(full_name) + plugin = module.Plugin(self, self.config, name) + self.add_jobs(plugin.thread_jobs()) + self.plugins[name] = plugin + self.logger.info(f"loaded external plugin {name}") + return plugin + + @staticmethod + def _register_module(spec, module): + # sys.modules needs to be modified for relative imports to work + # see https://stackoverflow.com/a/50395128 + sys.modules[spec.name] = module + + def get_external_plugin_dir(self): + return self.user_pkgpath + + def find_external_plugins(self): + """ read json file """ + from .constants import read_json + self.external_plugin_metadata = read_json('plugins.json', {}) + + def load_external_plugins(self): + for name, d in self.external_plugin_metadata.items(): + if not d.get('requires_wallet_type') and self.config.get('use_' + name): + try: + self.load_external_plugin(name) + except BaseException as e: + traceback.print_exc(file=sys.stdout) # shouldn't this be... suppressed unless -v? + self.logger.exception(f"cannot initialize plugin {name} {e!r}") + def get(self, name): return self.plugins.get(name) @@ -141,9 +226,17 @@ class Plugins(DaemonThread): """Imports the code of the given plugin. note: can be called from any thread. """ + if name in self.internal_plugin_metadata: + return self.load_internal_plugin(name) + elif name in self.external_plugin_metadata: + return self.load_external_plugin(name) + else: + raise Exception() + + def load_internal_plugin(self, name) -> 'BasePlugin': if name in self.plugins: return self.plugins[name] - full_name = f'electrum.plugins.{name}.{self.gui_name}' + full_name = f'electrum.plugins.{name}' + f'.{self.gui_name}' spec = importlib.util.find_spec(full_name) if spec is None: raise RuntimeError("%s implementation for %s plugin not found" diff --git a/electrum/simple_config.py b/electrum/simple_config.py index a8edefc7f..3b85373d5 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -242,14 +242,15 @@ class SimpleConfig(Logger): self.amt_precision_post_satoshi = self.BTC_AMOUNTS_PREC_POST_SAT self.amt_add_thousands_sep = self.BTC_AMOUNTS_ADD_THOUSANDS_SEP - def electrum_path(self): + def electrum_path_root(self): # Read electrum_path from command line # Otherwise use the user's default data directory. - path = self.get('electrum_path') - if path is None: - path = self.user_dir() - + path = self.get('electrum_path') or self.user_dir() make_dir(path, allow_symlink=False) + return path + + def electrum_path(self): + path = self.electrum_path_root() if self.get('testnet'): path = os.path.join(path, 'testnet') make_dir(path, allow_symlink=False)