qt.py (23765B)
1 from functools import partial 2 import threading 3 4 from PyQt5.QtCore import Qt, QEventLoop, pyqtSignal, QRegExp 5 from PyQt5.QtGui import QRegExpValidator 6 from PyQt5.QtWidgets import (QVBoxLayout, QLabel, QGridLayout, QPushButton, 7 QHBoxLayout, QButtonGroup, QGroupBox, QDialog, 8 QTextEdit, QLineEdit, QRadioButton, QCheckBox, QWidget, 9 QMessageBox, QFileDialog, QSlider, QTabWidget) 10 11 from electrum.gui.qt.util import (WindowModalDialog, WWLabel, Buttons, CancelButton, 12 OkButton, CloseButton) 13 from electrum.i18n import _ 14 from electrum.plugin import hook 15 from electrum.util import bh2u 16 17 from ..hw_wallet.qt import QtHandlerBase, QtPluginBase 18 from ..hw_wallet.plugin import only_hook_if_libraries_available 19 from .keepkey import KeepKeyPlugin, TIM_NEW, TIM_RECOVER, TIM_MNEMONIC 20 21 22 PASSPHRASE_HELP_SHORT =_( 23 "Passphrases allow you to access new wallets, each " 24 "hidden behind a particular case-sensitive passphrase.") 25 PASSPHRASE_HELP = PASSPHRASE_HELP_SHORT + " " + _( 26 "You need to create a separate Electrum wallet for each passphrase " 27 "you use as they each generate different addresses. Changing " 28 "your passphrase does not lose other wallets, each is still " 29 "accessible behind its own passphrase.") 30 RECOMMEND_PIN = _( 31 "You should enable PIN protection. Your PIN is the only protection " 32 "for your bitcoins if your device is lost or stolen.") 33 PASSPHRASE_NOT_PIN = _( 34 "If you forget a passphrase you will be unable to access any " 35 "bitcoins in the wallet behind it. A passphrase is not a PIN. " 36 "Only change this if you are sure you understand it.") 37 CHARACTER_RECOVERY = ( 38 "Use the recovery cipher shown on your device to input your seed words. " 39 "The cipher changes with every keypress.\n" 40 "After at most 4 letters the device will auto-complete a word.\n" 41 "Press SPACE or the Accept Word button to accept the device's auto-" 42 "completed word and advance to the next one.\n" 43 "Press BACKSPACE to go back a character or word.\n" 44 "Press ENTER or the Seed Entered button once the last word in your " 45 "seed is auto-completed.") 46 47 class CharacterButton(QPushButton): 48 def __init__(self, text=None): 49 QPushButton.__init__(self, text) 50 51 def keyPressEvent(self, event): 52 event.setAccepted(False) # Pass through Enter and Space keys 53 54 55 class CharacterDialog(WindowModalDialog): 56 57 def __init__(self, parent): 58 super(CharacterDialog, self).__init__(parent) 59 self.setWindowTitle(_("KeepKey Seed Recovery")) 60 self.character_pos = 0 61 self.word_pos = 0 62 self.loop = QEventLoop() 63 self.word_help = QLabel() 64 self.char_buttons = [] 65 66 vbox = QVBoxLayout(self) 67 vbox.addWidget(WWLabel(CHARACTER_RECOVERY)) 68 hbox = QHBoxLayout() 69 hbox.addWidget(self.word_help) 70 for i in range(4): 71 char_button = CharacterButton('*') 72 char_button.setMaximumWidth(36) 73 self.char_buttons.append(char_button) 74 hbox.addWidget(char_button) 75 self.accept_button = CharacterButton(_("Accept Word")) 76 self.accept_button.clicked.connect(partial(self.process_key, 32)) 77 self.rejected.connect(partial(self.loop.exit, 1)) 78 hbox.addWidget(self.accept_button) 79 hbox.addStretch(1) 80 vbox.addLayout(hbox) 81 82 self.finished_button = QPushButton(_("Seed Entered")) 83 self.cancel_button = QPushButton(_("Cancel")) 84 self.finished_button.clicked.connect(partial(self.process_key, 85 Qt.Key_Return)) 86 self.cancel_button.clicked.connect(self.rejected) 87 buttons = Buttons(self.finished_button, self.cancel_button) 88 vbox.addSpacing(40) 89 vbox.addLayout(buttons) 90 self.refresh() 91 self.show() 92 93 def refresh(self): 94 self.word_help.setText("Enter seed word %2d:" % (self.word_pos + 1)) 95 self.accept_button.setEnabled(self.character_pos >= 3) 96 self.finished_button.setEnabled((self.word_pos in (11, 17, 23) 97 and self.character_pos >= 3)) 98 for n, button in enumerate(self.char_buttons): 99 button.setEnabled(n == self.character_pos) 100 if n == self.character_pos: 101 button.setFocus() 102 103 def is_valid_alpha_space(self, key): 104 # Auto-completion requires at least 3 characters 105 if key == ord(' ') and self.character_pos >= 3: 106 return True 107 # Firmware aborts protocol if the 5th character is non-space 108 if self.character_pos >= 4: 109 return False 110 return (key >= ord('a') and key <= ord('z') 111 or (key >= ord('A') and key <= ord('Z'))) 112 113 def process_key(self, key): 114 self.data = None 115 if key == Qt.Key_Return and self.finished_button.isEnabled(): 116 self.data = {'done': True} 117 elif key == Qt.Key_Backspace and (self.word_pos or self.character_pos): 118 self.data = {'delete': True} 119 elif self.is_valid_alpha_space(key): 120 self.data = {'character': chr(key).lower()} 121 if self.data: 122 self.loop.exit(0) 123 124 def keyPressEvent(self, event): 125 self.process_key(event.key()) 126 if not self.data: 127 QDialog.keyPressEvent(self, event) 128 129 def get_char(self, word_pos, character_pos): 130 self.word_pos = word_pos 131 self.character_pos = character_pos 132 self.refresh() 133 if self.loop.exec_(): 134 self.data = None # User cancelled 135 136 137 class QtHandler(QtHandlerBase): 138 139 char_signal = pyqtSignal(object) 140 pin_signal = pyqtSignal(object, object) 141 close_char_dialog_signal = pyqtSignal() 142 143 def __init__(self, win, pin_matrix_widget_class, device): 144 super(QtHandler, self).__init__(win, device) 145 self.char_signal.connect(self.update_character_dialog) 146 self.pin_signal.connect(self.pin_dialog) 147 self.close_char_dialog_signal.connect(self._close_char_dialog) 148 self.pin_matrix_widget_class = pin_matrix_widget_class 149 self.character_dialog = None 150 151 def get_char(self, msg): 152 self.done.clear() 153 self.char_signal.emit(msg) 154 self.done.wait() 155 data = self.character_dialog.data 156 if not data or 'done' in data: 157 self.close_char_dialog_signal.emit() 158 return data 159 160 def _close_char_dialog(self): 161 if self.character_dialog: 162 self.character_dialog.accept() 163 self.character_dialog = None 164 165 def get_pin(self, msg, *, show_strength=True): 166 self.done.clear() 167 self.pin_signal.emit(msg, show_strength) 168 self.done.wait() 169 return self.response 170 171 def pin_dialog(self, msg, show_strength): 172 # Needed e.g. when resetting a device 173 self.clear_dialog() 174 dialog = WindowModalDialog(self.top_level_window(), _("Enter PIN")) 175 matrix = self.pin_matrix_widget_class(show_strength) 176 vbox = QVBoxLayout() 177 vbox.addWidget(QLabel(msg)) 178 vbox.addWidget(matrix) 179 vbox.addLayout(Buttons(CancelButton(dialog), OkButton(dialog))) 180 dialog.setLayout(vbox) 181 dialog.exec_() 182 self.response = str(matrix.get_value()) 183 self.done.set() 184 185 def update_character_dialog(self, msg): 186 if not self.character_dialog: 187 self.character_dialog = CharacterDialog(self.top_level_window()) 188 self.character_dialog.get_char(msg.word_pos, msg.character_pos) 189 self.done.set() 190 191 192 193 class QtPlugin(QtPluginBase): 194 # Derived classes must provide the following class-static variables: 195 # icon_file 196 # pin_matrix_widget_class 197 198 @only_hook_if_libraries_available 199 @hook 200 def receive_menu(self, menu, addrs, wallet): 201 if len(addrs) != 1: 202 return 203 for keystore in wallet.get_keystores(): 204 if type(keystore) == self.keystore_class: 205 def show_address(): 206 keystore.thread.add(partial(self.show_address, wallet, addrs[0], keystore)) 207 device_name = "{} ({})".format(self.device, keystore.label) 208 menu.addAction(_("Show on {}").format(device_name), show_address) 209 210 def show_settings_dialog(self, window, keystore): 211 def connect(): 212 device_id = self.choose_device(window, keystore) 213 return device_id 214 def show_dialog(device_id): 215 if device_id: 216 SettingsDialog(window, self, keystore, device_id).exec_() 217 keystore.thread.add(connect, on_success=show_dialog) 218 219 def request_trezor_init_settings(self, wizard, method, device): 220 vbox = QVBoxLayout() 221 next_enabled = True 222 label = QLabel(_("Enter a label to name your device:")) 223 name = QLineEdit() 224 hl = QHBoxLayout() 225 hl.addWidget(label) 226 hl.addWidget(name) 227 hl.addStretch(1) 228 vbox.addLayout(hl) 229 230 def clean_text(widget): 231 text = widget.toPlainText().strip() 232 return ' '.join(text.split()) 233 234 if method in [TIM_NEW, TIM_RECOVER]: 235 gb = QGroupBox() 236 hbox1 = QHBoxLayout() 237 gb.setLayout(hbox1) 238 # KeepKey recovery doesn't need a word count 239 if method == TIM_NEW: 240 vbox.addWidget(gb) 241 gb.setTitle(_("Select your seed length:")) 242 bg = QButtonGroup() 243 for i, count in enumerate([12, 18, 24]): 244 rb = QRadioButton(gb) 245 rb.setText(_("{} words").format(count)) 246 bg.addButton(rb) 247 bg.setId(rb, i) 248 hbox1.addWidget(rb) 249 rb.setChecked(True) 250 cb_pin = QCheckBox(_('Enable PIN protection')) 251 cb_pin.setChecked(True) 252 else: 253 text = QTextEdit() 254 text.setMaximumHeight(60) 255 if method == TIM_MNEMONIC: 256 msg = _("Enter your BIP39 mnemonic:") 257 else: 258 msg = _("Enter the master private key beginning with xprv:") 259 def set_enabled(): 260 from electrum.bip32 import is_xprv 261 wizard.next_button.setEnabled(is_xprv(clean_text(text))) 262 text.textChanged.connect(set_enabled) 263 next_enabled = False 264 265 vbox.addWidget(QLabel(msg)) 266 vbox.addWidget(text) 267 pin = QLineEdit() 268 pin.setValidator(QRegExpValidator(QRegExp('[1-9]{0,9}'))) 269 pin.setMaximumWidth(100) 270 hbox_pin = QHBoxLayout() 271 hbox_pin.addWidget(QLabel(_("Enter your PIN (digits 1-9):"))) 272 hbox_pin.addWidget(pin) 273 hbox_pin.addStretch(1) 274 275 if method in [TIM_NEW, TIM_RECOVER]: 276 vbox.addWidget(WWLabel(RECOMMEND_PIN)) 277 vbox.addWidget(cb_pin) 278 else: 279 vbox.addLayout(hbox_pin) 280 281 passphrase_msg = WWLabel(PASSPHRASE_HELP_SHORT) 282 passphrase_warning = WWLabel(PASSPHRASE_NOT_PIN) 283 passphrase_warning.setStyleSheet("color: red") 284 cb_phrase = QCheckBox(_('Enable passphrases')) 285 cb_phrase.setChecked(False) 286 vbox.addWidget(passphrase_msg) 287 vbox.addWidget(passphrase_warning) 288 vbox.addWidget(cb_phrase) 289 290 wizard.exec_layout(vbox, next_enabled=next_enabled) 291 292 if method in [TIM_NEW, TIM_RECOVER]: 293 item = bg.checkedId() 294 pin = cb_pin.isChecked() 295 else: 296 item = ' '.join(str(clean_text(text)).split()) 297 pin = str(pin.text()) 298 299 return (item, name.text(), pin, cb_phrase.isChecked()) 300 301 302 class Plugin(KeepKeyPlugin, QtPlugin): 303 icon_paired = "keepkey.png" 304 icon_unpaired = "keepkey_unpaired.png" 305 306 def create_handler(self, window): 307 return QtHandler(window, self.pin_matrix_widget_class(), self.device) 308 309 @classmethod 310 def pin_matrix_widget_class(self): 311 from keepkeylib.qt.pinmatrix import PinMatrixWidget 312 return PinMatrixWidget 313 314 315 class SettingsDialog(WindowModalDialog): 316 '''This dialog doesn't require a device be paired with a wallet. 317 We want users to be able to wipe a device even if they've forgotten 318 their PIN.''' 319 320 def __init__(self, window, plugin, keystore, device_id): 321 title = _("{} Settings").format(plugin.device) 322 super(SettingsDialog, self).__init__(window, title) 323 self.setMaximumWidth(540) 324 325 devmgr = plugin.device_manager() 326 config = devmgr.config 327 handler = keystore.handler 328 thread = keystore.thread 329 330 def invoke_client(method, *args, **kw_args): 331 unpair_after = kw_args.pop('unpair_after', False) 332 333 def task(): 334 client = devmgr.client_by_id(device_id) 335 if not client: 336 raise RuntimeError("Device not connected") 337 if method: 338 getattr(client, method)(*args, **kw_args) 339 if unpair_after: 340 devmgr.unpair_id(device_id) 341 return client.features 342 343 thread.add(task, on_success=update) 344 345 def update(features): 346 self.features = features 347 set_label_enabled() 348 bl_hash = bh2u(features.bootloader_hash) 349 bl_hash = "\n".join([bl_hash[:32], bl_hash[32:]]) 350 noyes = [_("No"), _("Yes")] 351 endis = [_("Enable Passphrases"), _("Disable Passphrases")] 352 disen = [_("Disabled"), _("Enabled")] 353 setchange = [_("Set a PIN"), _("Change PIN")] 354 355 version = "%d.%d.%d" % (features.major_version, 356 features.minor_version, 357 features.patch_version) 358 coins = ", ".join(coin.coin_name for coin in features.coins) 359 360 device_label.setText(features.label) 361 pin_set_label.setText(noyes[features.pin_protection]) 362 passphrases_label.setText(disen[features.passphrase_protection]) 363 bl_hash_label.setText(bl_hash) 364 label_edit.setText(features.label) 365 device_id_label.setText(features.device_id) 366 initialized_label.setText(noyes[features.initialized]) 367 version_label.setText(version) 368 coins_label.setText(coins) 369 clear_pin_button.setVisible(features.pin_protection) 370 clear_pin_warning.setVisible(features.pin_protection) 371 pin_button.setText(setchange[features.pin_protection]) 372 pin_msg.setVisible(not features.pin_protection) 373 passphrase_button.setText(endis[features.passphrase_protection]) 374 language_label.setText(features.language) 375 376 def set_label_enabled(): 377 label_apply.setEnabled(label_edit.text() != self.features.label) 378 379 def rename(): 380 invoke_client('change_label', label_edit.text()) 381 382 def toggle_passphrase(): 383 title = _("Confirm Toggle Passphrase Protection") 384 currently_enabled = self.features.passphrase_protection 385 if currently_enabled: 386 msg = _("After disabling passphrases, you can only pair this " 387 "Electrum wallet if it had an empty passphrase. " 388 "If its passphrase was not empty, you will need to " 389 "create a new wallet with the install wizard. You " 390 "can use this wallet again at any time by re-enabling " 391 "passphrases and entering its passphrase.") 392 else: 393 msg = _("Your current Electrum wallet can only be used with " 394 "an empty passphrase. You must create a separate " 395 "wallet with the install wizard for other passphrases " 396 "as each one generates a new set of addresses.") 397 msg += "\n\n" + _("Are you sure you want to proceed?") 398 if not self.question(msg, title=title): 399 return 400 invoke_client('toggle_passphrase', unpair_after=currently_enabled) 401 402 def set_pin(): 403 invoke_client('set_pin', remove=False) 404 405 def clear_pin(): 406 invoke_client('set_pin', remove=True) 407 408 def wipe_device(): 409 wallet = window.wallet 410 if wallet and sum(wallet.get_balance()): 411 title = _("Confirm Device Wipe") 412 msg = _("Are you SURE you want to wipe the device?\n" 413 "Your wallet still has bitcoins in it!") 414 if not self.question(msg, title=title, 415 icon=QMessageBox.Critical): 416 return 417 invoke_client('wipe_device', unpair_after=True) 418 419 def slider_moved(): 420 mins = timeout_slider.sliderPosition() 421 timeout_minutes.setText(_("{:2d} minutes").format(mins)) 422 423 def slider_released(): 424 config.set_session_timeout(timeout_slider.sliderPosition() * 60) 425 426 # Information tab 427 info_tab = QWidget() 428 info_layout = QVBoxLayout(info_tab) 429 info_glayout = QGridLayout() 430 info_glayout.setColumnStretch(2, 1) 431 device_label = QLabel() 432 pin_set_label = QLabel() 433 passphrases_label = QLabel() 434 version_label = QLabel() 435 device_id_label = QLabel() 436 bl_hash_label = QLabel() 437 bl_hash_label.setWordWrap(True) 438 coins_label = QLabel() 439 coins_label.setWordWrap(True) 440 language_label = QLabel() 441 initialized_label = QLabel() 442 rows = [ 443 (_("Device Label"), device_label), 444 (_("PIN set"), pin_set_label), 445 (_("Passphrases"), passphrases_label), 446 (_("Firmware Version"), version_label), 447 (_("Device ID"), device_id_label), 448 (_("Bootloader Hash"), bl_hash_label), 449 (_("Supported Coins"), coins_label), 450 (_("Language"), language_label), 451 (_("Initialized"), initialized_label), 452 ] 453 for row_num, (label, widget) in enumerate(rows): 454 info_glayout.addWidget(QLabel(label), row_num, 0) 455 info_glayout.addWidget(widget, row_num, 1) 456 info_layout.addLayout(info_glayout) 457 458 # Settings tab 459 settings_tab = QWidget() 460 settings_layout = QVBoxLayout(settings_tab) 461 settings_glayout = QGridLayout() 462 463 # Settings tab - Label 464 label_msg = QLabel(_("Name this {}. If you have multiple devices " 465 "their labels help distinguish them.") 466 .format(plugin.device)) 467 label_msg.setWordWrap(True) 468 label_label = QLabel(_("Device Label")) 469 label_edit = QLineEdit() 470 label_edit.setMinimumWidth(150) 471 label_edit.setMaxLength(plugin.MAX_LABEL_LEN) 472 label_apply = QPushButton(_("Apply")) 473 label_apply.clicked.connect(rename) 474 label_edit.textChanged.connect(set_label_enabled) 475 settings_glayout.addWidget(label_label, 0, 0) 476 settings_glayout.addWidget(label_edit, 0, 1, 1, 2) 477 settings_glayout.addWidget(label_apply, 0, 3) 478 settings_glayout.addWidget(label_msg, 1, 1, 1, -1) 479 480 # Settings tab - PIN 481 pin_label = QLabel(_("PIN Protection")) 482 pin_button = QPushButton() 483 pin_button.clicked.connect(set_pin) 484 settings_glayout.addWidget(pin_label, 2, 0) 485 settings_glayout.addWidget(pin_button, 2, 1) 486 pin_msg = QLabel(_("PIN protection is strongly recommended. " 487 "A PIN is your only protection against someone " 488 "stealing your bitcoins if they obtain physical " 489 "access to your {}.").format(plugin.device)) 490 pin_msg.setWordWrap(True) 491 pin_msg.setStyleSheet("color: red") 492 settings_glayout.addWidget(pin_msg, 3, 1, 1, -1) 493 494 # Settings tab - Session Timeout 495 timeout_label = QLabel(_("Session Timeout")) 496 timeout_minutes = QLabel() 497 timeout_slider = QSlider(Qt.Horizontal) 498 timeout_slider.setRange(1, 60) 499 timeout_slider.setSingleStep(1) 500 timeout_slider.setTickInterval(5) 501 timeout_slider.setTickPosition(QSlider.TicksBelow) 502 timeout_slider.setTracking(True) 503 timeout_msg = QLabel( 504 _("Clear the session after the specified period " 505 "of inactivity. Once a session has timed out, " 506 "your PIN and passphrase (if enabled) must be " 507 "re-entered to use the device.")) 508 timeout_msg.setWordWrap(True) 509 timeout_slider.setSliderPosition(config.get_session_timeout() // 60) 510 slider_moved() 511 timeout_slider.valueChanged.connect(slider_moved) 512 timeout_slider.sliderReleased.connect(slider_released) 513 settings_glayout.addWidget(timeout_label, 6, 0) 514 settings_glayout.addWidget(timeout_slider, 6, 1, 1, 3) 515 settings_glayout.addWidget(timeout_minutes, 6, 4) 516 settings_glayout.addWidget(timeout_msg, 7, 1, 1, -1) 517 settings_layout.addLayout(settings_glayout) 518 settings_layout.addStretch(1) 519 520 # Advanced tab 521 advanced_tab = QWidget() 522 advanced_layout = QVBoxLayout(advanced_tab) 523 advanced_glayout = QGridLayout() 524 525 # Advanced tab - clear PIN 526 clear_pin_button = QPushButton(_("Disable PIN")) 527 clear_pin_button.clicked.connect(clear_pin) 528 clear_pin_warning = QLabel( 529 _("If you disable your PIN, anyone with physical access to your " 530 "{} device can spend your bitcoins.").format(plugin.device)) 531 clear_pin_warning.setWordWrap(True) 532 clear_pin_warning.setStyleSheet("color: red") 533 advanced_glayout.addWidget(clear_pin_button, 0, 2) 534 advanced_glayout.addWidget(clear_pin_warning, 1, 0, 1, 5) 535 536 # Advanced tab - toggle passphrase protection 537 passphrase_button = QPushButton() 538 passphrase_button.clicked.connect(toggle_passphrase) 539 passphrase_msg = WWLabel(PASSPHRASE_HELP) 540 passphrase_warning = WWLabel(PASSPHRASE_NOT_PIN) 541 passphrase_warning.setStyleSheet("color: red") 542 advanced_glayout.addWidget(passphrase_button, 3, 2) 543 advanced_glayout.addWidget(passphrase_msg, 4, 0, 1, 5) 544 advanced_glayout.addWidget(passphrase_warning, 5, 0, 1, 5) 545 546 # Advanced tab - wipe device 547 wipe_device_button = QPushButton(_("Wipe Device")) 548 wipe_device_button.clicked.connect(wipe_device) 549 wipe_device_msg = QLabel( 550 _("Wipe the device, removing all data from it. The firmware " 551 "is left unchanged.")) 552 wipe_device_msg.setWordWrap(True) 553 wipe_device_warning = QLabel( 554 _("Only wipe a device if you have the recovery seed written down " 555 "and the device wallet(s) are empty, otherwise the bitcoins " 556 "will be lost forever.")) 557 wipe_device_warning.setWordWrap(True) 558 wipe_device_warning.setStyleSheet("color: red") 559 advanced_glayout.addWidget(wipe_device_button, 6, 2) 560 advanced_glayout.addWidget(wipe_device_msg, 7, 0, 1, 5) 561 advanced_glayout.addWidget(wipe_device_warning, 8, 0, 1, 5) 562 advanced_layout.addLayout(advanced_glayout) 563 advanced_layout.addStretch(1) 564 565 tabs = QTabWidget(self) 566 tabs.addTab(info_tab, _("Information")) 567 tabs.addTab(settings_tab, _("Settings")) 568 tabs.addTab(advanced_tab, _("Advanced")) 569 dialog_vbox = QVBoxLayout(self) 570 dialog_vbox.addWidget(tabs) 571 dialog_vbox.addLayout(Buttons(CloseButton(self))) 572 573 # Update information 574 invoke_client(None)