1
0

qt,qml: review rework, refactor spinner, add tor probe active indicator

This commit is contained in:
Sander van Grieken
2025-03-05 09:45:11 +01:00
parent fea598cfbe
commit f1e9abf04e
6 changed files with 93 additions and 57 deletions

View File

@@ -19,6 +19,8 @@ Item {
{ text: qsTr('SOCKS4'), value: 'socks4' } { text: qsTr('SOCKS4'), value: 'socks4' }
] ]
property bool _probing: false
function toProxyDict() { function toProxyDict() {
var p = {} var p = {}
p['enabled'] = pc.proxy_enabled p['enabled'] = pc.proxy_enabled
@@ -104,19 +106,33 @@ Item {
Layout.topMargin: constants.paddingLarge Layout.topMargin: constants.paddingLarge
padding: 0 padding: 0
background: Rectangle { background: Rectangle {
color: Material.dialogColor color: constants.darkerDialogBackground
} }
FlatButton { FlatButton {
enabled: proxy_enabled_cb.checked enabled: proxy_enabled_cb.checked && !_probing
text: qsTr('Detect TOR proxy') text: qsTr('Detect Tor proxy')
onClicked: Network.probeTor() onClicked: {
_probing = true
Network.probeTor()
}
} }
} }
BusyIndicator {
id: spinner
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: constants.paddingSmall
Layout.preferredWidth: constants.iconSizeXLarge
Layout.preferredHeight: constants.iconSizeXLarge
running: visible
visible: _probing
}
} }
Connections { Connections {
target: Network target: Network
function onTorProbeFinished(host, port) { function onTorProbeFinished(host, port) {
_probing = false
if (host && port) { if (host && port) {
proxytype.currentIndex = 0 proxytype.currentIndex = 0
proxy_port = ""+port proxy_port = ""+port

View File

@@ -25,21 +25,23 @@
from enum import IntEnum from enum import IntEnum
from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QTreeWidget, QTreeWidgetItem, QMenu, QGridLayout, QComboBox, QLineEdit, QDialog, QVBoxLayout, QHeaderView, QTreeWidget, QTreeWidgetItem, QMenu, QGridLayout, QComboBox, QLineEdit, QDialog, QVBoxLayout, QHeaderView,
QCheckBox, QTabWidget, QWidget, QLabel, QPushButton QCheckBox, QTabWidget, QWidget, QLabel, QPushButton, QHBoxLayout
) )
from PyQt6.QtGui import QIntValidator from PyQt6.QtGui import QIntValidator
from electrum.i18n import _ from electrum.i18n import _
from electrum import blockchain from electrum import blockchain
from electrum.interface import ServerAddr, PREFERRED_NETWORK_PROTOCOL from electrum.interface import ServerAddr, PREFERRED_NETWORK_PROTOCOL
from electrum.network import Network, ProxySettings from electrum.network import Network, ProxySettings, is_valid_host, is_valid_port
from electrum.logging import get_logger from electrum.logging import get_logger
from .util import (Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit, from .util import (
PasswordLineEdit, QtEventListener, qt_event_listener) Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit, PasswordLineEdit, QtEventListener,
qt_event_listener, Spinner
)
_logger = get_logger(__name__) _logger = get_logger(__name__)
@@ -260,16 +262,18 @@ class ProxyWidget(QWidget):
grid.addWidget(self.proxy_user, 2, 1, 1, 2) grid.addWidget(self.proxy_user, 2, 1, 1, 2)
grid.addWidget(self.proxy_password, 2, 3, 1, 2) grid.addWidget(self.proxy_password, 2, 3, 1, 2)
spacer = QVBoxLayout() detect_l = QHBoxLayout()
spacer.addStretch(1) self.detect_button = QPushButton(_('Detect Tor proxy'))
grid.addLayout(spacer, 3, 0, 1, 5) self.spinner = Spinner()
self.spinner.setMargin(5)
detect_l.addWidget(self.detect_button)
detect_l.addWidget(self.spinner)
self.detect_button = QPushButton(_('Detect TOR proxy')) grid.addLayout(detect_l, 3, 0, 1, 5, alignment=Qt.AlignmentFlag.AlignLeft)
grid.addWidget(self.detect_button, 4, 0, 1, 5, alignment=Qt.AlignmentFlag.AlignHCenter)
spacer = QVBoxLayout() spacer = QVBoxLayout()
spacer.addStretch(1) spacer.addStretch(1)
grid.addLayout(spacer, 5, 0, 1, 5) grid.addLayout(spacer, 4, 0, 1, 5)
self.update_from_config() self.update_from_config()
self.update() self.update()
@@ -293,7 +297,10 @@ class ProxyWidget(QWidget):
]: ]:
item.setEnabled(enabled) item.setEnabled(enabled)
if not self.proxy_port.hasAcceptableInput(): if not self.proxy_port.hasAcceptableInput() and not is_valid_port(self.proxy_port.text()):
return
if not is_valid_host(self.proxy_host.text()):
return return
net_params = self.network.get_parameters() net_params = self.network.get_parameters()
@@ -337,10 +344,15 @@ class ProxyWidget(QWidget):
return proxy return proxy
def detect_tor(self): def detect_tor(self):
self.detect_button.setEnabled(False)
self.spinner.setVisible(True)
ProxySettings.probe_tor(self.torProbeFinished.emit) # via signal ProxySettings.probe_tor(self.torProbeFinished.emit) # via signal
@pyqtSlot(str, int)
def on_tor_probe_finished(self, host: str, port: int): def on_tor_probe_finished(self, host: str, port: int):
if host is not None: self.detect_button.setEnabled(True)
self.spinner.setVisible(False)
if host:
self.proxy_mode.setCurrentIndex(1) self.proxy_mode.setCurrentIndex(1)
self.proxy_host.setText(host) self.proxy_host.setText(host)
self.proxy_port.setText(str(port)) self.proxy_port.setText(str(port))

View File

@@ -25,7 +25,7 @@ from electrum.submarine_swaps import SwapServerError
from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
from .paytoedit import InvalidPaymentIdentifier from .paytoedit import InvalidPaymentIdentifier
from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit, from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit,
get_iconname_camera, read_QIcon, ColorScheme, icon_path, IconLabel) get_iconname_camera, read_QIcon, ColorScheme, icon_path, IconLabel, Spinner)
from .invoice_list import InvoiceList from .invoice_list import InvoiceList
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -142,14 +142,8 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.paste_button.setToolTip(_('Paste invoice from clipboard')) self.paste_button.setToolTip(_('Paste invoice from clipboard'))
self.paste_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.paste_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.spinner = QMovie(icon_path('spinner.gif')) self.spinner = Spinner()
self.spinner.setScaledSize(QSize(24, 24)) grid.addWidget(self.spinner, 0, 1, 1, 4, Qt.AlignmentFlag.AlignRight)
self.spinner.setBackgroundColor(QColor('black'))
self.spinner_l = QLabel()
self.spinner_l.setMargin(5)
self.spinner_l.setVisible(False)
self.spinner_l.setMovie(self.spinner)
grid.addWidget(self.spinner_l, 0, 1, 1, 4, Qt.AlignmentFlag.AlignRight)
self.save_button = EnterButton(_("Save"), self.do_save_invoice) self.save_button = EnterButton(_("Save"), self.do_save_invoice)
self.save_button.setEnabled(False) self.save_button.setEnabled(False)
@@ -216,13 +210,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.setTabOrder(self.send_button, self.invoice_list) self.setTabOrder(self.send_button, self.invoice_list)
def showSpinner(self, b):
self.spinner_l.setVisible(b)
if b:
self.spinner.start()
else:
self.spinner.stop()
def on_amount_changed(self, text): def on_amount_changed(self, text):
# FIXME: implement full valid amount check to enable/disable Pay button # FIXME: implement full valid amount check to enable/disable Pay button
pi = self.payto_e.payment_identifier pi = self.payto_e.payment_identifier
@@ -388,7 +375,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
def prepare_for_send_tab_network_lookup(self): def prepare_for_send_tab_network_lookup(self):
for btn in [self.save_button, self.send_button, self.clear_button]: for btn in [self.save_button, self.send_button, self.clear_button]:
btn.setEnabled(False) btn.setEnabled(False)
self.showSpinner(True) self.spinner.setVisible(True)
def payment_request_error(self, error): def payment_request_error(self, error):
self.show_message(error) self.show_message(error)
@@ -499,7 +486,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
# TODO: resolve can happen while typing, we don't want message dialogs to pop up # TODO: resolve can happen while typing, we don't want message dialogs to pop up
# currently we don't set error for emaillike recipients to avoid just that # currently we don't set error for emaillike recipients to avoid just that
self.logger.debug('payment identifier resolve done') self.logger.debug('payment identifier resolve done')
self.showSpinner(False) self.spinner.setVisible(False)
if pi.error: if pi.error:
self.show_error(pi.error) self.show_error(pi.error)
self.do_clear() self.do_clear()
@@ -555,7 +542,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
return self.amount_e.get_amount() or 0 return self.amount_e.get_amount() or 0
def on_finalize_done(self, pi: PaymentIdentifier): def on_finalize_done(self, pi: PaymentIdentifier):
self.showSpinner(False) self.spinner.setVisible(False)
self.update_fields() self.update_fields()
if pi.error: if pi.error:
self.show_error(pi.error) self.show_error(pi.error)

