1
0

Merge branch 'pr/9507': qt: refactor NetworkChoiceLayout to ProxyWidget+ServerWidget

ref https://github.com/spesmilo/electrum/pull/9507
This commit is contained in:
SomberNight
2025-03-05 15:01:05 +00:00
18 changed files with 524 additions and 554 deletions

View File

@@ -25,7 +25,7 @@ import locale
import traceback
import sys
import queue
from typing import NamedTuple, Optional
from typing import TYPE_CHECKING, NamedTuple, Optional
from .version import ELECTRUM_VERSION
from . import constants
@@ -33,6 +33,9 @@ from .i18n import _
from .util import make_aiohttp_session, error_text_str_to_safe_str
from .logging import describe_os_version, Logger, get_git_version
if TYPE_CHECKING:
from .network import ProxySettings
class CrashReportResponse(NamedTuple):
status: Optional[str]
@@ -69,7 +72,7 @@ class BaseCrashReporter(Logger):
Logger.__init__(self)
self.exc_args = (exctype, value, tb)
def send_report(self, asyncio_loop, proxy, *, timeout=None) -> CrashReportResponse:
def send_report(self, asyncio_loop, proxy: 'ProxySettings', *, timeout=None) -> CrashReportResponse:
# FIXME the caller needs to catch generic "Exception", as this method does not have a well-defined API...
if constants.net.GENESIS[-4:] not in ["4943", "e26f"] and ".electrum.org" in BaseCrashReporter.report_server:
# Gah! Some kind of altcoin wants to send us crash reports.
@@ -98,7 +101,7 @@ class BaseCrashReporter(Logger):
)
return ret
async def do_post(self, proxy, url, data) -> str:
async def do_post(self, proxy: 'ProxySettings', url, data) -> str:
async with make_aiohttp_session(proxy) as session:
async with session.post(url, data=data, raise_for_status=True) as resp:
return await resp.text()

View File

@@ -227,21 +227,21 @@ Pane {
color: Material.accentColor
}
Label {
text: 'mode' in Network.proxy ? qsTr('enabled') : qsTr('disabled')
text: Network.proxy.enabled ? qsTr('enabled') : qsTr('disabled')
}
Label {
visible: 'mode' in Network.proxy
visible: Network.proxy.enabled
text: qsTr('Proxy server:');
color: Material.accentColor
}
Label {
visible: 'mode' in Network.proxy
text: Network.proxy['host'] ? Network.proxy['host'] + ':' + Network.proxy['port'] : ''
visible: Network.proxy.enabled
text: Network.proxy.host ? Network.proxy.host + ':' + Network.proxy.port : ''
}
Label {
visible: 'mode' in Network.proxy
visible: Network.proxy.enabled
text: qsTr('Proxy type:');
color: Material.accentColor
}
@@ -253,8 +253,8 @@ Pane {
source: '../../icons/tor_logo.png'
}
Label {
visible: 'mode' in Network.proxy
text: Network.isProxyTor ? 'TOR' : (Network.proxy['mode'] || '')
visible: Network.proxy.enabled
text: Network.isProxyTor ? 'TOR' : (Network.proxy.mode || '')
}
}

View File

@@ -37,12 +37,7 @@ ElDialog {
text: qsTr('Ok')
icon.source: '../../icons/confirmed.png'
onClicked: {
var proxy = proxyconfig.toProxyDict()
if (proxy && proxy['enabled'] == true) {
Network.proxy = proxy
} else {
Network.proxy = {'enabled': false}
}
Network.proxy = proxyconfig.toProxyDict()
rootItem.close()
}
}
@@ -52,17 +47,13 @@ ElDialog {
Component.onCompleted: {
var p = Network.proxy
if ('mode' in p) {
proxyconfig.proxy_enabled = true
proxyconfig.proxy_address = p['host']
proxyconfig.proxy_port = p['port']
proxyconfig.username = p['user']
proxyconfig.password = p['password']
proxyconfig.proxy_type = proxyconfig.proxy_type_map.map(function(x) {
return x.value
}).indexOf(p['mode'])
} else {
proxyconfig.proxy_enabled = false
}
proxyconfig.proxy_enabled = p['enabled']
proxyconfig.proxy_address = p['host']
proxyconfig.proxy_port = p['port']
proxyconfig.username = p['user']
proxyconfig.password = p['password']
proxyconfig.proxy_type = proxyconfig.proxy_type_map.map(function(x) {
return x.value
}).indexOf(p['mode'])
}
}

View File

@@ -10,7 +10,7 @@ Image {
property bool lagging: connected && Network.isLagging
property bool fork: connected && Network.chaintips > 1
property bool syncing: connected && Daemon.currentWallet && Daemon.currentWallet.synchronizing
property bool proxy: connected && 'mode' in Network.proxy && Network.proxy.mode
property bool proxy: connected && Network.proxy.enabled
// ?: in order to keep this a binding..
source: Qt.resolvedUrl(!connected

View File

@@ -19,17 +19,17 @@ Item {
{ text: qsTr('SOCKS4'), value: 'socks4' }
]
property bool _probing: false
function toProxyDict() {
var p = {}
p['enabled'] = pc.proxy_enabled
if (pc.proxy_enabled) {
var type = proxy_type_map[pc.proxy_type]['value']
p['mode'] = type
p['host'] = pc.proxy_address
p['port'] = pc.proxy_port
p['user'] = pc.username
p['password'] = pc.password
}
var type = proxy_type_map[pc.proxy_type]['value']
p['mode'] = type
p['host'] = pc.proxy_address
p['port'] = pc.proxy_port
p['user'] = pc.username
p['password'] = pc.password
return p
}
@@ -51,15 +51,6 @@ Item {
textRole: 'text'
valueRole: 'value'
model: proxy_type_map
onCurrentIndexChanged: {
if (currentIndex == 0) {
if (address.text == '' || port.text == '') {
address.text = "127.0.0.1"
port.text = "9050"
}
}
}
}
GridLayout {
@@ -109,5 +100,44 @@ Item {
enabled: proxy_enabled_cb.checked
}
}
Pane {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: constants.paddingLarge
padding: 0
background: Rectangle {
color: constants.darkerDialogBackground
}
FlatButton {
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
proxy_address = host
}
}
}
}

View File

