qt.py (13077B)
1 #!/usr/bin/env python3 2 # -*- mode: python -*- 3 # 4 # Electrum - lightweight Bitcoin client 5 # Copyright (C) 2016 The Electrum developers 6 # 7 # Permission is hereby granted, free of charge, to any person 8 # obtaining a copy of this software and associated documentation files 9 # (the "Software"), to deal in the Software without restriction, 10 # including without limitation the rights to use, copy, modify, merge, 11 # publish, distribute, sublicense, and/or sell copies of the Software, 12 # and to permit persons to whom the Software is furnished to do so, 13 # subject to the following conditions: 14 # 15 # The above copyright notice and this permission notice shall be 16 # included in all copies or substantial portions of the Software. 17 # 18 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 20 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 22 # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 23 # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 24 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 # SOFTWARE. 26 27 import threading 28 from functools import partial 29 from typing import TYPE_CHECKING, Union, Optional, Callable, Any 30 31 from PyQt5.QtCore import QObject, pyqtSignal 32 from PyQt5.QtWidgets import QVBoxLayout, QLineEdit, QHBoxLayout, QLabel 33 34 from electrum.gui.qt.password_dialog import PasswordLayout, PW_PASSPHRASE 35 from electrum.gui.qt.util import (read_QIcon, WWLabel, OkButton, WindowModalDialog, 36 Buttons, CancelButton, TaskThread, char_width_in_lineedit, 37 PasswordLineEdit) 38 from electrum.gui.qt.main_window import StatusBarButton, ElectrumWindow 39 from electrum.gui.qt.installwizard import InstallWizard 40 41 from electrum.i18n import _ 42 from electrum.logging import Logger 43 from electrum.util import parse_URI, InvalidBitcoinURI, UserCancelled, UserFacingException 44 from electrum.plugin import hook, DeviceUnpairableError 45 46 from .plugin import OutdatedHwFirmwareException, HW_PluginBase, HardwareHandlerBase 47 48 if TYPE_CHECKING: 49 from electrum.wallet import Abstract_Wallet 50 from electrum.keystore import Hardware_KeyStore 51 52 53 # The trickiest thing about this handler was getting windows properly 54 # parented on macOS. 55 class QtHandlerBase(HardwareHandlerBase, QObject, Logger): 56 '''An interface between the GUI (here, QT) and the device handling 57 logic for handling I/O.''' 58 59 passphrase_signal = pyqtSignal(object, object) 60 message_signal = pyqtSignal(object, object) 61 error_signal = pyqtSignal(object, object) 62 word_signal = pyqtSignal(object) 63 clear_signal = pyqtSignal() 64 query_signal = pyqtSignal(object, object) 65 yes_no_signal = pyqtSignal(object) 66 status_signal = pyqtSignal(object) 67 68 def __init__(self, win: Union[ElectrumWindow, InstallWizard], device: str): 69 QObject.__init__(self) 70 Logger.__init__(self) 71 assert win.gui_thread == threading.current_thread(), 'must be called from GUI thread' 72 self.clear_signal.connect(self.clear_dialog) 73 self.error_signal.connect(self.error_dialog) 74 self.message_signal.connect(self.message_dialog) 75 self.passphrase_signal.connect(self.passphrase_dialog) 76 self.word_signal.connect(self.word_dialog) 77 self.query_signal.connect(self.win_query_choice) 78 self.yes_no_signal.connect(self.win_yes_no_question) 79 self.status_signal.connect(self._update_status) 80 self.win = win 81 self.device = device 82 self.dialog = None 83 self.done = threading.Event() 84 85 def top_level_window(self): 86 return self.win.top_level_window() 87 88 def update_status(self, paired): 89 self.status_signal.emit(paired) 90 91 def _update_status(self, paired): 92 if hasattr(self, 'button'): 93 button = self.button 94 icon_name = button.icon_paired if paired else button.icon_unpaired 95 button.setIcon(read_QIcon(icon_name)) 96 97 def query_choice(self, msg, labels): 98 self.done.clear() 99 self.query_signal.emit(msg, labels) 100 self.done.wait() 101 return self.choice 102 103 def yes_no_question(self, msg): 104 self.done.clear() 105 self.yes_no_signal.emit(msg) 106 self.done.wait() 107 return self.ok 108 109 def show_message(self, msg, on_cancel=None): 110 self.message_signal.emit(msg, on_cancel) 111 112 def show_error(self, msg, blocking=False): 113 self.done.clear() 114 self.error_signal.emit(msg, blocking) 115 if blocking: 116 self.done.wait() 117 118 def finished(self): 119 self.clear_signal.emit() 120 121 def get_word(self, msg): 122 self.done.clear() 123 self.word_signal.emit(msg) 124 self.done.wait() 125 return self.word 126 127 def get_passphrase(self, msg, confirm): 128 self.done.clear() 129 self.passphrase_signal.emit(msg, confirm) 130 self.done.wait() 131 return self.passphrase 132 133 def passphrase_dialog(self, msg, confirm): 134 # If confirm is true, require the user to enter the passphrase twice 135 parent = self.top_level_window() 136 d = WindowModalDialog(parent, _("Enter Passphrase")) 137 if confirm: 138 OK_button = OkButton(d) 139 playout = PasswordLayout(msg=msg, kind=PW_PASSPHRASE, OK_button=OK_button) 140 vbox = QVBoxLayout() 141 vbox.addLayout(playout.layout()) 142 vbox.addLayout(Buttons(CancelButton(d), OK_button)) 143 d.setLayout(vbox) 144 passphrase = playout.new_password() if d.exec_() else None 145 else: 146 pw = PasswordLineEdit() 147 pw.setMinimumWidth(200) 148 vbox = QVBoxLayout() 149 vbox.addWidget(WWLabel(msg)) 150 vbox.addWidget(pw) 151 vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) 152 d.setLayout(vbox) 153 passphrase = pw.text() if d.exec_() else None 154 self.passphrase = passphrase 155 self.done.set() 156 157 def word_dialog(self, msg): 158 dialog = WindowModalDialog(self.top_level_window(), "") 159 hbox = QHBoxLayout(dialog) 160 hbox.addWidget(QLabel(msg)) 161 text = QLineEdit() 162 text.setMaximumWidth(12 * char_width_in_lineedit()) 163 text.returnPressed.connect(dialog.accept) 164 hbox.addWidget(text) 165 hbox.addStretch(1) 166 dialog.exec_() # Firmware cannot handle cancellation 167 self.word = text.text() 168 self.done.set() 169 170 def message_dialog(self, msg, on_cancel): 171 # Called more than once during signing, to confirm output and fee 172 self.clear_dialog() 173 title = _('Please check your {} device').format(self.device) 174 self.dialog = dialog = WindowModalDialog(self.top_level_window(), title) 175 l = QLabel(msg) 176 vbox = QVBoxLayout(dialog) 177 vbox.addWidget(l) 178 if on_cancel: 179 dialog.rejected.connect(on_cancel) 180 vbox.addLayout(Buttons(CancelButton(dialog))) 181 dialog.show() 182 183 def error_dialog(self, msg, blocking): 184 self.win.show_error(msg, parent=self.top_level_window()) 185 if blocking: 186 self.done.set() 187 188 def clear_dialog(self): 189 if self.dialog: 190 self.dialog.accept() 191 self.dialog = None 192 193 def win_query_choice(self, msg, labels): 194 try: 195 self.choice = self.win.query_choice(msg, labels) 196 except UserCancelled: 197 self.choice = None 198 self.done.set() 199 200 def win_yes_no_question(self, msg): 201 self.ok = self.win.question(msg) 202 self.done.set() 203 204 205 class QtPluginBase(object): 206 207 @hook 208 def load_wallet(self: Union['QtPluginBase', HW_PluginBase], wallet: 'Abstract_Wallet', window: ElectrumWindow): 209 relevant_keystores = [keystore for keystore in wallet.get_keystores() 210 if isinstance(keystore, self.keystore_class)] 211 if not relevant_keystores: 212 return 213 for keystore in relevant_keystores: 214 if not self.libraries_available: 215 message = keystore.plugin.get_library_not_available_message() 216 window.show_error(message) 217 return 218 tooltip = self.device + '\n' + (keystore.label or 'unnamed') 219 cb = partial(self._on_status_bar_button_click, window=window, keystore=keystore) 220 button = StatusBarButton(read_QIcon(self.icon_unpaired), tooltip, cb) 221 button.icon_paired = self.icon_paired 222 button.icon_unpaired = self.icon_unpaired 223 window.statusBar().addPermanentWidget(button) 224 handler = self.create_handler(window) 225 handler.button = button 226 keystore.handler = handler 227 keystore.thread = TaskThread(window, on_error=partial(self.on_task_thread_error, window, keystore)) 228 self.add_show_address_on_hw_device_button_for_receive_addr(wallet, keystore, window) 229 # Trigger pairings 230 def trigger_pairings(): 231 devmgr = self.device_manager() 232 devices = devmgr.scan_devices() 233 # first pair with all devices that can be auto-selected 234 for keystore in relevant_keystores: 235 try: 236 self.get_client(keystore=keystore, 237 force_pair=True, 238 allow_user_interaction=False, 239 devices=devices) 240 except UserCancelled: 241 pass 242 # now do manual selections 243 for keystore in relevant_keystores: 244 try: 245 self.get_client(keystore=keystore, 246 force_pair=True, 247 allow_user_interaction=True, 248 devices=devices) 249 except UserCancelled: 250 pass 251 252 some_keystore = relevant_keystores[0] 253 some_keystore.thread.add(trigger_pairings) 254 255 def _on_status_bar_button_click(self, *, window: ElectrumWindow, keystore: 'Hardware_KeyStore'): 256 try: 257 self.show_settings_dialog(window=window, keystore=keystore) 258 except (UserFacingException, UserCancelled) as e: 259 exc_info = (type(e), e, e.__traceback__) 260 self.on_task_thread_error(window=window, keystore=keystore, exc_info=exc_info) 261 262 def on_task_thread_error(self: Union['QtPluginBase', HW_PluginBase], window: ElectrumWindow, 263 keystore: 'Hardware_KeyStore', exc_info): 264 e = exc_info[1] 265 if isinstance(e, OutdatedHwFirmwareException): 266 if window.question(e.text_ignore_old_fw_and_continue(), title=_("Outdated device firmware")): 267 self.set_ignore_outdated_fw() 268 # will need to re-pair 269 devmgr = self.device_manager() 270 def re_pair_device(): 271 device_id = self.choose_device(window, keystore) 272 devmgr.unpair_id(device_id) 273 self.get_client(keystore) 274 keystore.thread.add(re_pair_device) 275 return 276 else: 277 window.on_error(exc_info) 278 279 def choose_device(self: Union['QtPluginBase', HW_PluginBase], window: ElectrumWindow, 280 keystore: 'Hardware_KeyStore') -> Optional[str]: 281 '''This dialog box should be usable even if the user has 282 forgotten their PIN or it is in bootloader mode.''' 283 assert window.gui_thread != threading.current_thread(), 'must not be called from GUI thread' 284 device_id = self.device_manager().xpub_id(keystore.xpub) 285 if not device_id: 286 try: 287 info = self.device_manager().select_device(self, keystore.handler, keystore) 288 except UserCancelled: 289 return 290 device_id = info.device.id_ 291 return device_id 292 293 def show_settings_dialog(self, window: ElectrumWindow, keystore: 'Hardware_KeyStore') -> None: 294 # default implementation (if no dialog): just try to connect to device 295 def connect(): 296 device_id = self.choose_device(window, keystore) 297 keystore.thread.add(connect) 298 299 def add_show_address_on_hw_device_button_for_receive_addr(self, wallet: 'Abstract_Wallet', 300 keystore: 'Hardware_KeyStore', 301 main_window: ElectrumWindow): 302 plugin = keystore.plugin 303 receive_address_e = main_window.receive_address_e 304 305 def show_address(): 306 addr = str(receive_address_e.text()) 307 keystore.thread.add(partial(plugin.show_address, wallet, addr, keystore)) 308 dev_name = f"{plugin.device} ({keystore.label})" 309 receive_address_e.addButton("eye1.png", show_address, _("Show on {}").format(dev_name)) 310 311 def create_handler(self, window: Union[ElectrumWindow, InstallWizard]) -> 'QtHandlerBase': 312 raise NotImplementedError()