Skip to content

Commit

Permalink
Allow external plugins
Browse files Browse the repository at this point in the history
 - borrows code brom ElectronCash
 - external plugins are imported as zip files
 - check hash from plugins.json file
  • Loading branch information
ecdsa committed Apr 13, 2024
1 parent 3e7d474 commit 858d999
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 15 deletions.
2 changes: 1 addition & 1 deletion electrum/gui/qt/__init__.py
Expand Up @@ -150,7 +150,7 @@ def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugin
self.reload_app_stylesheet()

# always load 2fa
self.plugins.load_plugin('trustedcoin')
self.plugins.load_internal_plugin('trustedcoin')

run_hook('init_qt', self)

Expand Down
10 changes: 5 additions & 5 deletions electrum/gui/qt/plugins_dialog.py
Expand Up @@ -63,15 +63,15 @@ def do_toggle(self, cb, name, i):
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())
Expand Down
101 changes: 97 additions & 4 deletions electrum/plugin.py
Expand Up @@ -27,12 +27,18 @@
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)
Expand All @@ -53,6 +59,7 @@
hooks = {}



class Plugins(DaemonThread):

LOGGING_SHORTCUT = 'p'
Expand All @@ -66,16 +73,21 @@ def __init__(self, config: SimpleConfig, gui_name):
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
Expand Down Expand Up @@ -122,15 +134,88 @@ def find_internal_plugins(self) -> Mapping[str, dict]:

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)

Expand All @@ -141,9 +226,17 @@ def load_plugin(self, name) -> 'BasePlugin':
"""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"
Expand Down
11 changes: 6 additions & 5 deletions electrum/simple_config.py
Expand Up @@ -242,14 +242,15 @@ def __init__(self, options=None, read_user_config_function=None,
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)
Expand Down

0 comments on commit 858d999

Please sign in to comment.