@@ -1,9 +1,10 @@
from typing import TYPE_CHECKING
from PyQt6.QtCore import pyqtProperty, pyqtSignal, QObject
from PyQt6.QtCore import pyqtProperty, pyqtSignal, QObject, pyqtSlot
from electrum.logging import get_logger
from electrum import constants
from electrum.network import ProxySettings
from electrum.interface import ServerAddr
from electrum.fee_policy import FEERATE_DEFAULT_RELAY
@@ -24,6 +25,7 @@ class QENetwork(QObject, QtEventListener):
serverHeightChanged = pyqtSignal([int], arguments=['height'])
proxySet = pyqtSignal()
proxyChanged = pyqtSignal()
torProbeFinished = pyqtSignal([str, int], arguments=['host', 'port'])
statusChanged = pyqtSignal()
feeHistogramUpdated = pyqtSignal()
chaintipsChanged = pyqtSignal()
@@ -237,14 +239,14 @@ class QENetwork(QObject, QtEventListener):
@pyqtProperty('QVariantMap', notify=proxyChanged)
def proxy(self):
net_params = self.network.get_parameters()
return net_params.proxy if net_params.proxy else {}
proxy = net_params.proxy
return proxy.to_dict()
@proxy.setter
def proxy(self, proxy_settings):
def proxy(self, proxy_dict):
net_params = self.network.get_parameters()
if not proxy_settings['enabled']:
proxy_settings = None
net_params = net_params._replace(proxy=proxy_settings)
proxy = ProxySettings.from_dict(proxy_dict)
net_params = net_params._replace(proxy=proxy)
self.network.run_from_another_thread(self.network.set_parameters(net_params))
self.proxyChanged.emit()
@@ -273,3 +275,7 @@ class QENetwork(QObject, QtEventListener):
if self._serverListModel is None:
self._serverListModel = QEServerListModel(self.network)
return self._serverListModel
@pyqtSlot()
def probeTor(self):
ProxySettings.probe_tor(self.torProbeFinished.emit) # via signal

View File

@@ -256,7 +256,6 @@ class ElectrumGui(BaseElectrumGui, Logger):
window.clean_up()
if self.network_dialog:
self.network_dialog.close()
self.network_dialog.clean_up()
self.network_dialog = None
if self.lightning_dialog:
self.lightning_dialog.close()
@@ -294,16 +293,13 @@ class ElectrumGui(BaseElectrumGui, Logger):
self.lightning_dialog = LightningDialog(self)
self.lightning_dialog.bring_to_top()
def show_network_dialog(self):
def show_network_dialog(self, proxy_tab=False):
if self.network_dialog:
self.network_dialog.on_event_network_updated()
self.network_dialog.show()
self.network_dialog.show(proxy_tab=proxy_tab)
self.network_dialog.raise_()
return
self.network_dialog = NetworkDialog(
network=self.daemon.network,
config=self.config)
self.network_dialog.show()
self.network_dialog = NetworkDialog(network=self.daemon.network)
self.network_dialog.show(proxy_tab=proxy_tab)
def _create_window_for_wallet(self, wallet):
w = ElectrumWindow(self, wallet)

View File

@@ -1005,12 +1005,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
if self.fx.is_enabled():
balance_text += self.fx.get_fiat_status_text(balance,
self.base_unit(), self.get_decimal_point()) or ''
if not self.network.proxy:
if not self.network.proxy or not self.network.proxy.enabled:
icon = read_QIcon("status_connected%s.png"%fork_str)
else:
icon = read_QIcon("status_connected_proxy%s.png"%fork_str)
else:
if self.network.proxy:
if self.network.proxy and self.network.proxy.enabled:
network_text = "{} ({})".format(_("Not connected"), _("proxy enabled"))
else:
network_text = _("Not connected")
@@ -1763,7 +1763,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
self.tor_button = StatusBarButton(
read_QIcon("tor_logo.png"),
_("Tor"),
self.gui_object.show_network_dialog,
partial(self.gui_object.show_network_dialog, proxy_tab=True),
sb_height,
)
sb.addPermanentWidget(self.tor_button)

View File

