qt.py (21197B)
1 from functools import partial 2 import threading 3 4 from PyQt5.QtCore import Qt, pyqtSignal, QRegExp 5 from PyQt5.QtGui import QRegExpValidator 6 from PyQt5.QtWidgets import (QVBoxLayout, QLabel, QGridLayout, QPushButton, 7 QHBoxLayout, QButtonGroup, QGroupBox, 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, getOpenFileName) 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 .safe_t import SafeTPlugin, 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 38 39 class QtHandler(QtHandlerBase): 40 41 pin_signal = pyqtSignal(object, object) 42 43 def __init__(self, win, pin_matrix_widget_class, device): 44 super(QtHandler, self).__init__(win, device) 45 self.pin_signal.connect(self.pin_dialog) 46 self.pin_matrix_widget_class = pin_matrix_widget_class 47 48 def get_pin(self, msg, *, show_strength=True): 49 self.done.clear() 50 self.pin_signal.emit(msg, show_strength) 51 self.done.wait() 52 return self.response 53 54 def pin_dialog(self, msg, show_strength): 55 # Needed e.g. when resetting a device 56 self.clear_dialog() 57 dialog = WindowModalDialog(self.top_level_window(), _("Enter PIN")) 58 matrix = self.pin_matrix_widget_class(show_strength) 59 vbox = QVBoxLayout() 60 vbox.addWidget(QLabel(msg)) 61 vbox.addWidget(matrix) 62 vbox.addLayout(Buttons(CancelButton(dialog), OkButton(dialog))) 63 dialog.setLayout(vbox) 64 dialog.exec_() 65 self.response = str(matrix.get_value()) 66 self.done.set() 67 68 69 class QtPlugin(QtPluginBase): 70 # Derived classes must provide the following class-static variables: 71 # icon_file 72 # pin_matrix_widget_class 73 74 @only_hook_if_libraries_available 75 @hook 76 def receive_menu(self, menu, addrs, wallet): 77 if len(addrs) != 1: 78 return 79 for keystore in wallet.get_keystores(): 80 if type(keystore) == self.keystore_class: 81 def show_address(keystore=keystore): 82 keystore.thread.add(partial(self.show_address, wallet, addrs[0], keystore)) 83 device_name = "{} ({})".format(self.device, keystore.label) 84 menu.addAction(_("Show on {}").format(device_name), show_address) 85 86 def show_settings_dialog(self, window, keystore): 87 def connect(): 88 device_id = self.choose_device(window, keystore) 89 return device_id 90 def show_dialog(device_id): 91 if device_id: 92 SettingsDialog(window, self, keystore, device_id).exec_() 93 keystore.thread.add(connect, on_success=show_dialog) 94 95 def request_safe_t_init_settings(self, wizard, method, device): 96 vbox = QVBoxLayout() 97 next_enabled = True 98 label = QLabel(_("Enter a label to name your device:")) 99 name = QLineEdit() 100 hl = QHBoxLayout() 101 hl.addWidget(label) 102 hl.addWidget(name) 103 hl.addStretch(1) 104 vbox.addLayout(hl) 105 106 def clean_text(widget): 107 text = widget.toPlainText().strip() 108 return ' '.join(text.split()) 109 110 if method in [TIM_NEW, TIM_RECOVER]: 111 gb = QGroupBox() 112 hbox1 = QHBoxLayout() 113 gb.setLayout(hbox1) 114 vbox.addWidget(gb) 115 gb.setTitle(_("Select your seed length:")) 116 bg = QButtonGroup() 117 for i, count in enumerate([12, 18, 24]): 118 rb = QRadioButton(gb) 119 rb.setText(_("{:d} words").format(count)) 120 bg.addButton(rb) 121 bg.setId(rb, i) 122 hbox1.addWidget(rb) 123 rb.setChecked(True) 124 cb_pin = QCheckBox(_('Enable PIN protection')) 125 cb_pin.setChecked(True) 126 else: 127 text = QTextEdit() 128 text.setMaximumHeight(60) 129 if method == TIM_MNEMONIC: 130 msg = _("Enter your BIP39 mnemonic:") 131 else: 132 msg = _("Enter the master private key beginning with xprv:") 133 def set_enabled(): 134 from electrum.bip32 import is_xprv 135 wizard.next_button.setEnabled(is_xprv(clean_text(text))) 136 text.textChanged.connect(set_enabled) 137 next_enabled = False 138 139 vbox.addWidget(QLabel(msg)) 140 vbox.addWidget(text) 141 pin = QLineEdit() 142 pin.setValidator(QRegExpValidator(QRegExp('[1-9]{0,9}'))) 143 pin.setMaximumWidth(100) 144 hbox_pin = QHBoxLayout() 145 hbox_pin.addWidget(QLabel(_("Enter your PIN (digits 1-9):"))) 146 hbox_pin.addWidget(pin) 147 hbox_pin.addStretch(1) 148 149 if method in [TIM_NEW, TIM_RECOVER]: 150 vbox.addWidget(WWLabel(RECOMMEND_PIN)) 151 vbox.addWidget(cb_pin) 152 else: 153 vbox.addLayout(hbox_pin) 154 155 passphrase_msg = WWLabel(PASSPHRASE_HELP_SHORT) 156 passphrase_warning = WWLabel(PASSPHRASE_NOT_PIN) 157 passphrase_warning.setStyleSheet("color: red") 158 cb_phrase = QCheckBox(_('Enable passphrases')) 159 cb_phrase.setChecked(False) 160 vbox.addWidget(passphrase_msg) 161 vbox.addWidget(passphrase_warning) 162 vbox.addWidget(cb_phrase) 163 164 wizard.exec_layout(vbox, next_enabled=next_enabled) 165 166 if method in [TIM_NEW, TIM_RECOVER]: 167 item = bg.checkedId() 168 pin = cb_pin.isChecked() 169 else: 170 item = ' '.join(str(clean_text(text)).split()) 171 pin = str(pin.text()) 172 173 return (item, name.text(), pin, cb_phrase.isChecked()) 174 175 176 class Plugin(SafeTPlugin, QtPlugin): 177 icon_unpaired = "safe-t_unpaired.png" 178 icon_paired = "safe-t.png" 179 180 def create_handler(self, window): 181 return QtHandler(window, self.pin_matrix_widget_class(), self.device) 182 183 @classmethod 184 def pin_matrix_widget_class(self): 185 from safetlib.qt.pinmatrix import PinMatrixWidget 186 return PinMatrixWidget 187 188 189 class SettingsDialog(WindowModalDialog): 190 '''This dialog doesn't require a device be paired with a wallet. 191 We want users to be able to wipe a device even if they've forgotten 192 their PIN.''' 193 194 def __init__(self, window, plugin, keystore, device_id): 195 title = _("{} Settings").format(plugin.device) 196 super(SettingsDialog, self).__init__(window, title) 197 self.setMaximumWidth(540) 198 199 devmgr = plugin.device_manager() 200 config = devmgr.config 201 handler = keystore.handler 202 thread = keystore.thread 203 hs_cols, hs_rows = (128, 64) 204 205 def invoke_client(method, *args, **kw_args): 206 unpair_after = kw_args.pop('unpair_after', False) 207 208 def task(): 209 client = devmgr.client_by_id(device_id) 210 if not client: 211 raise RuntimeError("Device not connected") 212 if method: 213 getattr(client, method)(*args, **kw_args) 214 if unpair_after: 215 devmgr.unpair_id(device_id) 216 return client.features 217 218 thread.add(task, on_success=update) 219 220 def update(features): 221 self.features = features 222 set_label_enabled() 223 if features.bootloader_hash: 224 bl_hash = bh2u(features.bootloader_hash) 225 bl_hash = "\n".join([bl_hash[:32], bl_hash[32:]]) 226 else: 227 bl_hash = "N/A" 228 noyes = [_("No"), _("Yes")] 229 endis = [_("Enable Passphrases"), _("Disable Passphrases")] 230 disen = [_("Disabled"), _("Enabled")] 231 setchange = [_("Set a PIN"), _("Change PIN")] 232 233 version = "%d.%d.%d" % (features.major_version, 234 features.minor_version, 235 features.patch_version) 236 237 device_label.setText(features.label) 238 pin_set_label.setText(noyes[features.pin_protection]) 239 passphrases_label.setText(disen[features.passphrase_protection]) 240 bl_hash_label.setText(bl_hash) 241 label_edit.setText(features.label) 242 device_id_label.setText(features.device_id) 243 initialized_label.setText(noyes[features.initialized]) 244 version_label.setText(version) 245 clear_pin_button.setVisible(features.pin_protection) 246 clear_pin_warning.setVisible(features.pin_protection) 247 pin_button.setText(setchange[features.pin_protection]) 248 pin_msg.setVisible(not features.pin_protection) 249 passphrase_button.setText(endis[features.passphrase_protection]) 250 language_label.setText(features.language) 251 252 def set_label_enabled(): 253 label_apply.setEnabled(label_edit.text() != self.features.label) 254 255 def rename(): 256 invoke_client('change_label', label_edit.text()) 257 258 def toggle_passphrase(): 259 title = _("Confirm Toggle Passphrase Protection") 260 currently_enabled = self.features.passphrase_protection 261 if currently_enabled: 262 msg = _("After disabling passphrases, you can only pair this " 263 "Electrum wallet if it had an empty passphrase. " 264 "If its passphrase was not empty, you will need to " 265 "create a new wallet with the install wizard. You " 266 "can use this wallet again at any time by re-enabling " 267 "passphrases and entering its passphrase.") 268 else: 269 msg = _("Your current Electrum wallet can only be used with " 270 "an empty passphrase. You must create a separate " 271 "wallet with the install wizard for other passphrases " 272 "as each one generates a new set of addresses.") 273 msg += "\n\n" + _("Are you sure you want to proceed?") 274 if not self.question(msg, title=title): 275 return 276 invoke_client('toggle_passphrase', unpair_after=currently_enabled) 277 278 def change_homescreen(): 279 filename = getOpenFileName( 280 parent=self, 281 title=_("Choose Homescreen"), 282 config=config, 283 ) 284 if not filename: 285 return # user cancelled 286 287 if filename.endswith('.toif'): 288 img = open(filename, 'rb').read() 289 if img[:8] != b'TOIf\x90\x00\x90\x00': 290 handler.show_error('File is not a TOIF file with size of 144x144') 291 return 292 else: 293 from PIL import Image # FIXME 294 im = Image.open(filename) 295 if im.size != (128, 64): 296 handler.show_error('Image must be 128 x 64 pixels') 297 return 298 im = im.convert('1') 299 pix = im.load() 300 img = bytearray(1024) 301 for j in range(64): 302 for i in range(128): 303 if pix[i, j]: 304 o = (i + j * 128) 305 img[o // 8] |= (1 << (7 - o % 8)) 306 img = bytes(img) 307 invoke_client('change_homescreen', img) 308 309 def clear_homescreen(): 310 invoke_client('change_homescreen', b'\x00') 311 312 def set_pin(): 313 invoke_client('set_pin', remove=False) 314 315 def clear_pin(): 316 invoke_client('set_pin', remove=True) 317 318 def wipe_device(): 319 wallet = window.wallet 320 if wallet and sum(wallet.get_balance()): 321 title = _("Confirm Device Wipe") 322 msg = _("Are you SURE you want to wipe the device?\n" 323 "Your wallet still has bitcoins in it!") 324 if not self.question(msg, title=title, 325 icon=QMessageBox.Critical): 326 return 327 invoke_client('wipe_device', unpair_after=True) 328 329 def slider_moved(): 330 mins = timeout_slider.sliderPosition() 331 timeout_minutes.setText(_("{:2d} minutes").format(mins)) 332 333 def slider_released(): 334 config.set_session_timeout(timeout_slider.sliderPosition() * 60) 335 336 # Information tab 337 info_tab = QWidget() 338 info_layout = QVBoxLayout(info_tab) 339 info_glayout = QGridLayout() 340 info_glayout.setColumnStretch(2, 1) 341 device_label = QLabel() 342 pin_set_label = QLabel() 343 passphrases_label = QLabel() 344 version_label = QLabel() 345 device_id_label = QLabel() 346 bl_hash_label = QLabel() 347 bl_hash_label.setWordWrap(True) 348 language_label = QLabel() 349 initialized_label = QLabel() 350 rows = [ 351 (_("Device Label"), device_label), 352 (_("PIN set"), pin_set_label), 353 (_("Passphrases"), passphrases_label), 354 (_("Firmware Version"), version_label), 355 (_("Device ID"), device_id_label), 356 (_("Bootloader Hash"), bl_hash_label), 357 (_("Language"), language_label), 358 (_("Initialized"), initialized_label), 359 ] 360 for row_num, (label, widget) in enumerate(rows): 361 info_glayout.addWidget(QLabel(label), row_num, 0) 362 info_glayout.addWidget(widget, row_num, 1) 363 info_layout.addLayout(info_glayout) 364 365 # Settings tab 366 settings_tab = QWidget() 367 settings_layout = QVBoxLayout(settings_tab) 368 settings_glayout = QGridLayout() 369 370 # Settings tab - Label 371 label_msg = QLabel(_("Name this {}. If you have multiple devices " 372 "their labels help distinguish them.") 373 .format(plugin.device)) 374 label_msg.setWordWrap(True) 375 label_label = QLabel(_("Device Label")) 376 label_edit = QLineEdit() 377 label_edit.setMinimumWidth(150) 378 label_edit.setMaxLength(plugin.MAX_LABEL_LEN) 379 label_apply = QPushButton(_("Apply")) 380 label_apply.clicked.connect(rename) 381 label_edit.textChanged.connect(set_label_enabled) 382 settings_glayout.addWidget(label_label, 0, 0) 383 settings_glayout.addWidget(label_edit, 0, 1, 1, 2) 384 settings_glayout.addWidget(label_apply, 0, 3) 385 settings_glayout.addWidget(label_msg, 1, 1, 1, -1) 386 387 # Settings tab - PIN 388 pin_label = QLabel(_("PIN Protection")) 389 pin_button = QPushButton() 390 pin_button.clicked.connect(set_pin) 391 settings_glayout.addWidget(pin_label, 2, 0) 392 settings_glayout.addWidget(pin_button, 2, 1) 393 pin_msg = QLabel(_("PIN protection is strongly recommended. " 394 "A PIN is your only protection against someone " 395 "stealing your bitcoins if they obtain physical " 396 "access to your {}.").format(plugin.device)) 397 pin_msg.setWordWrap(True) 398 pin_msg.setStyleSheet("color: red") 399 settings_glayout.addWidget(pin_msg, 3, 1, 1, -1) 400 401 # Settings tab - Homescreen 402 homescreen_label = QLabel(_("Homescreen")) 403 homescreen_change_button = QPushButton(_("Change...")) 404 homescreen_clear_button = QPushButton(_("Reset")) 405 homescreen_change_button.clicked.connect(change_homescreen) 406 try: 407 import PIL 408 except ImportError: 409 homescreen_change_button.setDisabled(True) 410 homescreen_change_button.setToolTip( 411 _("Required package 'PIL' is not available - Please install it.") 412 ) 413 homescreen_clear_button.clicked.connect(clear_homescreen) 414 homescreen_msg = QLabel(_("You can set the homescreen on your " 415 "device to personalize it. You must " 416 "choose a {} x {} monochrome black and " 417 "white image.").format(hs_cols, hs_rows)) 418 homescreen_msg.setWordWrap(True) 419 settings_glayout.addWidget(homescreen_label, 4, 0) 420 settings_glayout.addWidget(homescreen_change_button, 4, 1) 421 settings_glayout.addWidget(homescreen_clear_button, 4, 2) 422 settings_glayout.addWidget(homescreen_msg, 5, 1, 1, -1) 423 424 # Settings tab - Session Timeout 425 timeout_label = QLabel(_("Session Timeout")) 426 timeout_minutes = QLabel() 427 timeout_slider = QSlider(Qt.Horizontal) 428 timeout_slider.setRange(1, 60) 429 timeout_slider.setSingleStep(1) 430 timeout_slider.setTickInterval(5) 431 timeout_slider.setTickPosition(QSlider.TicksBelow) 432 timeout_slider.setTracking(True) 433 timeout_msg = QLabel( 434 _("Clear the session after the specified period " 435 "of inactivity. Once a session has timed out, " 436 "your PIN and passphrase (if enabled) must be " 437 "re-entered to use the device.")) 438 timeout_msg.setWordWrap(True) 439 timeout_slider.setSliderPosition(config.get_session_timeout() // 60) 440 slider_moved() 441 timeout_slider.valueChanged.connect(slider_moved) 442 timeout_slider.sliderReleased.connect(slider_released) 443 settings_glayout.addWidget(timeout_label, 6, 0) 444 settings_glayout.addWidget(timeout_slider, 6, 1, 1, 3) 445 settings_glayout.addWidget(timeout_minutes, 6, 4) 446 settings_glayout.addWidget(timeout_msg, 7, 1, 1, -1) 447 settings_layout.addLayout(settings_glayout) 448 settings_layout.addStretch(1) 449 450 # Advanced tab 451 advanced_tab = QWidget() 452 advanced_layout = QVBoxLayout(advanced_tab) 453 advanced_glayout = QGridLayout() 454 455 # Advanced tab - clear PIN 456 clear_pin_button = QPushButton(_("Disable PIN")) 457 clear_pin_button.clicked.connect(clear_pin) 458 clear_pin_warning = QLabel( 459 _("If you disable your PIN, anyone with physical access to your " 460 "{} device can spend your bitcoins.").format(plugin.device)) 461 clear_pin_warning.setWordWrap(True) 462 clear_pin_warning.setStyleSheet("color: red") 463 advanced_glayout.addWidget(clear_pin_button, 0, 2) 464 advanced_glayout.addWidget(clear_pin_warning, 1, 0, 1, 5) 465 466 # Advanced tab - toggle passphrase protection 467 passphrase_button = QPushButton() 468 passphrase_button.clicked.connect(toggle_passphrase) 469 passphrase_msg = WWLabel(PASSPHRASE_HELP) 470 passphrase_warning = WWLabel(PASSPHRASE_NOT_PIN) 471 passphrase_warning.setStyleSheet("color: red") 472 advanced_glayout.addWidget(passphrase_button, 3, 2) 473 advanced_glayout.addWidget(passphrase_msg, 4, 0, 1, 5) 474 advanced_glayout.addWidget(passphrase_warning, 5, 0, 1, 5) 475 476 # Advanced tab - wipe device 477 wipe_device_button = QPushButton(_("Wipe Device")) 478 wipe_device_button.clicked.connect(wipe_device) 479 wipe_device_msg = QLabel( 480 _("Wipe the device, removing all data from it. The firmware " 481 "is left unchanged.")) 482 wipe_device_msg.setWordWrap(True) 483 wipe_device_warning = QLabel( 484 _("Only wipe a device if you have the recovery seed written down " 485 "and the device wallet(s) are empty, otherwise the bitcoins " 486 "will be lost forever.")) 487 wipe_device_warning.setWordWrap(True) 488 wipe_device_warning.setStyleSheet("color: red") 489 advanced_glayout.addWidget(wipe_device_button, 6, 2) 490 advanced_glayout.addWidget(wipe_device_msg, 7, 0, 1, 5) 491 advanced_glayout.addWidget(wipe_device_warning, 8, 0, 1, 5) 492 advanced_layout.addLayout(advanced_glayout) 493 advanced_layout.addStretch(1) 494 495 tabs = QTabWidget(self) 496 tabs.addTab(info_tab, _("Information")) 497 tabs.addTab(settings_tab, _("Settings")) 498 tabs.addTab(advanced_tab, _("Advanced")) 499 dialog_vbox = QVBoxLayout(self) 500 dialog_vbox.addWidget(tabs) 501 dialog_vbox.addLayout(Buttons(CloseButton(self))) 502 503 # Update information 504 invoke_client(None)