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' }
]
property bool _probing: false
function toProxyDict() {
var p = {}
p['enabled'] = pc.proxy_enabled
@@ -104,19 +106,33 @@ Item {
Layout.topMargin: constants.paddingLarge
padding: 0
background: Rectangle {
color: Material.dialogColor
color: constants.darkerDialogBackground
}
FlatButton {
enabled: proxy_enabled_cb.checked
text: qsTr('Detect TOR proxy')
onClicked: Network.probeTor()
enabled: proxy_enabled_cb.checked && !_probing
text: qsTr('Detect Tor proxy')
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 {
target: Network
function onTorProbeFinished(host, port) {
_probing = false
if (host && port) {
proxytype.currentIndex = 0
proxy_port = ""+port

View File

@@ -25,21 +25,23 @@
from enum import IntEnum
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import (
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 electrum.i18n import _
from electrum import blockchain
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 .util import (Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit,
PasswordLineEdit, QtEventListener, qt_event_listener)
from .util import (
Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit, PasswordLineEdit, QtEventListener,
qt_event_listener, Spinner
)
_logger = get_logger(__name__)
@@ -260,16 +262,18 @@ class ProxyWidget(QWidget):
grid.addWidget(self.proxy_user, 2, 1, 1, 2)
grid.addWidget(self.proxy_password, 2, 3, 1, 2)
spacer = QVBoxLayout()
spacer.addStretch(1)
grid.addLayout(spacer, 3, 0, 1, 5)
detect_l = QHBoxLayout()
self.detect_button = QPushButton(_('Detect Tor proxy'))
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.addWidget(self.detect_button, 4, 0, 1, 5, alignment=Qt.AlignmentFlag.AlignHCenter)
grid.addLayout(detect_l, 3, 0, 1, 5, alignment=Qt.AlignmentFlag.AlignLeft)
spacer = QVBoxLayout()
spacer.addStretch(1)
grid.addLayout(spacer, 5, 0, 1, 5)
grid.addLayout(spacer, 4, 0, 1, 5)
self.update_from_config()
self.update()
@@ -293,7 +297,10 @@ class ProxyWidget(QWidget):
]:
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
net_params = self.network.get_parameters()
@@ -337,10 +344,15 @@ class ProxyWidget(QWidget):
return proxy
def detect_tor(self):
self.detect_button.setEnabled(False)
self.spinner.setVisible(True)
ProxySettings.probe_tor(self.torProbeFinished.emit) # via signal
@pyqtSlot(str, 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_host.setText(host)
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 .paytoedit import InvalidPaymentIdentifier
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
if TYPE_CHECKING:
@@ -142,14 +142,8 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
self.paste_button.setToolTip(_('Paste invoice from clipboard'))
self.paste_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.spinner = QMovie(icon_path('spinner.gif'))
self.spinner.setScaledSize(QSize(24, 24))
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.spinner = Spinner()
grid.addWidget(self.spinner, 0, 1, 1, 4, Qt.AlignmentFlag.AlignRight)
self.save_button = EnterButton(_("Save"), self.do_save_invoice)
self.save_button.setEnabled(False)
@@ -216,13 +210,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
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):
# FIXME: implement full valid amount check to enable/disable Pay button
pi = self.payto_e.payment_identifier
@@ -388,7 +375,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
def prepare_for_send_tab_network_lookup(self):
for btn in [self.save_button, self.send_button, self.clear_button]:
btn.setEnabled(False)
self.showSpinner(True)
self.spinner.setVisible(True)
def payment_request_error(self, 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
# currently we don't set error for emaillike recipients to avoid just that
self.logger.debug('payment identifier resolve done')
self.showSpinner(False)
self.spinner.setVisible(False)
if pi.error:
self.show_error(pi.error)
self.do_clear()
@@ -555,7 +542,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
return self.amount_e.get_amount() or 0
def on_finalize_done(self, pi: PaymentIdentifier):
self.showSpinner(False)
self.spinner.setVisible(False)
self.update_fields()
if 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.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.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QVBoxLayout, QLineEdit,
QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton,
@@ -117,6 +117,22 @@ class AmountLabel(QLabel):
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:
def __init__(self, help_text: str, *, help_title: str = None):
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
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:
MODES = ['socks4', 'socks5']
@@ -178,31 +193,21 @@ class ProxySettings:
def serialize_proxy_cfgstr(self):
return ':'.join([self.mode, self.host, self.port])
def deserialize_proxy_cfgstr(self, s: Optional[str], user: str = None, password: str = None) -> Optional[dict]:
if s is None or not isinstance(s, str) or s.lower() == 'none':
def deserialize_proxy_cfgstr(self, s: Optional[str], user: str = None, password: str = None) -> None:
if s is None or (isinstance(s, str) and s.lower() == 'none'):
self.set_defaults()
self.user = user
self.password = password
return
if not isinstance(s, str):
raise ValueError('proxy config not a string')
args = s.split(':')
if args[0] in ProxySettings.MODES:
self.mode = args[0]
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
if len(args) == 4 and is_valid_host(args[0]) and is_valid_port(args[1]): # host:port:user:pass,
self.host = args[0]
@@ -234,7 +239,7 @@ class ProxySettings:
proxy.deserialize_proxy_cfgstr(
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
@classmethod
@@ -253,7 +258,7 @@ class ProxySettings:
def detect_task(finished: Callable[[str | None, int | None], None]):
net_addr = detect_tor_socks_proxy()
if net_addr is None:
finished(None, None)
finished('', -1)
else:
host = net_addr[0]
port = net_addr[1]
@@ -750,14 +755,14 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
# return
self.config.NETWORK_AUTO_CONNECT = net_params.auto_connect
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_USER = proxy_user
self.config.NETWORK_PROXY_PASSWORD = proxy_pass
self.config.NETWORK_SERVER = str(server)
# abort if changes were not allowed by config
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_USER != proxy_user \
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_USER = ConfigVar('proxy_user', 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_NOONION = ConfigVar('noonion', default=False, type_=bool)
NETWORK_OFFLINE = ConfigVar('offline', default=False, type_=bool)