329 lines
13 KiB
Python
329 lines
13 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Electrum - lightweight Bitcoin client
|
|
# Copyright (C) 2025 The Electrum Developers
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person
|
|
# obtaining a copy of this software and associated documentation files
|
|
# (the "Software"), to deal in the Software without restriction,
|
|
# including without limitation the rights to use, copy, modify, merge,
|
|
# publish, distribute, sublicense, and/or sell copies of the Software,
|
|
# and to permit persons to whom the Software is furnished to do so,
|
|
# subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
|
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
from typing import TYPE_CHECKING, Optional
|
|
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 electrum.i18n import _
|
|
from electrum.plugin import hook
|
|
from electrum.gui.qt.util import (
|
|
WindowModalDialog, Buttons, OkButton, CancelButton, CloseButton,
|
|
read_QIcon_from_bytes, read_QPixmap_from_bytes,
|
|
)
|
|
from electrum.gui.common_qt.util import paintQR
|
|
|
|
from .nwcserver import NWCServerPlugin
|
|
|
|
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)
|