@@ -24,25 +24,24 @@
# SOFTWARE.
from enum import IntEnum
import threading
from PyQt6.QtCore import Qt, pyqtSignal, QThread
from PyQt6.QtWidgets import (QTreeWidget, QTreeWidgetItem, QMenu, QGridLayout, QComboBox,
QLineEdit, QDialog, QVBoxLayout, QHeaderView, QCheckBox,
QTabWidget, QWidget, QLabel)
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, 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
from electrum.network import Network, ProxySettings, is_valid_host, is_valid_port
from electrum.logging import get_logger
from electrum.util import detect_tor_socks_proxy
from electrum.simple_config import SimpleConfig
from .util import (Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit,
PasswordLineEdit)
from .util import 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__)
@@ -52,32 +51,23 @@ protocol_letters = 'ts'
class NetworkDialog(QDialog, QtEventListener):
def __init__(self, *, network: Network, config: 'SimpleConfig'):
def __init__(self, *, network: Network):
QDialog.__init__(self)
self.setWindowTitle(_('Network'))
self.setMinimumSize(500, 500)
self.nlayout = NetworkChoiceLayout(network, config)
self.tabs = tabs = QTabWidget()
self._blockchain_tab = blockchain_tab = ServerWidget(network)
self._proxy_tab = proxy_tab = ProxyWidget(network)
tabs.addTab(blockchain_tab, _('Overview'))
tabs.addTab(proxy_tab, _('Proxy'))
vbox = QVBoxLayout(self)
vbox.addLayout(self.nlayout.layout())
vbox.addWidget(self.tabs)
vbox.addLayout(Buttons(CloseButton(self)))
self.register_callbacks()
self._cleaned_up = False
def show(self):
def show(self, *, proxy_tab: bool = False):
super().show()
if td := self.nlayout.td:
td.trigger_rescan()
@qt_event_listener
def on_event_network_updated(self):
self.nlayout.update()
def clean_up(self):
if self._cleaned_up:
return
self._cleaned_up = True
self.nlayout.clean_up()
self.unregister_callbacks()
self.tabs.setCurrentWidget(self._proxy_tab if proxy_tab else self._blockchain_tab)
class NodesListWidget(QTreeWidget):
@@ -227,375 +217,172 @@ class NodesListWidget(QTreeWidget):
super().update()
class NetworkChoiceLayout(object):
# TODO consolidate to ProxyWidget+ServerWidget
# TODO TorDetector is unnecessary, Network tests socks5 peer and detects Tor
# TODO apply on editingFinished is not ideal, separate Apply button and on Close?
def __init__(self, network: Network, config: 'SimpleConfig', wizard=False):
class ProxyWidget(QWidget):
PROXY_MODES = {
'socks4': 'SOCKS4',
'socks5': 'SOCKS5/TOR'
}
torProbeFinished = pyqtSignal([str, int], arguments=['host', 'port'])
def __init__(self, network: Network, parent=None):
super().__init__(parent)
self.network = network
self.config = config
self.tor_proxy = None
self.config = network.config
self.tabs = tabs = QTabWidget()
self._proxy_tab = proxy_tab = QWidget()
blockchain_tab = QWidget()
tabs.addTab(blockchain_tab, _('Overview'))
tabs.addTab(proxy_tab, _('Proxy'))
tabs.currentChanged.connect(self._on_tab_changed)
fixed_width_hostname = 24 * char_width_in_lineedit()
fixed_width_port = 6 * char_width_in_lineedit()
# Proxy tab
grid = QGridLayout(proxy_tab)
grid.setSpacing(8)
# proxy setting
# proxy setting.
self.proxy_cb = QCheckBox(_('Use proxy'))
self.proxy_cb.clicked.connect(self.check_disable_proxy)
self.proxy_cb.clicked.connect(self.set_proxy)
self.proxy_mode = QComboBox()
self.proxy_mode.addItems(['SOCKS4', 'SOCKS5'])
for k, v in self.PROXY_MODES.items():
self.proxy_mode.addItem(v, k)
self.proxy_mode.setCurrentIndex(1)
self.proxy_host = QLineEdit()
self.proxy_host.setFixedWidth(fixed_width_hostname)
self.proxy_port = QLineEdit()
self.proxy_port.setFixedWidth(fixed_width_port)
self.proxy_port_validator = QIntValidator(1, 65535)
self.proxy_port.setValidator(self.proxy_port_validator)
self.proxy_user = QLineEdit()
self.proxy_user.setPlaceholderText(_("Proxy user"))
self.proxy_user.setPlaceholderText(_("Proxy username"))
self.proxy_password = PasswordLineEdit()
self.proxy_password.setPlaceholderText(_("Password"))
self.proxy_password.setFixedWidth(fixed_width_port)
self.proxy_mode.currentIndexChanged.connect(self.set_proxy)
self.proxy_host.editingFinished.connect(self.set_proxy)
self.proxy_port.editingFinished.connect(self.set_proxy)
self.proxy_user.editingFinished.connect(self.set_proxy)
self.proxy_password.editingFinished.connect(self.set_proxy)
self.proxy_mode.currentIndexChanged.connect(self.proxy_settings_changed)
self.proxy_host.textEdited.connect(self.proxy_settings_changed)
self.proxy_port.textEdited.connect(self.proxy_settings_changed)
self.proxy_user.textEdited.connect(self.proxy_settings_changed)
self.proxy_password.textEdited.connect(self.proxy_settings_changed)
self.tor_cb = QCheckBox(_("Use Tor Proxy"))
self.tor_cb.setIcon(read_QIcon("tor_logo.png"))
self.tor_cb.hide()
self.tor_cb.clicked.connect(self.use_tor_proxy)
grid.addWidget(self.tor_cb, 1, 0, 1, 3)
grid.addWidget(self.proxy_cb, 2, 0, 1, 3)
grid.addWidget(HelpButton(_('Proxy settings apply to all connections: with Electrum servers, but also with third-party services.')), 2, 4)
grid.addWidget(self.proxy_mode, 4, 1)
grid.addWidget(self.proxy_host, 4, 2)
grid.addWidget(self.proxy_port, 4, 3)
grid.addWidget(self.proxy_user, 5, 2)
grid.addWidget(self.proxy_password, 5, 3)
grid.setRowStretch(7, 1)
# Blockchain Tab
grid = QGridLayout(blockchain_tab)
msg = ' '.join([
_("Electrum connects to several nodes in order to download block headers and find out the longest blockchain."),
_("This blockchain is used to verify the transactions sent by your transaction server.")
])
self.status_label = QLabel('')
grid.addWidget(QLabel(_('Status') + ':'), 0, 0)
grid.addWidget(self.status_label, 0, 1, 1, 3)
grid.addWidget(HelpButton(msg), 0, 4)
self.autoconnect_cb = QCheckBox(_('Select server automatically'))
self.autoconnect_cb.setEnabled(self.config.cv.NETWORK_AUTO_CONNECT.is_modifiable())
self.autoconnect_cb.clicked.connect(self.set_server)
self.autoconnect_cb.clicked.connect(self.update)
msg = ' '.join([
_("If auto-connect is enabled, Electrum will always use a server that is on the longest blockchain."),
_("If it is disabled, you have to choose a server you want to use. Electrum will warn you if your server is lagging.")
])
grid.addWidget(self.autoconnect_cb, 1, 0, 1, 3)
grid.addWidget(HelpButton(msg), 1, 4)
self.server_e = QLineEdit()
self.server_e.setFixedWidth(fixed_width_hostname + fixed_width_port)
self.server_e.editingFinished.connect(self.set_server)
msg = _("Electrum sends your wallet addresses to a single server, in order to receive your transaction history.")
grid.addWidget(QLabel(_('Server') + ':'), 2, 0)
grid.addWidget(self.server_e, 2, 1, 1, 3)
grid.addWidget(HelpButton(msg), 2, 4)
self.height_label = QLabel('')
msg = _('This is the height of your local copy of the blockchain.')
grid.addWidget(QLabel(_('Blockchain') + ':'), 3, 0)
grid.addWidget(self.height_label, 3, 1)
grid.addWidget(HelpButton(msg), 3, 4)
self.split_label = QLabel('')
grid.addWidget(self.split_label, 4, 0, 1, 3)
self.nodes_list_widget = NodesListWidget(network=self.network)
self.nodes_list_widget.followServer.connect(self.follow_server)
self.nodes_list_widget.followChain.connect(self.follow_branch)
def do_set_server(server):
self.server_e.setText(server)
self.set_server()
self.nodes_list_widget.setServer.connect(do_set_server)
grid.addWidget(self.nodes_list_widget, 6, 0, 1, 5)
vbox = QVBoxLayout()
vbox.addWidget(tabs)
self.layout_ = vbox
# tor detector
self.td = td = TorDetector()
td.found_proxy.connect(self.suggest_proxy)
td.start()
self.fill_in_proxy_settings()
self.update()
def clean_up(self):
if self.td:
self.td.found_proxy.disconnect()
self.td.stop()
self.td = None
def check_disable_proxy(self, b):
if not self.config.cv.NETWORK_PROXY.is_modifiable():
b = False
for w in [self.proxy_mode, self.proxy_host, self.proxy_port, self.proxy_user, self.proxy_password]:
w.setEnabled(b)
def enable_set_server(self):
if self.config.cv.NETWORK_SERVER.is_modifiable():
enabled = not self.autoconnect_cb.isChecked()
self.server_e.setEnabled(enabled)
else:
for w in [self.autoconnect_cb, self.server_e, self.nodes_list_widget]:
w.setEnabled(False)
def update(self):
net_params = self.network.get_parameters()
server = net_params.server
auto_connect = net_params.auto_connect
if not self.server_e.hasFocus():
self.server_e.setText(server.to_friendly_name())
self.autoconnect_cb.setChecked(auto_connect)
height_str = "%d "%(self.network.get_local_height()) + _('blocks')
self.height_label.setText(height_str)
self.status_label.setText(self.network.get_status())
chains = self.network.get_blockchains()
if len(chains) > 1:
chain = self.network.blockchain()
forkpoint = chain.get_max_forkpoint()
name = chain.get_name()
msg = _('Chain split detected at block {0}').format(forkpoint) + '\n'
msg += (_('You are following branch') if auto_connect else _('Your server is on branch'))+ ' ' + name
msg += ' (%d %s)' % (chain.get_branch_size(), _('blocks'))
else:
msg = ''
self.split_label.setText(msg)
self.nodes_list_widget.update()
self.enable_set_server()
def fill_in_proxy_settings(self):
proxy_config = self.network.get_parameters().proxy
if not proxy_config:
proxy_config = {"mode": "none", "host": "localhost", "port": "9050"}
b = proxy_config.get('mode') != "none"
self.check_disable_proxy(b)
if b:
self.proxy_cb.setChecked(True)
self.proxy_mode.setCurrentIndex(
self.proxy_mode.findText(str(proxy_config.get("mode").upper())))
self.proxy_host.setText(proxy_config.get("host"))
self.proxy_port.setText(proxy_config.get("port"))
self.proxy_user.setText(proxy_config.get("user", ""))
self.proxy_password.setText(proxy_config.get("password", ""))
def layout(self):
return self.layout_
def follow_branch(self, chain_id):
self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id))
self.update()
def follow_server(self, server: ServerAddr):
self.network.run_from_another_thread(self.network.follow_chain_given_server(server))
self.update()
def accept(self):
pass
def set_server(self):
net_params = self.network.get_parameters()
try:
server = ServerAddr.from_str_with_inference(str(self.server_e.text()))
if not server: raise Exception("failed to parse")
except Exception:
return
net_params = net_params._replace(server=server,
auto_connect=self.autoconnect_cb.isChecked())
self.network.run_from_another_thread(self.network.set_parameters(net_params))
def set_proxy(self):
net_params = self.network.get_parameters()
if self.proxy_cb.isChecked():
if not self.proxy_port.hasAcceptableInput():
return
proxy = {'mode':str(self.proxy_mode.currentText()).lower(),
'host':str(self.proxy_host.text()),
'port':str(self.proxy_port.text()),
'user':str(self.proxy_user.text()),
'password':str(self.proxy_password.text())}
else:
proxy = None
self.tor_cb.setChecked(False)
net_params = net_params._replace(proxy=proxy)
self.network.run_from_another_thread(self.network.set_parameters(net_params))
def _on_tab_changed(self):
if self.tabs.currentWidget() is self._proxy_tab:
self.td.trigger_rescan()
def suggest_proxy(self, found_proxy):
if found_proxy is None:
self.tor_cb.hide()
return
self.tor_proxy = found_proxy
self.tor_cb.setText(_("Use Tor proxy at port {}").format(str(found_proxy[1])))
if (self.proxy_cb.isChecked()
and self.proxy_mode.currentIndex() == self.proxy_mode.findText('SOCKS5')
and self.proxy_host.text() == "127.0.0.1"
and self.proxy_port.text() == str(found_proxy[1])):
self.tor_cb.setChecked(True)
self.tor_cb.show()
def use_tor_proxy(self, use_it):
if not use_it:
self.proxy_cb.setChecked(False)
else:
socks5_mode_index = self.proxy_mode.findText('SOCKS5')
if socks5_mode_index == -1:
_logger.info("can't find proxy_mode 'SOCKS5'")
return
self.proxy_mode.setCurrentIndex(socks5_mode_index)
self.proxy_host.setText("127.0.0.1")
self.proxy_port.setText(str(self.tor_proxy[1]))
self.proxy_user.setText("")
self.proxy_password.setText("")
self.tor_cb.setChecked(True)
self.proxy_cb.setChecked(True)
self.check_disable_proxy(use_it)
self.set_proxy()
def proxy_settings_changed(self):
self.tor_cb.setChecked(False)
class TorDetector(QThread):
found_proxy = pyqtSignal(object)
def __init__(self):
QThread.__init__(self)
self._work_to_do_evt = threading.Event()
self._stopping = False
def run(self):
while True:
# do rescan
net_addr = detect_tor_socks_proxy()
self.found_proxy.emit(net_addr)
# wait until triggered
self._work_to_do_evt.wait()
self._work_to_do_evt.clear()
if self._stopping:
return
def trigger_rescan(self) -> None:
self._work_to_do_evt.set()
def stop(self):
self._stopping = True
self._work_to_do_evt.set()
self.exit()
self.wait()
class ProxyWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
fixed_width_hostname = 24 * char_width_in_lineedit()
fixed_width_port = 6 * char_width_in_lineedit()
self.proxy_password.setPlaceholderText(_("Proxy password"))
grid = QGridLayout(self)
grid.setSpacing(8)
# proxy setting.
self.proxy_cb = QCheckBox(_('Use proxy'))
self.proxy_mode = QComboBox()
self.proxy_mode.addItems(['SOCKS4', 'SOCKS5'])
self.proxy_mode.setCurrentIndex(1)
self.proxy_host = QLineEdit()
self.proxy_host.setFixedWidth(fixed_width_hostname)
self.proxy_port = QLineEdit()
self.proxy_port.setFixedWidth(fixed_width_port)
self.proxy_user = QLineEdit()
self.proxy_user.setPlaceholderText(_("Proxy user"))
self.proxy_password = PasswordLineEdit()
self.proxy_password.setPlaceholderText(_("Password"))
self.proxy_password.setFixedWidth(fixed_width_port)
grid.addWidget(self.proxy_cb, 0, 0, 1, 4)
proxy_helpbutton = HelpButton(
_('Proxy settings apply to all connections: with Electrum servers, but also with third-party services.'))
grid.addWidget(proxy_helpbutton, 0, 4, alignment=Qt.AlignmentFlag.AlignRight)
grid.addWidget(self.proxy_mode, 1, 0, 1, 1)
grid.addWidget(self.proxy_host, 1, 1, 1, 3)
grid.addWidget(self.proxy_port, 1, 4, 1, 1)
grid.addWidget(self.proxy_user, 2, 1, 1, 2)
grid.addWidget(self.proxy_password, 2, 3, 1, 2)
grid.addWidget(self.proxy_cb, 0, 0, 1, 3)
grid.addWidget(HelpButton(_('Proxy settings apply to all connections: with Electrum servers, but also with third-party services.')), 0, 4)
grid.addWidget(self.proxy_mode, 1, 1)
grid.addWidget(self.proxy_host, 1, 2)
grid.addWidget(self.proxy_port, 1, 3)
grid.addWidget(self.proxy_user, 2, 2)
grid.addWidget(self.proxy_password, 2, 3)
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)
def get_proxy_settings(self):
return {
'enabled': self.proxy_cb.isChecked(),
'mode': ['socks4', 'socks5'][self.proxy_mode.currentIndex()],
'host': self.proxy_host.text(),
'port': self.proxy_port.text(),
'user': self.proxy_user.text(),
'password': self.proxy_password.text()
}
grid.addLayout(detect_l, 3, 0, 1, 5, alignment=Qt.AlignmentFlag.AlignLeft)
spacer = QVBoxLayout()
spacer.addStretch(1)
grid.addLayout(spacer, 4, 0, 1, 5)
self.update_from_config()
self.update()
# connect signal handlers after init from config
self.proxy_cb.stateChanged.connect(self.on_proxy_enable_toggle)
self.proxy_mode.currentIndexChanged.connect(self.on_proxy_settings_changed)
self.proxy_host.editingFinished.connect(self.on_proxy_settings_changed)
self.proxy_port.editingFinished.connect(self.on_proxy_settings_changed)
self.proxy_user.editingFinished.connect(self.on_proxy_settings_changed)
self.proxy_password.editingFinished.connect(self.on_proxy_settings_changed)
self.detect_button.clicked.connect(self.detect_tor)
self.torProbeFinished.connect(self.on_tor_probe_finished)
def update(self):
enabled = self.proxy_cb.isChecked() and self.config.cv.NETWORK_PROXY.is_modifiable()
for item in [
self.proxy_mode, self.proxy_host, self.proxy_port, self.proxy_user, self.proxy_password,
self.detect_button
]:
item.setEnabled(enabled)
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()
proxy = self.get_proxy_settings()
net_params = net_params._replace(proxy=proxy)
self.network.run_from_another_thread(self.network.set_parameters(net_params))
def update_from_config(self):
proxy = ProxySettings.from_config(self.config)
self.proxy_cb.setChecked(proxy.enabled)
self.proxy_mode.setCurrentText(self.PROXY_MODES.get(proxy.mode))
self.proxy_host.setText(proxy.host)
self.proxy_port.setText(proxy.port)
self.proxy_user.setText(proxy.user)
self.proxy_password.setText(proxy.password)
if not self.config.cv.NETWORK_PROXY.is_modifiable():
for w in [
self.proxy_cb, self.proxy_mode, self.proxy_host, self.proxy_port,
self.proxy_user, self.proxy_password, self.detect_button
]:
w.setEnabled(False)
def on_proxy_enable_toggle(self):
# probe if enabled and no pre-existing settings
# if self.proxy_cb.isChecked() and (not self.proxy_host.text() or not self.proxy_port.text()):
# self.detect_tor()
self.update()
def on_proxy_settings_changed(self):
self.update()
def get_proxy_settings(self) -> ProxySettings:
proxy = ProxySettings()
proxy.enabled = self.proxy_cb.isChecked()
proxy.mode = self.proxy_mode.currentData()
proxy.host = self.proxy_host.text()
proxy.port = self.proxy_port.text()
proxy.user = self.proxy_user.text()
proxy.password = self.proxy_password.text()
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):
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))
self.update()
class ServerWidget(QWidget, QtEventListener):
def __init__(self, network, parent=None):
def __init__(self, network: Network, parent=None):
super().__init__(parent)
self.network = network
self.config = network.config
fixed_width_hostname = 24 * char_width_in_lineedit()
fixed_width_port = 6 * char_width_in_lineedit()
self.setLayout(QVBoxLayout())
grid = QGridLayout(self)
grid = QGridLayout()
msg = ' '.join([
_("Electrum connects to several nodes in order to download block headers and find out the longest blockchain."),
_("This blockchain is used to verify the transactions sent by your transaction server.")
])
self.status_label_header = QLabel(_('Status') + ':')
self.status_label = QLabel('')
grid.addWidget(QLabel(_('Status') + ':'), 0, 0)
self.status_label_helpbutton = HelpButton(msg)
grid.addWidget(self.status_label_header, 0, 0)
grid.addWidget(self.status_label, 0, 1, 1, 3)
grid.addWidget(HelpButton(msg), 0, 4)
grid.addWidget(self.status_label_helpbutton, 0, 4)
self.autoconnect_cb = QCheckBox(_('Select server automatically'))
self.autoconnect_cb.setEnabled(self.config.cv.NETWORK_AUTO_CONNECT.is_modifiable())
self.autoconnect_cb.stateChanged.connect(self.on_server_settings_changed)
msg = ' '.join([
_("If auto-connect is enabled, Electrum will always use a server that is on the longest blockchain."),
_("If it is disabled, you have to choose a server you want to use. Electrum will warn you if your server is lagging.")
@@ -604,17 +391,19 @@ class ServerWidget(QWidget, QtEventListener):
grid.addWidget(HelpButton(msg), 1, 4)
self.server_e = QLineEdit()
self.server_e.setFixedWidth(fixed_width_hostname + fixed_width_port)
self.server_e.editingFinished.connect(self.on_server_settings_changed)
msg = _("Electrum sends your wallet addresses to a single server, in order to receive your transaction history.")
grid.addWidget(QLabel(_('Server') + ':'), 2, 0)
grid.addWidget(self.server_e, 2, 1, 1, 3)
grid.addWidget(HelpButton(msg), 2, 4)
self.height_label = QLabel('')
msg = _('This is the height of your local copy of the blockchain.')
grid.addWidget(QLabel(_('Blockchain') + ':'), 3, 0)
self.height_label_header = QLabel(_('Blockchain') + ':')
self.height_label = QLabel('')
self.height_label_helpbutton = HelpButton(msg)
grid.addWidget(self.height_label_header, 3, 0)
grid.addWidget(self.height_label, 3, 1)
grid.addWidget(HelpButton(msg), 3, 4)
grid.addWidget(self.height_label_helpbutton, 3, 4)
self.split_label = QLabel('')
grid.addWidget(self.split_label, 4, 0, 1, 3)
@@ -636,9 +425,62 @@ class ServerWidget(QWidget, QtEventListener):
self.register_callbacks()
self.destroyed.connect(lambda: self.unregister_callbacks())
self.update_from_config()
self.update()
@qt_event_listener
def on_event_network_updated(self):
self.nodes_list_widget.update()
self.nodes_list_widget.update() # NOTE: move event handling to widget itself?
self.update()
def on_server_settings_changed(self):
if not self.network._was_started:
self.update()
return
auto_connect = self.autoconnect_cb.isChecked()
server = self.server_e.text().strip()
net_params = self.network.get_parameters()
if server != net_params.server or auto_connect != net_params.auto_connect:
self.set_server()
def update(self):
auto_connect = self.autoconnect_cb.isChecked()
self.server_e.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable() and not auto_connect)
for item in [
self.status_label_header, self.status_label, self.status_label_helpbutton,
self.height_label_header, self.height_label, self.height_label_helpbutton]:
item.setVisible(self.network._was_started)
msg = ''
if self.network._was_started:
# Network was started, so we don't run in initial setup wizard.
# behavior in this case is to apply changes immediately.
# Also, we show block height and potential chain tips
height_str = _('{} blocks').format(self.network.get_local_height())
self.height_label.setText(height_str)
self.status_label.setText(self.network.get_status())
chains = self.network.get_blockchains()
if len(chains) > 1:
chain = self.network.blockchain()
forkpoint = chain.get_max_forkpoint()
name = chain.get_name()
msg = _('Chain split detected at block {0}').format(forkpoint) + '\n'
if auto_connect:
msg += _('You are following branch {}').format(name)
else:
msg += _('Your server is on branch {0} ({1} blocks)').format(name, chain.get_branch_size())
self.split_label.setText(msg)
def update_from_config(self):
auto_connect = self.config.NETWORK_AUTO_CONNECT
self.autoconnect_cb.setChecked(auto_connect)
server = self.config.NETWORK_SERVER
self.server_e.setText(server)
self.autoconnect_cb.setEnabled(self.config.cv.NETWORK_AUTO_CONNECT.is_modifiable())
self.server_e.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable() and not auto_connect)
self.nodes_list_widget.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable())
def follow_branch(self, chain_id):
self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id))

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
@@ -395,7 +382,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)
@@ -506,7 +493,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()
@@ -562,7 +549,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

