tqt.py - electrum - Electrum Bitcoin wallet
HTML git clone https://git.parazyd.org/electrum
DIR Log
DIR Files
DIR Refs
DIR Submodules
---
tqt.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)