View File

@@ -11,7 +11,7 @@ from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, List, Any, Se
from PyQt6 import QtCore from PyQt6 import QtCore
from PyQt6.QtGui import (QFont, QColor, QCursor, QPixmap, QImage, from PyQt6.QtGui import (QFont, QColor, QCursor, QPixmap, QImage,
QPalette, QIcon, QFontMetrics, QPainter, QContextMenuEvent) QPalette, QIcon, QFontMetrics, QPainter, QContextMenuEvent, QMovie)
from PyQt6.QtCore import (Qt, pyqtSignal, QCoreApplication, QThread, QSize, QRect, QPoint, QObject) from PyQt6.QtCore import (Qt, pyqtSignal, QCoreApplication, QThread, QSize, QRect, QPoint, QObject)
from PyQt6.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QVBoxLayout, QLineEdit, from PyQt6.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QVBoxLayout, QLineEdit,
QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton, QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton,
@@ -117,6 +117,22 @@ class AmountLabel(QLabel):
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
class Spinner(QLabel):
def __init__(self, *args, **kwargs):
QLabel.__init__(self, *args, **kwargs)
self.spinner = QMovie(icon_path('spinner.gif'))
self.spinner.setScaledSize(QSize(20, 20))
self.spinner.frameChanged.connect(lambda: self.setPixmap(self.spinner.currentPixmap()))
self.setVisible(False)
def setVisible(self, visible):
if visible:
self.spinner.start()
else:
self.spinner.stop()
super().setVisible(visible)
class HelpMixin: class HelpMixin:
def __init__(self, help_text: str, *, help_title: str = None): def __init__(self, help_text: str, *, help_title: str = None):
assert isinstance(self, QWidget), "HelpMixin must be a QWidget instance!" assert isinstance(self, QWidget), "HelpMixin must be a QWidget instance!"

