From 333b3db8ea0c095564297132b285a2164d216b9b Mon Sep 17 00:00:00 2001 From: ThomasV Date: Thu, 9 May 2024 15:38:57 +0200 Subject: [PATCH] Unix: Import external plugins from /opt/electrum_plugins --- contrib/make_plugin | 44 +++------ electrum/gui/qt/plugins_dialog.py | 33 ++----- electrum/plugin.py | 147 +++++++++++++----------------- electrum/plugins.json | 14 --- 4 files changed, 80 insertions(+), 158 deletions(-) delete mode 100644 electrum/plugins.json diff --git a/contrib/make_plugin b/contrib/make_plugin index ea0b0be24..45083fe16 100755 --- a/contrib/make_plugin +++ b/contrib/make_plugin @@ -12,7 +12,10 @@ if len(sys.argv) != 2: print(f"usage: {os.path.basename(__file__)} ", file=sys.stderr) sys.exit(1) + source_dir = sys.argv[1] # where the plugin source code is +if source_dir.endswith('/'): + source_dir = source_dir[:-1] plugin_name = os.path.basename(source_dir) dest_dir = os.path.dirname(source_dir) @@ -31,39 +34,16 @@ with zipfile.ZipFile(zip_path, 'w') as zip_object: zip_object.write(file_path, dest_path) print('added', dest_path) -# read hash -with open(zip_path, 'rb') as f: - s = f.read() -_hash = bytes(hashlib.sha256(s).digest()).hex() - -# read metadata +# read version zip_file = zipimport.zipimporter(zip_path) module = zip_file.load_module(plugin_name) -plugin_metadata = { - 'hash': _hash, - 'description': module.description, - 'display_name': module.fullname, - 'available_for': module.available_for, - 'download_url': module.download_url, - 'author': module.author, - 'licence': module.licence, - 'version': module.version, -} -print(json.dumps(plugin_metadata, indent=4)) - -# update plugins.json file -json_path = os.path.join(os.path.dirname(__file__), '..', 'electrum', 'plugins.json') -with open(json_path, 'r') as f: - s = f.read() -try: - metadata = json.loads(s) -except: - metadata = {} -metadata[plugin_name] = plugin_metadata -with open(json_path, 'w') as f: - f.write(json.dumps(metadata, indent=4)) +if not module.version: + raise Exception('version not set') +versioned_plugin_name = plugin_name + '-' + module.version + '.zip' +zip_path_with_version = os.path.join(dest_dir, versioned_plugin_name) # rename zip file -if module.version: - zip_path_with_version = os.path.join(dest_dir, plugin_name + '-' + module.version + '.zip') - os.rename(zip_path, zip_path_with_version) +os.rename(zip_path, zip_path_with_version) + +print(f'Created {zip_path_with_version}') + diff --git a/electrum/gui/qt/plugins_dialog.py b/electrum/gui/qt/plugins_dialog.py index 5ac1b04dc..9cc9ea21f 100644 --- a/electrum/gui/qt/plugins_dialog.py +++ b/electrum/gui/qt/plugins_dialog.py @@ -43,10 +43,7 @@ class PluginDialog(WindowModalDialog): msg = '\n'.join(map(lambda x: x[1], requires)) form.addRow(QLabel(_('Requires') + ':'), WWLabel(msg)) vbox.addLayout(form) - if name in self.plugins.internal_plugin_metadata: - text = _('Disable') if p else _('Enable') - else: - text = _('Remove') if p else _('Install') + text = _('Disable') if p else _('Enable') toggle_button = QPushButton(text) toggle_button.clicked.connect(partial(self.do_toggle, toggle_button, name)) close_button = CloseButton(self) @@ -56,27 +53,8 @@ class PluginDialog(WindowModalDialog): def do_toggle(self, button, name): button.setEnabled(False) - if name in self.plugins.internal_plugin_metadata: - p = self.plugins.toggle(name) - self.cb.setChecked(bool(p)) - else: - p = self.plugins.get(name) - if not p: - #if not self.window.window.question("Install plugin '%s'?"%name): - # return - coro = self.plugins.download_external_plugin(name) - def on_success(x): - self.plugins.enable(name) - p = self.plugins.get(name) - self.cb.setChecked(bool(p)) - self.window.window.run_coroutine_from_thread(coro, "Downloading '%s' "%name, on_result=on_success) - else: - #if not self.window.window.question("Remove plugin '%s'?"%name): - # return - self.plugins.disable(name) - self.cb.setChecked(False) - self.plugins.remove_external_plugin(name) - + p = self.plugins.toggle(name) + self.cb.setChecked(bool(p)) self.close() self.window.enable_settings_widget(name, self.index) # note: all enabled plugins will receive this hook: @@ -141,12 +119,13 @@ class PluginsDialog(WindowModalDialog): cb.setChecked(plugin_is_loaded and p.is_enabled()) grid.addWidget(cb, i, 0) self.enable_settings_widget(name, i) - cb.clicked.connect(partial(self.show_plugin_dialog, name, metadata, cb, i)) + cb.clicked.connect(partial(self.show_plugin_dialog, name, cb, i)) #grid.setRowStretch(len(descriptions), 1) - def show_plugin_dialog(self, name, metadata, cb, i): + def show_plugin_dialog(self, name, cb, i): p = self.plugins.get(name) + metadata = self.plugins.descriptions[name] cb.setChecked(p is not None and p.is_enabled()) d = PluginDialog(name, metadata, cb, self, i) d.exec() diff --git a/electrum/plugin.py b/electrum/plugin.py index 817242664..81213dd31 100644 --- a/electrum/plugin.py +++ b/electrum/plugin.py @@ -74,9 +74,6 @@ class Plugins(DaemonThread): 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() @@ -101,14 +98,7 @@ class Plugins(DaemonThread): spec = importlib.util.find_spec(full_name) if spec is None: # pkgutil found it but importlib can't ?! raise Exception(f"Error pre-loading {full_name}: no spec") - try: - module = importlib.util.module_from_spec(spec) - # sys.modules needs to be modified for relative imports to work - # see https://stackoverflow.com/a/50395128 - sys.modules[spec.name] = module - spec.loader.exec_module(module) - except Exception as e: - raise Exception(f"Error pre-loading {full_name}: {repr(e)}") from e + module = self.exec_module_from_spec(spec, full_name) d = module.__dict__ if 'fullname' not in d: continue @@ -130,6 +120,20 @@ class Plugins(DaemonThread): raise Exception(f"duplicate plugins? for {name=}") self.internal_plugin_metadata[name] = d + def exec_module_from_spec(self, spec, path): + try: + module = importlib.util.module_from_spec(spec) + # sys.modules needs to be modified for relative imports to work + # see https://stackoverflow.com/a/50395128 + sys.modules[path] = module + if sys.version_info >= (3, 10): + spec.loader.exec_module(module) + else: + module = spec.loader.load_module(path) + except Exception as e: + raise Exception(f"Error pre-loading {path}: {repr(e)}") from e + return module + def load_plugins(self): self.load_internal_plugins() self.load_external_plugins() @@ -142,46 +146,6 @@ class Plugins(DaemonThread): except BaseException as e: self.logger.exception(f"cannot initialize plugin {name}: {e}") - def requires_download(self, name): - metadata = self.external_plugin_metadata.get(name) - if not metadata: - return False - if os.path.exists(self.external_plugin_path(name)): - return False - return True - - def check_plugin_hash(self, name: str)-> bool: - from .crypto import sha256 - metadata = self.external_plugin_metadata.get(name) - filename = self.external_plugin_path(name) - if not os.path.exists(filename): - return False - with open(filename, 'rb') as f: - s = f.read() - if sha256(s).hex() != metadata['hash']: - return False - return True - - async def download_external_plugin(self, name): - metadata = self.external_plugin_metadata.get(name) - if metadata is None: - raise Exception(f"unknown external plugin {name}") - url = metadata['download_url'] - filename = self.external_plugin_path(name) - async with aiohttp.ClientSession() as session: - async with session.get(url) as resp: - if resp.status == 200: - with open(filename, 'wb') as fd: - async for chunk in resp.content.iter_chunked(10): - fd.write(chunk) - if not self.check_plugin_hash(name): - os.unlink(filename) - raise Exception(f"wrong plugin hash {name}") - - def remove_external_plugin(self, name): - filename = self.external_plugin_path(name) - os.unlink(filename) - def load_external_plugin(self, name): if name in self.plugins: return self.plugins[name] @@ -191,56 +155,69 @@ class Plugins(DaemonThread): if metadata is None: self.logger.exception(f"attempted to load unknown external plugin {name}") return - filename = self.external_plugin_path(name) - if not os.path.exists(filename): - return - if not self.check_plugin_hash(name): - self.logger.exception(f"wrong hash for plugin '{name}'") - os.unlink(filename) - return - try: - zipfile = zipimport.zipimporter(filename) - except zipimport.ZipImportError: - self.logger.exception(f"unable to load zip plugin '{filename}'") - return - try: - module = zipfile.load_module(name) - except zipimport.ZipImportError as e: - self.logger.exception(f"unable to load zip plugin '{filename}' 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(f"{self.gui_name} implementation for {name} plugin not found") - 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) + module = self.exec_module_from_spec(spec, 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 _has_root_permissions(self, path): + return os.stat(path).st_uid == 0 and not os.access(path, os.W_OK) def get_external_plugin_dir(self): - return self.user_pkgpath + if sys.platform not in ['linux', 'darwin'] and not sys.platform.startswith('freebsd'): + return + pkg_path = '/opt/electrum_plugins' + if not os.path.exists(pkg_path): + self.logger.info(f'direcctory {pkg_path} does not exist') + return + if not self._has_root_permissions(pkg_path): + self.logger.info(f'not loading {pkg_path}: directory has user write permissions') + return + return pkg_path def external_plugin_path(self, name): - return os.path.join(self.get_external_plugin_dir(), name + '.zip') + metadata = self.external_plugin_metadata[name] + filename = metadata['filename'] + return os.path.join(self.get_external_plugin_dir(), filename) def find_external_plugins(self): - """ read json file """ - from .constants import read_json - self.external_plugin_metadata = read_json('plugins.json', {}) + pkg_path = self.get_external_plugin_dir() + if pkg_path is None: + return + for filename in os.listdir(pkg_path): + path = os.path.join(pkg_path, filename) + if not self._has_root_permissions(path): + self.logger.info(f'not loading {path}: file has user write permissions') + continue + try: + zipfile = zipimport.zipimporter(path) + except zipimport.ZipImportError: + self.logger.exception(f"unable to load zip plugin '{filename}'") + continue + for name, b in pkgutil.iter_zipimport_modules(zipfile): + if b is False: + continue + if name in self.internal_plugin_metadata: + raise Exception(f"duplicate plugins for name={name}") + if name in self.external_plugin_metadata: + raise Exception(f"duplicate plugins for name={name}") + spec = zipfile.find_spec(name) + module = self.exec_module_from_spec(spec, f'electrum_external_plugins.{name}') + d = module.__dict__ + gui_good = self.gui_name in d.get('available_for', []) + if not gui_good: + continue + d['filename'] = filename + if 'fullname' not in d: + continue + d['display_name'] = d['fullname'] + self.external_plugin_metadata[name] = d def load_external_plugins(self): for name, d in self.external_plugin_metadata.items(): diff --git a/electrum/plugins.json b/electrum/plugins.json deleted file mode 100644 index 3e0f5b905..000000000 --- a/electrum/plugins.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "virtualkeyboard": { - "hash": "6c9478657abc7765c067aba7a1d8905a67dad7860b6b5d53f5d4e376fa43eef7", - "description": "Add an optional virtual keyboard to the password dialog.\nWarning: do not use this if it makes you pick a weaker password.", - "display_name": "Virtual Keyboard", - "available_for": [ - "qt" - ], - "download_url": "https://raw.githubusercontent.com/spesmilo/electrum-plugins/master/virtualkeyboard-0.0.1.zip", - "author": "The Electrum developers", - "licence": "MIT", - "version": "0.0.1" - } -}