From 3ff84f08a6026fcd4bacde24463b1ba459386fad Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 4 Apr 2025 16:44:44 +0200 Subject: [PATCH] plugins: psbt_nostr: implement for qml --- .../gui/qml/components/ExportTxDialog.qml | 12 ++ .../components/controls/ButtonContainer.qml | 8 +- electrum/gui/qml/components/main.qml | 39 ++++++ electrum/gui/qml/qeapp.py | 16 ++- electrum/plugins/psbt_nostr/manifest.json | 2 +- electrum/plugins/psbt_nostr/psbt_nostr.py | 3 + electrum/plugins/psbt_nostr/qml.py | 116 ++++++++++++++---- electrum/plugins/psbt_nostr/qml/main.qml | 54 ++++++++ electrum/plugins/psbt_nostr/qt.py | 5 +- 9 files changed, 221 insertions(+), 34 deletions(-) create mode 100644 electrum/plugins/psbt_nostr/qml/main.qml diff --git a/electrum/gui/qml/components/ExportTxDialog.qml b/electrum/gui/qml/components/ExportTxDialog.qml index 82850aa60..62b4291bf 100644 --- a/electrum/gui/qml/components/ExportTxDialog.qml +++ b/electrum/gui/qml/components/ExportTxDialog.qml @@ -76,6 +76,7 @@ ElDialog { } ButtonContainer { + id: buttons Layout.fillWidth: true FlatButton { @@ -97,6 +98,17 @@ ElDialog { AppController.doShare(dialog.text, dialog.title) } } + function beforeLayout() { + var export_tx_buttons = app.pluginsComponentsByName('export_tx_button') + for (var i=0; i < export_tx_buttons.length; i++) { + var b = export_tx_buttons[i].createObject(buttons, { + dialog: dialog + }) + b.Layout.fillWidth = true + b.Layout.preferredWidth = 1 + buttons.addItem(b) + } + } } } diff --git a/electrum/gui/qml/components/controls/ButtonContainer.qml b/electrum/gui/qml/components/controls/ButtonContainer.qml index 5a3d18c43..527897c0b 100644 --- a/electrum/gui/qml/components/controls/ButtonContainer.qml +++ b/electrum/gui/qml/components/controls/ButtonContainer.qml @@ -35,7 +35,13 @@ Container { contentItem = contentRoot } - Component.onCompleted: fillContentItem() + // override this function to dynamically add buttons. + function beforeLayout() {} + + Component.onCompleted: { + beforeLayout() + fillContentItem() + } Component { id: containerLayout diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 14df2cf4b..203bf20dc 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -39,6 +39,8 @@ ApplicationWindow property var _exceptionDialog + property var pluginobjects: ({}) + property QtObject appMenu: Menu { id: menu @@ -640,6 +642,43 @@ ApplicationWindow }) app._exceptionDialog.open() } + function onPluginLoaded(name) { + console.log('plugin ' + name + ' loaded') + var loader = AppController.plugin(name).loader + if (loader == undefined) + return + var url = Qt.resolvedUrl('../../../plugins/' + name + '/qml/' + loader) + var comp = Qt.createComponent(url) + if (comp.status == Component.Error) { + console.log('Could not find/parse PluginLoader for plugin ' + name) + console.log(comp.errorString()) + return + } + var obj = comp.createObject(app) + if (obj != null) + app.pluginobjects[name] = obj + } + } + + function pluginsComponentsByName(comp_name) { + // return named QML components from plugins + var plugins = AppController.plugins + var result = [] + for (var i=0; i < plugins.length; i++) { + if (!plugins[i].enabled) + continue + var pluginobject = app.pluginobjects[plugins[i].name] + if (!pluginobject) + continue + if (!(comp_name in pluginobject)) + continue + var comp = pluginobject[comp_name] + if (!comp) + continue + + result.push(comp) + } + return result } Connections { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index bb8587824..3efc496d2 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -74,6 +74,8 @@ class QEAppController(BaseCrashReporter, QObject): sendingBugreportFailure = pyqtSignal(str) secureWindowChanged = pyqtSignal() wantCloseChanged = pyqtSignal() + pluginLoaded = pyqtSignal(str) + startupFinished = pyqtSignal() def __init__(self, qeapp: 'ElectrumQmlApplication', plugins: 'Plugins'): BaseCrashReporter.__init__(self, None, None, None) @@ -229,8 +231,11 @@ class QEAppController(BaseCrashReporter, QObject): if scheme == BITCOIN_BIP21_URI_SCHEME or scheme == LIGHTNING_URI_SCHEME: self.uriReceived.emit(data) - def startupFinished(self): + def startup_finished(self): self._app_started = True + self.startupFinished.emit() + for plugin_name in self._plugins.plugins.keys(): + self.pluginLoaded.emit(plugin_name) if self._intent: self.on_new_intent(self._intent) @@ -304,18 +309,16 @@ class QEAppController(BaseCrashReporter, QObject): self.logger.debug('None!') return None - @pyqtProperty('QVariant', notify=_dummy) + @pyqtProperty('QVariantList', notify=_dummy) def plugins(self): s = [] for item in self._plugins.descriptions: - self.logger.info(item) s.append({ 'name': item, 'fullname': self._plugins.descriptions[item]['fullname'], 'enabled': bool(self._plugins.get(item)) }) - self.logger.debug(f'{str(s)}') return s @pyqtSlot(str, bool) @@ -511,10 +514,11 @@ class ElectrumQmlApplication(QGuiApplication): # slot is called after loading root QML. If object is None, it has failed. @pyqtSlot('QObject*', 'QUrl') def objectCreated(self, object, url): + self.engine.objectCreated.disconnect(self.objectCreated) if object is None: self._valid = False - self.engine.objectCreated.disconnect(self.objectCreated) - self.appController.startupFinished() + else: + self.appController.startup_finished() def message_handler(self, line, funct, file): # filter out common harmless messages diff --git a/electrum/plugins/psbt_nostr/manifest.json b/electrum/plugins/psbt_nostr/manifest.json index 9d0c60f98..702d13ca1 100644 --- a/electrum/plugins/psbt_nostr/manifest.json +++ b/electrum/plugins/psbt_nostr/manifest.json @@ -3,7 +3,7 @@ "fullname": "Nostr Multisig", "description": "This plugin facilitates the use of multi-signatures wallets. It sends and receives partially signed transactions from/to your cosigner wallet. PSBTs are sent and retrieved from Nostr relays.", "author": "The Electrum Developers", - "available_for": ["qt"], "icon":"nostr_multisig.png", + "available_for": ["qt", "qml"], "version": "0.0.1" } diff --git a/electrum/plugins/psbt_nostr/psbt_nostr.py b/electrum/plugins/psbt_nostr/psbt_nostr.py index e850149c6..2240b06e2 100644 --- a/electrum/plugins/psbt_nostr/psbt_nostr.py +++ b/electrum/plugins/psbt_nostr/psbt_nostr.py @@ -203,6 +203,9 @@ class CosignerWallet(Logger): # note that tx could also be unrelated from wallet?... (not ismine inputs) return True + def mark_event_rcvd(self, event_id): + self.known_events[event_id] = now() + def prepare_messages(self, tx: Union[Transaction, PartialTransaction]) -> List[Tuple[str, str]]: messages = [] for xpub, pubkey in self.cosigner_list: diff --git a/electrum/plugins/psbt_nostr/qml.py b/electrum/plugins/psbt_nostr/qml.py index d58ef0d35..735843a9c 100644 --- a/electrum/plugins/psbt_nostr/qml.py +++ b/electrum/plugins/psbt_nostr/qml.py @@ -22,10 +22,19 @@ # 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 +import asyncio +import concurrent +from typing import TYPE_CHECKING, List, Tuple, Optional +from PyQt6.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot + +from electrum import util from electrum.plugin import hook +from electrum.transaction import PartialTransaction, tx_from_any from electrum.wallet import Multisig_Wallet +from electrum.util import EventListener, event_listener + +from electrum.gui.qml.qewallet import QEWallet from .psbt_nostr import PsbtNostrPlugin, CosignerWallet @@ -34,40 +43,101 @@ if TYPE_CHECKING: from electrum.gui.qml import ElectrumQmlApplication +class QReceiveSignalObject(QObject): + def __init__(self, plugin: 'Plugin'): + QObject.__init__(self) + self._plugin = plugin + + cosignerReceivedPsbt = pyqtSignal(str, str, str) + sendPsbtFailed = pyqtSignal(str, arguments=['reason']) + sendPsbtSuccess = pyqtSignal() + + @pyqtProperty(str) + def loader(self): + return 'main.qml' + + @pyqtSlot(QEWallet, str) + def sendPsbt(self, wallet: 'QEWallet', tx: str): + cosigner_wallet = self._plugin.cosigner_wallets[wallet.wallet] + if not cosigner_wallet: + return + cosigner_wallet.send_psbt(tx_from_any(tx, deserialize=True)) + + @pyqtSlot(QEWallet, str) + def acceptPsbt(self, wallet: 'QEWallet', event_id: str): + cosigner_wallet = self._plugin.cosigner_wallets[wallet.wallet] + if not cosigner_wallet: + return + cosigner_wallet.accept_psbt(event_id) + + class Plugin(PsbtNostrPlugin): def __init__(self, parent, config, name): super().__init__(parent, config, name) + self.so = QReceiveSignalObject(self) self._app = None @hook def init_qml(self, app: 'ElectrumQmlApplication'): - # if self._init_qt_received: # only need/want the first signal - # return - # self._init_qt_received = True self._app = app - # plugin enable for already open wallets - for wallet in app.daemon.get_wallets(): + self.so.setParent(app) # parent in QObject tree + # plugin enable for already open wallet + wallet = app.daemon.currentWallet.wallet if app.daemon.currentWallet else None + if wallet: self.load_wallet(wallet) @hook def load_wallet(self, wallet: 'Abstract_Wallet'): + # remove existing, only foreground wallet active + if len(self.cosigner_wallets): + self.remove_cosigner_wallet(self.cosigner_wallets[0]) if not isinstance(wallet, Multisig_Wallet): return - self.add_cosigner_wallet(wallet, CosignerWallet(wallet)) + self.add_cosigner_wallet(wallet, QmlCosignerWallet(wallet, self)) - # @hook - # def on_close_window(self, window): - # wallet = window.wallet - # self.remove_cosigner_wallet(wallet) - # - # @hook - # def transaction_dialog(self, d: 'TxDialog'): - # if cw := self.cosigner_wallets.get(d.wallet): - # assert isinstance(cw, QtCosignerWallet) - # cw.hook_transaction_dialog(d) - # - # @hook - # def transaction_dialog_update(self, d: 'TxDialog'): - # if cw := self.cosigner_wallets.get(d.wallet): - # assert isinstance(cw, QtCosignerWallet) - # cw.hook_transaction_dialog_update(d) + +class QmlCosignerWallet(EventListener, CosignerWallet): + + def __init__(self, wallet: 'Multisig_Wallet', plugin: 'Plugin'): + CosignerWallet.__init__(self, wallet) + self.plugin = plugin + self.register_callbacks() + + self.pending = None + + @event_listener + def on_event_psbt_nostr_received(self, wallet, pubkey, event, tx: 'PartialTransaction'): + if self.wallet == wallet: + self.plugin.so.cosignerReceivedPsbt.emit(pubkey, event, tx.serialize()) + self.on_receive(pubkey, event, tx) + + def close(self): + super().close() + self.unregister_callbacks() + + def do_send(self, messages: List[Tuple[str, str]], txid: Optional[str] = None): + if not messages: + return + coro = self.send_direct_messages(messages) + + loop = util.get_asyncio_loop() + assert util.get_running_loop() != loop, 'must not be called from asyncio thread' + self._result = None + self._future = asyncio.run_coroutine_threadsafe(coro, loop) + + try: + self._result = self._future.result() + self.plugin.so.sendPsbtSuccess.emit() + except concurrent.futures.CancelledError: + pass + except Exception as e: + self.plugin.so.sendPsbtFailed.emit(str(e)) + + def on_receive(self, pubkey, event_id, tx): + self.pending = (pubkey, event_id, tx) + + def accept_psbt(self, my_event_id): + pubkey, event_id, tx = self.pending + if event_id == my_event_id: + self.mark_event_rcvd(event_id) + self.pending = None diff --git a/electrum/plugins/psbt_nostr/qml/main.qml b/electrum/plugins/psbt_nostr/qml/main.qml new file mode 100644 index 000000000..a50440620 --- /dev/null +++ b/electrum/plugins/psbt_nostr/qml/main.qml @@ -0,0 +1,54 @@ +import QtQuick + +import org.electrum + +import "../../../gui/qml/components/controls" + +Item { + Connections { + target: AppController ? AppController.plugin('psbt_nostr') : null + function onCosignerReceivedPsbt(pubkey, event, tx) { + var dialog = app.messageDialog.createObject(app, { + text: [ + qsTr('A transaction was received from your cosigner.'), + qsTr('Do you want to open it now?') + ].join('\n'), + yesno: true + }) + dialog.accepted.connect(function () { + app.stack.push(Qt.resolvedUrl('../../../gui/qml/components/TxDetails.qml'), { + rawtx: tx + }) + target.acceptPsbt(Daemon.currentWallet, event) + }) + dialog.open() + } + } + + property variant export_tx_button: Component { + FlatButton { + id: psbt_nostr_send_button + property variant dialog + text: qsTr('Nostr') + icon.source: Qt.resolvedUrl('../../../gui/icons/network.png') + visible: Daemon.currentWallet.isMultisig && Daemon.currentWallet.walletType != '2fa' + onClicked: { + console.log('about to psbt nostr send') + psbt_nostr_send_button.enabled = false + AppController.plugin('psbt_nostr').sendPsbt(Daemon.currentWallet, dialog.text) + } + Connections { + target: AppController ? AppController.plugin('psbt_nostr') : null + function onSendPsbtFailed(message) { + psbt_nostr_send_button.enabled = true + var dialog = app.messageDialog.createObject(app, { + text: qsTr('Sending PSBT to co-signer failed:\n%1').arg(message) + }) + dialog.open() + } + } + + } + } + +} diff --git a/electrum/plugins/psbt_nostr/qt.py b/electrum/plugins/psbt_nostr/qt.py index 36e91f265..e50ca4980 100644 --- a/electrum/plugins/psbt_nostr/qt.py +++ b/electrum/plugins/psbt_nostr/qt.py @@ -34,10 +34,9 @@ from electrum.wallet import Multisig_Wallet, Abstract_Wallet from electrum.util import UserCancelled, event_listener, EventListener from electrum.gui.qt.transaction_dialog import show_transaction, TxDialog -from .psbt_nostr import PsbtNostrPlugin, CosignerWallet, now +from .psbt_nostr import PsbtNostrPlugin, CosignerWallet if TYPE_CHECKING: - from electrum.gui.qt import ElectrumGui from electrum.gui.qt.main_window import ElectrumWindow @@ -133,5 +132,5 @@ class QtCosignerWallet(EventListener, CosignerWallet): _("An transaction was received from your cosigner.") + '\n' + _("Do you want to open it now?")): return - self.known_events[event_id] = now() + self.mark_event_rcvd(event_id) show_transaction(tx, parent=window, prompt_if_unsaved=True)