1
0
Files
electrum/electrum/gui/qt/settings_dialog.py
ThomasV 5ce80332ec Qt: do not expose watchtower in settings
running a watchtower requires to be able to run a daemon with the
watchtower plugin enabled. If users succeed at doing that, we
can expect them to be able to configure theclient using the
command line.

(that would not work with QML, though. maybe QML could use an
advanced config editor)
2025-02-26 12:32:38 +01:00

466 lines
19 KiB
Python

#!/usr/bin/env python
#
# Electrum - lightweight Bitcoin client
# Copyright (C) 2012 thomasv@gitorious
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import ast
from typing import TYPE_CHECKING
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (QComboBox, QTabWidget, QDialog, QSpinBox, QCheckBox, QLabel,
QVBoxLayout, QGridLayout, QLineEdit, QWidget, QHBoxLayout, QSlider)
from electrum.i18n import _, languages
from electrum import util
from electrum.util import base_units_list, event_listener
from electrum.gui import messages
from .util import ColorScheme, HelpLabel, Buttons, CloseButton, QtEventListener
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig, ConfigVarWithConfig
from .main_window import ElectrumWindow
def checkbox_from_configvar(cv: 'ConfigVarWithConfig') -> QCheckBox:
short_desc = cv.get_short_desc()
assert short_desc is not None, f"short_desc missing for {cv}"
cb = QCheckBox(short_desc)
if (long_desc := cv.get_long_desc()) is not None:
cb.setToolTip(messages.to_rtf(long_desc))
return cb
class SettingsDialog(QDialog, QtEventListener):
def __init__(self, window: 'ElectrumWindow', config: 'SimpleConfig'):
QDialog.__init__(self)
self.setWindowTitle(_('Preferences'))
self.setMinimumWidth(500)
self.config = config
self.network = window.network
self.app = window.app
self.need_restart = False
self.fx = window.fx
self.wallet = window.wallet
self.register_callbacks()
self.app.alias_received_signal.connect(self.set_alias_color)
vbox = QVBoxLayout()
tabs = QTabWidget()
# language
lang_label = HelpLabel.from_configvar(self.config.cv.LOCALIZATION_LANGUAGE)
lang_combo = QComboBox()
lang_combo.addItems(list(languages.values()))
lang_keys = list(languages.keys())
lang_cur_setting = self.config.LOCALIZATION_LANGUAGE
try:
index = lang_keys.index(lang_cur_setting)
except ValueError: # not in list
index = 0
lang_combo.setCurrentIndex(index)
if not self.config.cv.LOCALIZATION_LANGUAGE.is_modifiable():
for w in [lang_combo, lang_label]: w.setEnabled(False)
def on_lang(x):
lang_request = list(languages.keys())[lang_combo.currentIndex()]
if lang_request != self.config.LOCALIZATION_LANGUAGE:
self.config.LOCALIZATION_LANGUAGE = lang_request
self.need_restart = True
lang_combo.currentIndexChanged.connect(on_lang)
nz_label = HelpLabel.from_configvar(self.config.cv.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT)
nz = QSpinBox()
nz.setMinimum(0)
nz.setMaximum(self.config.decimal_point)
nz.setValue(self.config.num_zeros)
if not self.config.cv.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT.is_modifiable():
for w in [nz, nz_label]: w.setEnabled(False)
def on_nz():
value = nz.value()
if self.config.num_zeros != value:
self.config.num_zeros = value
self.config.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT = value
self.app.refresh_tabs_signal.emit()
self.app.update_status_signal.emit()
nz.valueChanged.connect(on_nz)
# lightning
trampoline_cb = checkbox_from_configvar(self.config.cv.LIGHTNING_USE_GOSSIP)
trampoline_cb.setChecked(not self.config.LIGHTNING_USE_GOSSIP)
def on_trampoline_checked(_x):
use_trampoline = trampoline_cb.isChecked()
if not use_trampoline:
if not window.question('\n'.join([
_("Are you sure you want to disable trampoline?"),
_("Without this option, Electrum will need to sync with the Lightning network on every start."),
_("This may impact the reliability of your payments."),
])):
trampoline_cb.setCheckState(Qt.CheckState.Checked)
return
self.config.LIGHTNING_USE_GOSSIP = not use_trampoline
if not use_trampoline:
self.network.start_gossip()
else:
self.network.run_from_another_thread(
self.network.stop_gossip())
legacy_add_trampoline_cb.setEnabled(use_trampoline)
util.trigger_callback('ln_gossip_sync_progress')
# FIXME: update all wallet windows
util.trigger_callback('channels_updated', self.wallet)
trampoline_cb.stateChanged.connect(on_trampoline_checked)
legacy_add_trampoline_cb = checkbox_from_configvar(self.config.cv.LIGHTNING_LEGACY_ADD_TRAMPOLINE)
legacy_add_trampoline_cb.setChecked(self.config.LIGHTNING_LEGACY_ADD_TRAMPOLINE)
legacy_add_trampoline_cb.setEnabled(trampoline_cb.isChecked())
def on_legacy_add_trampoline_checked(_x):
self.config.LIGHTNING_LEGACY_ADD_TRAMPOLINE = legacy_add_trampoline_cb.isChecked()
legacy_add_trampoline_cb.stateChanged.connect(on_legacy_add_trampoline_checked)
lnfee_hlabel = HelpLabel.from_configvar(self.config.cv.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
lnfee_map = [500, 1_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000]
def lnfee_update_vlabel(fee_val: int):
lnfee_vlabel.setText(_("{}% of payment").format(f"{fee_val / 10 ** 4:.2f}"))
def lnfee_slider_moved():
pos = lnfee_slider.sliderPosition()
fee_val = lnfee_map[pos]
lnfee_update_vlabel(fee_val)
def lnfee_slider_released():
pos = lnfee_slider.sliderPosition()
fee_val = lnfee_map[pos]
self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS = fee_val
lnfee_slider = QSlider(Qt.Orientation.Horizontal)
lnfee_slider.setRange(0, len(lnfee_map)-1)
lnfee_slider.setTracking(True)
try:
lnfee_spos = lnfee_map.index(self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
except ValueError:
lnfee_spos = 0
lnfee_slider.setSliderPosition(lnfee_spos)
lnfee_vlabel = QLabel("")
lnfee_update_vlabel(self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
lnfee_slider.valueChanged.connect(lnfee_slider_moved)
lnfee_slider.sliderReleased.connect(lnfee_slider_released)
lnfee_hbox = QHBoxLayout()
lnfee_hbox.setContentsMargins(0, 0, 0, 0)
lnfee_hbox.addWidget(lnfee_vlabel)
lnfee_hbox.addWidget(lnfee_slider)
lnfee_hbox_w = QWidget()
lnfee_hbox_w.setLayout(lnfee_hbox)
alias_label = HelpLabel.from_configvar(self.config.cv.OPENALIAS_ID)
alias = self.config.OPENALIAS_ID
self.alias_e = QLineEdit(alias)
self.set_alias_color()
self.alias_e.editingFinished.connect(self.on_alias_edit)
nostr_relays_label = HelpLabel.from_configvar(self.config.cv.NOSTR_RELAYS)
nostr_relays = self.config.NOSTR_RELAYS
self.nostr_relays_e = QLineEdit(nostr_relays)
def on_nostr_edit():
self.config.NOSTR_RELAYS = str(self.nostr_relays_e.text())
self.nostr_relays_e.editingFinished.connect(on_nostr_edit)
msat_cb = checkbox_from_configvar(self.config.cv.BTC_AMOUNTS_PREC_POST_SAT)
msat_cb.setChecked(self.config.BTC_AMOUNTS_PREC_POST_SAT > 0)
def on_msat_checked(_x):
prec = 3 if msat_cb.isChecked() else 0
if self.config.amt_precision_post_satoshi != prec:
self.config.amt_precision_post_satoshi = prec
self.config.BTC_AMOUNTS_PREC_POST_SAT = prec
self.app.refresh_tabs_signal.emit()
msat_cb.stateChanged.connect(on_msat_checked)
# units
units = base_units_list
msg = (_('Base unit of your wallet.')
+ '\n1 BTC = 1000 mBTC. 1 mBTC = 1000 bits. 1 bit = 100 sat.\n'
+ _('This setting affects the Send tab, and all balance related fields.'))
unit_label = HelpLabel(_('Base unit') + ':', msg)
unit_combo = QComboBox()
unit_combo.addItems(units)
unit_combo.setCurrentIndex(units.index(self.config.get_base_unit()))
def on_unit(x, nz):
unit_result = units[unit_combo.currentIndex()]
if self.config.get_base_unit() == unit_result:
return
self.config.set_base_unit(unit_result)
nz.setMaximum(self.config.decimal_point)
self.app.refresh_tabs_signal.emit()
self.app.update_status_signal.emit()
self.app.refresh_amount_edits_signal.emit()
unit_combo.currentIndexChanged.connect(lambda x: on_unit(x, nz))
thousandsep_cb = checkbox_from_configvar(self.config.cv.BTC_AMOUNTS_ADD_THOUSANDS_SEP)
thousandsep_cb.setChecked(self.config.BTC_AMOUNTS_ADD_THOUSANDS_SEP)
def on_set_thousandsep(_x):
checked = thousandsep_cb.isChecked()
if self.config.amt_add_thousands_sep != checked:
self.config.amt_add_thousands_sep = checked
self.config.BTC_AMOUNTS_ADD_THOUSANDS_SEP = checked
self.app.refresh_tabs_signal.emit()
thousandsep_cb.stateChanged.connect(on_set_thousandsep)
qr_combo = QComboBox()
qr_combo.addItem("Default", "default")
qr_label = HelpLabel.from_configvar(self.config.cv.VIDEO_DEVICE_PATH)
from .qrreader import find_system_cameras
system_cameras = find_system_cameras()
for cam_desc, cam_path in system_cameras.items():
qr_combo.addItem(cam_desc, cam_path)
index = qr_combo.findData(self.config.VIDEO_DEVICE_PATH)
qr_combo.setCurrentIndex(index)
def on_video_device(x):
self.config.VIDEO_DEVICE_PATH = qr_combo.itemData(x)
qr_combo.currentIndexChanged.connect(on_video_device)
colortheme_combo = QComboBox()
colortheme_combo.addItem(_('Light'), 'default')
colortheme_combo.addItem(_('Dark'), 'dark')
index = colortheme_combo.findData(self.config.GUI_QT_COLOR_THEME)
colortheme_combo.setCurrentIndex(index)
colortheme_label = QLabel(self.config.cv.GUI_QT_COLOR_THEME.get_short_desc() + ':')
def on_colortheme(x):
self.config.GUI_QT_COLOR_THEME = colortheme_combo.itemData(x)
self.need_restart = True
colortheme_combo.currentIndexChanged.connect(on_colortheme)
updatecheck_cb = checkbox_from_configvar(self.config.cv.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS)
updatecheck_cb.setChecked(self.config.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS)
def on_set_updatecheck(_x):
self.config.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS = updatecheck_cb.isChecked()
updatecheck_cb.stateChanged.connect(on_set_updatecheck)
filelogging_cb = checkbox_from_configvar(self.config.cv.WRITE_LOGS_TO_DISK)
filelogging_cb.setChecked(self.config.WRITE_LOGS_TO_DISK)
def on_set_filelogging(_x):
self.config.WRITE_LOGS_TO_DISK = filelogging_cb.isChecked()
self.need_restart = True
filelogging_cb.stateChanged.connect(on_set_filelogging)
block_explorers = sorted(util.block_explorer_info().keys())
BLOCK_EX_CUSTOM_ITEM = _("Custom URL")
if BLOCK_EX_CUSTOM_ITEM in block_explorers: # malicious translation?
block_explorers.remove(BLOCK_EX_CUSTOM_ITEM)
block_explorers.append(BLOCK_EX_CUSTOM_ITEM)
block_ex_label = HelpLabel.from_configvar(self.config.cv.BLOCK_EXPLORER)
block_ex_combo = QComboBox()
block_ex_custom_e = QLineEdit(str(self.config.BLOCK_EXPLORER_CUSTOM or ''))
block_ex_combo.addItems(block_explorers)
block_ex_combo.setCurrentIndex(
block_ex_combo.findText(util.block_explorer(self.config) or BLOCK_EX_CUSTOM_ITEM))
def showhide_block_ex_custom_e():
block_ex_custom_e.setVisible(block_ex_combo.currentText() == BLOCK_EX_CUSTOM_ITEM)
showhide_block_ex_custom_e()
def on_be_combo(x):
if block_ex_combo.currentText() == BLOCK_EX_CUSTOM_ITEM:
on_be_edit()
else:
be_result = block_explorers[block_ex_combo.currentIndex()]
self.config.BLOCK_EXPLORER_CUSTOM = None
self.config.BLOCK_EXPLORER = be_result
showhide_block_ex_custom_e()
block_ex_combo.currentIndexChanged.connect(on_be_combo)
def on_be_edit():
val = block_ex_custom_e.text()
try:
val = ast.literal_eval(val) # to also accept tuples
except Exception:
pass
self.config.BLOCK_EXPLORER_CUSTOM = val
block_ex_custom_e.editingFinished.connect(on_be_edit)
block_ex_hbox = QHBoxLayout()
block_ex_hbox.setContentsMargins(0, 0, 0, 0)
block_ex_hbox.setSpacing(0)
block_ex_hbox.addWidget(block_ex_combo)
block_ex_hbox.addWidget(block_ex_custom_e)
block_ex_hbox_w = QWidget()
block_ex_hbox_w.setLayout(block_ex_hbox)
# Fiat Currency
self.history_rates_cb = checkbox_from_configvar(self.config.cv.FX_HISTORY_RATES)
ccy_combo = QComboBox()
ex_combo = QComboBox()
def update_currencies():
if not self.fx:
return
h = self.config.FX_HISTORY_RATES
currencies = sorted(self.fx.get_currencies(h))
ccy_combo.clear()
ccy_combo.addItems([_('None')] + currencies)
if self.fx.is_enabled():
ccy_combo.setCurrentIndex(ccy_combo.findText(self.fx.get_currency()))
def update_exchanges():
if not self.fx: return
b = self.fx.is_enabled()
ex_combo.setEnabled(b)
if b:
h = self.config.FX_HISTORY_RATES
c = self.fx.get_currency()
exchanges = self.fx.get_exchanges_by_ccy(c, h)
else:
exchanges = self.fx.get_exchanges_by_ccy('USD', False)
ex_combo.blockSignals(True)
ex_combo.clear()
ex_combo.addItems(sorted(exchanges))
ex_combo.setCurrentIndex(ex_combo.findText(self.fx.config_exchange()))
ex_combo.blockSignals(False)
def on_currency(hh):
if not self.fx: return
b = bool(ccy_combo.currentIndex())
ccy = str(ccy_combo.currentText()) if b else None
self.fx.set_enabled(b)
if b and ccy != self.fx.ccy:
self.fx.set_currency(ccy)
update_exchanges()
self.app.update_fiat_signal.emit()
def on_exchange(idx):
exchange = str(ex_combo.currentText())
if self.fx and self.fx.is_enabled() and exchange and exchange != self.fx.exchange.name():
self.fx.set_exchange(exchange)
self.app.update_fiat_signal.emit()
def on_history_rates(_x):
self.config.FX_HISTORY_RATES = self.history_rates_cb.isChecked()
if not self.fx:
return
update_exchanges()
window.app.update_fiat_signal.emit()
update_currencies()
update_exchanges()
ccy_combo.currentIndexChanged.connect(on_currency)
self.history_rates_cb.setChecked(self.config.FX_HISTORY_RATES)
self.history_rates_cb.stateChanged.connect(on_history_rates)
ex_combo.currentIndexChanged.connect(on_exchange)
gui_widgets = []
gui_widgets.append((lang_label, lang_combo))
gui_widgets.append((colortheme_label, colortheme_combo))
gui_widgets.append((block_ex_label, block_ex_hbox_w))
units_widgets = []
units_widgets.append((unit_label, unit_combo))
units_widgets.append((nz_label, nz))
units_widgets.append((msat_cb, None))
units_widgets.append((thousandsep_cb, None))
lightning_widgets = []
lightning_widgets.append((trampoline_cb, None))
lightning_widgets.append((legacy_add_trampoline_cb, None))
lightning_widgets.append((lnfee_hlabel, lnfee_hbox_w))
fiat_widgets = []
fiat_widgets.append((QLabel(_('Fiat currency')), ccy_combo))
fiat_widgets.append((QLabel(_('Source')), ex_combo))
fiat_widgets.append((self.history_rates_cb, None))
misc_widgets = []
misc_widgets.append((updatecheck_cb, None))
misc_widgets.append((filelogging_cb, None))
misc_widgets.append((nostr_relays_label, self.nostr_relays_e))
misc_widgets.append((alias_label, self.alias_e))
misc_widgets.append((qr_label, qr_combo))
tabs_info = [
(gui_widgets, _('Appearance')),
(units_widgets, _('Units')),
(fiat_widgets, _('Fiat')),
(lightning_widgets, _('Lightning')),
(misc_widgets, _('Misc')),
]
for widgets, name in tabs_info:
tab = QWidget()
tab_vbox = QVBoxLayout(tab)
grid = QGridLayout()
for a,b in widgets:
i = grid.rowCount()
if b:
if a:
grid.addWidget(a, i, 0)
grid.addWidget(b, i, 1)
else:
grid.addWidget(a, i, 0, 1, 2)
tab_vbox.addLayout(grid)
tab_vbox.addStretch(1)
tabs.addTab(tab, name)
vbox.addWidget(tabs)
vbox.addStretch(1)
vbox.addLayout(Buttons(CloseButton(self)))
self.setLayout(vbox)
@event_listener
def on_event_alias_received(self):
self.app.alias_received_signal.emit()
def set_alias_color(self):
if not self.config.OPENALIAS_ID:
self.alias_e.setStyleSheet("")
return
if self.wallet.contacts.alias_info:
alias_addr, alias_name, validated = self.wallet.contacts.alias_info
self.alias_e.setStyleSheet((ColorScheme.GREEN if validated else ColorScheme.RED).as_stylesheet(True))
else:
self.alias_e.setStyleSheet(ColorScheme.RED.as_stylesheet(True))
def on_alias_edit(self):
self.alias_e.setStyleSheet("")
alias = str(self.alias_e.text())
self.config.OPENALIAS_ID = alias
if alias:
self.wallet.contacts.fetch_openalias(self.config)
def closeEvent(self, event):
self.unregister_callbacks()
try:
self.app.alias_received_signal.disconnect(self.set_alias_color)
except TypeError:
pass # 'method' object is not connected
event.accept()