electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

plugin.py (29046B)


      1 #!/usr/bin/env python
      2 #
      3 # Electrum - lightweight Bitcoin client
      4 # Copyright (C) 2015 Thomas Voegtlin
      5 #
      6 # Permission is hereby granted, free of charge, to any person
      7 # obtaining a copy of this software and associated documentation files
      8 # (the "Software"), to deal in the Software without restriction,
      9 # including without limitation the rights to use, copy, modify, merge,
     10 # publish, distribute, sublicense, and/or sell copies of the Software,
     11 # and to permit persons to whom the Software is furnished to do so,
     12 # subject to the following conditions:
     13 #
     14 # The above copyright notice and this permission notice shall be
     15 # included in all copies or substantial portions of the Software.
     16 #
     17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
     18 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
     19 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
     20 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
     21 # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
     22 # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
     23 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
     24 # SOFTWARE.
     25 import os
     26 import pkgutil
     27 import importlib.util
     28 import time
     29 import threading
     30 import sys
     31 from typing import (NamedTuple, Any, Union, TYPE_CHECKING, Optional, Tuple,
     32                     Dict, Iterable, List, Sequence, Callable, TypeVar)
     33 import concurrent
     34 from concurrent import futures
     35 from functools import wraps, partial
     36 
     37 from .i18n import _
     38 from .util import (profiler, DaemonThread, UserCancelled, ThreadJob, UserFacingException)
     39 from . import bip32
     40 from . import plugins
     41 from .simple_config import SimpleConfig
     42 from .logging import get_logger, Logger
     43 
     44 if TYPE_CHECKING:
     45     from .plugins.hw_wallet import HW_PluginBase, HardwareClientBase, HardwareHandlerBase
     46     from .keystore import Hardware_KeyStore
     47     from .wallet import Abstract_Wallet
     48 
     49 
     50 _logger = get_logger(__name__)
     51 plugin_loaders = {}
     52 hook_names = set()
     53 hooks = {}
     54 
     55 
     56 class Plugins(DaemonThread):
     57 
     58     LOGGING_SHORTCUT = 'p'
     59 
     60     @profiler
     61     def __init__(self, config: SimpleConfig, gui_name):
     62         DaemonThread.__init__(self)
     63         self.setName('Plugins')
     64         self.pkgpath = os.path.dirname(plugins.__file__)
     65         self.config = config
     66         self.hw_wallets = {}
     67         self.plugins = {}  # type: Dict[str, BasePlugin]
     68         self.gui_name = gui_name
     69         self.descriptions = {}
     70         self.device_manager = DeviceMgr(config)
     71         self.load_plugins()
     72         self.add_jobs(self.device_manager.thread_jobs())
     73         self.start()
     74 
     75     def load_plugins(self):
     76         for loader, name, ispkg in pkgutil.iter_modules([self.pkgpath]):
     77             full_name = f'electrum.plugins.{name}'
     78             spec = importlib.util.find_spec(full_name)
     79             if spec is None:  # pkgutil found it but importlib can't ?!
     80                 raise Exception(f"Error pre-loading {full_name}: no spec")
     81             try:
     82                 module = importlib.util.module_from_spec(spec)
     83                 # sys.modules needs to be modified for relative imports to work
     84                 # see https://stackoverflow.com/a/50395128
     85                 sys.modules[spec.name] = module
     86                 spec.loader.exec_module(module)
     87             except Exception as e:
     88                 raise Exception(f"Error pre-loading {full_name}: {repr(e)}") from e
     89             d = module.__dict__
     90             gui_good = self.gui_name in d.get('available_for', [])
     91             if not gui_good:
     92                 continue
     93             details = d.get('registers_wallet_type')
     94             if details:
     95                 self.register_wallet_type(name, gui_good, details)
     96             details = d.get('registers_keystore')
     97             if details:
     98                 self.register_keystore(name, gui_good, details)
     99             self.descriptions[name] = d
    100             if not d.get('requires_wallet_type') and self.config.get('use_' + name):
    101                 try:
    102                     self.load_plugin(name)
    103                 except BaseException as e:
    104                     self.logger.exception(f"cannot initialize plugin {name}: {e}")
    105 
    106     def get(self, name):
    107         return self.plugins.get(name)
    108 
    109     def count(self):
    110         return len(self.plugins)
    111 
    112     def load_plugin(self, name) -> 'BasePlugin':
    113         if name in self.plugins:
    114             return self.plugins[name]
    115         full_name = f'electrum.plugins.{name}.{self.gui_name}'
    116         spec = importlib.util.find_spec(full_name)
    117         if spec is None:
    118             raise RuntimeError("%s implementation for %s plugin not found"
    119                                % (self.gui_name, name))
    120         try:
    121             module = importlib.util.module_from_spec(spec)
    122             spec.loader.exec_module(module)
    123             plugin = module.Plugin(self, self.config, name)
    124         except Exception as e:
    125             raise Exception(f"Error loading {name} plugin: {repr(e)}") from e
    126         self.add_jobs(plugin.thread_jobs())
    127         self.plugins[name] = plugin
    128         self.logger.info(f"loaded {name}")
    129         return plugin
    130 
    131     def close_plugin(self, plugin):
    132         self.remove_jobs(plugin.thread_jobs())
    133 
    134     def enable(self, name: str) -> 'BasePlugin':
    135         self.config.set_key('use_' + name, True, True)
    136         p = self.get(name)
    137         if p:
    138             return p
    139         return self.load_plugin(name)
    140 
    141     def disable(self, name: str) -> None:
    142         self.config.set_key('use_' + name, False, True)
    143         p = self.get(name)
    144         if not p:
    145             return
    146         self.plugins.pop(name)
    147         p.close()
    148         self.logger.info(f"closed {name}")
    149 
    150     def toggle(self, name: str) -> Optional['BasePlugin']:
    151         p = self.get(name)
    152         return self.disable(name) if p else self.enable(name)
    153 
    154     def is_available(self, name: str, wallet: 'Abstract_Wallet') -> bool:
    155         d = self.descriptions.get(name)
    156         if not d:
    157             return False
    158         deps = d.get('requires', [])
    159         for dep, s in deps:
    160             try:
    161                 __import__(dep)
    162             except ImportError as e:
    163                 self.logger.warning(f'Plugin {name} unavailable: {repr(e)}')
    164                 return False
    165         requires = d.get('requires_wallet_type', [])
    166         return not requires or wallet.wallet_type in requires
    167 
    168     def get_hardware_support(self):
    169         out = []
    170         for name, (gui_good, details) in self.hw_wallets.items():
    171             if gui_good:
    172                 try:
    173                     p = self.get_plugin(name)
    174                     if p.is_enabled():
    175                         out.append(HardwarePluginToScan(name=name,
    176                                                         description=details[2],
    177                                                         plugin=p,
    178                                                         exception=None))
    179                 except Exception as e:
    180                     self.logger.exception(f"cannot load plugin for: {name}")
    181                     out.append(HardwarePluginToScan(name=name,
    182                                                     description=details[2],
    183                                                     plugin=None,
    184                                                     exception=e))
    185         return out
    186 
    187     def register_wallet_type(self, name, gui_good, wallet_type):
    188         from .wallet import register_wallet_type, register_constructor
    189         self.logger.info(f"registering wallet type {(wallet_type, name)}")
    190         def loader():
    191             plugin = self.get_plugin(name)
    192             register_constructor(wallet_type, plugin.wallet_class)
    193         register_wallet_type(wallet_type)
    194         plugin_loaders[wallet_type] = loader
    195 
    196     def register_keystore(self, name, gui_good, details):
    197         from .keystore import register_keystore
    198         def dynamic_constructor(d):
    199             return self.get_plugin(name).keystore_class(d)
    200         if details[0] == 'hardware':
    201             self.hw_wallets[name] = (gui_good, details)
    202             self.logger.info(f"registering hardware {name}: {details}")
    203             register_keystore(details[1], dynamic_constructor)
    204 
    205     def get_plugin(self, name: str) -> 'BasePlugin':
    206         if name not in self.plugins:
    207             self.load_plugin(name)
    208         return self.plugins[name]
    209 
    210     def run(self):
    211         while self.is_running():
    212             time.sleep(0.1)
    213             self.run_jobs()
    214         self.on_stop()
    215 
    216 
    217 def hook(func):
    218     hook_names.add(func.__name__)
    219     return func
    220 
    221 def run_hook(name, *args):
    222     results = []
    223     f_list = hooks.get(name, [])
    224     for p, f in f_list:
    225         if p.is_enabled():
    226             try:
    227                 r = f(*args)
    228             except Exception:
    229                 _logger.exception(f"Plugin error. plugin: {p}, hook: {name}")
    230                 r = False
    231             if r:
    232                 results.append(r)
    233 
    234     if results:
    235         assert len(results) == 1, results
    236         return results[0]
    237 
    238 
    239 class BasePlugin(Logger):
    240 
    241     def __init__(self, parent, config: 'SimpleConfig', name):
    242         self.parent = parent  # type: Plugins  # The plugins object
    243         self.name = name
    244         self.config = config
    245         self.wallet = None
    246         Logger.__init__(self)
    247         # add self to hooks
    248         for k in dir(self):
    249             if k in hook_names:
    250                 l = hooks.get(k, [])
    251                 l.append((self, getattr(self, k)))
    252                 hooks[k] = l
    253 
    254     def __str__(self):
    255         return self.name
    256 
    257     def close(self):
    258         # remove self from hooks
    259         for attr_name in dir(self):
    260             if attr_name in hook_names:
    261                 # found attribute in self that is also the name of a hook
    262                 l = hooks.get(attr_name, [])
    263                 try:
    264                     l.remove((self, getattr(self, attr_name)))
    265                 except ValueError:
    266                     # maybe attr name just collided with hook name and was not hook
    267                     continue
    268                 hooks[attr_name] = l
    269         self.parent.close_plugin(self)
    270         self.on_close()
    271 
    272     def on_close(self):
    273         pass
    274 
    275     def requires_settings(self) -> bool:
    276         return False
    277 
    278     def thread_jobs(self):
    279         return []
    280 
    281     def is_enabled(self):
    282         return self.is_available() and self.config.get('use_'+self.name) is True
    283 
    284     def is_available(self):
    285         return True
    286 
    287     def can_user_disable(self):
    288         return True
    289 
    290     def settings_widget(self, window):
    291         raise NotImplementedError()
    292 
    293     def settings_dialog(self, window):
    294         raise NotImplementedError()
    295 
    296 
    297 class DeviceUnpairableError(UserFacingException): pass
    298 class HardwarePluginLibraryUnavailable(Exception): pass
    299 class CannotAutoSelectDevice(Exception): pass
    300 
    301 
    302 class Device(NamedTuple):
    303     path: Union[str, bytes]
    304     interface_number: int
    305     id_: str
    306     product_key: Any   # when using hid, often Tuple[int, int]
    307     usage_page: int
    308     transport_ui_string: str
    309 
    310 
    311 class DeviceInfo(NamedTuple):
    312     device: Device
    313     label: Optional[str] = None
    314     initialized: Optional[bool] = None
    315     exception: Optional[Exception] = None
    316     plugin_name: Optional[str] = None  # manufacturer, e.g. "trezor"
    317     soft_device_id: Optional[str] = None  # if available, used to distinguish same-type hw devices
    318     model_name: Optional[str] = None  # e.g. "Ledger Nano S"
    319 
    320 
    321 class HardwarePluginToScan(NamedTuple):
    322     name: str
    323     description: str
    324     plugin: Optional['HW_PluginBase']
    325     exception: Optional[Exception]
    326 
    327 
    328 PLACEHOLDER_HW_CLIENT_LABELS = {None, "", " "}
    329 
    330 
    331 # hidapi is not thread-safe
    332 # see https://github.com/signal11/hidapi/issues/205#issuecomment-527654560
    333 #     https://github.com/libusb/hidapi/issues/45
    334 #     https://github.com/signal11/hidapi/issues/45#issuecomment-4434598
    335 #     https://github.com/signal11/hidapi/pull/414#issuecomment-445164238
    336 # It is not entirely clear to me, exactly what is safe and what isn't, when
    337 # using multiple threads...
    338 # Hence, we use a single thread for all device communications, including
    339 # enumeration. Everything that uses hidapi, libusb, etc, MUST run on
    340 # the following thread:
    341 _hwd_comms_executor = concurrent.futures.ThreadPoolExecutor(
    342     max_workers=1,
    343     thread_name_prefix='hwd_comms_thread'
    344 )
    345 
    346 
    347 T = TypeVar('T')
    348 
    349 
    350 def run_in_hwd_thread(func: Callable[[], T]) -> T:
    351     if threading.current_thread().name.startswith("hwd_comms_thread"):
    352         return func()
    353     else:
    354         fut = _hwd_comms_executor.submit(func)
    355         return fut.result()
    356         #except (concurrent.futures.CancelledError, concurrent.futures.TimeoutError) as e:
    357 
    358 
    359 def runs_in_hwd_thread(func):
    360     @wraps(func)
    361     def wrapper(*args, **kwargs):
    362         return run_in_hwd_thread(partial(func, *args, **kwargs))
    363     return wrapper
    364 
    365 
    366 def assert_runs_in_hwd_thread():
    367     if not threading.current_thread().name.startswith("hwd_comms_thread"):
    368         raise Exception("must only be called from HWD communication thread")
    369 
    370 
    371 class DeviceMgr(ThreadJob):
    372     '''Manages hardware clients.  A client communicates over a hardware
    373     channel with the device.
    374 
    375     In addition to tracking device HID IDs, the device manager tracks
    376     hardware wallets and manages wallet pairing.  A HID ID may be
    377     paired with a wallet when it is confirmed that the hardware device
    378     matches the wallet, i.e. they have the same master public key.  A
    379     HID ID can be unpaired if e.g. it is wiped.
    380 
    381     Because of hotplugging, a wallet must request its client
    382     dynamically each time it is required, rather than caching it
    383     itself.
    384 
    385     The device manager is shared across plugins, so just one place
    386     does hardware scans when needed.  By tracking HID IDs, if a device
    387     is plugged into a different port the wallet is automatically
    388     re-paired.
    389 
    390     Wallets are informed on connect / disconnect events.  It must
    391     implement connected(), disconnected() callbacks.  Being connected
    392     implies a pairing.  Callbacks can happen in any thread context,
    393     and we do them without holding the lock.
    394 
    395     Confusingly, the HID ID (serial number) reported by the HID system
    396     doesn't match the device ID reported by the device itself.  We use
    397     the HID IDs.
    398 
    399     This plugin is thread-safe.  Currently only devices supported by
    400     hidapi are implemented.'''
    401 
    402     def __init__(self, config: SimpleConfig):
    403         ThreadJob.__init__(self)
    404         # Keyed by xpub.  The value is the device id
    405         # has been paired, and None otherwise. Needs self.lock.
    406         self.xpub_ids = {}  # type: Dict[str, str]
    407         # A list of clients.  The key is the client, the value is
    408         # a (path, id_) pair. Needs self.lock.
    409         self.clients = {}  # type: Dict[HardwareClientBase, Tuple[Union[str, bytes], str]]
    410         # What we recognise.  (vendor_id, product_id) -> Plugin
    411         self._recognised_hardware = {}  # type: Dict[Tuple[int, int], HW_PluginBase]
    412         self._recognised_vendor = {}  # type: Dict[int, HW_PluginBase]  # vendor_id -> Plugin
    413         # Custom enumerate functions for devices we don't know about.
    414         self._enumerate_func = set()  # Needs self.lock.
    415 
    416         self.lock = threading.RLock()
    417 
    418         self.config = config
    419 
    420     def thread_jobs(self):
    421         # Thread job to handle device timeouts
    422         return [self]
    423 
    424     def run(self):
    425         '''Handle device timeouts.  Runs in the context of the Plugins
    426         thread.'''
    427         with self.lock:
    428             clients = list(self.clients.keys())
    429         cutoff = time.time() - self.config.get_session_timeout()
    430         for client in clients:
    431             client.timeout(cutoff)
    432 
    433     def register_devices(self, device_pairs, *, plugin: 'HW_PluginBase'):
    434         for pair in device_pairs:
    435             self._recognised_hardware[pair] = plugin
    436 
    437     def register_vendor_ids(self, vendor_ids: Iterable[int], *, plugin: 'HW_PluginBase'):
    438         for vendor_id in vendor_ids:
    439             self._recognised_vendor[vendor_id] = plugin
    440 
    441     def register_enumerate_func(self, func):
    442         with self.lock:
    443             self._enumerate_func.add(func)
    444 
    445     @runs_in_hwd_thread
    446     def create_client(self, device: 'Device', handler: Optional['HardwareHandlerBase'],
    447                       plugin: 'HW_PluginBase') -> Optional['HardwareClientBase']:
    448         # Get from cache first
    449         client = self._client_by_id(device.id_)
    450         if client:
    451             return client
    452         client = plugin.create_client(device, handler)
    453         if client:
    454             self.logger.info(f"Registering {client}")
    455             with self.lock:
    456                 self.clients[client] = (device.path, device.id_)
    457         return client
    458 
    459     def xpub_id(self, xpub):
    460         with self.lock:
    461             return self.xpub_ids.get(xpub)
    462 
    463     def xpub_by_id(self, id_):
    464         with self.lock:
    465             for xpub, xpub_id in self.xpub_ids.items():
    466                 if xpub_id == id_:
    467                     return xpub
    468             return None
    469 
    470     def unpair_xpub(self, xpub):
    471         with self.lock:
    472             if xpub not in self.xpub_ids:
    473                 return
    474             _id = self.xpub_ids.pop(xpub)
    475         self._close_client(_id)
    476 
    477     def unpair_id(self, id_):
    478         xpub = self.xpub_by_id(id_)
    479         if xpub:
    480             self.unpair_xpub(xpub)
    481         else:
    482             self._close_client(id_)
    483 
    484     def _close_client(self, id_):
    485         with self.lock:
    486             client = self._client_by_id(id_)
    487             self.clients.pop(client, None)
    488         if client:
    489             client.close()
    490 
    491     def pair_xpub(self, xpub, id_):
    492         with self.lock:
    493             self.xpub_ids[xpub] = id_
    494 
    495     def _client_by_id(self, id_) -> Optional['HardwareClientBase']:
    496         with self.lock:
    497             for client, (path, client_id) in self.clients.items():
    498                 if client_id == id_:
    499                     return client
    500         return None
    501 
    502     def client_by_id(self, id_, *, scan_now: bool = True) -> Optional['HardwareClientBase']:
    503         '''Returns a client for the device ID if one is registered.  If
    504         a device is wiped or in bootloader mode pairing is impossible;
    505         in such cases we communicate by device ID and not wallet.'''
    506         if scan_now:
    507             self.scan_devices()
    508         return self._client_by_id(id_)
    509 
    510     @runs_in_hwd_thread
    511     def client_for_keystore(self, plugin: 'HW_PluginBase', handler: Optional['HardwareHandlerBase'],
    512                             keystore: 'Hardware_KeyStore',
    513                             force_pair: bool, *,
    514                             devices: Sequence['Device'] = None,
    515                             allow_user_interaction: bool = True) -> Optional['HardwareClientBase']:
    516         self.logger.info("getting client for keystore")
    517         if handler is None:
    518             raise Exception(_("Handler not found for") + ' ' + plugin.name + '\n' + _("A library is probably missing."))
    519         handler.update_status(False)
    520         if devices is None:
    521             devices = self.scan_devices()
    522         xpub = keystore.xpub
    523         derivation = keystore.get_derivation_prefix()
    524         assert derivation is not None
    525         client = self.client_by_xpub(plugin, xpub, handler, devices)
    526         if client is None and force_pair:
    527             try:
    528                 info = self.select_device(plugin, handler, keystore, devices,
    529                                           allow_user_interaction=allow_user_interaction)
    530             except CannotAutoSelectDevice:
    531                 pass
    532             else:
    533                 client = self.force_pair_xpub(plugin, handler, info, xpub, derivation)
    534         if client:
    535             handler.update_status(True)
    536         if client:
    537             # note: if select_device was called, we might also update label etc here:
    538             keystore.opportunistically_fill_in_missing_info_from_device(client)
    539         self.logger.info("end client for keystore")
    540         return client
    541 
    542     def client_by_xpub(self, plugin: 'HW_PluginBase', xpub, handler: 'HardwareHandlerBase',
    543                        devices: Sequence['Device']) -> Optional['HardwareClientBase']:
    544         _id = self.xpub_id(xpub)
    545         client = self._client_by_id(_id)
    546         if client:
    547             # An unpaired client might have another wallet's handler
    548             # from a prior scan.  Replace to fix dialog parenting.
    549             client.handler = handler
    550             return client
    551 
    552         for device in devices:
    553             if device.id_ == _id:
    554                 return self.create_client(device, handler, plugin)
    555 
    556     def force_pair_xpub(self, plugin: 'HW_PluginBase', handler: 'HardwareHandlerBase',
    557                         info: 'DeviceInfo', xpub, derivation) -> Optional['HardwareClientBase']:
    558         # The wallet has not been previously paired, so let the user
    559         # choose an unpaired device and compare its first address.
    560         xtype = bip32.xpub_type(xpub)
    561         client = self._client_by_id(info.device.id_)
    562         if client and client.is_pairable():
    563             # See comment above for same code
    564             client.handler = handler
    565             # This will trigger a PIN/passphrase entry request
    566             try:
    567                 client_xpub = client.get_xpub(derivation, xtype)
    568             except (UserCancelled, RuntimeError):
    569                  # Bad / cancelled PIN / passphrase
    570                 client_xpub = None
    571             if client_xpub == xpub:
    572                 self.pair_xpub(xpub, info.device.id_)
    573                 return client
    574 
    575         # The user input has wrong PIN or passphrase, or cancelled input,
    576         # or it is not pairable
    577         raise DeviceUnpairableError(
    578             _('Electrum cannot pair with your {}.\n\n'
    579               'Before you request bitcoins to be sent to addresses in this '
    580               'wallet, ensure you can pair with your device, or that you have '
    581               'its seed (and passphrase, if any).  Otherwise all bitcoins you '
    582               'receive will be unspendable.').format(plugin.device))
    583 
    584     def unpaired_device_infos(self, handler: Optional['HardwareHandlerBase'], plugin: 'HW_PluginBase',
    585                               devices: Sequence['Device'] = None,
    586                               include_failing_clients=False) -> List['DeviceInfo']:
    587         '''Returns a list of DeviceInfo objects: one for each connected,
    588         unpaired device accepted by the plugin.'''
    589         if not plugin.libraries_available:
    590             message = plugin.get_library_not_available_message()
    591             raise HardwarePluginLibraryUnavailable(message)
    592         if devices is None:
    593             devices = self.scan_devices()
    594         devices = [dev for dev in devices if not self.xpub_by_id(dev.id_)]
    595         infos = []
    596         for device in devices:
    597             if not plugin.can_recognize_device(device):
    598                 continue
    599             try:
    600                 client = self.create_client(device, handler, plugin)
    601                 if not client:
    602                     continue
    603                 label = client.label()
    604                 is_initialized = client.is_initialized()
    605                 soft_device_id = client.get_soft_device_id()
    606                 model_name = client.device_model_name()
    607             except Exception as e:
    608                 self.logger.error(f'failed to create client for {plugin.name} at {device.path}: {repr(e)}')
    609                 if include_failing_clients:
    610                     infos.append(DeviceInfo(device=device, exception=e, plugin_name=plugin.name))
    611                 continue
    612             infos.append(DeviceInfo(device=device,
    613                                     label=label,
    614                                     initialized=is_initialized,
    615                                     plugin_name=plugin.name,
    616                                     soft_device_id=soft_device_id,
    617                                     model_name=model_name))
    618 
    619         return infos
    620 
    621     def select_device(self, plugin: 'HW_PluginBase', handler: 'HardwareHandlerBase',
    622                       keystore: 'Hardware_KeyStore', devices: Sequence['Device'] = None,
    623                       *, allow_user_interaction: bool = True) -> 'DeviceInfo':
    624         """Select the device to use for keystore."""
    625         # ideally this should not be called from the GUI thread...
    626         # assert handler.get_gui_thread() != threading.current_thread(), 'must not be called from GUI thread'
    627         while True:
    628             infos = self.unpaired_device_infos(handler, plugin, devices)
    629             if infos:
    630                 break
    631             if not allow_user_interaction:
    632                 raise CannotAutoSelectDevice()
    633             msg = _('Please insert your {}').format(plugin.device)
    634             if keystore.label:
    635                 msg += ' ({})'.format(keystore.label)
    636             msg += '. {}\n\n{}'.format(
    637                 _('Verify the cable is connected and that '
    638                   'no other application is using it.'),
    639                 _('Try to connect again?')
    640             )
    641             if not handler.yes_no_question(msg):
    642                 raise UserCancelled()
    643             devices = None
    644 
    645         # select device automatically. (but only if we have reasonable expectation it is the correct one)
    646         # method 1: select device by id
    647         if keystore.soft_device_id:
    648             for info in infos:
    649                 if info.soft_device_id == keystore.soft_device_id:
    650                     return info
    651         # method 2: select device by label
    652         #           but only if not a placeholder label and only if there is no collision
    653         device_labels = [info.label for info in infos]
    654         if (keystore.label not in PLACEHOLDER_HW_CLIENT_LABELS
    655                 and device_labels.count(keystore.label) == 1):
    656             for info in infos:
    657                 if info.label == keystore.label:
    658                     return info
    659         # method 3: if there is only one device connected, and we don't have useful label/soft_device_id
    660         #           saved for keystore anyway, select it
    661         if (len(infos) == 1
    662                 and keystore.label in PLACEHOLDER_HW_CLIENT_LABELS
    663                 and keystore.soft_device_id is None):
    664             return infos[0]
    665 
    666         if not allow_user_interaction:
    667             raise CannotAutoSelectDevice()
    668         # ask user to select device manually
    669         msg = _("Please select which {} device to use:").format(plugin.device)
    670         descriptions = ["{label} ({maybe_model}{init}, {transport})"
    671                         .format(label=info.label or _("An unnamed {}").format(info.plugin_name),
    672                                 init=(_("initialized") if info.initialized else _("wiped")),
    673                                 transport=info.device.transport_ui_string,
    674                                 maybe_model=f"{info.model_name}, " if info.model_name else "")
    675                         for info in infos]
    676         c = handler.query_choice(msg, descriptions)
    677         if c is None:
    678             raise UserCancelled()
    679         info = infos[c]
    680         # note: updated label/soft_device_id will be saved after pairing succeeds
    681         return info
    682 
    683     @runs_in_hwd_thread
    684     def _scan_devices_with_hid(self) -> List['Device']:
    685         try:
    686             import hid
    687         except ImportError:
    688             return []
    689 
    690         devices = []
    691         for d in hid.enumerate(0, 0):
    692             vendor_id = d['vendor_id']
    693             product_key = (vendor_id, d['product_id'])
    694             plugin = None
    695             if product_key in self._recognised_hardware:
    696                 plugin = self._recognised_hardware[product_key]
    697             elif vendor_id in self._recognised_vendor:
    698                 plugin = self._recognised_vendor[vendor_id]
    699             if plugin:
    700                 device = plugin.create_device_from_hid_enumeration(d, product_key=product_key)
    701                 if device:
    702                     devices.append(device)
    703         return devices
    704 
    705     @runs_in_hwd_thread
    706     @profiler
    707     def scan_devices(self) -> Sequence['Device']:
    708         self.logger.info("scanning devices...")
    709 
    710         # First see what's connected that we know about
    711         devices = self._scan_devices_with_hid()
    712 
    713         # Let plugin handlers enumerate devices we don't know about
    714         with self.lock:
    715             enumerate_funcs = list(self._enumerate_func)
    716         for f in enumerate_funcs:
    717             try:
    718                 new_devices = f()
    719             except BaseException as e:
    720                 self.logger.error('custom device enum failed. func {}, error {}'
    721                                   .format(str(f), repr(e)))
    722             else:
    723                 devices.extend(new_devices)
    724 
    725         # find out what was disconnected
    726         pairs = [(dev.path, dev.id_) for dev in devices]
    727         disconnected_clients = []
    728         with self.lock:
    729             connected = {}
    730             for client, pair in self.clients.items():
    731                 if pair in pairs and client.has_usable_connection_with_device():
    732                     connected[client] = pair
    733                 else:
    734                     disconnected_clients.append((client, pair[1]))
    735             self.clients = connected
    736 
    737         # Unpair disconnected devices
    738         for client, id_ in disconnected_clients:
    739             self.unpair_id(id_)
    740             if client.handler:
    741                 client.handler.update_status(False)
    742 
    743         return devices