diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py index 60728d514..821de7536 100644 --- a/electrum/gui/qml/__init__.py +++ b/electrum/gui/qml/__init__.py @@ -63,7 +63,7 @@ class ElectrumGui(Logger): self.gui_thread = threading.current_thread() self.plugins = plugins - self.app = ElectrumQmlApplication(sys.argv, config, daemon) + self.app = ElectrumQmlApplication(sys.argv, config, daemon, plugins) # timer self.timer = QTimer(self.app) self.timer.setSingleShot(False) diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml index 2ff49ae5a..0da65d614 100644 --- a/electrum/gui/qml/components/Preferences.qml +++ b/electrum/gui/qml/components/Preferences.qml @@ -1,6 +1,6 @@ import QtQuick 2.6 -import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.3 import QtQuick.Controls.Material 2.0 import org.electrum 1.0 @@ -12,175 +12,212 @@ Pane { property string title: qsTr("Preferences") + padding: 0 + property var _baseunits: ['BTC','mBTC','bits','sat'] - Flickable { + ColumnLayout { anchors.fill: parent - contentHeight: rootLayout.height - interactive: height < contentHeight - - GridLayout { - id: rootLayout - columns: 2 - width: parent.width - Label { - text: qsTr('Language') + TabBar { + id: tabbar + Layout.fillWidth: true + currentIndex: swipeview.currentIndex + TabButton { + text: qsTr('Preferences') + font.pixelSize: constants.fontSizeLarge } - - ElComboBox { - id: language - enabled: false + TabButton { + text: qsTr('Plugins') + font.pixelSize: constants.fontSizeLarge } + } - Label { - text: qsTr('Base unit') - } + SwipeView { + id: swipeview - ElComboBox { - id: baseUnit - model: _baseunits - onCurrentValueChanged: { - if (activeFocus) - Config.baseUnit = currentValue - } - } + Layout.fillHeight: true + Layout.fillWidth: true + currentIndex: tabbar.currentIndex - Switch { - id: thousands - Layout.columnSpan: 2 - text: qsTr('Add thousands separators to bitcoin amounts') - onCheckedChanged: { - if (activeFocus) - Config.thousandsSeparator = checked - } - } + Flickable { + contentHeight: prefsPane.height + interactive: height < contentHeight + clip: true - Switch { - id: checkSoftware - Layout.columnSpan: 2 - text: qsTr('Automatically check for software updates') - enabled: false - } + Pane { + id: prefsPane + GridLayout { + columns: 2 + width: parent.width - Switch { - id: fiatEnable - text: qsTr('Fiat Currency') - onCheckedChanged: { - if (activeFocus) - Daemon.fx.enabled = checked - } - } + Label { + text: qsTr('Language') + } - ElComboBox { - id: currencies - model: Daemon.fx.currencies - enabled: Daemon.fx.enabled - onCurrentValueChanged: { - if (activeFocus) - Daemon.fx.fiatCurrency = currentValue - } - } + ElComboBox { + id: language + enabled: false + } - Switch { - id: historicRates - text: qsTr('Historic rates') - enabled: Daemon.fx.enabled - Layout.columnSpan: 2 - onCheckedChanged: { - if (activeFocus) - Daemon.fx.historicRates = checked - } - } + Label { + text: qsTr('Base unit') + } - Label { - text: qsTr('Source') - enabled: Daemon.fx.enabled - } + ElComboBox { + id: baseUnit + model: _baseunits + onCurrentValueChanged: { + if (activeFocus) + Config.baseUnit = currentValue + } + } - ElComboBox { - id: rateSources - enabled: Daemon.fx.enabled - model: Daemon.fx.rateSources - onModelChanged: { - currentIndex = rateSources.indexOfValue(Daemon.fx.rateSource) - } - onCurrentValueChanged: { - if (activeFocus) - Daemon.fx.rateSource = currentValue - } - } + Switch { + id: thousands + Layout.columnSpan: 2 + text: qsTr('Add thousands separators to bitcoin amounts') + onCheckedChanged: { + if (activeFocus) + Config.thousandsSeparator = checked + } + } - Switch { - id: spendUnconfirmed - text: qsTr('Spend unconfirmed') - Layout.columnSpan: 2 - onCheckedChanged: { - if (activeFocus) - Config.spendUnconfirmed = checked - } - } + Switch { + id: checkSoftware + Layout.columnSpan: 2 + text: qsTr('Automatically check for software updates') + enabled: false + } - Label { - text: qsTr('PIN') - } + Switch { + id: fiatEnable + text: qsTr('Fiat Currency') + onCheckedChanged: { + if (activeFocus) + Daemon.fx.enabled = checked + } + } - RowLayout { - Label { - text: Config.pinCode == '' ? qsTr('Off'): qsTr('On') - color: Material.accentColor - Layout.rightMargin: constants.paddingMedium - } - Button { - text: qsTr('Enable') - visible: Config.pinCode == '' - onClicked: { - var dialog = pinSetup.createObject(preferences, {mode: 'enter'}) - dialog.accepted.connect(function() { - Config.pinCode = dialog.pincode - dialog.close() - }) - dialog.open() - } - } - Button { - text: qsTr('Change') - visible: Config.pinCode != '' - onClicked: { - var dialog = pinSetup.createObject(preferences, {mode: 'change', pincode: Config.pinCode}) - dialog.accepted.connect(function() { - Config.pinCode = dialog.pincode - dialog.close() - }) - dialog.open() - } - } - Button { - text: qsTr('Remove') - visible: Config.pinCode != '' - onClicked: { - Config.pinCode = '' - } - } - } + ElComboBox { + id: currencies + model: Daemon.fx.currencies + enabled: Daemon.fx.enabled + onCurrentValueChanged: { + if (activeFocus) + Daemon.fx.fiatCurrency = currentValue + } + } - Label { - text: qsTr('Lightning Routing') - } + Switch { + id: historicRates + text: qsTr('Historic rates') + enabled: Daemon.fx.enabled + Layout.columnSpan: 2 + onCheckedChanged: { + if (activeFocus) + Daemon.fx.historicRates = checked + } + } + + Label { + text: qsTr('Source') + enabled: Daemon.fx.enabled + } + + ElComboBox { + id: rateSources + enabled: Daemon.fx.enabled + model: Daemon.fx.rateSources + onModelChanged: { + currentIndex = rateSources.indexOfValue(Daemon.fx.rateSource) + } + onCurrentValueChanged: { + if (activeFocus) + Daemon.fx.rateSource = currentValue + } + } + + Switch { + id: spendUnconfirmed + text: qsTr('Spend unconfirmed') + Layout.columnSpan: 2 + onCheckedChanged: { + if (activeFocus) + Config.spendUnconfirmed = checked + } + } + + Label { + text: qsTr('PIN') + } + + RowLayout { + Label { + text: Config.pinCode == '' ? qsTr('Off'): qsTr('On') + color: Material.accentColor + Layout.rightMargin: constants.paddingMedium + } + Button { + text: qsTr('Enable') + visible: Config.pinCode == '' + onClicked: { + var dialog = pinSetup.createObject(preferences, {mode: 'enter'}) + dialog.accepted.connect(function() { + Config.pinCode = dialog.pincode + dialog.close() + }) + dialog.open() + } + } + Button { + text: qsTr('Change') + visible: Config.pinCode != '' + onClicked: { + var dialog = pinSetup.createObject(preferences, {mode: 'change', pincode: Config.pinCode}) + dialog.accepted.connect(function() { + Config.pinCode = dialog.pincode + dialog.close() + }) + dialog.open() + } + } + Button { + text: qsTr('Remove') + visible: Config.pinCode != '' + onClicked: { + Config.pinCode = '' + } + } + } + + Label { + text: qsTr('Lightning Routing') + } - ElComboBox { - id: lnRoutingType - enabled: Daemon.currentWallet && Daemon.currentWallet.isLightning + ElComboBox { + id: lnRoutingType + enabled: Daemon.currentWallet && Daemon.currentWallet.isLightning + + valueRole: 'key' + textRole: 'label' + model: ListModel { + ListElement { key: 'gossip'; label: qsTr('Gossip') } + ListElement { key: 'trampoline'; label: qsTr('Trampoline') } + } + onCurrentValueChanged: { + if (activeFocus) + Config.useGossip = currentValue == 'gossip' + } + } + } - valueRole: 'key' - textRole: 'label' - model: ListModel { - ListElement { key: 'gossip'; label: qsTr('Gossip') } - ListElement { key: 'trampoline'; label: qsTr('Trampoline') } } - onCurrentValueChanged: { - if (activeFocus) - Config.useGossip = currentValue == 'gossip' + } + + Pane { + ColumnLayout { + id: pluginsRootLayout } } } @@ -192,6 +229,19 @@ Pane { Pin {} } + Component { + id: pluginHeader + RowLayout { + property QtObject plugin + Switch { + checked: plugin.pluginEnabled + } + Label { + text: plugin.name + } + } + } + Component.onCompleted: { baseUnit.currentIndex = _baseunits.indexOf(Config.baseUnit) thousands.checked = Config.thousandsSeparator @@ -201,5 +251,15 @@ Pane { fiatEnable.checked = Daemon.fx.enabled spendUnconfirmed.checked = Config.spendUnconfirmed lnRoutingType.currentIndex = Config.useGossip ? 0 : 1 + + var labelsPlugin = AppController.plugin('labels') + if (labelsPlugin) { + pluginHeader.createObject(pluginsRootLayout, { plugin: labelsPlugin }) +// console.log(Qt.resolvedUrl(labelsPlugin.settingsComponent())) + if (labelsPlugin.settingsComponent()) { + var component = Qt.createComponent(Qt.resolvedUrl(labelsPlugin.settingsComponent())) + component.createObject(pluginsRootLayout, {plugin: labelsPlugin}) + } + } } } diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 630697928..b8ef3abc9 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -34,11 +34,12 @@ notification = None class QEAppController(QObject): userNotify = pyqtSignal(str) - def __init__(self, qedaemon): + def __init__(self, qedaemon, plugins): super().__init__() self.logger = get_logger(__name__) self._qedaemon = qedaemon + self._plugins = plugins # set up notification queue and notification_timer self.user_notification_queue = queue.Queue() @@ -131,11 +132,22 @@ class QEAppController(QObject): def clipboardToText(self): return QGuiApplication.clipboard().text() + @pyqtSlot(str, result=QObject) + def plugin(self, plugin_name): + self.logger.warning(f'now {self._plugins.count()} plugins loaded') + plugin = self._plugins.get(plugin_name) + self.logger.debug(f'plugin with name {plugin_name} is {str(type(plugin))}') + if plugin: + return plugin.so + else: + self.logger.debug('None!') + return None + class ElectrumQmlApplication(QGuiApplication): _valid = True - def __init__(self, args, config, daemon): + def __init__(self, args, config, daemon, plugins): super().__init__(args) self.logger = get_logger(__name__) @@ -162,7 +174,6 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') self.engine = QQmlApplicationEngine(parent=self) - self.engine.addImportPath('./qml') screensize = self.primaryScreen().size() @@ -181,13 +192,13 @@ class ElectrumQmlApplication(QGuiApplication): self.context = self.engine.rootContext() self._qeconfig = QEConfig(config) self._qenetwork = QENetwork(daemon.network, self._qeconfig) - self._qedaemon = QEDaemon(daemon) - self._appController = QEAppController(self._qedaemon) + self.daemon = QEDaemon(daemon) + self.appController = QEAppController(self.daemon, plugins) self._maxAmount = QEAmount(is_max=True) - self.context.setContextProperty('AppController', self._appController) + self.context.setContextProperty('AppController', self.appController) self.context.setContextProperty('Config', self._qeconfig) self.context.setContextProperty('Network', self._qenetwork) - self.context.setContextProperty('Daemon', self._qedaemon) + self.context.setContextProperty('Daemon', self.daemon) self.context.setContextProperty('FixedFont', self.fixedFont) self.context.setContextProperty('MAX', self._maxAmount) self.context.setContextProperty('QRIP', self.qr_ip_h) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 8727e5829..385a4a4df 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -7,6 +7,7 @@ from electrum.i18n import _ from electrum.logging import get_logger from electrum.util import WalletFileException, standardize_path from electrum.wallet import Abstract_Wallet +from electrum.plugin import run_hook from electrum.lnchannel import ChannelState from .auth import AuthMixin, auth_protect @@ -179,6 +180,7 @@ class QEDaemon(AuthMixin, QObject): self._logger.info('use single password disabled by config') self.daemon.config.save_last_wallet(wallet) + run_hook('load_wallet', wallet) else: self._logger.info('could not open wallet') self.walletOpenError.emit('could not open wallet') diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 3b6d0838d..9bc312805 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -62,6 +62,7 @@ class QEWallet(AuthMixin, QObject, QtEventListener): transactionSigned = pyqtSignal([str], arguments=['txid']) #broadcastSucceeded = pyqtSignal([str], arguments=['txid']) broadcastFailed = pyqtSignal([str,str,str], arguments=['txid','code','reason']) + labelsUpdated = pyqtSignal() _network_signal = pyqtSignal(str, object) diff --git a/electrum/plugins/labels/Labels.qml b/electrum/plugins/labels/Labels.qml new file mode 100644 index 000000000..708768a25 --- /dev/null +++ b/electrum/plugins/labels/Labels.qml @@ -0,0 +1,45 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.14 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +//import "controls" + +Item { + width: parent.width + height: rootLayout.height + + property QtObject plugin + + RowLayout { + id: rootLayout + Button { + text: 'Force upload' + enabled: !plugin.busy + onClicked: plugin.upload() + } + Button { + text: 'Force download' + enabled: !plugin.busy + onClicked: plugin.download() + } + } + + Connections { + target: plugin + function onUploadSuccess() { + console.log('upload success') + } + function onUploadFailed() { + console.log('upload failed') + } + function onDownloadSuccess() { + console.log('download success') + } + function onDownloadFailed() { + console.log('download failed') + } + } +} diff --git a/electrum/plugins/labels/__init__.py b/electrum/plugins/labels/__init__.py index 4596bcae4..63e0520c0 100644 --- a/electrum/plugins/labels/__init__.py +++ b/electrum/plugins/labels/__init__.py @@ -5,5 +5,5 @@ description = ' '.join([ _("Save your wallet labels on a remote server, and synchronize them across multiple devices where you use Electrum."), _("Labels, transactions IDs and addresses are encrypted before they are sent to the remote server.") ]) -available_for = ['qt', 'kivy', 'cmdline'] +available_for = ['qt', 'qml', 'kivy', 'cmdline'] diff --git a/electrum/plugins/labels/qml.py b/electrum/plugins/labels/qml.py new file mode 100644 index 000000000..d2e3d88bd --- /dev/null +++ b/electrum/plugins/labels/qml.py @@ -0,0 +1,138 @@ +import threading + +from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot + +from electrum.i18n import _ +from electrum.plugin import hook + +from electrum.gui.qml.qewallet import QEWallet + +from .labels import LabelsPlugin + +class Plugin(LabelsPlugin): + + class QSignalObject(QObject): + pluginChanged = pyqtSignal() + pluginEnabledChanged = pyqtSignal() + labelsChanged = pyqtSignal() + busyChanged = pyqtSignal() + uploadSuccess = pyqtSignal() + uploadFailed = pyqtSignal() + downloadSuccess = pyqtSignal() + downloadFailed = pyqtSignal() + + _busy = False + + def __init__(self, plugin, parent = None): + super().__init__(parent) + self.plugin = plugin + + @pyqtProperty(str, notify=pluginChanged) + def name(self): return _('Labels Plugin') + + @pyqtProperty(bool, notify=busyChanged) + def busy(self): return self._busy + + @pyqtProperty(bool, notify=pluginEnabledChanged) + def pluginEnabled(self): return self.plugin.is_enabled() + + @pyqtSlot(result=str) + def settingsComponent(self): return '../../../plugins/labels/Labels.qml' + + @pyqtSlot() + def upload(self): + assert self.plugin + + self._busy = True + self.busyChanged.emit() + + self.plugin.push_async() + + def upload_finished(self, result): + if result: + self.uploadSuccess.emit() + else: + self.uploadFailed.emit() + self._busy = False + self.busyChanged.emit() + + @pyqtSlot() + def download(self): + assert self.plugin + + self._busy = True + self.busyChanged.emit() + + self.plugin.pull_async() + + def download_finished(self, result): + if result: + self.downloadSuccess.emit() + else: + self.downloadFailed.emit() + self._busy = False + self.busyChanged.emit() + + def __init__(self, *args): + LabelsPlugin.__init__(self, *args) + + @hook + def load_wallet(self, wallet): + self.logger.info(f'load_wallet hook for wallet {str(type(wallet))}') + self.start_wallet(wallet) + + def push_async(self): + if not self._app.daemon.currentWallet: + self.logger.error('No current wallet') + self.so.download_finished(False) + return + + wallet = self._app.daemon.currentWallet.wallet + + def push_thread(wallet): + try: + self.push(wallet) + self.so.upload_finished(True) + self._app.appController.userNotify.emit(_('Labels uploaded')) + except Exception as e: + self.logger.error(repr(e)) + self.so.upload_finished(False) + self._app.appController.userNotify.emit(repr(e)) + + threading.Thread(target=push_thread,args=[wallet]).start() + + def pull_async(self): + if not self._app.daemon.currentWallet: + self.logger.error('No current wallet') + self.so.download_finished(False) + return + + wallet = self._app.daemon.currentWallet.wallet + def pull_thread(wallet): + try: + self.pull(wallet, True) + self.so.download_finished(True) + self._app.appController.userNotify.emit(_('Labels downloaded')) + except Exception as e: + self.logger.error(repr(e)) + self.so.download_finished(False) + self._app.appController.userNotify.emit(repr(e)) + + threading.Thread(target=pull_thread,args=[wallet]).start() + + + def on_pulled(self, wallet): + self.logger.info('on pulled') + _wallet = QEWallet.getInstanceFor(wallet) + self.logger.debug('wallet ' + ('found' if _wallet else 'not found')) + if _wallet: + _wallet.labelsUpdated.emit() + + @hook + def init_qml(self, gui: 'ElectrumGui'): + self.logger.debug('init_qml hook called') + self.logger.debug(f'gui={str(type(gui))}') + self._app = gui.app + # important: QSignalObject needs to be parented, as keeping a ref + # in the plugin is not enough to avoid gc + self.so = Plugin.QSignalObject(self, self._app) diff --git a/electrum/plugins/qml_test/qml.py b/electrum/plugins/qml_test/qml.py index bfe54506e..0d5233812 100644 --- a/electrum/plugins/qml_test/qml.py +++ b/electrum/plugins/qml_test/qml.py @@ -10,8 +10,6 @@ class Plugin(BasePlugin): def __init__(self, parent, config, name): BasePlugin.__init__(self, parent, config, name) - _logger = get_logger(__name__) - @hook def init_qml(self, gui: 'ElectrumGui'): - self._logger.debug('init_qml hook called') + self.logger.debug('init_qml hook called')