View File

@@ -159,6 +159,21 @@ def pick_random_server(hostmap=None, *, allowed_protocols: Iterable[str],
return random.choice(eligible) if eligible else None return random.choice(eligible) if eligible else None
def is_valid_port(ps: str):
try:
return 0 < int(ps) < 65535
except ValueError:
return False
def is_valid_host(ph: str):
try:
NetAddress(ph, '1')
except ValueError:
return False
return True
class ProxySettings: class ProxySettings:
MODES = ['socks4', 'socks5'] MODES = ['socks4', 'socks5']
@@ -178,31 +193,21 @@ class ProxySettings:
def serialize_proxy_cfgstr(self): def serialize_proxy_cfgstr(self):
return ':'.join([self.mode, self.host, self.port]) return ':'.join([self.mode, self.host, self.port])
def deserialize_proxy_cfgstr(self, s: Optional[str], user: str = None, password: str = None) -> Optional[dict]: def deserialize_proxy_cfgstr(self, s: Optional[str], user: str = None, password: str = None) -> None:
if s is None or not isinstance(s, str) or s.lower() == 'none': if s is None or (isinstance(s, str) and s.lower() == 'none'):
self.set_defaults() self.set_defaults()
self.user = user self.user = user
self.password = password self.password = password
return return
if not isinstance(s, str):
raise ValueError('proxy config not a string')
args = s.split(':') args = s.split(':')
if args[0] in ProxySettings.MODES: if args[0] in ProxySettings.MODES:
self.mode = args[0] self.mode = args[0]
args = args[1:] args = args[1:]
def is_valid_port(ps: str):
try:
return 0 < int(ps) < 65535
except ValueError:
return False
def is_valid_host(ph: str):
try:
NetAddress(ph, '1')
except ValueError:
return False
return True
# detect migrate from old settings # detect migrate from old settings
if len(args) == 4 and is_valid_host(args[0]) and is_valid_port(args[1]): # host:port:user:pass, if len(args) == 4 and is_valid_host(args[0]) and is_valid_port(args[1]): # host:port:user:pass,
self.host = args[0] self.host = args[0]
@@ -234,7 +239,7 @@ class ProxySettings:
proxy.deserialize_proxy_cfgstr( proxy.deserialize_proxy_cfgstr(
config.NETWORK_PROXY, config.NETWORK_PROXY_USER, config.NETWORK_PROXY_PASSWORD config.NETWORK_PROXY, config.NETWORK_PROXY_USER, config.NETWORK_PROXY_PASSWORD
) )
proxy.enabled = config.NETWORK_ENABLE_PROXY proxy.enabled = config.NETWORK_PROXY_ENABLED
return proxy return proxy
@classmethod @classmethod
@@ -253,7 +258,7 @@ class ProxySettings:
def detect_task(finished: Callable[[str | None, int | None], None]): def detect_task(finished: Callable[[str | None, int | None], None]):
net_addr = detect_tor_socks_proxy() net_addr = detect_tor_socks_proxy()
if net_addr is None: if net_addr is None:
finished(None, None) finished('', -1)
else: else:
host = net_addr[0] host = net_addr[0]
port = net_addr[1] port = net_addr[1]
@@ -750,14 +755,14 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
# return # return
self.config.NETWORK_AUTO_CONNECT = net_params.auto_connect self.config.NETWORK_AUTO_CONNECT = net_params.auto_connect
self.config.NETWORK_ONESERVER = net_params.oneserver self.config.NETWORK_ONESERVER = net_params.oneserver
self.config.NETWORK_ENABLE_PROXY = proxy_enabled self.config.NETWORK_PROXY_ENABLED = proxy_enabled
self.config.NETWORK_PROXY = proxy_str self.config.NETWORK_PROXY = proxy_str
self.config.NETWORK_PROXY_USER = proxy_user self.config.NETWORK_PROXY_USER = proxy_user
self.config.NETWORK_PROXY_PASSWORD = proxy_pass self.config.NETWORK_PROXY_PASSWORD = proxy_pass
self.config.NETWORK_SERVER = str(server) self.config.NETWORK_SERVER = str(server)
# abort if changes were not allowed by config # abort if changes were not allowed by config
if self.config.NETWORK_SERVER != str(server) \ if self.config.NETWORK_SERVER != str(server) \
or self.config.NETWORK_ENABLE_PROXY != proxy_enabled \ or self.config.NETWORK_PROXY_ENABLED != proxy_enabled \
or self.config.NETWORK_PROXY != proxy_str \ or self.config.NETWORK_PROXY != proxy_str \
or self.config.NETWORK_PROXY_USER != proxy_user \ or self.config.NETWORK_PROXY_USER != proxy_user \
or self.config.NETWORK_PROXY_PASSWORD != proxy_pass \ or self.config.NETWORK_PROXY_PASSWORD != proxy_pass \

View File

@@ -950,7 +950,7 @@ class SimpleConfig(Logger):
NETWORK_PROXY = ConfigVar('proxy', default=None, type_=str, convert_getter=lambda v: "none" if v is None else v) NETWORK_PROXY = ConfigVar('proxy', default=None, type_=str, convert_getter=lambda v: "none" if v is None else v)
NETWORK_PROXY_USER = ConfigVar('proxy_user', default=None, type_=str) NETWORK_PROXY_USER = ConfigVar('proxy_user', default=None, type_=str)
NETWORK_PROXY_PASSWORD = ConfigVar('proxy_password', default=None, type_=str) NETWORK_PROXY_PASSWORD = ConfigVar('proxy_password', default=None, type_=str)
NETWORK_ENABLE_PROXY = ConfigVar('enable_proxy', default=lambda config: config.NETWORK_PROXY not in [None, "none"], type_=bool) NETWORK_PROXY_ENABLED = ConfigVar('enable_proxy', default=lambda config: config.NETWORK_PROXY not in [None, "none"], type_=bool)
NETWORK_SERVER = ConfigVar('server', default=None, type_=str) NETWORK_SERVER = ConfigVar('server', default=None, type_=str)
NETWORK_NOONION = ConfigVar('noonion', default=False, type_=bool) NETWORK_NOONION = ConfigVar('noonion', default=False, type_=bool)
NETWORK_OFFLINE = ConfigVar('offline', default=False, type_=bool) NETWORK_OFFLINE = ConfigVar('offline', default=False, type_=bool)