@@ -88,16 +88,15 @@ class WCWelcome(WizardComponent):
class WCProxyConfig(WizardComponent):
def __init__(self, parent, wizard):
WizardComponent.__init__(self, parent, wizard, title=_('Proxy'))
self.pw = ProxyWidget(self)
self.pw = ProxyWidget(wizard._daemon.network, self)
self.pw.proxy_cb.setChecked(True)
self.pw.proxy_host.setText('localhost')
self.pw.proxy_port.setText('9050')
self.layout().addWidget(self.pw)
self.layout().addStretch(1)
self._valid = True
def apply(self):
self.wizard_data['proxy'] = self.pw.get_proxy_settings()
self.wizard_data['proxy'] = self.pw.get_proxy_settings().to_dict()
class WCServerConfig(WizardComponent):

View File

@@ -23,7 +23,7 @@ from electrum.transaction import PartialTxOutput
from electrum.wallet import Wallet, Abstract_Wallet
from electrum.wallet_db import WalletDB
from electrum.storage import WalletStorage
from electrum.network import NetworkParameters, TxBroadcastError, BestEffortRequestFailed
from electrum.network import NetworkParameters, TxBroadcastError, BestEffortRequestFailed, ProxySettings
from electrum.interface import ServerAddr
from electrum.invoices import Invoice
@@ -753,10 +753,14 @@ class ElectrumGui(BaseElectrumGui, EventListener):
self.show_message("Error:" + server_str + "\nIn doubt, type \"auto-connect\"")
return False
if out.get('server') or out.get('proxy') or out.get('proxy user') or out.get('proxy pass'):
new_proxy_config = electrum.network.deserialize_proxy(out.get('proxy')) if out.get('proxy') else proxy_config
if new_proxy_config:
new_proxy_config['user'] = out.get('proxy user') if 'proxy user' in out else proxy_config['user']
new_proxy_config['pass'] = out.get('proxy pass') if 'proxy pass' in out else proxy_config['pass']
if out.get('proxy'):
new_proxy_config = ProxySettings()
new_proxy_config.deserialize_proxy_cfgstr(out.get('proxy'))
new_proxy_config.user = out.get('proxy user', proxy_config.user)
new_proxy_config.password = out.get('proxy pass', proxy_config.password)
new_proxy_config.enabled = True
else:
new_proxy_config = proxy_config
net_params = NetworkParameters(
server=server_addr,
proxy=new_proxy_config,

View File

@@ -31,7 +31,10 @@ import threading
import socket
import json
import sys
from typing import NamedTuple, Optional, Sequence, List, Dict, Tuple, TYPE_CHECKING, Iterable, Set, Any, TypeVar
from typing import (
NamedTuple, Optional, Sequence, List, Dict, Tuple, TYPE_CHECKING, Iterable, Set, Any, TypeVar,
Callable
)
import traceback
import concurrent
from concurrent import futures
@@ -48,7 +51,7 @@ from . import util
from .util import (log_exceptions, ignore_exceptions, OldTaskGroup,
bfh, make_aiohttp_session, send_exception_to_crash_reporter,
is_hash256_str, is_non_negative_integer, MyEncoder, NetworkRetryManager,
error_text_str_to_safe_str)
error_text_str_to_safe_str, detect_tor_socks_proxy)
from .bitcoin import COIN, DummyAddress, DummyAddressUsedInTxException
from . import constants
from . import blockchain
@@ -158,65 +161,133 @@ def pick_random_server(hostmap=None, *, allowed_protocols: Iterable[str],
return random.choice(eligible) if eligible else None
class NetworkParameters(NamedTuple):
server: ServerAddr
proxy: Optional[dict]
auto_connect: bool
oneserver: bool = False
def is_valid_port(ps: str):
try:
return 0 < int(ps) < 65535
except ValueError:
return False
proxy_modes = ['socks4', 'socks5']
def is_valid_host(ph: str):
try:
NetAddress(ph, '1')
except ValueError:
return False
return True
def serialize_proxy(p):
if not isinstance(p, dict):
return None
return ':'.join([p.get('mode'), p.get('host'), p.get('port')])
class ProxySettings:
MODES = ['socks4', 'socks5']
probe_fut = None
def deserialize_proxy(s: Optional[str], user: str = None, password: str = None) -> Optional[dict]:
if not isinstance(s, str):
return None
if s.lower() == 'none':
return None
proxy = {"mode": "socks5", "host": "localhost"}
def __init__(self):
self.enabled = False
self.mode = 'socks5'
self.host = ''
self.port = ''
self.user = None
self.password = None
args = s.split(':')
if args[0] in proxy_modes:
proxy['mode'] = args[0]
args = args[1:]
def set_defaults(self):
self.__init__() # call __init__ for default values
def is_valid_port(ps: str):
try:
return 0 < int(ps) < 65535
except ValueError:
return False
def serialize_proxy_cfgstr(self):
return ':'.join([self.mode, self.host, self.port])
def is_valid_host(ph: str):
try:
NetAddress(ph, '1')
except ValueError:
return False
return True
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
# 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,
proxy['host'] = args[0]
proxy['port'] = args[1]
proxy['user'] = args[2]
proxy['password'] = args[3]
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:]
# 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]
self.port = args[1]
self.user = args[2]
self.password = args[3]
else:
self.host = ':'.join(args[:-1])
self.port = args[-1]
self.user = user
self.password = password
if not is_valid_host(self.host) or not is_valid_port(self.port):
self.enabled = False
def to_dict(self):
return {
'enabled': self.enabled,
'mode': self.mode,
'host': self.host,
'port': self.port,
'user': self.user,
'password': self.password
}
@classmethod
def from_config(cls, config: 'SimpleConfig') -> 'ProxySettings':
proxy = ProxySettings()
proxy.deserialize_proxy_cfgstr(
config.NETWORK_PROXY, config.NETWORK_PROXY_USER, config.NETWORK_PROXY_PASSWORD
)
proxy.enabled = config.NETWORK_PROXY_ENABLED
return proxy
proxy['host'] = ':'.join(args[:-1])
proxy['port'] = args[-1]
@classmethod
def from_dict(cls, d: dict) -> 'ProxySettings':
proxy = ProxySettings()
proxy.enabled = d.get('enabled', proxy.enabled)
proxy.mode = d.get('mode', proxy.mode)
proxy.host = d.get('host', proxy.host)
proxy.port = d.get('port', proxy.port)
proxy.user = d.get('user', proxy.user)
proxy.password = d.get('password', proxy.password)
return proxy
if not is_valid_host(proxy['host']) or not is_valid_port(proxy['port']):
return None
@classmethod
def probe_tor(cls, on_finished: Callable[[str | None, int | None], None]):
async def detect_task(finished: Callable[[str | None, int | None], None]):
net_addr = await detect_tor_socks_proxy()
if net_addr is None:
finished('', -1)
else:
host = net_addr[0]
port = net_addr[1]
finished(host, port)
cls.probe_fut = None
proxy['user'] = user
proxy['password'] = password
if cls.probe_fut: # one probe at a time
return
cls.probe_fut = asyncio.run_coroutine_threadsafe(detect_task(on_finished), util.get_asyncio_loop())
return proxy
def __eq__(self, other):
return self.enabled == other.enabled \
and self.mode == other.mode \
and self.host == other.host \
and self.port == other.port \
and self.user == other.user \
and self.password == other.password
def __str__(self):
return f'{self.enabled=} {self.mode=} {self.host=} {self.port=} {self.user=}'
class NetworkParameters(NamedTuple):
server: ServerAddr
proxy: ProxySettings
auto_connect: bool
oneserver: bool = False
class BestEffortRequestFailed(NetworkException): pass
@@ -325,7 +396,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
self._allowed_protocols = {PREFERRED_NETWORK_PROTOCOL}
self.proxy = None # type: Optional[dict]
self.proxy = ProxySettings()
self.is_proxy_tor = None
self._init_parameters_from_config()
@@ -535,8 +606,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
dns_hacks.configure_dns_resolver()
self.auto_connect = self.config.NETWORK_AUTO_CONNECT
self._set_default_server()
self._set_proxy(deserialize_proxy(self.config.NETWORK_PROXY, self.config.NETWORK_PROXY_USER,
self.config.NETWORK_PROXY_PASSWORD))
self._set_proxy(ProxySettings.from_config(self.config))
self._maybe_set_oneserver()
def get_donation_address(self):
@@ -659,7 +729,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
self.default_server = pick_random_server(allowed_protocols=self._allowed_protocols)
assert isinstance(self.default_server, ServerAddr), f"invalid type for default_server: {self.default_server!r}"
def _set_proxy(self, proxy: Optional[dict]):
def _set_proxy(self, proxy: ProxySettings):
if self.proxy == proxy:
return
@@ -673,9 +743,9 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
util.trigger_callback('proxy_set', self.proxy)
def _detect_if_proxy_is_tor(self) -> None:
def tor_probe_task(p):
async def tor_probe_task(p):
assert p is not None
is_tor = util.is_tor_socks_port(p['host'], int(p['port']))
is_tor = await util.is_tor_socks_port(p.host, int(p.port))
if self.proxy == p: # is this the proxy we probed?
if self.is_proxy_tor != is_tor:
self.logger.info(f'Proxy is {"" if is_tor else "not "}TOR')
@@ -683,32 +753,38 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
util.trigger_callback('tor_probed', is_tor)
proxy = self.proxy
if proxy and proxy['mode'] == 'socks5':
t = threading.Thread(target=tor_probe_task, args=(proxy,), daemon=True)
t.start()
if proxy and proxy.enabled and proxy.mode == 'socks5':
# FIXME GC issues? do we need to store the Future?
asyncio.run_coroutine_threadsafe(tor_probe_task(proxy), self.asyncio_loop)
@log_exceptions
async def set_parameters(self, net_params: NetworkParameters):
proxy = net_params.proxy
proxy_str = serialize_proxy(proxy)
proxy_user = proxy['user'] if proxy else None
proxy_pass = proxy['password'] if proxy else None
proxy_str = proxy.serialize_proxy_cfgstr()
proxy_enabled = proxy.enabled
proxy_user = proxy.user
proxy_pass = proxy.password
server = net_params.server
# sanitize parameters
try:
if proxy:
proxy_modes.index(proxy['mode']) + 1
int(proxy['port'])
# proxy_modes.index(proxy['mode']) + 1
ProxySettings.MODES.index(proxy.mode) + 1
# int(proxy['port'])
int(proxy.port)
except Exception:
return
proxy.enabled = False
# return
self.config.NETWORK_AUTO_CONNECT = net_params.auto_connect
self.config.NETWORK_ONESERVER = net_params.oneserver
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_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 \
@@ -885,7 +961,7 @@ class Network(Logger, NetworkRetryManager[ServerAddr]):
return self.config.NETWORK_TIMEOUT
if self.oneserver and not self.auto_connect:
return request_type.MOST_RELAXED
if self.proxy:
if self.proxy and self.proxy.enabled:
return request_type.RELAXED
return request_type.NORMAL

