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.plugin import hook from functools import partial from datetime import datetime from PyQt6.QtWidgets import QVBoxLayout, 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): window.wallet_menu.addAction('Nostr Wallet Connect', partial(self.settings_dialog, window)) 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 = QVBoxLayout(d) # Connections list main_layout.addWidget(QLabel(_("Existing Connections:"))) 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.ResizeToContents) 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)) main_layout.addWidget(connections_list) # 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) main_layout.addWidget(delete_btn) # Create Connection button create_btn = QPushButton(_("Create Connection")) 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) main_layout.addWidget(create_btn) # Add the info and close button to the footer close_button = OkButton(d, label=_("Close")) info_button = QPushButton(_("Info")) 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)) footer_buttons = Buttons( info_button, close_button, ) main_layout.addLayout(footer_buttons) d.setLayout(main_layout) # Resize the dialog to show the connections list properly conn_list_width = sum(header.sectionSize(i) for i in range(header.count())) d.resize(min(conn_list_width + 40, 600), d.height()) 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 value_limit = limit_edit.value() if limit_edit.value() else 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=value_limit, 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)