electrum

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

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)