View File

@@ -566,6 +566,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_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)

View File

@@ -67,7 +67,7 @@ from .i18n import _
from .logging import get_logger, Logger
if TYPE_CHECKING:
from .network import Network
from .network import Network, ProxySettings
from .interface import Interface
from .simple_config import SimpleConfig
from .paymentrequest import PaymentRequest
@@ -1313,7 +1313,7 @@ def format_short_id(short_channel_id: Optional[bytes]):
+ 'x' + str(int.from_bytes(short_channel_id[6:], 'big'))
def make_aiohttp_session(proxy: Optional[dict], headers=None, timeout=None):
def make_aiohttp_session(proxy: Optional['ProxySettings'], headers=None, timeout=None):
if headers is None:
headers = {'User-Agent': 'Electrum'}
if timeout is None:
@@ -1324,13 +1324,13 @@ def make_aiohttp_session(proxy: Optional[dict], headers=None, timeout=None):
timeout = aiohttp.ClientTimeout(total=timeout)
ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path)
if proxy:
if proxy and proxy.enabled:
connector = ProxyConnector(
proxy_type=ProxyType.SOCKS5 if proxy['mode'] == 'socks5' else ProxyType.SOCKS4,
host=proxy['host'],
port=int(proxy['port']),
username=proxy.get('user', None),
password=proxy.get('password', None),
proxy_type=ProxyType.SOCKS5 if proxy.mode == 'socks5' else ProxyType.SOCKS4,
host=proxy.host,
port=int(proxy.port),
username=proxy.user,
password=proxy.password,
rdns=True, # needed to prevent DNS leaks over proxy
ssl=ssl_context,
)
@@ -1547,32 +1547,51 @@ class NetworkJobOnDefaultServer(Logger, ABC):
return s
def detect_tor_socks_proxy() -> Optional[Tuple[str, int]]:
async def detect_tor_socks_proxy() -> Optional[Tuple[str, int]]:
# Probable ports for Tor to listen at
candidates = [
("127.0.0.1", 9050),
("127.0.0.1", 9051),
("127.0.0.1", 9150),
]
for net_addr in candidates:
if is_tor_socks_port(*net_addr):
return net_addr
return None
proxy_addr = None
async def test_net_addr(net_addr):
is_tor = await is_tor_socks_port(*net_addr)
# set result, and cancel remaining probes
if is_tor:
nonlocal proxy_addr
proxy_addr = net_addr
await group.cancel_remaining()
async with OldTaskGroup() as group:
for net_addr in candidates:
await group.spawn(test_net_addr(net_addr))
return proxy_addr
def is_tor_socks_port(host: str, port: int) -> bool:
@log_exceptions
async def is_tor_socks_port(host: str, port: int) -> bool:
# mimic "tor-resolve 0.0.0.0".
# see https://github.com/spesmilo/electrum/issues/7317#issuecomment-1369281075
# > this is a socks5 handshake, followed by a socks RESOLVE request as defined in
# > [tor's socks extension spec](https://github.com/torproject/torspec/blob/7116c9cdaba248aae07a3f1d0e15d9dd102f62c5/socks-extensions.txt#L63),
# > resolving 0.0.0.0, which being an IP, tor resolves itself without needing to ask a relay.
writer = None
try:
with socket.create_connection((host, port), timeout=10) as s:
# mimic "tor-resolve 0.0.0.0".
# see https://github.com/spesmilo/electrum/issues/7317#issuecomment-1369281075
# > this is a socks5 handshake, followed by a socks RESOLVE request as defined in
# > [tor's socks extension spec](https://github.com/torproject/torspec/blob/7116c9cdaba248aae07a3f1d0e15d9dd102f62c5/socks-extensions.txt#L63),
# > resolving 0.0.0.0, which being an IP, tor resolves itself without needing to ask a relay.
s.send(b'\x05\x01\x00\x05\xf0\x00\x03\x070.0.0.0\x00\x00')
if s.recv(1024) == b'\x05\x00\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00':
async with async_timeout(10):
reader, writer = await asyncio.open_connection(host, port)
writer.write(b'\x05\x01\x00\x05\xf0\x00\x03\x070.0.0.0\x00\x00')
await writer.drain()
data = await reader.read(1024)
if data == b'\x05\x00\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00':
return True
except socket.error:
pass
return False
return False
except (OSError, asyncio.TimeoutError):
return False
finally:
if writer:
writer.close()
AS_LIB_USER_I_WANT_TO_MANAGE_MY_OWN_ASYNCIO_LOOP = False # used by unit tests
@@ -1969,10 +1988,10 @@ class ESocksProxy(aiorpcx.SOCKSProxy):
@classmethod
def from_network_settings(cls, network: Optional['Network']) -> Optional['ESocksProxy']:
if not network or not network.proxy:
if not network or not network.proxy or not network.proxy.enabled:
return None
proxy = network.proxy
username, pw = proxy.get('user'), proxy.get('password')
username, pw = proxy.user, proxy.password
if not username or not pw:
# is_proxy_tor is tri-state; None indicates it is still probing the proxy to test for TOR
if network.is_proxy_tor:
@@ -1981,10 +2000,10 @@ class ESocksProxy(aiorpcx.SOCKSProxy):
auth = None
else:
auth = aiorpcx.socks.SOCKSUserAuth(username, pw)
addr = aiorpcx.NetAddress(proxy['host'], proxy['port'])
if proxy['mode'] == "socks4":
addr = aiorpcx.NetAddress(proxy.host, proxy.port)
if proxy.mode == "socks4":
ret = cls(addr, aiorpcx.socks.SOCKS4a, auth)
elif proxy['mode'] == "socks5":
elif proxy.mode == "socks5":
ret = cls(addr, aiorpcx.socks.SOCKS5, auth)
else:
raise NotImplementedError # http proxy not available with aiorpcx

View File

@@ -7,6 +7,7 @@ from electrum.i18n import _
from electrum.interface import ServerAddr
from electrum.keystore import hardware_keystore
from electrum.logging import get_logger
from electrum.network import ProxySettings
from electrum.plugin import run_hook
from electrum.slip39 import EncryptedSeed
from electrum.storage import WalletStorage, StorageEncryptionVersion
@@ -731,9 +732,8 @@ class ServerConnectWizard(AbstractWizard):
return
self._logger.debug(f'configuring proxy: {proxy_settings!r}')
net_params = self._daemon.network.get_parameters()
if not proxy_settings['enabled']:
proxy_settings = None
net_params = net_params._replace(proxy=proxy_settings, auto_connect=bool(wizard_data['autoconnect']))
proxy = ProxySettings.from_dict(proxy_settings)
net_params = net_params._replace(proxy=proxy, auto_connect=bool(wizard_data['autoconnect']))
self._daemon.network.run_from_another_thread(self._daemon.network.set_parameters(net_params))
def do_configure_server(self, wizard_data: dict):