don't return the spending methods pay_invoice and multi_pay_invoice in the get_info request and the info event so connections can be used for services that enforce receive only connections.
300 lines
12 KiB
Python
300 lines
12 KiB
Python
from electrum.i18n import _
|
|
from .nwcserver import NWCServerPlugin
|
|
from electrum.gui.qt.util import WindowModalDialog, Buttons, OkButton, CancelButton, \
|
|
CloseButton
|
|
from electrum.gui.common_qt.util import paintQR
|
|
from electrum.gui.qt.util import read_QIcon_from_bytes, read_QPixmap_from_bytes
|
|
from electrum.plugin import hook
|
|
from functools import partial
|
|
from datetime import datetime
|
|
|
|
from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QTreeWidget, QTreeWidgetItem, \
|
|
QTextEdit, QApplication, QSpinBox, QSizePolicy, QComboBox, QLineEdit
|
|
from PyQt6.QtGui import QPixmap, QImage
|
|
from PyQt6.QtCore import Qt
|
|
|
|
from typing import TYPE_CHECKING, Optional
|
|
if TYPE_CHECKING:
|
|
from electrum.wallet import Abstract_Wallet
|
|
from electrum.gui.qt.main_window import ElectrumWindow
|
|
|
|
|
|
class Plugin(NWCServerPlugin):
|
|
def __init__(self, *args):
|
|
NWCServerPlugin.__init__(self, *args)
|
|
self._init_qt_received = False
|
|
|
|
@hook
|
|
def load_wallet(self, wallet: 'Abstract_Wallet', window: 'ElectrumWindow'):
|
|
if not wallet.has_lightning():
|
|
return
|
|
self.start_plugin(wallet)
|
|
|
|
@hook
|
|
def init_menubar(self, window):
|
|
ma = window.wallet_menu.addAction('Nostr Wallet Connect', partial(self.settings_dialog, window))
|
|
icon = read_QIcon_from_bytes(self.read_file('nwc.png'))
|
|
ma.setIcon(icon)
|
|
|
|
def settings_dialog(self, window: WindowModalDialog):
|
|
if not self.initialized:
|
|
window.show_error(
|
|
_("{} plugin requires a lightning enabled wallet. Open a lightning-enabled wallet first.")
|
|
.format("NWC"))
|
|
return
|
|
if window.wallet != self.nwc_server.wallet:
|
|
window.show_error('not using this wallet')
|
|
return
|
|
|
|
d = WindowModalDialog(window, _("Nostr Wallet Connect"))
|
|
main_layout = QHBoxLayout(d)
|
|
|
|
# Create the logo label.
|
|
logo_label = QLabel()
|
|
pixmap = read_QPixmap_from_bytes(self.read_file('nwc.png'))
|
|
logo_label.setPixmap(pixmap.scaled(50, 50))
|
|
logo_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
|
|
|
vbox = QVBoxLayout()
|
|
main_layout.addWidget(logo_label)
|
|
main_layout.addSpacing(16)
|
|
main_layout.addLayout(vbox)
|
|
|
|
# Connections list
|
|
connections_list = QTreeWidget()
|
|
connections_list.setHeaderLabels([_("Name"), _("Budget [{}]").format(self.config.get_base_unit()), _("Expiry")])
|
|
# Set the resize mode for all columns to adjust to content
|
|
header = connections_list.header()
|
|
header.setSectionResizeMode(0, header.ResizeMode.Stretch)
|
|
header.setSectionResizeMode(1, header.ResizeMode.ResizeToContents)
|
|
header.setSectionResizeMode(2, header.ResizeMode.ResizeToContents)
|
|
header.setStretchLastSection(False)
|
|
# Set size policy to expand horizontally
|
|
connections_list.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
|
# Make the widget update its size when data changes
|
|
connections_list.setAutoExpandDelay(0)
|
|
|
|
def update_connections_list():
|
|
# Clear the list and repopulate it
|
|
connections_list.clear()
|
|
connections = self.list_connections()
|
|
for name, conn in connections.items():
|
|
if conn['valid_until'] == 'unset':
|
|
expiry = _("never")
|
|
else:
|
|
expiry = datetime.fromtimestamp(conn['valid_until']).isoformat(' ')[:-3]
|
|
if conn['daily_limit_sat'] == 'unset':
|
|
limit = _('unlimited')
|
|
else:
|
|
budget = self.config.format_amount(conn['daily_limit_sat'])
|
|
used = self.config.format_amount(
|
|
self.nwc_server.get_used_budget(conn['client_pub']))
|
|
limit = f"{used}/{budget}"
|
|
item = QTreeWidgetItem(
|
|
[
|
|
name,
|
|
limit,
|
|
expiry
|
|
]
|
|
)
|
|
connections_list.addTopLevelItem(item)
|
|
|
|
update_connections_list()
|
|
connections_list.setMinimumHeight(min(connections_list.sizeHint().height(), 400))
|
|
|
|
# Delete button - initially disabled
|
|
delete_btn = QPushButton(_("Delete"))
|
|
delete_btn.setEnabled(False)
|
|
|
|
# Function to delete the selected connection
|
|
def delete_selected_connection():
|
|
selected_items = connections_list.selectedItems()
|
|
if not selected_items:
|
|
return
|
|
for item in selected_items:
|
|
try:
|
|
self.remove_connection(item.text(0))
|
|
except ValueError:
|
|
self.logger.error(f"Failed to remove connection: {item.text(0)}")
|
|
return
|
|
update_connections_list()
|
|
if self.nwc_server:
|
|
self.nwc_server.restart_event_handler()
|
|
delete_btn.setEnabled(False)
|
|
|
|
# Enable delete button when an item is selected
|
|
def on_item_selected():
|
|
delete_btn.setEnabled(bool(connections_list.selectedItems()))
|
|
|
|
connections_list.itemSelectionChanged.connect(on_item_selected)
|
|
delete_btn.clicked.connect(delete_selected_connection)
|
|
|
|
# Create Connection button
|
|
create_btn = QPushButton(_("Create"))
|
|
|
|
def create_connection():
|
|
# Show a dialog to create a new connection
|
|
connection_string = self.connection_info_input_dialog(window)
|
|
if connection_string:
|
|
update_connections_list()
|
|
self.show_new_connection_dialog(window, connection_string)
|
|
create_btn.clicked.connect(create_connection)
|
|
|
|
# Add the info and close button to the footer
|
|
close_button = OkButton(d, label=_("Close"))
|
|
info_button = QPushButton(_("Help"))
|
|
info = _("This plugin allows you to create Nostr Wallet Connect connections and "
|
|
"remote control your wallet using Nostr NIP-47.")
|
|
warning = _("Most NWC clients only use a single of your relays, so ensure the relays accept your events.")
|
|
supported_methods = _("Supported NIP-47 methods: {}").format(", ".join(self.nwc_server.SUPPORTED_METHODS))
|
|
info_msg = f"{info}\n\n{warning}\n\n{supported_methods}"
|
|
info_button.clicked.connect(lambda: window.show_message(info_msg))
|
|
|
|
title_hbox = QHBoxLayout()
|
|
title_hbox.addStretch(1)
|
|
title_hbox.addWidget(info_button)
|
|
|
|
footer_buttons = Buttons(
|
|
create_btn,
|
|
delete_btn,
|
|
close_button,
|
|
)
|
|
|
|
vbox.addLayout(title_hbox)
|
|
vbox.addWidget(QLabel(_('Existing Connections:')))
|
|
vbox.addWidget(connections_list)
|
|
vbox.addLayout(footer_buttons)
|
|
d.setLayout(main_layout)
|
|
|
|
return bool(d.exec())
|
|
|
|
def connection_info_input_dialog(self, window) -> Optional[str]:
|
|
# Create input dialog for connection parameters
|
|
input_dialog = WindowModalDialog(window, _("Enter NWC connection parameters"))
|
|
layout = QVBoxLayout(input_dialog)
|
|
|
|
# Name field (mandatory)
|
|
layout.addWidget(QLabel(_("Connection Name (required):")))
|
|
name_edit = QLineEdit()
|
|
name_edit.setMaximumHeight(30)
|
|
layout.addWidget(name_edit)
|
|
|
|
# Daily limit field (optional)
|
|
layout.addWidget(QLabel(_("Daily Satoshi Budget (optional):")))
|
|
limit_edit = OptionalSpinBox()
|
|
limit_edit.setRange(-1, 100_000_000)
|
|
limit_edit.setMaximumHeight(30)
|
|
layout.addWidget(limit_edit)
|
|
|
|
# Validity period field (optional)
|
|
layout.addWidget(QLabel(_("Valid for seconds (optional):")))
|
|
validity_edit = OptionalSpinBox()
|
|
validity_edit.setRange(-1, 63072000)
|
|
validity_edit.setMaximumHeight(30)
|
|
layout.addWidget(validity_edit)
|
|
|
|
def change_nwc_relay(url):
|
|
self.config.NWC_RELAY = url
|
|
|
|
# dropdown menu to select prioritized nwc relay from self.config.NOSTR_RELAYS
|
|
main_relay_label = QLabel(_("Main NWC Relay:"))
|
|
relay_tooltip = (
|
|
_("Most clients only use the first relay url encoded in the connection string.")
|
|
+ "\n" + _("The selected relay will be put first in the connection string."))
|
|
main_relay_label.setToolTip(relay_tooltip)
|
|
layout.addWidget(main_relay_label)
|
|
relay_combo = QComboBox()
|
|
relay_combo.setMaximumHeight(30)
|
|
relay_combo.addItems(self.config.NOSTR_RELAYS.split(","))
|
|
relay_combo.setCurrentText(self.config.NWC_RELAY) # type: ignore
|
|
relay_combo.currentTextChanged.connect(lambda: change_nwc_relay(relay_combo.currentText()))
|
|
layout.addWidget(relay_combo)
|
|
|
|
# Buttons
|
|
buttons = Buttons(OkButton(input_dialog), CancelButton(input_dialog))
|
|
layout.addLayout(buttons)
|
|
|
|
if not input_dialog.exec():
|
|
return None
|
|
|
|
# Validate inputs
|
|
name = name_edit.text().strip()
|
|
if not name or len(name) < 1:
|
|
window.show_error(_("Connection name is required"))
|
|
return None
|
|
duration_limit = validity_edit.value() if validity_edit.value() else None
|
|
|
|
# Call create_connection function with user-provided parameters
|
|
try:
|
|
connection_string = self.create_connection(
|
|
name=name,
|
|
daily_limit_sat=limit_edit.value(),
|
|
valid_for_sec=duration_limit
|
|
)
|
|
except ValueError as e:
|
|
window.show_error(str(e))
|
|
return None
|
|
|
|
if not connection_string:
|
|
window.show_error(_("Failed to create connection"))
|
|
return None
|
|
|
|
return connection_string
|
|
|
|
@staticmethod
|
|
def show_new_connection_dialog(window, connection_string: str):
|
|
# Create popup with QR code
|
|
popup = WindowModalDialog(window, _("New NWC Connection"))
|
|
vbox = QVBoxLayout(popup)
|
|
|
|
qr: Optional[QImage] = paintQR(connection_string)
|
|
if not qr:
|
|
return
|
|
qr_pixmap = QPixmap.fromImage(qr)
|
|
qr_label = QLabel()
|
|
qr_label.setPixmap(qr_pixmap)
|
|
qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
vbox.addWidget(QLabel(_("Scan this QR code with your nwc client:")))
|
|
vbox.addWidget(qr_label)
|
|
|
|
# Add connection string text that can be copied
|
|
vbox.addWidget(QLabel(_("Or copy this connection string:")))
|
|
text_edit = QTextEdit()
|
|
text_edit.setText(connection_string)
|
|
text_edit.setReadOnly(True)
|
|
text_edit.setMaximumHeight(80)
|
|
vbox.addWidget(text_edit)
|
|
|
|
warning_label = QLabel(_("After closing this window you won't be able to "
|
|
"access the connection string again!"))
|
|
warning_label.setStyleSheet("color: red;")
|
|
warning_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
vbox.addWidget(warning_label)
|
|
|
|
# Button to copy to clipboard
|
|
copy_btn = QPushButton(_("Copy to clipboard"))
|
|
copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(connection_string))
|
|
|
|
vbox.addLayout(Buttons(copy_btn, CloseButton(popup)))
|
|
|
|
popup.setLayout(vbox)
|
|
popup.exec()
|
|
|
|
|
|
class OptionalSpinBox(QSpinBox):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setSpecialValueText(" ")
|
|
self.setMinimum(-1)
|
|
self.setValue(-1)
|
|
|
|
def value(self):
|
|
# Return None if at special value, otherwise return the actual value
|
|
val = super().value()
|
|
return None if val == -1 else val
|
|
|
|
def setValue(self, value):
|
|
# Accept None to set to the special empty value
|
|
super().setValue(-1 if value is None else value)
|