diff --git a/contrib/android/Dockerfile b/contrib/android/Dockerfile index ab9636234..338e961d4 100644 --- a/contrib/android/Dockerfile +++ b/contrib/android/Dockerfile @@ -169,8 +169,8 @@ RUN cd /opt \ && git remote add sombernight https://github.com/SomberNight/python-for-android \ && git remote add accumulator https://github.com/accumulator/python-for-android \ && git fetch --all \ - # commit: from branch accumulator/qt5-wip - && git checkout "64490fc4a7b1f727f1f07c86e1bdc6b291ffc6da^{commit}" \ + # commit: from branch sombernight/qt5-wip + && git checkout "c6e39ae1fb4eb8d547eb70b26b89beda7e6ff4b6^{commit}" \ && python3 -m pip install --no-build-isolation --no-dependencies --user -e . # build env vars diff --git a/contrib/android/buildozer_qml.spec b/contrib/android/buildozer_qml.spec index 0deda56b8..e21c57c89 100644 --- a/contrib/android/buildozer_qml.spec +++ b/contrib/android/buildozer_qml.spec @@ -13,18 +13,23 @@ package.domain = org.electrum source.dir = . # (list) Source files to include (let empty to include all the files) -source.include_exts = py,png,jpg,qml,qmltypes,ttf,txt,gif,pem,mo,vs,fs,json,csv,so +source.include_exts = py,png,jpg,qml,qmltypes,ttf,txt,gif,pem,mo,json,csv,so # (list) Source files to exclude (let empty to not exclude anything) source.exclude_exts = spec # (list) List of directory to exclude (let empty to not exclude anything) -source.exclude_dirs = bin, build, dist, contrib, +source.exclude_dirs = bin, build, dist, contrib, env, electrum/tests, + electrum/www, electrum/gui/qt, electrum/gui/kivy, packages/qdarkstyle, - packages/qtpy + packages/qtpy, + packages/bin, + packages/share, + packages/pkg_resources, + packages/setuptools # (list) List of exclusions using pattern matching source.exclude_patterns = Makefile,setup*, @@ -51,7 +56,9 @@ requirements = libsecp256k1, cryptography, pyqt5sip, - pyqt5 + pyqt5, + pillow, + libzbar # (str) Presplash of the application #presplash.filename = %(source.dir)s/gui/kivy/theming/splash.png diff --git a/contrib/android/p4a_recipes/Pillow/__init__.py b/contrib/android/p4a_recipes/Pillow/__init__.py new file mode 100644 index 000000000..a08cd3b28 --- /dev/null +++ b/contrib/android/p4a_recipes/Pillow/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.Pillow import PillowRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert PillowRecipe._version == "7.0.0" +assert PillowRecipe.depends == ['png', 'jpeg', 'freetype', 'setuptools', 'python3'] +assert PillowRecipe.python_depends == [] + + +class PillowRecipePinned(util.InheritedRecipeMixin, PillowRecipe): + sha512sum = "187173a525d4f3f01b4898633263b53a311f337aa7b159c64f79ba8c7006fd44798a058e7cc5d8f1116bad008e4142ff303456692329fe73b0e115ef5c225d73" + + +recipe = PillowRecipePinned() diff --git a/contrib/android/p4a_recipes/freetype/__init__.py b/contrib/android/p4a_recipes/freetype/__init__.py new file mode 100644 index 000000000..0d8920655 --- /dev/null +++ b/contrib/android/p4a_recipes/freetype/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.freetype import FreetypeRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert FreetypeRecipe._version == "2.10.1" +assert FreetypeRecipe.depends == [] +assert FreetypeRecipe.python_depends == [] + + +class FreetypeRecipePinned(util.InheritedRecipeMixin, FreetypeRecipe): + sha512sum = "346c682744bcf06ca9d71265c108a242ad7d78443eff20142454b72eef47ba6d76671a6e931ed4c4c9091dd8f8515ebdd71202d94b073d77931345ff93cfeaa7" + + +recipe = FreetypeRecipePinned() diff --git a/contrib/android/p4a_recipes/jpeg/__init__.py b/contrib/android/p4a_recipes/jpeg/__init__.py new file mode 100644 index 000000000..dc3e2998e --- /dev/null +++ b/contrib/android/p4a_recipes/jpeg/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.jpeg import JpegRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert JpegRecipe._version == "2.0.1" +assert JpegRecipe.depends == [] +assert JpegRecipe.python_depends == [] + + +class JpegRecipePinned(util.InheritedRecipeMixin, JpegRecipe): + sha512sum = "d456515dcda7c5e2e257c9fd1441f3a5cff0d33281237fb9e3584bbec08a181c4b037947a6f87d805977ec7528df39b12a5d32f6e8db878a62bcc90482f86e0e" + + +recipe = JpegRecipePinned() diff --git a/contrib/android/p4a_recipes/libiconv/__init__.py b/contrib/android/p4a_recipes/libiconv/__init__.py new file mode 100644 index 000000000..4d0ba2f59 --- /dev/null +++ b/contrib/android/p4a_recipes/libiconv/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.libiconv import LibIconvRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert LibIconvRecipe._version == "1.15" +assert LibIconvRecipe.depends == [] +assert LibIconvRecipe.python_depends == [] + + +class LibIconvRecipePinned(util.InheritedRecipeMixin, LibIconvRecipe): + sha512sum = "1233fe3ca09341b53354fd4bfe342a7589181145a1232c9919583a8c9979636855839049f3406f253a9d9829908816bb71fd6d34dd544ba290d6f04251376b1a" + + +recipe = LibIconvRecipePinned() diff --git a/contrib/android/p4a_recipes/libzbar/__init__.py b/contrib/android/p4a_recipes/libzbar/__init__.py new file mode 100644 index 000000000..531ed598f --- /dev/null +++ b/contrib/android/p4a_recipes/libzbar/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.libzbar import LibZBarRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert LibZBarRecipe._version == "0.10" +assert LibZBarRecipe.depends == ['libiconv'] +assert LibZBarRecipe.python_depends == [] + + +class LibZBarRecipePinned(util.InheritedRecipeMixin, LibZBarRecipe): + sha512sum = "d624f8ab114bf59c62e364f8b3e334bece48f5c11654739d810ed2b8553b8390a70763b0ae12d83c1472cfeda5d9e1a0b7c9c60228a79bf9f5a6fae4a9f7ccb9" + + +recipe = LibZBarRecipePinned() diff --git a/contrib/android/p4a_recipes/png/__init__.py b/contrib/android/p4a_recipes/png/__init__.py new file mode 100644 index 000000000..b4f500a2e --- /dev/null +++ b/contrib/android/p4a_recipes/png/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.png import PngRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert PngRecipe._version == "1.6.37" +assert PngRecipe.depends == [] +assert PngRecipe.python_depends == [] + + +class PngRecipePinned(util.InheritedRecipeMixin, PngRecipe): + sha512sum = "f304f8aaaee929dbeff4ee5260c1ab46d231dcb0261f40f5824b5922804b6b4ed64c91cbf6cc1e08554c26f50ac017899a5971190ca557bc3c11c123379a706f" + + +recipe = PngRecipePinned() diff --git a/contrib/android/p4a_recipes/pyqt5/__init__.py b/contrib/android/p4a_recipes/pyqt5/__init__.py new file mode 100644 index 000000000..16e74e5d8 --- /dev/null +++ b/contrib/android/p4a_recipes/pyqt5/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.pyqt5 import PyQt5Recipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert PyQt5Recipe._version == "5.15.6" +assert PyQt5Recipe.depends == ['qt5', 'pyjnius', 'setuptools', 'pyqt5sip'] +assert PyQt5Recipe.python_depends == [] + + +class PyQt5RecipePinned(util.InheritedRecipeMixin, PyQt5Recipe): + sha512sum = "65fd663cb70e8701e49bd4b39dc9384546cf2edd1b3bab259ca64b50908f48bdc02ca143f36cd6b429075f5616dcc7b291607dcb63afa176e828cded3b82f5c7" + + +recipe = PyQt5RecipePinned() diff --git a/contrib/android/p4a_recipes/pyqt5sip/__init__.py b/contrib/android/p4a_recipes/pyqt5sip/__init__.py new file mode 100644 index 000000000..fd760faac --- /dev/null +++ b/contrib/android/p4a_recipes/pyqt5sip/__init__.py @@ -0,0 +1,18 @@ +import os + +from pythonforandroid.recipes.pyqt5sip import PyQt5SipRecipe +from pythonforandroid.util import load_source + +util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py')) + + +assert PyQt5SipRecipe._version == "12.9.0" +assert PyQt5SipRecipe.depends == ['setuptools', 'python3'] +assert PyQt5SipRecipe.python_depends == [] + + +class PyQt5SipRecipePinned(util.InheritedRecipeMixin, PyQt5SipRecipe): + sha512sum = "ca6f3b18b64391fded88732a8109a04d85727bbddecdf126679b187c7f0487c3c1f69ada3e8c54051281a43c6f2de70390ac5ff18a1bed79994070ddde730c5f" + + +recipe = PyQt5SipRecipePinned() diff --git a/contrib/android/p4a_recipes/qt5/__init__.py b/contrib/android/p4a_recipes/qt5/__init__.py new file mode 100644 index 000000000..f3c9b0880 --- /dev/null +++ b/contrib/android/p4a_recipes/qt5/__init__.py @@ -0,0 +1,8 @@ +from pythonforandroid.recipes.qt5 import Qt5Recipe + + +assert Qt5Recipe._version == "9b43a43ee96198674060c6b9591e515e2d27c28f" +assert Qt5Recipe.depends == ['python3'] +assert Qt5Recipe.python_depends == [] + +recipe = Qt5Recipe() diff --git a/electrum/commands.py b/electrum/commands.py index 526611513..c952b0221 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1502,7 +1502,7 @@ def get_parser(): # gui parser_gui = subparsers.add_parser('gui', description="Run Electrum's Graphical User Interface.", help="Run GUI (default)") parser_gui.add_argument("url", nargs='?', default=None, help="bitcoin URI (or bip70 file)") - parser_gui.add_argument("-g", "--gui", dest="gui", help="select graphical user interface", choices=['qt', 'kivy', 'text', 'stdio']) + parser_gui.add_argument("-g", "--gui", dest="gui", help="select graphical user interface", choices=['qt', 'kivy', 'text', 'stdio', 'qml']) parser_gui.add_argument("-m", action="store_true", dest="hide_gui", default=False, help="hide GUI on startup") parser_gui.add_argument("-L", "--lang", dest="language", default=None, help="default language used in GUI") parser_gui.add_argument("--daemon", action="store_true", dest="daemon", default=False, help="keep daemon running after GUI is closed") diff --git a/electrum/gui/icons/closebutton.png b/electrum/gui/icons/closebutton.png new file mode 100644 index 000000000..349241801 Binary files /dev/null and b/electrum/gui/icons/closebutton.png differ diff --git a/electrum/gui/icons/confirmed_bw.png b/electrum/gui/icons/confirmed_bw.png new file mode 100644 index 000000000..ba66aa084 Binary files /dev/null and b/electrum/gui/icons/confirmed_bw.png differ diff --git a/electrum/gui/icons/copy_bw.png b/electrum/gui/icons/copy_bw.png new file mode 100644 index 000000000..739edbecb Binary files /dev/null and b/electrum/gui/icons/copy_bw.png differ diff --git a/electrum/gui/icons/delete.png b/electrum/gui/icons/delete.png new file mode 100644 index 000000000..02a7d58ba Binary files /dev/null and b/electrum/gui/icons/delete.png differ diff --git a/electrum/gui/icons/globe.png b/electrum/gui/icons/globe.png new file mode 100644 index 000000000..d56382d0c Binary files /dev/null and b/electrum/gui/icons/globe.png differ diff --git a/electrum/gui/icons/mail_icon.png b/electrum/gui/icons/mail_icon.png new file mode 100644 index 000000000..32f163259 Binary files /dev/null and b/electrum/gui/icons/mail_icon.png differ diff --git a/electrum/gui/icons/paste.png b/electrum/gui/icons/paste.png new file mode 100644 index 000000000..e70bb37f9 Binary files /dev/null and b/electrum/gui/icons/paste.png differ diff --git a/electrum/gui/icons/pen.png b/electrum/gui/icons/pen.png new file mode 100644 index 000000000..74b9468e5 Binary files /dev/null and b/electrum/gui/icons/pen.png differ diff --git a/electrum/gui/icons/save.png b/electrum/gui/icons/save.png new file mode 100644 index 000000000..43859c85f Binary files /dev/null and b/electrum/gui/icons/save.png differ diff --git a/electrum/gui/icons/share.png b/electrum/gui/icons/share.png new file mode 100644 index 000000000..d0dc761d4 Binary files /dev/null and b/electrum/gui/icons/share.png differ diff --git a/electrum/gui/icons/wallet.png b/electrum/gui/icons/wallet.png new file mode 100644 index 000000000..d740fc7a8 Binary files /dev/null and b/electrum/gui/icons/wallet.png differ diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py new file mode 100644 index 000000000..f0113352b --- /dev/null +++ b/electrum/gui/qml/__init__.py @@ -0,0 +1,117 @@ +import os +import signal +import sys +import traceback +import threading +import re +from typing import Optional, TYPE_CHECKING, List + +try: + import PyQt5 +except Exception: + sys.exit("Error: Could not import PyQt5 on Linux systems, you may try 'sudo apt-get install python3-pyqt5'") + +try: + import PyQt5.QtQml +except Exception: + sys.exit("Error: Could not import PyQt5.QtQml on Linux systems, you may try 'sudo apt-get install python3-pyqt5.qtquick'") + +from PyQt5.QtCore import QLocale, QTimer +from PyQt5.QtGui import QGuiApplication +import PyQt5.QtCore as QtCore + +from electrum.i18n import _, set_language, languages +from electrum.plugin import run_hook +from electrum.base_wizard import GoBack +from electrum.util import (UserCancelled, profiler, + WalletFileException, BitcoinException, get_new_wallet_name) +from electrum.wallet import Wallet, Abstract_Wallet +from electrum.wallet_db import WalletDB +from electrum.logging import Logger, get_logger + +if TYPE_CHECKING: + from electrum.daemon import Daemon + from electrum.simple_config import SimpleConfig + from electrum.plugin import Plugins + +from .qeapp import ElectrumQmlApplication + +class UncaughtException(Exception): + pass + + +class ElectrumGui(Logger): + + @profiler + def __init__(self, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'): + set_language(config.get('language', self.get_default_language())) + Logger.__init__(self) + #os.environ['QML_IMPORT_TRACE'] = '1' + #os.environ['QT_DEBUG_PLUGINS'] = '1' + + self.logger.info(f"Qml GUI starting up... Qt={QtCore.QT_VERSION_STR}, PyQt={QtCore.PYQT_VERSION_STR}") + self.logger.info("CWD=%s" % os.getcwd()) + # Uncomment this call to verify objects are being properly + # GC-ed when windows are closed + #network.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer, + # ElectrumWindow], interval=5)]) + QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_X11InitThreads) + if hasattr(QtCore.Qt, "AA_ShareOpenGLContexts"): + QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts) + if hasattr(QGuiApplication, 'setDesktopFileName'): + QGuiApplication.setDesktopFileName('electrum.desktop') + if hasattr(QtCore.Qt, "AA_EnableHighDpiScaling"): + QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling); + + if not "QT_QUICK_CONTROLS_STYLE" in os.environ: + os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" + + self.gui_thread = threading.current_thread() + self.plugins = plugins + self.app = ElectrumQmlApplication(sys.argv, config, daemon) + # timer + self.timer = QTimer(self.app) + self.timer.setSingleShot(False) + self.timer.setInterval(500) # msec + self.timer.timeout.connect(lambda: None) # periodically enter python scope + + sys.excepthook = self.excepthook + threading.excepthook = self.texcepthook + + # Initialize any QML plugins + run_hook('init_qml', self) + self.app.engine.load('electrum/gui/qml/components/main.qml') + + def close(self): + self.app.quit() + + def excepthook(self, exc_type, exc_value, exc_tb): + tb = "".join(traceback.format_exception(exc_type, exc_value, exc_tb)) + self.logger.exception(tb) + self.app._valid = False + self.close() + + def texcepthook(self, arg): + tb = "".join(traceback.format_exception(arg.exc_type, arg.exc_value, arg.exc_tb)) + self.logger.exception(tb) + self.app._valid = False + self.close() + + def main(self): + if not self.app._valid: + return + + self.timer.start() + signal.signal(signal.SIGINT, lambda *args: self.stop()) + + self.logger.info('Entering main loop') + self.app.exec_() + + def stop(self): + self.logger.info('closing GUI') + self.app.quit() + + def get_default_language(self): + name = QLocale.system().name() + return name if name in languages else 'en_UK' + diff --git a/electrum/gui/qml/auth.py b/electrum/gui/qml/auth.py new file mode 100644 index 000000000..a7e141204 --- /dev/null +++ b/electrum/gui/qml/auth.py @@ -0,0 +1,58 @@ +from functools import wraps, partial + +from PyQt5.QtCore import pyqtSignal, pyqtSlot + +from electrum.logging import get_logger + +def auth_protect(func=None, reject=None, method='pin'): + if func is None: + return partial(auth_protect, reject=reject, method=method) + + @wraps(func) + def wrapper(self, *args, **kwargs): + self._logger.debug(str(self)) + if hasattr(self, '__auth_fcall'): + self._logger.debug('object already has a pending authed function call') + raise Exception('object already has a pending authed function call') + setattr(self, '__auth_fcall', (func,args,kwargs,reject)) + getattr(self, 'authRequired').emit(method) + + return wrapper + +class AuthMixin: + _auth_logger = get_logger(__name__) + + authRequired = pyqtSignal([str],arguments=['method']) + + @pyqtSlot() + def authProceed(self): + self._auth_logger.debug('Proceeding with authed fn()') + try: + self._auth_logger.debug(str(getattr(self, '__auth_fcall'))) + (func,args,kwargs,reject) = getattr(self, '__auth_fcall') + r = func(self, *args, **kwargs) + return r + except Exception as e: + self._auth_logger.error('Error executing wrapped fn(): %s' % repr(e)) + raise e + finally: + delattr(self,'__auth_fcall') + + @pyqtSlot() + def authCancel(self): + self._auth_logger.debug('Cancelling authed fn()') + if not hasattr(self, '__auth_fcall'): + return + + try: + (func,args,kwargs,reject) = getattr(self, '__auth_fcall') + if reject is not None: + if hasattr(self, reject): + getattr(self, reject)() + else: + self._auth_logger.error('Reject method \'%s\' not defined' % reject) + except Exception as e: + self._auth_logger.error('Error executing reject function \'%s\': %s' % (reject, repr(e))) + raise e + finally: + delattr(self, '__auth_fcall') diff --git a/electrum/gui/qml/components/About.qml b/electrum/gui/qml/components/About.qml new file mode 100644 index 000000000..b66453432 --- /dev/null +++ b/electrum/gui/qml/components/About.qml @@ -0,0 +1,90 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +Pane { + property string title: qsTr("About Electrum") + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + interactive: height < contentHeight + + GridLayout { + id: rootLayout + columns: 2 + width: parent.width + + Item { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: parent.width + Layout.preferredHeight: parent.width * 3/4 // reduce height, empty space in png + + Image { + id: electrum_logo + width: parent.width + height: width + source: '../../icons/electrum_presplash.png' + } + } + + Label { + text: qsTr('Version') + Layout.alignment: Qt.AlignRight + } + Label { + text: BUILD.electrum_version + } + Label { + text: qsTr('APK Version') + Layout.alignment: Qt.AlignRight + } + Label { + text: BUILD.apk_version + } + Label { + text: qsTr('Protocol version') + Layout.alignment: Qt.AlignRight + } + Label { + text: BUILD.protocol_version + } + Label { + text: qsTr('License') + Layout.alignment: Qt.AlignRight + } + Label { + text: qsTr('MIT License') + } + Label { + text: qsTr('Homepage') + Layout.alignment: Qt.AlignRight + } + Label { + text: qsTr('https://electrum.org') + textFormat: Text.RichText + onLinkActivated: Qt.openUrlExternally(link) + } + Label { + text: qsTr('Developers') + Layout.alignment: Qt.AlignRight + } + Label { + text: 'Thomas Voegtlin\nSomberNight\nSander van Grieken' + } + Item { + width: 1 + height: constants.paddingXLarge + Layout.columnSpan: 2 + } + Label { + text: qsTr('Distributed by Electrum Technologies GmbH') + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + } + } + } + +} diff --git a/electrum/gui/qml/components/AddressDetails.qml b/electrum/gui/qml/components/AddressDetails.qml new file mode 100644 index 000000000..34bbc348e --- /dev/null +++ b/electrum/gui/qml/components/AddressDetails.qml @@ -0,0 +1,268 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + width: parent.width + height: parent.height + + property string address + + property string title: qsTr("Address details") + + signal addressDetailsChanged + + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Spend from') + //onTriggered: + icon.source: '../../icons/tab_send.png' + enabled: false + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Sign/Verify') + icon.source: '../../icons/key.png' + enabled: false + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Encrypt/Decrypt') + icon.source: '../../icons/mail_icon.png' + enabled: false + } + } + } + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: rootLayout + width: parent.width + columns: 2 + + Label { + text: qsTr('Address') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: root.address + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + Layout.fillWidth: true + wrapMode: Text.Wrap + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Address'), 'text': root.address }) + dialog.open() + } + } + } + } + + Label { + text: qsTr('Label') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + id: labelContent + + property bool editmode: false + + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + visible: !labelContent.editmode + text: addressdetails.label + wrapMode: Text.Wrap + Layout.fillWidth: true + font.pixelSize: constants.fontSizeLarge + } + ToolButton { + visible: !labelContent.editmode + icon.source: '../../icons/pen.png' + icon.color: 'transparent' + onClicked: { + labelEdit.text = addressdetails.label + labelContent.editmode = true + labelEdit.focus = true + } + } + TextField { + id: labelEdit + visible: labelContent.editmode + text: addressdetails.label + font.pixelSize: constants.fontSizeLarge + Layout.fillWidth: true + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/confirmed.png' + icon.color: 'transparent' + onClicked: { + labelContent.editmode = false + addressdetails.set_label(labelEdit.text) + } + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/delete.png' + icon.color: 'transparent' + onClicked: labelContent.editmode = false + } + } + } + + Label { + text: qsTr('Public keys') + Layout.columnSpan: 2 + color: Material.accentColor + } + + Repeater { + model: addressdetails.pubkeys + delegate: TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + RowLayout { + width: parent.width + Label { + text: modelData + Layout.fillWidth: true + wrapMode: Text.Wrap + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Public key'), 'text': modelData }) + dialog.open() + } + } + } + } + } + + Label { + text: qsTr('Script type') + color: Material.accentColor + } + + Label { + text: addressdetails.scriptType + Layout.fillWidth: true + } + + Label { + text: qsTr('Balance') + color: Material.accentColor + } + + RowLayout { + Label { + font.family: FixedFont + text: Config.formatSats(addressdetails.balance) + } + Label { + color: Material.accentColor + text: Config.baseUnit + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(addressdetails.balance) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + + Label { + text: qsTr('Transactions') + color: Material.accentColor + } + + Label { + text: addressdetails.numTx + } + + Label { + text: qsTr('Derivation path') + color: Material.accentColor + } + + Label { + text: addressdetails.derivationPath + } + + Label { + text: qsTr('Frozen') + color: Material.accentColor + } + + Label { + text: addressdetails.isFrozen ? qsTr('Frozen') : qsTr('Not frozen') + } + + ColumnLayout { + Layout.columnSpan: 2 + + Button { + text: addressdetails.isFrozen ? qsTr('Unfreeze') : qsTr('Freeze') + onClicked: addressdetails.freeze(!addressdetails.isFrozen) + } + } + } + } + + AddressDetails { + id: addressdetails + wallet: Daemon.currentWallet + address: root.address + onFrozenChanged: addressDetailsChanged() + onLabelChanged: addressDetailsChanged() + } + + Component { + id: share + GenericShareDialog {} + } +} diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml new file mode 100644 index 000000000..daef18775 --- /dev/null +++ b/electrum/gui/qml/components/Addresses.qml @@ -0,0 +1,172 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +Pane { + id: rootItem + padding: 0 + width: parent.width + property string title: Daemon.currentWallet.name + ' - ' + qsTr('Addresses') + + ColumnLayout { + id: layout + width: parent.width + height: parent.height + + Item { + width: parent.width + Layout.fillHeight: true + + ListView { + id: listview + width: parent.width + height: parent.height + clip: true + model: Daemon.currentWallet.addressModel + currentIndex: -1 + + section.property: 'type' + section.criteria: ViewSection.FullString + section.delegate: sectionDelegate + + delegate: ItemDelegate { + id: delegate + width: ListView.view.width + height: delegateLayout.height + highlighted: ListView.isCurrentItem + + font.pixelSize: constants.fontSizeMedium // set default font size for child controls + + onClicked: { + var page = app.stack.push(Qt.resolvedUrl('AddressDetails.qml'), {'address': model.address}) + page.addressDetailsChanged.connect(function() { + // update listmodel when details change + listview.model.update_address(model.address) + }) + } + + ColumnLayout { + id: delegateLayout + spacing: 0 + x: constants.paddingMedium + width: parent.width - 2*constants.paddingMedium + + Item { + Layout.preferredWidth: 1 + Layout.preferredHeight: constants.paddingTiny + } + + GridLayout { + columns: 2 + Label { + id: indexLabel + font.bold: true + text: '#' + ('00'+model.iaddr).slice(-2) + Layout.fillWidth: true + } + Label { + font.family: FixedFont + text: model.address + elide: Text.ElideMiddle + Layout.fillWidth: true + } + + Rectangle { + id: useIndicator + Layout.preferredWidth: constants.iconSizeMedium + Layout.preferredHeight: constants.iconSizeMedium + color: model.held + ? Qt.rgba(1,0,0,0.75) + : model.numtx > 0 + ? model.balance.satsInt == 0 + ? Qt.rgba(0.5,0.5,0.5,1) + : Qt.rgba(0.75,0.75,0.75,1) + : model.type == 'receive' + ? Qt.rgba(0,1,0,0.5) + : Qt.rgba(1,0.93,0,0.75) + } + + RowLayout { + Label { + id: labelLabel + font.pixelSize: model.label != '' ? constants.fontSizeLarge : constants.fontSizeSmall + text: model.label != '' ? model.label : '' + opacity: model.label != '' ? 1.0 : 0.8 + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + Label { + font.family: FixedFont + text: Config.formatSats(model.balance, false) + visible: model.balance.satsInt != 0 + } + Label { + color: Material.accentColor + text: Config.baseUnit + ',' + visible: model.balance.satsInt != 0 + } + Label { + text: model.numtx + visible: model.numtx > 0 + } + Label { + color: Material.accentColor + text: qsTr('tx') + visible: model.numtx > 0 + } + } + } + + Item { + Layout.preferredWidth: 1 + Layout.preferredHeight: constants.paddingSmall + } + } + } + + ScrollIndicator.vertical: ScrollIndicator { } + } + + } + } + + Component { + id: sectionDelegate + Rectangle { + id: root + width: ListView.view.width + height: childrenRect.height + color: 'transparent' + + required property string section + + RowLayout { + x: constants.paddingMedium + width: parent.width - 2 * constants.paddingMedium + + Rectangle { + Layout.preferredHeight: 1 + Layout.fillWidth: true + color: Material.accentColor + } + Label { + padding: constants.paddingMedium + text: root.section + ' ' + qsTr('addresses') + font.bold: true + font.pixelSize: constants.fontSizeMedium + } + Rectangle { + Layout.preferredHeight: 1 + Layout.fillWidth: true + color: Material.accentColor + } + } + } + } + +} diff --git a/electrum/gui/qml/components/BalanceSummary.qml b/electrum/gui/qml/components/BalanceSummary.qml new file mode 100644 index 000000000..6d59814b1 --- /dev/null +++ b/electrum/gui/qml/components/BalanceSummary.qml @@ -0,0 +1,160 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +Frame { + id: root + height: layout.height + font.pixelSize: constants.fontSizeMedium + + property string formattedBalance + property string formattedBalanceFiat + property string formattedUnconfirmed + property string formattedUnconfirmedFiat + property string formattedFrozen + property string formattedFrozenFiat + property string formattedLightningBalance + property string formattedLightningBalanceFiat + + function setBalances() { + root.formattedBalance = Config.formatSats(Daemon.currentWallet.confirmedBalance) + root.formattedUnconfirmed = Config.formatSats(Daemon.currentWallet.unconfirmedBalance) + root.formattedFrozen = Config.formatSats(Daemon.currentWallet.frozenBalance) + root.formattedLightningBalance = Config.formatSats(Daemon.currentWallet.lightningBalance) + if (Daemon.fx.enabled) { + root.formattedBalanceFiat = Daemon.fx.fiatValue(Daemon.currentWallet.confirmedBalance, false) + root.formattedUnconfirmedFiat = Daemon.fx.fiatValue(Daemon.currentWallet.unconfirmedBalance, false) + root.formattedFrozenFiat = Daemon.fx.fiatValue(Daemon.currentWallet.frozenBalance, false) + root.formattedLightningBalanceFiat = Daemon.fx.fiatValue(Daemon.currentWallet.lightningBalance, false) + } + } + + GridLayout { + id: layout + + columns: 2 + Label { + font.pixelSize: constants.fontSizeLarge + text: qsTr('Balance: ') + } + RowLayout { + Label { + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + text: formattedBalance + } + Label { + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + text: Config.baseUnit + } + Label { + font.pixelSize: constants.fontSizeMedium + text: Daemon.fx.enabled + ? '(' + root.formattedBalanceFiat + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + Label { + visible: Daemon.currentWallet.unconfirmedBalance.satsInt > 0 + font.pixelSize: constants.fontSizeSmall + text: qsTr('Unconfirmed: ') + } + RowLayout { + visible: Daemon.currentWallet.unconfirmedBalance.satsInt > 0 + Label { + font.pixelSize: constants.fontSizeSmall + font.family: FixedFont + text: formattedUnconfirmed + } + Label { + font.pixelSize: constants.fontSizeSmall + color: Material.accentColor + text: Config.baseUnit + } + Label { + font.pixelSize: constants.fontSizeSmall + text: Daemon.fx.enabled + ? '(' + root.formattedUnconfirmedFiat + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + Label { + visible: Daemon.currentWallet.frozenBalance.satsInt > 0 + font.pixelSize: constants.fontSizeSmall + text: qsTr('Frozen: ') + } + RowLayout { + visible: Daemon.currentWallet.frozenBalance.satsInt > 0 + Label { + font.pixelSize: constants.fontSizeSmall + font.family: FixedFont + text: root.formattedFrozen + } + Label { + font.pixelSize: constants.fontSizeSmall + color: Material.accentColor + text: Config.baseUnit + } + Label { + font.pixelSize: constants.fontSizeSmall + text: Daemon.fx.enabled + ? '(' + root.formattedFrozenFiat + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + Label { + visible: Daemon.currentWallet.isLightning + font.pixelSize: constants.fontSizeSmall + text: qsTr('Lightning: ') + } + RowLayout { + visible: Daemon.currentWallet.isLightning + Label { + font.pixelSize: constants.fontSizeSmall + font.family: FixedFont + text: formattedLightningBalance + } + Label { + font.pixelSize: constants.fontSizeSmall + color: Material.accentColor + text: Config.baseUnit + } + Label { + font.pixelSize: constants.fontSizeSmall + text: Daemon.fx.enabled + ? '(' + root.formattedLightningBalanceFiat + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + } + + // instead of all these explicit connections, we should expose + // formatted balances directly as a property + Connections { + target: Config + function onBaseUnitChanged() { setBalances() } + function onThousandsSeparatorChanged() { setBalances() } + } + + Connections { + target: Daemon + function onWalletLoaded() { setBalances() } + } + + Connections { + target: Daemon.fx + function onEnabledUpdated() { setBalances() } + function onQuotesUpdated() { setBalances() } + } + + Connections { + target: Daemon.currentWallet + function onBalanceChanged() { + setBalances() + } + } + + Component.onCompleted: setBalances() +} diff --git a/electrum/gui/qml/components/ChannelDetails.qml b/electrum/gui/qml/components/ChannelDetails.qml new file mode 100644 index 000000000..cea3b5b90 --- /dev/null +++ b/electrum/gui/qml/components/ChannelDetails.qml @@ -0,0 +1,270 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + width: parent.width + height: parent.height + + property string channelid + + property string title: qsTr("Channel details") + + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Backup'); + enabled: false + onTriggered: {} + icon.source: '../../icons/file.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Close channel'); + enabled: channeldetails.canClose + onTriggered: { + var dialog = closechannel.createObject(root, { 'channelid': channelid }) + dialog.open() + } + icon.source: '../../icons/closebutton.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Delete channel'); + enabled: channeldetails.canDelete + onTriggered: { + var dialog = app.messageDialog.createObject(root, + { + 'text': qsTr('Are you sure you want to delete this channel? This will purge associated transactions from your wallet history.'), + 'yesno': true + } + ) + dialog.yesClicked.connect(function() { + channeldetails.deleteChannel() + app.stack.pop() + Daemon.currentWallet.historyModel.init_model() // needed here? + Daemon.currentWallet.channelModel.remove_channel(channelid) + }) + dialog.open() + } + icon.source: '../../icons/delete.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + enabled: channeldetails.isOpen + text: channeldetails.frozenForSending ? qsTr('Unfreeze (for sending)') : qsTr('Freeze (for sending)') + onTriggered: channeldetails.freezeForSending() + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + enabled: channeldetails.isOpen + text: channeldetails.frozenForReceiving ? qsTr('Unfreeze (for receiving)') : qsTr('Freeze (for receiving)') + onTriggered: channeldetails.freezeForReceiving() + } + } + } + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: rootLayout + width: parent.width + columns: 2 + + Label { + text: qsTr('Channel name') + color: Material.accentColor + } + + Label { + text: channeldetails.name + } + + Label { + text: qsTr('Short channel ID') + color: Material.accentColor + } + + Label { + text: channeldetails.short_cid + } + + Label { + text: qsTr('State') + color: Material.accentColor + } + + Label { + text: channeldetails.state + } + + Label { + text: qsTr('Initiator') + color: Material.accentColor + } + + Label { + text: channeldetails.initiator + } + + Label { + text: qsTr('Capacity') + color: Material.accentColor + } + + RowLayout { + Label { + font.family: FixedFont + text: Config.formatSats(channeldetails.capacity) + } + Label { + color: Material.accentColor + text: Config.baseUnit + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(channeldetails.capacity) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + + Label { + text: qsTr('Can send') + color: Material.accentColor + } + + RowLayout { + visible: !channeldetails.frozenForSending && channeldetails.isOpen + Label { + font.family: FixedFont + text: Config.formatSats(channeldetails.canSend) + } + Label { + color: Material.accentColor + text: Config.baseUnit + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(channeldetails.canSend) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + Label { + visible: channeldetails.frozenForSending && channeldetails.isOpen + text: qsTr('n/a (frozen)') + } + Label { + visible: !channeldetails.isOpen + text: qsTr('n/a (channel not open)') + } + + Label { + text: qsTr('Can Receive') + color: Material.accentColor + } + + RowLayout { + visible: !channeldetails.frozenForReceiving && channeldetails.isOpen + Label { + font.family: FixedFont + text: Config.formatSats(channeldetails.canReceive) + } + Label { + color: Material.accentColor + text: Config.baseUnit + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(channeldetails.canReceive) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + Label { + visible: channeldetails.frozenForReceiving && channeldetails.isOpen + text: qsTr('n/a (frozen)') + } + Label { + visible: !channeldetails.isOpen + text: qsTr('n/a (channel not open)') + } + + Label { + text: qsTr('Channel type') + color: Material.accentColor + } + + Label { + text: channeldetails.channelType + } + + Label { + text: qsTr('Remote node ID') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: channeldetails.pubkey + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + Layout.fillWidth: true + wrapMode: Text.Wrap + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Channel node ID'), 'text': channeldetails.pubkey }) + dialog.open() + } + } + } + } + + } + } + + ChannelDetails { + id: channeldetails + wallet: Daemon.currentWallet + channelid: root.channelid + } + + Component { + id: share + GenericShareDialog {} + } + + Component { + id: closechannel + CloseChannelDialog {} + } +} diff --git a/electrum/gui/qml/components/Channels.qml b/electrum/gui/qml/components/Channels.qml new file mode 100644 index 000000000..4962d10dd --- /dev/null +++ b/electrum/gui/qml/components/Channels.qml @@ -0,0 +1,162 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + property string title: qsTr("Lightning Channels") + + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Swap'); + enabled: Daemon.currentWallet.lightningCanSend.satsInt > 0 || Daemon.currentWallet.lightningCanReceive.satInt > 0 + onTriggered: { + var dialog = swapDialog.createObject(root) + dialog.open() + } + icon.source: '../../icons/status_waiting.png' + } + } + MenuSeparator {} + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Open Channel'); + onTriggered: app.stack.push(Qt.resolvedUrl('OpenChannel.qml')) + icon.source: '../../icons/lightning.png' + } + } + } + + ColumnLayout { + id: layout + width: parent.width + height: parent.height + + GridLayout { + id: summaryLayout + Layout.preferredWidth: parent.width + columns: 2 + + Label { + Layout.columnSpan: 2 + text: qsTr('You have %1 open channels').arg(Daemon.currentWallet.channelModel.numOpenChannels) + color: Material.accentColor + } + + Label { + text: qsTr('You can send:') + color: Material.accentColor + } + + RowLayout { + Layout.fillWidth: true + Label { + text: Config.formatSats(Daemon.currentWallet.lightningCanSend) + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(Daemon.currentWallet.lightningCanSend) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + + Label { + text: qsTr('You can receive:') + color: Material.accentColor + } + + RowLayout { + Layout.fillWidth: true + Label { + text: Config.formatSats(Daemon.currentWallet.lightningCanReceive) + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + Label { + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(Daemon.currentWallet.lightningCanReceive) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + } + + } + + Frame { + id: channelsFrame + Layout.preferredWidth: parent.width + Layout.fillHeight: true + verticalPadding: 0 + horizontalPadding: 0 + background: PaneInsetBackground {} + + ColumnLayout { + spacing: 0 + anchors.fill: parent + + Item { + Layout.preferredHeight: hitem.height + Layout.preferredWidth: parent.width + Rectangle { + anchors.fill: parent + color: Qt.lighter(Material.background, 1.25) + } + RowLayout { + id: hitem + width: parent.width + Label { + text: qsTr('Channels') + font.pixelSize: constants.fontSizeLarge + color: Material.accentColor + } + } + } + + ListView { + id: listview + Layout.preferredWidth: parent.width + Layout.fillHeight: true + clip: true + model: Daemon.currentWallet.channelModel + + delegate: ChannelDelegate { + onClicked: { + app.stack.push(Qt.resolvedUrl('ChannelDetails.qml'), { 'channelid': model.cid }) + } + } + + ScrollIndicator.vertical: ScrollIndicator { } + } + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + Button { + text: qsTr('Open Channel') + onClicked: app.stack.push(Qt.resolvedUrl('OpenChannel.qml')) + } + } + + } + + Component { + id: swapDialog + Swap {} + } +} diff --git a/electrum/gui/qml/components/CloseChannelDialog.qml b/electrum/gui/qml/components/CloseChannelDialog.qml new file mode 100644 index 000000000..2bd2c34aa --- /dev/null +++ b/electrum/gui/qml/components/CloseChannelDialog.qml @@ -0,0 +1,130 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Dialog { + id: dialog + width: parent.width + height: parent.height + + property string channelid + + title: qsTr('Close Channel') + standardButtons: closing ? 0 : Dialog.Cancel + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + property bool closing: false + + closePolicy: Popup.NoAutoClose + + GridLayout { + id: layout + width: parent.width + height: parent.height + columns: 2 + + Label { + text: qsTr('Channel name') + color: Material.accentColor + } + + Label { + text: channeldetails.name + } + + Label { + text: qsTr('Short channel ID') + color: Material.accentColor + } + + Label { + text: channeldetails.short_cid + } + + InfoTextArea { + Layout.columnSpan: 2 + text: qsTr(channeldetails.message_force_close) + } + + ColumnLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + + ButtonGroup { + id: closetypegroup + } + + RadioButton { + ButtonGroup.group: closetypegroup + property string closetype: 'cooperative' + checked: true + enabled: !closing && channeldetails.canCoopClose + text: qsTr('Cooperative close') + } + RadioButton { + ButtonGroup.group: closetypegroup + property string closetype: 'force' + enabled: !closing && channeldetails.canForceClose + text: qsTr('Request Force-close') + } + } + + Button { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + text: qsTr('Close') + enabled: !closing + onClicked: { + closing = true + channeldetails.close_channel(closetypegroup.checkedButton.closetype) + } + + } + + ColumnLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + Label { + id: errorText + visible: !closing && errorText + wrapMode: Text.Wrap + Layout.preferredWidth: layout.width + } + Label { + text: qsTr('Closing...') + visible: closing + } + BusyIndicator { + visible: closing + } + } + Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } + + } + + ChannelDetails { + id: channeldetails + wallet: Daemon.currentWallet + channelid: dialog.channelid + + onChannelCloseSuccess: { + closing = false + dialog.close() + } + + onChannelCloseFailed: { + closing = false + errorText.text = message + } + } + +} diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml new file mode 100644 index 000000000..e649fcaf2 --- /dev/null +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -0,0 +1,262 @@ +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" + +Dialog { + id: dialog + + required property QtObject finalizer + required property Amount satoshis + property string address + property string message + property alias amountLabelText: amountLabel.text + property alias sendButtonText: sendButton.text + + signal txcancelled + signal txaccepted + + title: qsTr('Confirm Transaction') + + // copy these to finalizer + onAddressChanged: finalizer.address = address + onSatoshisChanged: finalizer.amount = satoshis + + width: parent.width + height: parent.height + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + function updateAmountText() { + btcValue.text = Config.formatSats(finalizer.effectiveAmount, false) + fiatValue.text = Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(finalizer.effectiveAmount, false) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + } + + GridLayout { + id: layout + width: parent.width + height: parent.height + columns: 2 + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Label { + id: amountLabel + text: qsTr('Amount to send') + } + + RowLayout { + Layout.fillWidth: true + Label { + id: btcValue + font.bold: true + } + + Label { + text: Config.baseUnit + color: Material.accentColor + } + + Label { + id: fiatValue + Layout.fillWidth: true + font.pixelSize: constants.fontSizeMedium + } + + Component.onCompleted: updateAmountText() + Connections { + target: finalizer + function onEffectiveAmountChanged() { + updateAmountText() + } + } + } + + Label { + text: qsTr('Mining fee') + } + + RowLayout { + Label { + id: fee + text: Config.formatSats(finalizer.fee) + } + + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + text: qsTr('Fee rate') + } + + RowLayout { + Label { + id: feeRate + text: finalizer.feeRate + } + + Label { + text: 'sat/vB' + color: Material.accentColor + } + } + + Label { + text: qsTr('Target') + } + + Label { + id: targetdesc + text: finalizer.target + } + + Slider { + id: feeslider + snapMode: Slider.SnapOnRelease + stepSize: 1 + from: 0 + to: finalizer.sliderSteps + onValueChanged: { + if (activeFocus) + finalizer.sliderPos = value + } + Component.onCompleted: { + value = finalizer.sliderPos + } + Connections { + target: finalizer + function onSliderPosChanged() { + feeslider.value = finalizer.sliderPos + } + } + } + + ComboBox { + id: target + textRole: 'text' + valueRole: 'value' + model: [ + { text: qsTr('ETA'), value: 1 }, + { text: qsTr('Mempool'), value: 2 }, + { text: qsTr('Static'), value: 0 } + ] + onCurrentValueChanged: { + if (activeFocus) + finalizer.method = currentValue + } + Component.onCompleted: { + currentIndex = indexOfValue(finalizer.method) + } + } + + InfoTextArea { + Layout.columnSpan: 2 + visible: finalizer.warning != '' + text: finalizer.warning + iconStyle: InfoTextArea.IconStyle.Warn + } + + CheckBox { + id: final_cb + text: qsTr('Replace-by-Fee') + Layout.columnSpan: 2 + checked: finalizer.rbf + visible: finalizer.canRbf + } + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Label { + text: qsTr('Outputs') + Layout.columnSpan: 2 + } + + Repeater { + model: finalizer.outputs + delegate: TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + RowLayout { + width: parent.width + Label { + text: modelData.address + Layout.fillWidth: true + wrapMode: Text.Wrap + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + color: modelData.is_mine ? constants.colorMine : Material.foreground + } + Label { + text: Config.formatSats(modelData.value_sats) + font.pixelSize: constants.fontSizeMedium + font.family: FixedFont + } + Label { + text: Config.baseUnit + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + } + } + } + } + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } + + RowLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + + Button { + text: qsTr('Cancel') + onClicked: { + txcancelled() + dialog.close() + } + } + + Button { + id: sendButton + text: qsTr('Pay') + enabled: finalizer.valid + onClicked: { + txaccepted() + finalizer.send_onchain() + dialog.close() + } + } + } + } + +} diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml new file mode 100644 index 000000000..f6eb7293d --- /dev/null +++ b/electrum/gui/qml/components/Constants.qml @@ -0,0 +1,35 @@ +import QtQuick 2.6 +import QtQuick.Controls.Material 2.0 + +Item { + readonly property int paddingTiny: 4 //deprecated + readonly property int paddingXXSmall: 4 + readonly property int paddingXSmall: 6 + readonly property int paddingSmall: 8 + readonly property int paddingMedium: 12 + readonly property int paddingLarge: 16 + readonly property int paddingXLarge: 20 + readonly property int paddingXXLarge: 28 + + readonly property int fontSizeXSmall: 10 + readonly property int fontSizeSmall: 12 + readonly property int fontSizeMedium: 15 + readonly property int fontSizeLarge: 18 + readonly property int fontSizeXLarge: 22 + readonly property int fontSizeXXLarge: 28 + + readonly property int iconSizeSmall: 16 + readonly property int iconSizeMedium: 24 + readonly property int iconSizeLarge: 32 + readonly property int iconSizeXLarge: 48 + readonly property int iconSizeXXLarge: 64 + + property color colorCredit: "#ff80ff80" + property color colorDebit: "#ffff8080" + property color mutedForeground: 'gray' //Qt.lighter(Material.background, 2) + property color colorMine: "yellow" + + property color colorLightningLocal: "blue" + property color colorLightningRemote: "yellow" + +} diff --git a/electrum/gui/qml/components/History.qml b/electrum/gui/qml/components/History.qml new file mode 100644 index 000000000..5c1c5a8b9 --- /dev/null +++ b/electrum/gui/qml/components/History.qml @@ -0,0 +1,211 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 +import QtQml.Models 2.2 + +import org.electrum 1.0 + +Pane { + id: rootItem + visible: Daemon.currentWallet !== undefined + clip: true + + ListView { + id: listview + width: parent.width + height: parent.height + + model: visualModel + + section.property: 'section' + section.criteria: ViewSection.FullString + section.delegate: RowLayout { + width: ListView.view.width + required property string section + Label { + text: section == 'today' + ? qsTr('Today') + : section == 'yesterday' + ? qsTr('Yesterday') + : section == 'lastweek' + ? qsTr('Last week') + : section == 'lastmonth' + ? qsTr('Last month') + : qsTr('Older') + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingLarge + font.pixelSize: constants.fontSizeLarge + color: Material.accentColor + } + } + + DelegateModel { + id: visualModel + model: Daemon.currentWallet.historyModel + + groups: [ + DelegateModelGroup { name: 'today'; includeByDefault: false }, + DelegateModelGroup { name: 'yesterday'; includeByDefault: false }, + DelegateModelGroup { name: 'lastweek'; includeByDefault: false }, + DelegateModelGroup { name: 'lastmonth'; includeByDefault: false }, + DelegateModelGroup { name: 'older'; includeByDefault: false } + ] + + delegate: Item { + id: delegate + width: ListView.view.width + height: delegateLayout.height + + ColumnLayout { + id: delegateLayout + width: parent.width + spacing: 0 + + ItemDelegate { + Layout.fillWidth: true + Layout.preferredHeight: txinfo.height + + onClicked: { + if (model.lightning) { + var page = app.stack.push(Qt.resolvedUrl('LightningPaymentDetails.qml'), {'key': model.key}) + page.detailsChanged.connect(function() { + // update listmodel when details change + visualModel.model.update_tx_label(model.key, page.label) + }) + } else { + var page = app.stack.push(Qt.resolvedUrl('TxDetails.qml'), {'txid': model.key}) + page.detailsChanged.connect(function() { + // update listmodel when details change + visualModel.model.update_tx_label(model.key, page.label) + }) + } + } + + GridLayout { + id: txinfo + columns: 3 + + x: constants.paddingSmall + width: delegate.width - 2*constants.paddingSmall + + Item { Layout.columnSpan: 3; Layout.preferredWidth: 1; Layout.preferredHeight: 1} + + Image { + readonly property variant tx_icons : [ + "../../../gui/icons/unconfirmed.png", + "../../../gui/icons/clock1.png", + "../../../gui/icons/clock2.png", + "../../../gui/icons/clock3.png", + "../../../gui/icons/clock4.png", + "../../../gui/icons/clock5.png", + "../../../gui/icons/confirmed_bw.png" + ] + + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + Layout.alignment: Qt.AlignVCenter + Layout.rowSpan: 2 + source: model.lightning ? "../../../gui/icons/lightning.png" : tx_icons[Math.min(6,model.confirmations)] + } + + Label { + Layout.fillWidth: true + font.pixelSize: model.label !== '' ? constants.fontSizeLarge : constants.fontSizeMedium + text: model.label !== '' ? model.label : '' + color: model.label !== '' ? Material.foreground : constants.mutedForeground + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + Label { + id: valueLabel + font.family: FixedFont + font.pixelSize: constants.fontSizeMedium + Layout.alignment: Qt.AlignRight + font.bold: true + color: model.incoming ? constants.colorCredit : constants.colorDebit + + function updateText() { + text = Config.formatSats(model.value) + } + Component.onCompleted: updateText() + } + Label { + font.pixelSize: constants.fontSizeSmall + text: model.date + color: constants.mutedForeground + } + Label { + id: fiatLabel + font.pixelSize: constants.fontSizeSmall + Layout.alignment: Qt.AlignRight + color: constants.mutedForeground + + function updateText() { + if (!Daemon.fx.enabled) { + text = '' + } else if (Daemon.fx.historicRates) { + text = Daemon.fx.fiatValueHistoric(model.value, model.timestamp) + ' ' + Daemon.fx.fiatCurrency + } else { + text = Daemon.fx.fiatValue(model.value, false) + ' ' + Daemon.fx.fiatCurrency + } + } + Component.onCompleted: updateText() + } + Item { Layout.columnSpan: 3; Layout.preferredWidth: 1; Layout.preferredHeight: 1 } + } + } + + Rectangle { + visible: delegate.ListView.section == delegate.ListView.nextSection + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: Qt.rgba(0,0,0,0.10) + } + + } + // as the items in the model are not bindings to QObjects, + // hook up events that might change the appearance + Connections { + target: Config + function onBaseUnitChanged() { valueLabel.updateText() } + function onThousandsSeparatorChanged() { valueLabel.updateText() } + } + + Connections { + target: Daemon.fx + function onHistoricRatesChanged() { fiatLabel.updateText() } + function onQuotesUpdated() { fiatLabel.updateText() } + function onHistoryUpdated() { fiatLabel.updateText() } + function onEnabledUpdated() { fiatLabel.updateText() } + } + + Component.onCompleted: { + if (model.section == 'today') { + delegate.DelegateModel.inToday = true + } else if (model.section == 'yesterday') { + delegate.DelegateModel.inYesterday = true + } else if (model.section == 'lastweek') { + delegate.DelegateModel.inLastweek = true + } else if (model.section == 'lastmonth') { + delegate.DelegateModel.inLastmonth = true + } else if (model.section == 'older') { + delegate.DelegateModel.inOlder = true + } + } + + } // delegate + } + + ScrollIndicator.vertical: ScrollIndicator { } + + } + + Connections { + target: Network + function onHeightChanged(height) { + Daemon.currentWallet.historyModel.updateBlockchainHeight(height) + } + } +} diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml new file mode 100644 index 000000000..48b8daee5 --- /dev/null +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -0,0 +1,233 @@ +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" + +Dialog { + id: dialog + + property Invoice invoice + property string invoice_key + + signal doPay + + width: parent.width + height: parent.height + + title: qsTr('Invoice') + standardButtons: invoice_key != '' ? Dialog.Close : Dialog.Cancel + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + GridLayout { + id: layout + width: parent.width + height: parent.height + columns: 2 + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Label { + text: qsTr('Type') + } + + RowLayout { + Layout.fillWidth: true + Image { + Layout.preferredWidth: constants.iconSizeSmall + Layout.preferredHeight: constants.iconSizeSmall + source: invoice.invoiceType == Invoice.LightningInvoice + ? "../../icons/lightning.png" + : "../../icons/bitcoin.png" + } + + Label { + text: invoice.invoiceType == Invoice.OnchainInvoice + ? qsTr('On chain') + : invoice.invoiceType == Invoice.LightningInvoice + ? qsTr('Lightning') + : '' + Layout.fillWidth: true + } + } + + Label { + text: qsTr('Amount to send') + } + + RowLayout { + Layout.fillWidth: true + Label { + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + font.bold: true + text: Config.formatSats(invoice.amount, false) + } + + Label { + text: Config.baseUnit + color: Material.accentColor + } + + Label { + id: fiatValue + Layout.fillWidth: true + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(invoice.amount, false) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + font.pixelSize: constants.fontSizeMedium + } + } + + Label { + text: qsTr('Description') + } + + Label { + text: invoice.message + Layout.fillWidth: true + wrapMode: Text.Wrap + elide: Text.ElideRight + } + + Label { + visible: invoice.invoiceType == Invoice.OnchainInvoice + text: qsTr('Address') + } + + Label { + visible: invoice.invoiceType == Invoice.OnchainInvoice + Layout.fillWidth: true + text: invoice.address + font.family: FixedFont + wrapMode: Text.Wrap + } + + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + text: qsTr('Remote Pubkey') + } + + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + Layout.fillWidth: true + text: invoice.lnprops.pubkey + font.family: FixedFont + wrapMode: Text.Wrap + } + + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + text: qsTr('Route via (t)') + } + + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + Layout.fillWidth: true + text: invoice.lnprops.t + font.family: FixedFont + wrapMode: Text.Wrap + } + + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + text: qsTr('Route via (r)') + } + + Label { + visible: invoice.invoiceType == Invoice.LightningInvoice + Layout.fillWidth: true + text: invoice.lnprops.r + font.family: FixedFont + wrapMode: Text.Wrap + } + + Label { + text: qsTr('Status') + } + + Label { + text: invoice.status_str + } + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Item { Layout.preferredHeight: constants.paddingLarge; Layout.preferredWidth: 1 } + + InfoTextArea { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + visible: invoice.userinfo + text: invoice.userinfo + } + + RowLayout { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + spacing: constants.paddingMedium + + Button { + text: qsTr('Delete') + icon.source: '../../icons/delete.png' + visible: invoice_key != '' + onClicked: { + invoice.wallet.delete_invoice(invoice_key) + dialog.close() + } + } + + Button { + text: qsTr('Save') + icon.source: '../../icons/save.png' + visible: invoice_key == '' + enabled: invoice.canSave + onClicked: { + invoice.save_invoice() + dialog.close() + } + } + + Button { + text: qsTr('Pay now') + icon.source: '../../icons/confirmed.png' + enabled: invoice.invoiceType != Invoice.Invalid && invoice.canPay + onClicked: { + if (invoice_key == '') // save invoice if not retrieved from key + invoice.save_invoice() + dialog.close() + if (invoice.invoiceType == Invoice.OnchainInvoice) { + doPay() // only signal here + } else if (invoice.invoiceType == Invoice.LightningInvoice) { + doPay() // only signal here + } + } + } + } + + Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } + } + + Component.onCompleted: { + if (invoice_key != '') { + invoice.initFromKey(invoice_key) + } + } +} diff --git a/electrum/gui/qml/components/LightningPaymentDetails.qml b/electrum/gui/qml/components/LightningPaymentDetails.qml new file mode 100644 index 000000000..63e423707 --- /dev/null +++ b/electrum/gui/qml/components/LightningPaymentDetails.qml @@ -0,0 +1,261 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + width: parent.width + height: parent.height + + property string title: qsTr("Lightning payment details") + + property string key + + property alias label: lnpaymentdetails.label + + signal detailsChanged + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + clip: true + interactive: height < contentHeight + + GridLayout { + id: rootLayout + width: parent.width + columns: 2 + + Label { + text: qsTr('Status') + color: Material.accentColor + } + + Label { + text: lnpaymentdetails.status + } + + Label { + text: qsTr('Date') + color: Material.accentColor + } + + Label { + text: lnpaymentdetails.date + } + + Label { + text: lnpaymentdetails.amount.msatsInt > 0 + ? qsTr('Amount received') + : qsTr('Amount sent') + color: Material.accentColor + } + + RowLayout { + Label { + text: Config.formatMilliSats(lnpaymentdetails.amount) + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + visible: lnpaymentdetails.amount.msatsInt < 0 + text: qsTr('Transaction fee') + color: Material.accentColor + } + + RowLayout { + visible: lnpaymentdetails.amount.msatsInt < 0 + Label { + text: Config.formatMilliSats(lnpaymentdetails.fee) + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + text: qsTr('Label') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + id: labelContent + + property bool editmode: false + + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + visible: !labelContent.editmode + text: lnpaymentdetails.label + wrapMode: Text.Wrap + Layout.fillWidth: true + font.pixelSize: constants.fontSizeLarge + } + ToolButton { + visible: !labelContent.editmode + icon.source: '../../icons/pen.png' + icon.color: 'transparent' + onClicked: { + labelEdit.text = lnpaymentdetails.label + labelContent.editmode = true + labelEdit.focus = true + } + } + TextField { + id: labelEdit + visible: labelContent.editmode + text: lnpaymentdetails.label + font.pixelSize: constants.fontSizeLarge + Layout.fillWidth: true + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/confirmed.png' + icon.color: 'transparent' + onClicked: { + labelContent.editmode = false + lnpaymentdetails.set_label(labelEdit.text) + } + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/delete.png' + icon.color: 'transparent' + onClicked: labelContent.editmode = false + } + } + } + + Label { + text: qsTr('Payment hash') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: lnpaymentdetails.payment_hash + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + Layout.fillWidth: true + wrapMode: Text.Wrap + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Payment hash'), 'text': lnpaymentdetails.payment_hash }) + dialog.open() + } + } + } + } + + Label { + text: qsTr('Preimage') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: lnpaymentdetails.preimage + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + Layout.fillWidth: true + wrapMode: Text.Wrap + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Preimage'), 'text': lnpaymentdetails.preimage }) + dialog.open() + } + } + } + } + + Label { + text: qsTr('Lightning invoice') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + Layout.fillWidth: true + text: lnpaymentdetails.invoice + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + wrapMode: Text.Wrap + maximumLineCount: 3 + elide: Text.ElideRight + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: enabled ? 'transparent' : constants.mutedForeground + enabled: lnpaymentdetails.invoice != '' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Lightning Invoice'), 'text': lnpaymentdetails.invoice }) + dialog.open() + } + } + } + } + + + } + } + + LnPaymentDetails { + id: lnpaymentdetails + wallet: Daemon.currentWallet + key: root.key + onLabelChanged: root.detailsChanged() + } + + Component { + id: share + GenericShareDialog {} + } + +} diff --git a/electrum/gui/qml/components/LightningPaymentProgressDialog.qml b/electrum/gui/qml/components/LightningPaymentProgressDialog.qml new file mode 100644 index 000000000..3dc4191fe --- /dev/null +++ b/electrum/gui/qml/components/LightningPaymentProgressDialog.qml @@ -0,0 +1,130 @@ +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" + +Dialog { + id: dialog + + required property string invoice_key + + width: parent.width + height: parent.height + + title: qsTr('Paying Lightning Invoice...') + standardButtons: Dialog.Cancel + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + Item { + id: s + state: '' + states: [ + State { + name: '' + }, + State { + name: 'success' + PropertyChanges { target: spinner; running: false } + PropertyChanges { target: helpText; text: qsTr('Paid!') } + PropertyChanges { target: dialog; standardButtons: Dialog.Ok } + PropertyChanges { target: icon; source: '../../icons/confirmed.png' } + }, + State { + name: 'failed' + PropertyChanges { target: spinner; running: false } + PropertyChanges { target: helpText; text: qsTr('Payment failed') } + PropertyChanges { target: dialog; standardButtons: Dialog.Ok } + PropertyChanges { target: errorText; visible: true } + PropertyChanges { target: icon; source: '../../icons/warning.png' } + } + ] + transitions: [ + Transition { + from: '' + to: 'success' + PropertyAnimation { target: helpText; properties: 'text'; duration: 0} + NumberAnimation { target: icon; properties: 'opacity'; from: 0; to: 1; duration: 200 } + NumberAnimation { target: icon; properties: 'scale'; from: 0; to: 1; duration: 500 + easing.type: Easing.OutBack + easing.overshoot: 10 + } + }, + Transition { + from: '' + to: 'failed' + PropertyAnimation { target: helpText; properties: 'text'; duration: 0} + NumberAnimation { target: icon; properties: 'opacity'; from: 0; to: 1; duration: 500 } + } + ] + } + + ColumnLayout { + id: content + anchors.centerIn: parent + + Item { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: constants.iconSizeXXLarge + Layout.preferredHeight: constants.iconSizeXXLarge + + BusyIndicator { + id: spinner + visible: s.state == '' + width: constants.iconSizeXXLarge + height: constants.iconSizeXXLarge + } + + Image { + id: icon + width: constants.iconSizeXXLarge + height: constants.iconSizeXXLarge + } + } + + Label { + id: helpText + text: qsTr('Paying...') + font.pixelSize: constants.fontSizeXXLarge + Layout.alignment: Qt.AlignHCenter + } + + Label { + id: errorText + font.pixelSize: constants.fontSizeLarge + Layout.alignment: Qt.AlignHCenter + } + } + + Connections { + target: Daemon.currentWallet + function onPaymentSucceeded(key) { + if (key != invoice_key) { + console.log('wrong invoice ' + key + ' != ' + invoice_key) + return + } + console.log('payment succeeded!') + s.state = 'success' + } + function onPaymentFailed(key, reason) { + if (key != invoice_key) { + console.log('wrong invoice ' + key + ' != ' + invoice_key) + return + } + console.log('payment failed: ' + reason) + s.state = 'failed' + errorText.text = reason + } + function onPaymentAuthRejected() { + dialog.close() + } + } +} diff --git a/electrum/gui/qml/components/NetworkStats.qml b/electrum/gui/qml/components/NetworkStats.qml new file mode 100644 index 000000000..abf42a7de --- /dev/null +++ b/electrum/gui/qml/components/NetworkStats.qml @@ -0,0 +1,90 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +Pane { + property string title: qsTr('Network') + + GridLayout { + columns: 3 + + Label { + text: qsTr("Network: "); + color: Material.primaryHighlightedTextColor; + font.bold: true + } + Label { + text: Network.networkName + Layout.columnSpan: 2 + } + + Label { + text: qsTr("Server: "); + color: Material.primaryHighlightedTextColor; + font.bold: true + } + Label { + text: Network.server + Layout.columnSpan: 2 + } + + Label { + text: qsTr("Local Height: "); + color: Material.primaryHighlightedTextColor; + font.bold: true + + } + Label { + text: Network.height + Layout.columnSpan: 2 + } + + Label { + text: qsTr("Status: "); + color: Material.primaryHighlightedTextColor; + font.bold: true + } + Image { + Layout.preferredWidth: constants.iconSizeSmall + Layout.preferredHeight: constants.iconSizeSmall + source: Network.status == 'connecting' || Network.status == 'disconnected' + ? '../../icons/status_disconnected.png' + : Network.status == 'connected' + ? Daemon.currentWallet && !Daemon.currentWallet.isUptodate + ? '../../icons/status_lagging.png' + : '../../icons/status_connected.png' + : '../../icons/status_connected.png' + } + Label { + text: Network.status + } + + Label { + text: qsTr("Network fees: "); + color: Material.primaryHighlightedTextColor; + font.bold: true + } + Label { + id: feeHistogram + Layout.columnSpan: 2 + } + } + + function setFeeHistogram() { + var txt = '' + Network.feeHistogram.forEach(function(item) { + txt = txt + item[0] + ': ' + item[1] + '\n'; + }) + feeHistogram.text = txt.trim() + } + + Connections { + target: Network + function onFeeHistogramUpdated() { + setFeeHistogram() + } + } + + Component.onCompleted: setFeeHistogram() +} diff --git a/electrum/gui/qml/components/NewWalletWizard.qml b/electrum/gui/qml/components/NewWalletWizard.qml new file mode 100644 index 000000000..3a82c5918 --- /dev/null +++ b/electrum/gui/qml/components/NewWalletWizard.qml @@ -0,0 +1,110 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import "wizard" + +Wizard { + id: walletwizard + + title: qsTr('New Wallet') + + signal walletCreated + + property alias path: walletdb.path + + enter: null // disable transition + + // State transition functions. These functions are called when the 'Next' + // button is pressed. Depending on the data create the next page + // in the conversation. + + function walletnameDone(d) { + console.log('wallet name done') + var page = _loadNextComponent(components.wallettype, wizard_data) + page.next.connect(function() {wallettypeDone()}) + } + + function wallettypeDone(d) { + console.log('wallet type done') + var page = _loadNextComponent(components.keystore, wizard_data) + page.next.connect(function() {keystoretypeDone()}) + } + + function keystoretypeDone(d) { + console.log('keystore type done') + var page + switch(wizard_data['keystore_type']) { + case 'createseed': + page = _loadNextComponent(components.createseed, wizard_data) + page.next.connect(function() {createseedDone()}) + break + case 'haveseed': + page = _loadNextComponent(components.haveseed, wizard_data) + page.next.connect(function() {haveseedDone()}) + break +// case 'masterkey' +// case 'hardware' + } + } + + function createseedDone(d) { + console.log('create seed done') + var page = _loadNextComponent(components.confirmseed, wizard_data) + page.next.connect(function() {confirmseedDone()}) + } + + function confirmseedDone(d) { + console.log('confirm seed done') + var page = _loadNextComponent(components.walletpassword, wizard_data) + page.next.connect(function() {walletpasswordDone()}) + page.last = true + } + + function haveseedDone(d) { + console.log('have seed done') + if (wizard_data['seed_type'] == 'bip39') { + var page = _loadNextComponent(components.bip39refine, wizard_data) + page.next.connect(function() {bip39refineDone()}) + } else { + var page = _loadNextComponent(components.walletpassword, wizard_data) + page.next.connect(function() {walletpasswordDone()}) + page.last = true + } + } + + function bip39refineDone(d) { + console.log('bip39 refine done') + var page = _loadNextComponent(components.walletpassword, wizard_data) + page.next.connect(function() {walletpasswordDone()}) + page.last = true + } + + function walletpasswordDone(d) { + console.log('walletpassword done') + var page = _loadNextComponent(components.walletpassword, wizard_data) + } + + WizardComponents { + id: components + } + + Component.onCompleted: { + _setWizardData({}) + var start = _loadNextComponent(components.walletname) + start.next.connect(function() {walletnameDone()}) + } + + onAccepted: { + console.log('Finished new wallet wizard') + walletdb.create_storage(wizard_data) + } + + WalletDB { + id: walletdb + onCreateSuccess: walletwizard.walletCreated() + } +} + diff --git a/electrum/gui/qml/components/OpenChannel.qml b/electrum/gui/qml/components/OpenChannel.qml new file mode 100644 index 000000000..c996858cc --- /dev/null +++ b/electrum/gui/qml/components/OpenChannel.qml @@ -0,0 +1,187 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + + property string title: qsTr("Open Lightning Channel") + + GridLayout { + id: form + width: parent.width + rowSpacing: constants.paddingSmall + columnSpacing: constants.paddingSmall + columns: 4 + + Label { + text: qsTr('Node') + } + + // gossip + TextArea { + id: node + visible: Config.useGossip + Layout.columnSpan: 2 + Layout.fillWidth: true + font.family: FixedFont + wrapMode: Text.Wrap + placeholderText: qsTr('Paste or scan node uri/pubkey') + onActiveFocusChanged: { + if (!activeFocus) + channelopener.nodeid = text + } + } + + RowLayout { + visible: Config.useGossip + spacing: 0 + ToolButton { + icon.source: '../../icons/paste.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + onClicked: { + channelopener.nodeid = AppController.clipboardToText() + node.text = channelopener.nodeid + } + } + ToolButton { + icon.source: '../../icons/qrcode.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + scale: 1.2 + onClicked: { + var page = app.stack.push(Qt.resolvedUrl('Scan.qml')) + page.onFound.connect(function() { + channelopener.nodeid = page.scanData + node.text = channelopener.nodeid + }) + } + } + } + + // trampoline + ComboBox { + id: tnode + visible: !Config.useGossip + Layout.columnSpan: 3 + Layout.fillWidth: true + model: channelopener.trampolineNodeNames + onCurrentValueChanged: { + channelopener.nodeid = tnode.currentValue + } + } + + Label { + text: qsTr('Amount') + } + + BtcField { + id: amount + fiatfield: amountFiat + Layout.preferredWidth: parent.width /3 + onTextChanged: channelopener.amount = Config.unitsToSats(amount.text) + enabled: !is_max.checked + } + + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + Label { + text: Config.baseUnit + color: Material.accentColor + } + Switch { + id: is_max + text: qsTr('Max') + onCheckedChanged: { + channelopener.amount = checked ? MAX : Config.unitsToSats(amount.text) + } + } + } + + Item { width: 1; height: 1; visible: Daemon.fx.enabled } + + FiatField { + id: amountFiat + btcfield: amount + visible: Daemon.fx.enabled + Layout.preferredWidth: parent.width /3 + enabled: !is_max.checked + } + + Label { + visible: Daemon.fx.enabled + text: Daemon.fx.fiatCurrency + color: Material.accentColor + Layout.fillWidth: true + } + + Item { visible: Daemon.fx.enabled ; height: 1; width: 1 } + + RowLayout { + Layout.columnSpan: 4 + Layout.alignment: Qt.AlignHCenter + + Button { + text: qsTr('Open Channel') + enabled: channelopener.valid + onClicked: channelopener.open_channel() + } + } + } + + Component { + id: confirmOpenChannelDialog + ConfirmTxDialog { + title: qsTr('Confirm Open Channel') + amountLabelText: qsTr('Channel capacity') + sendButtonText: qsTr('Open Channel') + finalizer: channelopener.finalizer + } + } + + + ChannelOpener { + id: channelopener + wallet: Daemon.currentWallet + onValidationError: { + if (code == 'invalid_nodeid') { + var dialog = app.messageDialog.createObject(root, { 'text': message }) + dialog.open() + } + } + onConflictingBackup: { + var dialog = app.messageDialog.createObject(root, { 'text': message, 'yesno': true }) + dialog.open() + dialog.yesClicked.connect(function() { + channelopener.open_channel(true) + }) + } + onFinalizerChanged: { + var dialog = confirmOpenChannelDialog.createObject(root, { + 'satoshis': channelopener.amount + }) + dialog.open() + } + onChannelOpenError: { + var dialog = app.messageDialog.createObject(root, { 'text': message }) + dialog.open() + } + onChannelOpenSuccess: { + var message = 'success!' + if (!has_backup) + message = message + ' (but no backup. TODO: show QR)' + var dialog = app.messageDialog.createObject(root, { 'text': message }) + dialog.open() + channelopener.wallet.channelModel.new_channel(cid) + app.stack.pop() + } + } + +} diff --git a/electrum/gui/qml/components/OpenWallet.qml b/electrum/gui/qml/components/OpenWallet.qml new file mode 100644 index 000000000..715ae2685 --- /dev/null +++ b/electrum/gui/qml/components/OpenWallet.qml @@ -0,0 +1,137 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: openwalletdialog + + property string title: qsTr("Open Wallet") + + property string name + property string path + + property bool _unlockClicked: false + + GridLayout { + columns: 2 + width: parent.width + + Label { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + text: name + } + + MessagePane { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + text: qsTr("Wallet requires password to unlock") + visible: wallet_db.needsPassword + width: parent.width * 2/3 + warning: true + } + + MessagePane { + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + text: qsTr("Invalid Password") + visible: !wallet_db.validPassword && _unlockClicked + width: parent.width * 2/3 + error: true + } + + Label { + text: qsTr('Password') + visible: wallet_db.needsPassword + } + + TextField { + id: password + visible: wallet_db.needsPassword + echoMode: TextInput.Password + inputMethodHints: Qt.ImhSensitiveData + onTextChanged: { + unlockButton.enabled = true + _unlockClicked = false + } + onAccepted: { + unlock() + } + } + + Button { + id: unlockButton + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + visible: wallet_db.needsPassword + text: qsTr("Unlock") + onClicked: { + unlock() + } + } + + Label { + text: qsTr('Select HW device') + visible: wallet_db.needsHWDevice + } + + ComboBox { + id: hw_device + model: ['','Not implemented'] + visible: wallet_db.needsHWDevice + } + + Label { + text: qsTr('Wallet requires splitting') + visible: wallet_db.requiresSplit + } + + Button { + visible: wallet_db.requiresSplit + text: qsTr('Split wallet') + onClicked: wallet_db.doSplit() + } + + BusyIndicator { + id: busy + running: false + Layout.columnSpan: 2 + Layout.alignment: Qt.AlignHCenter + } + } + + function unlock() { + unlockButton.enabled = false + _unlockClicked = true + wallet_db.password = password.text + openwalletdialog.forceActiveFocus() + } + + WalletDB { + id: wallet_db + path: openwalletdialog.path + onSplitFinished: { + // if wallet needed splitting, we close the pane and refresh the wallet list + Daemon.availableWallets.reload() + app.stack.pop() + } + onReadyChanged: { + if (ready) { + busy.running = true + Daemon.load_wallet(openwalletdialog.path, password.text) + app.stack.pop(null) + } + } + onInvalidPassword: { + password.forceActiveFocus() + } + } + + Component.onCompleted: { + password.forceActiveFocus() + } +} diff --git a/electrum/gui/qml/components/Pin.qml b/electrum/gui/qml/components/Pin.qml new file mode 100644 index 000000000..492135180 --- /dev/null +++ b/electrum/gui/qml/components/Pin.qml @@ -0,0 +1,90 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Dialog { + id: root + + width: parent.width * 2/3 + height: parent.height * 1/3 + + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + focus: true + + standardButtons: Dialog.Cancel + + property string mode // [check, enter, change] + property string pincode // old one passed in when change, new one passed out + + property int _phase: mode == 'enter' ? 1 : 0 // 0 = existing pin, 1 = new pin, 2 = re-enter new pin + property string _pin + + function submit() { + if (_phase == 0) { + if (pin.text == pincode) { + pin.text = '' + if (mode == 'check') + accepted() + else + _phase = 1 + return + } + } + if (_phase == 1) { + _pin = pin.text + pin.text = '' + _phase = 2 + return + } + if (_phase == 2) { + if (_pin == pin.text) { + pincode = pin.text + accepted() + } + return + } + } + + ColumnLayout { + width: parent.width + height: parent.height + + Label { + text: [qsTr('Enter PIN'), qsTr('Enter New PIN'), qsTr('Re-enter New PIN')][_phase] + font.pixelSize: constants.fontSizeXXLarge + Layout.alignment: Qt.AlignHCenter + } + + TextField { + id: pin + Layout.preferredWidth: root.width *2/3 + Layout.alignment: Qt.AlignHCenter + font.pixelSize: constants.fontSizeXXLarge + maximumLength: 6 + inputMethodHints: Qt.ImhDigitsOnly + echoMode: TextInput.Password + focus: true + onTextChanged: { + if (text.length == 6) { + submit() + } + } + } + + Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } + } + +} diff --git a/electrum/gui/qml/components/Preferences.qml b/electrum/gui/qml/components/Preferences.qml new file mode 100644 index 000000000..ef3251ea8 --- /dev/null +++ b/electrum/gui/qml/components/Preferences.qml @@ -0,0 +1,199 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +Pane { + id: preferences + + property string title: qsTr("Preferences") + + ColumnLayout { + anchors.fill: parent + + Flickable { + Layout.fillHeight: true + Layout.fillWidth: true + + GridLayout { + id: rootLayout + columns: 2 + + Label { + text: qsTr('Language') + } + + ComboBox { + id: language + enabled: false + } + + Label { + text: qsTr('Base unit') + } + + ComboBox { + id: baseUnit + model: ['BTC','mBTC','bits','sat'] + onCurrentValueChanged: { + if (activeFocus) + Config.baseUnit = currentValue + } + } + + Switch { + id: thousands + Layout.columnSpan: 2 + text: qsTr('Add thousands separators to bitcoin amounts') + onCheckedChanged: { + if (activeFocus) + Config.thousandsSeparator = checked + } + } + + Switch { + id: checkSoftware + Layout.columnSpan: 2 + text: qsTr('Automatically check for software updates') + enabled: false + } + + Switch { + id: fiatEnable + text: qsTr('Fiat Currency') + onCheckedChanged: { + if (activeFocus) + Daemon.fx.enabled = checked + } + } + + ComboBox { + id: currencies + model: Daemon.fx.currencies + enabled: Daemon.fx.enabled + onCurrentValueChanged: { + if (activeFocus) + Daemon.fx.fiatCurrency = currentValue + } + } + + 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 + } + + ComboBox { + 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') + } + + ComboBox { + id: lnRoutingType + valueRole: 'key' + textRole: 'label' + enabled: Daemon.currentWallet != null && Daemon.currentWallet.isLightning && false + model: ListModel { + ListElement { key: 'gossip'; label: qsTr('Gossip') } + ListElement { key: 'trampoline'; label: qsTr('Trampoline') } + } + } + } + + } + + } + + Component { + id: pinSetup + Pin {} + } + + Component.onCompleted: { + baseUnit.currentIndex = ['BTC','mBTC','bits','sat'].indexOf(Config.baseUnit) + thousands.checked = Config.thousandsSeparator + currencies.currentIndex = currencies.indexOfValue(Daemon.fx.fiatCurrency) + historicRates.checked = Daemon.fx.historicRates + rateSources.currentIndex = rateSources.indexOfValue(Daemon.fx.rateSource) + fiatEnable.checked = Daemon.fx.enabled + spendUnconfirmed.checked = Config.spendUnconfirmed + lnRoutingType.currentIndex = Config.useGossip ? 0 : 1 + } +} diff --git a/electrum/gui/qml/components/Receive.qml b/electrum/gui/qml/components/Receive.qml new file mode 100644 index 000000000..440f524c3 --- /dev/null +++ b/electrum/gui/qml/components/Receive.qml @@ -0,0 +1,239 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.14 +import QtQuick.Controls.Material 2.0 +import QtQml.Models 2.1 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: rootItem + visible: Daemon.currentWallet !== undefined + + GridLayout { + id: form + width: parent.width + rowSpacing: constants.paddingSmall + columnSpacing: constants.paddingSmall + columns: 4 + + Label { + text: qsTr('Message') + } + + TextField { + id: message + placeholderText: qsTr('Description of payment request') + Layout.columnSpan: 3 + Layout.fillWidth: true + } + + Label { + text: qsTr('Request') + wrapMode: Text.WordWrap + Layout.rightMargin: constants.paddingXLarge + } + + BtcField { + id: amount + fiatfield: amountFiat + Layout.preferredWidth: parent.width /3 + } + + Label { + text: Config.baseUnit + color: Material.accentColor + } + + Item { width: 1; height: 1; Layout.fillWidth: true } + + Item { visible: Daemon.fx.enabled; width: 1; height: 1 } + + FiatField { + id: amountFiat + btcfield: amount + visible: Daemon.fx.enabled + Layout.preferredWidth: parent.width /3 + } + + Label { + visible: Daemon.fx.enabled + text: Daemon.fx.fiatCurrency + color: Material.accentColor + } + + Item { visible: Daemon.fx.enabled; width: 1; height: 1; Layout.fillWidth: true } + + Label { + text: qsTr('Expires after') + Layout.fillWidth: false + } + + ComboBox { + id: expires + Layout.columnSpan: 2 + + textRole: 'text' + valueRole: 'value' + + model: ListModel { + id: expiresmodel + Component.onCompleted: { + // we need to fill the model like this, as ListElement can't evaluate script + expiresmodel.append({'text': qsTr('10 minutes'), 'value': 10*60}) + expiresmodel.append({'text': qsTr('1 hour'), 'value': 60*60}) + expiresmodel.append({'text': qsTr('1 day'), 'value': 24*60*60}) + expiresmodel.append({'text': qsTr('1 week'), 'value': 7*24*60*60}) + expiresmodel.append({'text': qsTr('1 month'), 'value': 31*24*60*60}) + expiresmodel.append({'text': qsTr('Never'), 'value': 0}) + expires.currentIndex = 0 + } + } + + // redefine contentItem, as the default crops the widest item + contentItem: Label { + text: expires.currentText + padding: constants.paddingLarge + font.pixelSize: constants.fontSizeMedium + } + } + + Item { width: 1; height: 1; Layout.fillWidth: true } + + Button { + Layout.columnSpan: 4 + Layout.alignment: Qt.AlignHCenter + text: qsTr('Create Request') + icon.source: '../../icons/qrcode.png' + onClicked: { + createRequest() + } + } + } + + Frame { + verticalPadding: 0 + horizontalPadding: 0 + + anchors { + top: form.bottom + topMargin: constants.paddingXLarge + left: parent.left + right: parent.right + bottom: parent.bottom + } + + background: PaneInsetBackground {} + + ColumnLayout { + spacing: 0 + anchors.fill: parent + + Item { + Layout.preferredHeight: hitem.height + Layout.preferredWidth: parent.width + Rectangle { + anchors.fill: parent + color: Qt.lighter(Material.background, 1.25) + } + RowLayout { + id: hitem + width: parent.width + Label { + text: qsTr('Receive queue') + font.pixelSize: constants.fontSizeLarge + color: Material.accentColor + } + } + } + + ListView { + id: listview + Layout.fillHeight: true + Layout.fillWidth: true + clip: true + + model: DelegateModel { + id: delegateModel + model: Daemon.currentWallet.requestModel + delegate: InvoiceDelegate { + onClicked: { + var dialog = requestdialog.createObject(app, {'modelItem': model}) + dialog.open() + } + } + } + + remove: Transition { + NumberAnimation { properties: 'scale'; to: 0.75; duration: 300 } + NumberAnimation { properties: 'opacity'; to: 0; duration: 300 } + } + removeDisplaced: Transition { + SequentialAnimation { + PauseAnimation { duration: 200 } + SpringAnimation { properties: 'y'; duration: 100; spring: 5; damping: 0.5; mass: 2 } + } + } + + ScrollIndicator.vertical: ScrollIndicator { } + } + } + } + + // make clicking the dialog background move the scope away from textedit fields + // so the keyboard goes away + MouseArea { + anchors.fill: parent + z: -1000 + onClicked: parkFocus.focus = true + FocusScope { id: parkFocus } + } + + Component { + id: requestdialog + RequestDialog { + onClosed: destroy() + } + } + + function createRequest(ignoreGaplimit = false) { + var qamt = Config.unitsToSats(amount.text) + if (qamt.satsInt > Daemon.currentWallet.lightningCanReceive.satsInt) { + console.log('Creating OnChain request') + Daemon.currentWallet.create_request(qamt, message.text, expires.currentValue, false, ignoreGaplimit) + } else { + console.log('Creating Lightning request') + Daemon.currentWallet.create_request(qamt, message.text, expires.currentValue, true) + } + } + + Connections { + target: Daemon.currentWallet + function onRequestCreateSuccess() { + message.text = '' + amount.text = '' + var dialog = requestdialog.createObject(app, { + 'modelItem': delegateModel.items.get(0).model + }) + dialog.open() + } + function onRequestCreateError(code, error) { + if (code == 'gaplimit') { + var dialog = app.messageDialog.createObject(app, {'text': error, 'yesno': true}) + dialog.yesClicked.connect(function() { + createRequest(true) + }) + } else { + console.log(error) + var dialog = app.messageDialog.createObject(app, {'text': error}) + } + dialog.open() + } + function onRequestStatusChanged(key, status) { + Daemon.currentWallet.requestModel.updateRequest(key, status) + } + } + +} diff --git a/electrum/gui/qml/components/RequestDialog.qml b/electrum/gui/qml/components/RequestDialog.qml new file mode 100644 index 000000000..d7dc00138 --- /dev/null +++ b/electrum/gui/qml/components/RequestDialog.qml @@ -0,0 +1,228 @@ +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 + +Dialog { + id: dialog + title: qsTr('Payment Request') + + property var modelItem + + property string _bip21uri + + parent: Overlay.overlay + modal: true + standardButtons: Dialog.Close + + width: parent.width + height: parent.height + + Overlay.modal: Rectangle { + color: "#aa000000" + } + + header: RowLayout { + width: dialog.width + Label { + Layout.fillWidth: true + text: dialog.title + visible: dialog.title + elide: Label.ElideRight + padding: constants.paddingXLarge + bottomPadding: 0 + font.bold: true + font.pixelSize: constants.fontSizeMedium + } + } + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: rootLayout + width: parent.width + rowSpacing: constants.paddingMedium + columns: 5 + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 5 + color: Material.accentColor + } + + Image { + id: qr + Layout.columnSpan: 5 + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingSmall + Layout.bottomMargin: constants.paddingSmall + + Rectangle { + property int size: 57 // should be qr pixel multiple + color: 'white' + x: (parent.width - size) / 2 + y: (parent.height - size) / 2 + width: size + height: size + + Image { + + source: '../../icons/electrum.png' + x: 1 + y: 1 + width: parent.width - 2 + height: parent.height - 2 + scale: 0.9 + } + } + } + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 5 + color: Material.accentColor + } + + RowLayout { + Layout.columnSpan: 5 + Layout.alignment: Qt.AlignHCenter + Button { + icon.source: '../../icons/delete.png' + text: qsTr('Delete') + onClicked: { + Daemon.currentWallet.delete_request(modelItem.key) + dialog.close() + } + } + Button { + icon.source: '../../icons/copy_bw.png' + icon.color: 'transparent' + text: 'Copy' + onClicked: { + if (modelItem.is_lightning) + AppController.textToClipboard(modelItem.lightning_invoice) + else + AppController.textToClipboard(_bip21uri) + + } + } + Button { + icon.source: '../../icons/share.png' + text: 'Share' + onClicked: { + enabled = false + if (modelItem.is_lightning) + AppController.doShare(modelItem.lightning_invoice, qsTr('Payment Request')) + else + AppController.doShare(_bip21uri, qsTr('Payment Request')) + enabled = true + } + } + } + Label { + visible: modelItem.message != '' + text: qsTr('Description') + } + Label { + visible: modelItem.message != '' + Layout.columnSpan: 4 + Layout.fillWidth: true + wrapMode: Text.Wrap + text: modelItem.message + font.pixelSize: constants.fontSizeLarge + } + + Label { + visible: modelItem.amount.satsInt != 0 + text: qsTr('Amount') + } + Label { + visible: modelItem.amount.satsInt != 0 + text: Config.formatSats(modelItem.amount) + font.family: FixedFont + font.pixelSize: constants.fontSizeLarge + font.bold: true + } + Label { + visible: modelItem.amount.satsInt != 0 + text: Config.baseUnit + color: Material.accentColor + font.pixelSize: constants.fontSizeLarge + } + + Label { + id: fiatValue + visible: modelItem.amount.satsInt != 0 + Layout.fillWidth: true + Layout.columnSpan: 2 + text: Daemon.fx.enabled + ? '(' + Daemon.fx.fiatValue(modelItem.amount, false) + ' ' + Daemon.fx.fiatCurrency + ')' + : '' + font.pixelSize: constants.fontSizeMedium + wrapMode: Text.Wrap + } + + Label { + text: qsTr('Address') + visible: !modelItem.is_lightning + } + Label { + Layout.fillWidth: true + Layout.columnSpan: 3 + visible: !modelItem.is_lightning + font.family: FixedFont + font.pixelSize: constants.fontSizeLarge + wrapMode: Text.WrapAnywhere + text: modelItem.address + } + ToolButton { + icon.source: '../../icons/copy_bw.png' + visible: !modelItem.is_lightning + onClicked: { + AppController.textToClipboard(modelItem.address) + } + } + + Label { + text: qsTr('Status') + } + Label { + Layout.columnSpan: 4 + Layout.fillWidth: true + font.pixelSize: constants.fontSizeLarge + text: modelItem.status_str + } + + } + } + + Connections { + target: Daemon.currentWallet + function onRequestStatusChanged(key, status) { + if (key != modelItem.key) + return + modelItem = Daemon.currentWallet.get_request(key) + } + } + + Component.onCompleted: { + if (!modelItem.is_lightning) { + _bip21uri = bitcoin.create_bip21_uri(modelItem.address, modelItem.amount, modelItem.message, modelItem.timestamp, modelItem.expiration - modelItem.timestamp) + qr.source = 'image://qrgen/' + _bip21uri + } else { + qr.source = 'image://qrgen/' + modelItem.lightning_invoice + } + } + + Bitcoin { + id: bitcoin + } +} diff --git a/electrum/gui/qml/components/Scan.qml b/electrum/gui/qml/components/Scan.qml new file mode 100644 index 000000000..d0cb4ea23 --- /dev/null +++ b/electrum/gui/qml/components/Scan.qml @@ -0,0 +1,38 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 + +import org.electrum 1.0 + +import "controls" + +Item { + id: scanPage + property string title: qsTr('Scan') + + property bool toolbar: false + + property string scanData + property string error + + signal found + + QRScan { + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width + + onFound: { + scanPage.scanData = scanData + scanPage.found() + app.stack.pop() + } + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + id: button + anchors.bottom: parent.bottom + text: 'Cancel' + onClicked: app.stack.pop() + } +} diff --git a/electrum/gui/qml/components/Send.qml b/electrum/gui/qml/components/Send.qml new file mode 100644 index 000000000..972b76a38 --- /dev/null +++ b/electrum/gui/qml/components/Send.qml @@ -0,0 +1,359 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.0 +import QtQuick.Controls.Material 2.0 +import QtQml.Models 2.1 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: rootItem + + function clear() { + recipient.text = '' + amount.text = '' + message.text = '' + is_max.checked = false + } + + GridLayout { + id: form + width: parent.width + rowSpacing: constants.paddingSmall + columnSpacing: constants.paddingSmall + columns: 3 + + BalanceSummary { + Layout.columnSpan: 3 + Layout.alignment: Qt.AlignHCenter + } + + Label { + text: qsTr('Recipient') + } + + RowLayout { + Layout.fillWidth: true + Layout.columnSpan: 2 + + TextArea { + id: recipient + Layout.fillWidth: true + font.family: FixedFont + wrapMode: Text.Wrap + placeholderText: qsTr('Paste address or invoice') + onTextChanged: { + //if (activeFocus) + //userEnteredPayment.recipient = text + userEnteredPayment.recipient = recipient.text + } + } + + spacing: 0 + ToolButton { + icon.source: '../../icons/paste.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + onClicked: invoice.recipient = AppController.clipboardToText() + } + ToolButton { + icon.source: '../../icons/qrcode.png' + icon.height: constants.iconSizeMedium + icon.width: constants.iconSizeMedium + scale: 1.2 + onClicked: { + var page = app.stack.push(Qt.resolvedUrl('Scan.qml')) + page.onFound.connect(function() { + invoice.recipient = page.scanData + }) + } + } + } + + Label { + text: qsTr('Amount') + } + + BtcField { + id: amount + fiatfield: amountFiat + enabled: !is_max.checked + Layout.preferredWidth: parent.width /3 + onTextChanged: { + userEnteredPayment.amount = is_max.checked ? MAX : Config.unitsToSats(amount.text) + } + } + + RowLayout { + Layout.fillWidth: true + + Label { + text: Config.baseUnit + color: Material.accentColor + } + Switch { + id: is_max + text: qsTr('Max') + onCheckedChanged: { + userEnteredPayment.amount = is_max.checked ? MAX : Config.unitsToSats(amount.text) + } + } + } + + Item { width: 1; height: 1; visible: Daemon.fx.enabled } + + FiatField { + id: amountFiat + btcfield: amount + visible: Daemon.fx.enabled + enabled: !is_max.checked + Layout.preferredWidth: parent.width /3 + } + + Label { + Layout.fillWidth: true + visible: Daemon.fx.enabled + text: Daemon.fx.fiatCurrency + color: Material.accentColor + } + + Label { + text: qsTr('Description') + } + + TextField { + id: message + placeholderText: qsTr('Message') + Layout.columnSpan: 2 + Layout.fillWidth: true + onTextChanged: { + userEnteredPayment.message = message.text + } + } + + RowLayout { + Layout.columnSpan: 3 + Layout.alignment: Qt.AlignHCenter + spacing: constants.paddingMedium + + Button { + text: qsTr('Save') + enabled: userEnteredPayment.canSave + icon.source: '../../icons/save.png' + onClicked: { + userEnteredPayment.save_invoice() + userEnteredPayment.clear() + rootItem.clear() + } + } + + Button { + text: qsTr('Pay now') + enabled: userEnteredPayment.canPay + icon.source: '../../icons/confirmed.png' + onClicked: { + var dialog = confirmPaymentDialog.createObject(app, { + 'address': recipient.text, + 'satoshis': is_max.checked ? MAX : Config.unitsToSats(amount.text), + 'message': message.text + }) + dialog.txaccepted.connect(function() { + userEnteredPayment.clear() + rootItem.clear() + }) + dialog.open() + } + } + + } + } + + Frame { + verticalPadding: 0 + horizontalPadding: 0 + + anchors { + top: form.bottom + topMargin: constants.paddingXLarge + left: parent.left + right: parent.right + bottom: parent.bottom + } + + background: PaneInsetBackground {} + + ColumnLayout { + spacing: 0 + anchors.fill: parent + + Item { + Layout.preferredHeight: hitem.height + Layout.preferredWidth: parent.width + Rectangle { + anchors.fill: parent + color: Qt.lighter(Material.background, 1.25) + } + RowLayout { + id: hitem + width: parent.width + Label { + text: qsTr('Send queue') + font.pixelSize: constants.fontSizeLarge + color: Material.accentColor + } + } + } + + ListView { + id: listview + Layout.fillHeight: true + Layout.fillWidth: true + clip: true + + model: DelegateModel { + id: delegateModel + model: Daemon.currentWallet.invoiceModel + delegate: InvoiceDelegate { + onClicked: { + var dialog = invoiceDialog.createObject(app, {'invoice' : invoice, 'invoice_key': model.key}) + dialog.open() + } + } + } + + remove: Transition { + NumberAnimation { properties: 'scale'; to: 0.75; duration: 300 } + NumberAnimation { properties: 'opacity'; to: 0; duration: 300 } + } + removeDisplaced: Transition { + SequentialAnimation { + PauseAnimation { duration: 200 } + SpringAnimation { properties: 'y'; duration: 100; spring: 5; damping: 0.5; mass: 2 } + } + } + + ScrollIndicator.vertical: ScrollIndicator { } + } + } + } + + Component { + id: confirmPaymentDialog + ConfirmTxDialog { + title: qsTr('Confirm Payment') + finalizer: TxFinalizer { + wallet: Daemon.currentWallet + canRbf: true + } + } + } + + Component { + id: lightningPaymentProgressDialog + LightningPaymentProgressDialog {} + } + + Component { + id: invoiceDialog + InvoiceDialog { + onDoPay: { + if (invoice.invoiceType == Invoice.OnchainInvoice) { + var dialog = confirmPaymentDialog.createObject(rootItem, { + 'address': invoice.address, + 'satoshis': invoice.amount, + 'message': invoice.message + }) + dialog.open() + } else if (invoice.invoiceType == Invoice.LightningInvoice) { + console.log('About to pay lightning invoice') + if (invoice.key == '') { + console.log('No invoice key, aborting') + return + } + var dialog = lightningPaymentProgressDialog.createObject(rootItem, { + invoice_key: invoice.key + }) + dialog.open() + Daemon.currentWallet.pay_lightning_invoice(invoice.key) + } + } + } + } + + Connections { + target: Daemon.currentWallet + function onInvoiceStatusChanged(key, status) { + Daemon.currentWallet.invoiceModel.updateInvoice(key, status) + } + } + + // make clicking the dialog background move the scope away from textedit fields + // so the keyboard goes away + MouseArea { + anchors.fill: parent + z: -1000 + onClicked: parkFocus.focus = true + FocusScope { id: parkFocus } + } + + + UserEnteredPayment { + id: userEnteredPayment + wallet: Daemon.currentWallet + + //onValidationError: { + //if (recipient.activeFocus) { + //// no popups when editing + //return + //} + //var dialog = app.messageDialog.createObject(app, {'text': message }) + //dialog.open() +//// rootItem.clear() + //} + + onInvoiceSaved: { + Daemon.currentWallet.invoiceModel.init_model() + } + } + + InvoiceParser { + id: invoice + wallet: Daemon.currentWallet + onValidationError: { + if (recipient.activeFocus) { + // no popups when editing + return + } + var dialog = app.messageDialog.createObject(app, {'text': message }) + dialog.open() + rootItem.clear() + } + onValidationWarning: { + if (code == 'no_channels') { + var dialog = app.messageDialog.createObject(app, {'text': message }) + dialog.open() + // TODO: ask user to open a channel, if funds allow + // and maybe store invoice if expiry allows + } + } + onValidationSuccess: { + // address only -> fill form fields and clear this instance + // else -> show invoice confirmation dialog + if (invoiceType == Invoice.OnchainOnlyAddress) { + recipient.text = invoice.recipient + invoice.clear() + } else { + var dialog = invoiceDialog.createObject(rootItem, {'invoice': invoice}) + dialog.open() + } + } + onInvoiceCreateError: console.log(code + ' ' + message) + + onInvoiceSaved: { + Daemon.currentWallet.invoiceModel.init_model() + } + } + +} diff --git a/electrum/gui/qml/components/ServerConnectWizard.qml b/electrum/gui/qml/components/ServerConnectWizard.qml new file mode 100644 index 000000000..0f80d47c7 --- /dev/null +++ b/electrum/gui/qml/components/ServerConnectWizard.qml @@ -0,0 +1,53 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 + +import "wizard" + +Wizard { + id: serverconnectwizard + + title: qsTr('How do you want to connect to a server?') + + enter: null // disable transition + + onAccepted: { + var proxy = wizard_data['proxy'] + if (proxy && proxy['enabled'] == true) { + Network.proxy = proxy + } else { + Network.proxy = {'enabled': false} + } + Config.autoConnect = wizard_data['autoconnect'] + if (!wizard_data['autoconnect']) { + Network.server = wizard_data['server'] + } + } + + Component.onCompleted: { + var start = _loadNextComponent(autoconnect) + start.next.connect(function() {autoconnectDone()}) + } + + function autoconnectDone() { + var page = _loadNextComponent(proxyconfig, wizard_data) + page.next.connect(function() {proxyconfigDone()}) + } + + function proxyconfigDone() { + var page = _loadNextComponent(serverconfig, wizard_data) + } + + property Component autoconnect: Component { + WCAutoConnect {} + } + + property Component proxyconfig: Component { + WCProxyConfig {} + } + + property Component serverconfig: Component { + WCServerConfig {} + } + +} diff --git a/electrum/gui/qml/components/Splash.qml b/electrum/gui/qml/components/Splash.qml new file mode 100644 index 000000000..8afc04f68 --- /dev/null +++ b/electrum/gui/qml/components/Splash.qml @@ -0,0 +1,16 @@ +import QtQuick 2.0 + +Item { + property bool toolbar: false + + Rectangle { + anchors.fill: parent + color: '#111144' + } + + Image { + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + source: "../../icons/electrum.png" + } +} diff --git a/electrum/gui/qml/components/Swap.qml b/electrum/gui/qml/components/Swap.qml new file mode 100644 index 000000000..9a6532256 --- /dev/null +++ b/electrum/gui/qml/components/Swap.qml @@ -0,0 +1,205 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Dialog { + id: root + + width: parent.width + height: parent.height + + title: qsTr('Lightning Swap') + standardButtons: Dialog.Cancel + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + GridLayout { + id: layout + width: parent.width + height: parent.height + columns: 2 + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Label { + text: qsTr('You send') + color: Material.accentColor + } + + RowLayout { + Label { + id: tosend + text: Config.formatSats(swaphelper.tosend) + font.family: FixedFont + visible: swaphelper.valid + } + Label { + text: Config.baseUnit + color: Material.accentColor + visible: swaphelper.valid + } + Label { + text: swaphelper.isReverse ? qsTr('(offchain)') : qsTr('(onchain)') + visible: swaphelper.valid + } + } + + Label { + text: qsTr('You receive') + color: Material.accentColor + } + + RowLayout { + Layout.fillWidth: true + Label { + id: toreceive + text: Config.formatSats(swaphelper.toreceive) + font.family: FixedFont + visible: swaphelper.valid + } + Label { + text: Config.baseUnit + color: Material.accentColor + visible: swaphelper.valid + } + Label { + text: swaphelper.isReverse ? qsTr('(onchain)') : qsTr('(offchain)') + visible: swaphelper.valid + } + } + + Label { + text: qsTr('Server fee') + color: Material.accentColor + } + + RowLayout { + Label { + text: swaphelper.serverfeeperc + } + Label { + text: Config.formatSats(swaphelper.serverfee) + font.family: FixedFont + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + text: qsTr('Mining fee') + color: Material.accentColor + } + + RowLayout { + Label { + text: Config.formatSats(swaphelper.miningfee) + font.family: FixedFont + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Slider { + id: swapslider + Layout.columnSpan: 2 + Layout.preferredWidth: 2/3 * layout.width + Layout.alignment: Qt.AlignHCenter + + from: swaphelper.rangeMin + to: swaphelper.rangeMax + + onValueChanged: { + if (activeFocus) + swaphelper.sliderPos = value + } + Component.onCompleted: { + value = swaphelper.sliderPos + } + Connections { + target: swaphelper + function onSliderPosChanged() { + swapslider.value = swaphelper.sliderPos + } + } + } + + InfoTextArea { + Layout.columnSpan: 2 + visible: swaphelper.userinfo != '' + text: swaphelper.userinfo + } + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Button { + Layout.alignment: Qt.AlignHCenter + Layout.columnSpan: 2 + text: qsTr('Ok') + enabled: swaphelper.valid + onClicked: swaphelper.executeSwap() + } + + Item { Layout.fillHeight: true; Layout.preferredWidth: 1; Layout.columnSpan: 2 } + } + + SwapHelper { + id: swaphelper + wallet: Daemon.currentWallet + onError: { + var dialog = app.messageDialog.createObject(root, {'text': message}) + dialog.open() + } + onConfirm: { + var dialog = app.messageDialog.createObject(app, {'text': message, 'yesno': true}) + dialog.yesClicked.connect(function() { + dialog.close() + swaphelper.executeSwap(true) + root.close() + }) + dialog.open() + } + onAuthRequired: { // TODO: don't replicate this code + if (swaphelper.wallet.verify_password('')) { + // wallet has no password + console.log('wallet has no password, proceeding') + swaphelper.authProceed() + } else { + var dialog = app.passwordDialog.createObject(app, {'title': qsTr('Enter current password')}) + dialog.accepted.connect(function() { + if (swaphelper.wallet.verify_password(dialog.password)) { + swaphelper.wallet.authProceed() + } else { + swaphelper.wallet.authCancel() + } + }) + dialog.rejected.connect(function() { + swaphelper.wallet.authCancel() + }) + dialog.open() + } + } + } +} diff --git a/electrum/gui/qml/components/TxDetails.qml b/electrum/gui/qml/components/TxDetails.qml new file mode 100644 index 000000000..d9788a7ec --- /dev/null +++ b/electrum/gui/qml/components/TxDetails.qml @@ -0,0 +1,262 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: root + width: parent.width + height: parent.height + + property string title: qsTr("Transaction details") + + property string txid + + property alias label: txdetails.label + + signal detailsChanged + + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Bump fee') + enabled: txdetails.canBump + //onTriggered: + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Cancel double-spend') + enabled: txdetails.canCancel + } + } + } + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + clip: true + interactive: height < contentHeight + + GridLayout { + id: rootLayout + width: parent.width + columns: 2 + + Label { + text: qsTr('Status') + color: Material.accentColor + } + + Label { + text: txdetails.status + } + + Label { + text: qsTr('Mempool depth') + color: Material.accentColor + visible: !txdetails.isMined + } + + Label { + text: txdetails.mempoolDepth + visible: !txdetails.isMined + } + + Label { + text: qsTr('Date') + color: Material.accentColor + } + + Label { + text: txdetails.date + } + + Label { + text: txdetails.amount.satsInt > 0 + ? qsTr('Amount received') + : qsTr('Amount sent') + color: Material.accentColor + } + + RowLayout { + Label { + text: Config.formatSats(txdetails.amount) + font.family: FixedFont + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + visible: txdetails.amount.satsInt < 0 + text: qsTr('Transaction fee') + color: Material.accentColor + } + + RowLayout { + visible: txdetails.amount.satsInt < 0 + Label { + text: Config.formatSats(txdetails.fee) + font.family: FixedFont + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + text: qsTr('Label') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + id: labelContent + + property bool editmode: false + + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + visible: !labelContent.editmode + text: txdetails.label + wrapMode: Text.Wrap + Layout.fillWidth: true + font.pixelSize: constants.fontSizeLarge + } + ToolButton { + visible: !labelContent.editmode + icon.source: '../../icons/pen.png' + icon.color: 'transparent' + onClicked: { + labelEdit.text = txdetails.label + labelContent.editmode = true + labelEdit.focus = true + } + } + TextField { + id: labelEdit + visible: labelContent.editmode + text: txdetails.label + font.pixelSize: constants.fontSizeLarge + Layout.fillWidth: true + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/confirmed.png' + icon.color: 'transparent' + onClicked: { + labelContent.editmode = false + txdetails.set_label(labelEdit.text) + } + } + ToolButton { + visible: labelContent.editmode + icon.source: '../../icons/delete.png' + icon.color: 'transparent' + onClicked: labelContent.editmode = false + } + } + } + + Label { + text: qsTr('Transaction ID') + Layout.columnSpan: 2 + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: root.txid + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + Layout.fillWidth: true + wrapMode: Text.Wrap + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(root, { 'title': qsTr('Transaction ID'), 'text': root.txid }) + dialog.open() + } + } + } + } + + Label { + text: qsTr('Outputs') + Layout.columnSpan: 2 + color: Material.accentColor + } + + Repeater { + model: txdetails.outputs + delegate: TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + RowLayout { + width: parent.width + Label { + text: modelData.address + Layout.fillWidth: true + wrapMode: Text.Wrap + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + color: modelData.is_mine ? constants.colorMine : Material.foreground + } + Label { + text: Config.formatSats(modelData.value) + font.pixelSize: constants.fontSizeMedium + font.family: FixedFont + } + Label { + text: Config.baseUnit + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + } + } + } + } + + } + } + + TxDetails { + id: txdetails + wallet: Daemon.currentWallet + txid: root.txid + onLabelChanged: root.detailsChanged() + } + + Component { + id: share + GenericShareDialog {} + } + +} diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml new file mode 100644 index 000000000..fba989b83 --- /dev/null +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -0,0 +1,168 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.0 +import QtQml 2.6 + +Item { + id: rootItem + + property string title: Daemon.currentWallet ? Daemon.currentWallet.name : '' + + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Addresses'); + onTriggered: menu.openPage(Qt.resolvedUrl('Addresses.qml')); + enabled: Daemon.currentWallet != null + icon.source: '../../icons/tab_addresses.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Wallets'); + onTriggered: menu.openPage(Qt.resolvedUrl('Wallets.qml')) + icon.source: '../../icons/wallet.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Network'); + onTriggered: menu.openPage(Qt.resolvedUrl('NetworkStats.qml')) + icon.source: '../../icons/network.png' + } + } + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Channels'); + enabled: Daemon.currentWallet != null && Daemon.currentWallet.isLightning + onTriggered: menu.openPage(Qt.resolvedUrl('Channels.qml')) + icon.source: '../../icons/lightning.png' + } + } + + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Preferences'); + onTriggered: menu.openPage(Qt.resolvedUrl('Preferences.qml')) + icon.source: '../../icons/preferences.png' + } + } + + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('About'); + onTriggered: menu.openPage(Qt.resolvedUrl('About.qml')) + icon.source: '../../icons/electrum.png' + } + } + + function openPage(url) { + stack.push(url) + currentIndex = -1 + } + } + + ColumnLayout { + anchors.centerIn: parent + width: parent.width + spacing: 2*constants.paddingXLarge + visible: Daemon.currentWallet == null + + Label { + text: qsTr('No wallet loaded') + font.pixelSize: constants.fontSizeXXLarge + Layout.alignment: Qt.AlignHCenter + } + + Button { + text: qsTr('Open/Create Wallet') + Layout.alignment: Qt.AlignHCenter + onClicked: { + stack.push(Qt.resolvedUrl('Wallets.qml')) + } + } + } + + ColumnLayout { + anchors.fill: parent + visible: Daemon.currentWallet != null + + SwipeView { + id: swipeview + + Layout.fillHeight: true + Layout.fillWidth: true + currentIndex: tabbar.currentIndex + + Item { + Loader { + anchors.fill: parent + Receive { + id: receive + anchors.fill: parent + } + } + } + + Item { + Loader { + anchors.fill: parent + History { + id: history + anchors.fill: parent + } + } + } + + + Item { + enabled: !Daemon.currentWallet.isWatchOnly + Loader { + anchors.fill: parent + Send { + anchors.fill: parent + } + } + } + + } + + TabBar { + id: tabbar + position: TabBar.Footer + Layout.fillWidth: true + currentIndex: swipeview.currentIndex + TabButton { + text: qsTr('Receive') + font.pixelSize: constants.fontSizeLarge + } + TabButton { + text: qsTr('History') + font.pixelSize: constants.fontSizeLarge + } + TabButton { + enabled: !Daemon.currentWallet.isWatchOnly + text: qsTr('Send') + font.pixelSize: constants.fontSizeLarge + } + Component.onCompleted: tabbar.setCurrentIndex(1) + } + + } + + Connections { + target: Daemon + function onWalletLoaded() { + tabbar.setCurrentIndex(1) + } + } + +} + diff --git a/electrum/gui/qml/components/Wallets.qml b/electrum/gui/qml/components/Wallets.qml new file mode 100644 index 000000000..5a3f816d1 --- /dev/null +++ b/electrum/gui/qml/components/Wallets.qml @@ -0,0 +1,348 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Pane { + id: rootItem + + property string title: qsTr('Wallets') + + function createWallet() { + var dialog = app.newWalletWizard.createObject(rootItem) + dialog.open() + dialog.walletCreated.connect(function() { + Daemon.availableWallets.reload() + // and load the new wallet + Daemon.load_wallet(dialog.path, dialog.wizard_data['password']) + }) + } + + function enableLightning() { + var dialog = app.messageDialog.createObject(rootItem, + {'text': qsTr('Enable Lightning for this wallet?'), 'yesno': true}) + dialog.yesClicked.connect(function() { + Daemon.currentWallet.enableLightning() + }) + dialog.open() + } + + function deleteWallet() { + var dialog = app.messageDialog.createObject(rootItem, + {'text': qsTr('Really delete this wallet?'), 'yesno': true}) + dialog.yesClicked.connect(function() { + Daemon.delete_wallet(Daemon.currentWallet) + }) + dialog.open() + } + + function changePassword() { + // trigger dialog via wallet (auth then signal) + Daemon.start_change_password() + } + + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Create Wallet'); + onTriggered: rootItem.createWallet() + icon.source: '../../icons/wallet.png' + } + } + Component { + id: changePasswordComp + MenuItem { + icon.color: 'transparent' + enabled: Daemon.currentWallet // != null + action: Action { + text: qsTr('Change Password'); + onTriggered: rootItem.changePassword() + icon.source: '../../icons/lock.png' + } + } + } + Component { + id: deleteWalletComp + MenuItem { + icon.color: 'transparent' + enabled: Daemon.currentWallet // != null + action: Action { + text: qsTr('Delete Wallet'); + onTriggered: rootItem.deleteWallet() + icon.source: '../../icons/delete.png' + } + } + } + + Component { + id: enableLightningComp + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Enable Lightning'); + onTriggered: rootItem.enableLightning() + enabled: Daemon.currentWallet != null && Daemon.currentWallet.canHaveLightning && !Daemon.currentWallet.isLightning + icon.source: '../../icons/lightning.png' + } + } + } + + Component { + id: sepComp + MenuSeparator {} + } + + // add items dynamically, if using visible: false property the menu item isn't removed but empty + Component.onCompleted: { + if (Daemon.currentWallet != null) { + menu.insertItem(0, sepComp.createObject(menu)) + if (Daemon.currentWallet.canHaveLightning && !Daemon.currentWallet.isLightning) { + menu.insertItem(0, enableLightningComp.createObject(menu)) + } + menu.insertItem(0, deleteWalletComp.createObject(menu)) + menu.insertItem(0, changePasswordComp.createObject(menu)) + } + } + } + + ColumnLayout { + id: layout + width: parent.width + height: parent.height + + GridLayout { + id: detailsLayout + visible: Daemon.currentWallet != null + Layout.preferredWidth: parent.width + + columns: 4 + + Label { text: 'Wallet'; Layout.columnSpan: 2; color: Material.accentColor } + Label { text: Daemon.currentWallet.name; font.bold: true /*pixelSize: constants.fontSizeLarge*/; Layout.columnSpan: 2 } + + Label { text: 'derivation prefix (BIP32)'; visible: Daemon.currentWallet.isDeterministic; color: Material.accentColor; Layout.columnSpan: 2 } + Label { text: Daemon.currentWallet.derivationPrefix; visible: Daemon.currentWallet.isDeterministic; Layout.columnSpan: 2 } + + Label { text: 'txinType'; color: Material.accentColor } + Label { text: Daemon.currentWallet.txinType } + + Label { text: 'is deterministic'; color: Material.accentColor } + Label { text: Daemon.currentWallet.isDeterministic } + + Label { text: 'is watch only'; color: Material.accentColor } + Label { text: Daemon.currentWallet.isWatchOnly } + + Label { text: 'is Encrypted'; color: Material.accentColor } + Label { text: Daemon.currentWallet.isEncrypted } + + Label { text: 'is Hardware'; color: Material.accentColor } + Label { text: Daemon.currentWallet.isHardware } + + Label { text: 'is Lightning'; color: Material.accentColor } + Label { text: Daemon.currentWallet.isLightning } + + Label { text: 'has Seed'; color: Material.accentColor } + Label { text: Daemon.currentWallet.hasSeed; Layout.columnSpan: 3 } + + Label { Layout.columnSpan:4; text: qsTr('Master Public Key'); color: Material.accentColor } + + TextHighlightPane { + Layout.columnSpan: 4 + Layout.fillWidth: true + padding: 0 + leftPadding: constants.paddingSmall + + RowLayout { + width: parent.width + Label { + text: Daemon.currentWallet.masterPubkey + wrapMode: Text.Wrap + Layout.fillWidth: true + font.family: FixedFont + font.pixelSize: constants.fontSizeMedium + } + ToolButton { + icon.source: '../../icons/share.png' + icon.color: 'transparent' + onClicked: { + var dialog = share.createObject(rootItem, { + 'title': qsTr('Master Public Key'), + 'text': Daemon.currentWallet.masterPubkey + }) + dialog.open() + } + } + } + } + } + + ColumnLayout { + visible: Daemon.currentWallet == null + + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: constants.paddingXXLarge + Layout.topMargin: constants.paddingXXLarge + spacing: 2*constants.paddingXLarge + + Label { + text: qsTr('No wallet loaded') + font.pixelSize: constants.fontSizeXXLarge + Layout.alignment: Qt.AlignHCenter + } + + } + + Frame { + id: detailsFrame + Layout.topMargin: constants.paddingXLarge + Layout.preferredWidth: parent.width + Layout.fillHeight: true + verticalPadding: 0 + horizontalPadding: 0 + background: PaneInsetBackground {} + + ColumnLayout { + spacing: 0 + anchors.fill: parent + + Item { + Layout.preferredHeight: hitem.height + Layout.preferredWidth: parent.width + Rectangle { + anchors.fill: parent + color: Qt.lighter(Material.background, 1.25) + } + RowLayout { + id: hitem + width: parent.width + Label { + text: qsTr('Available wallets') + font.pixelSize: constants.fontSizeLarge + color: Material.accentColor + } + } + } + + ListView { + id: listview + Layout.preferredWidth: parent.width + Layout.fillHeight: true + clip: true + model: Daemon.availableWallets + + delegate: ItemDelegate { + width: ListView.view.width + height: row.height + + onClicked: { + Daemon.load_wallet(model.path) + } + + RowLayout { + id: row + spacing: 10 + x: constants.paddingSmall + width: parent.width - 2 * constants.paddingSmall + + Image { + id: walleticon + source: "../../icons/wallet.png" + fillMode: Image.PreserveAspectFit + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + Layout.topMargin: constants.paddingSmall + Layout.bottomMargin: constants.paddingSmall + } + + Label { + font.pixelSize: constants.fontSizeLarge + text: model.name + color: model.active ? Material.foreground : Qt.darker(Material.foreground, 1.20) + Layout.fillWidth: true + } + + Tag { + visible: Daemon.currentWallet && model.name == Daemon.currentWallet.name + text: qsTr('Current') + border.color: Material.foreground + font.bold: true + labelcolor: Material.foreground + } + Tag { + visible: model.active + text: qsTr('Active') + border.color: 'green' + labelcolor: 'green' + } + Tag { + visible: !model.active + text: qsTr('Not loaded') + border.color: 'grey' + labelcolor: 'grey' + } + } + } + + ScrollIndicator.vertical: ScrollIndicator { } + } + } + } + + Button { + Layout.alignment: Qt.AlignHCenter + text: 'Create Wallet' + onClicked: rootItem.createWallet() + } + } + + Connections { + target: Daemon + function onWalletLoaded() { + Daemon.availableWallets.reload() + app.stack.pop() + } + function onRequestNewPassword() { // new unified password (all wallets) + var dialog = app.passwordDialog.createObject(app, + { + 'confirmPassword': true, + 'title': qsTr('Enter new password'), + 'infotext': qsTr('If you forget your password, you\'ll need to\ + restore from seed. Please make sure you have your seed stored safely') + } ) + dialog.accepted.connect(function() { + Daemon.set_password(dialog.password) + }) + dialog.open() + } + } + + Connections { + target: Daemon.currentWallet + function onRequestNewPassword() { // new wallet password + var dialog = app.passwordDialog.createObject(app, + { + 'confirmPassword': true, + 'title': qsTr('Enter new password'), + 'infotext': qsTr('If you forget your password, you\'ll need to\ + restore from seed. Please make sure you have your seed stored safely') + } ) + dialog.accepted.connect(function() { + Daemon.currentWallet.set_password(dialog.password) + }) + dialog.open() + } + } + + Component { + id: share + GenericShareDialog { + onClosed: destroy() + } + } +} diff --git a/electrum/gui/qml/components/WizardComponents.qml b/electrum/gui/qml/components/WizardComponents.qml new file mode 100644 index 000000000..c6f910dad --- /dev/null +++ b/electrum/gui/qml/components/WizardComponents.qml @@ -0,0 +1,44 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "wizard" + +Item { + property Component walletname: Component { + WCWalletName {} + } + + property Component wallettype: Component { + WCWalletType {} + } + + property Component keystore: Component { + WCKeystoreType {} + } + + property Component createseed: Component { + WCCreateSeed {} + } + + property Component haveseed: Component { + WCHaveSeed {} + } + + property Component confirmseed: Component { + WCConfirmSeed {} + } + + property Component bip39refine: Component { + WCBIP39Refine {} + } + + property Component walletpassword: Component { + WCWalletPassword {} + } + + +} diff --git a/electrum/gui/qml/components/controls/BtcField.qml b/electrum/gui/qml/components/controls/BtcField.qml new file mode 100644 index 000000000..9436542c1 --- /dev/null +++ b/electrum/gui/qml/components/controls/BtcField.qml @@ -0,0 +1,30 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 + +import org.electrum 1.0 + +TextField { + id: amount + + required property TextField fiatfield + + font.family: FixedFont + placeholderText: qsTr('Amount') + inputMethodHints: Qt.ImhPreferNumbers + property Amount textAsSats + onTextChanged: { + textAsSats = Config.unitsToSats(amount.text) + if (fiatfield.activeFocus) + return + fiatfield.text = text == '' ? '' : Daemon.fx.fiatValue(amount.textAsSats) + } + + Connections { + target: Config + function onBaseUnitChanged() { + amount.text = amount.textAsSats.satsInt != 0 + ? Config.satsToUnits(amount.textAsSats) + : '' + } + } +} diff --git a/electrum/gui/qml/components/controls/ChannelDelegate.qml b/electrum/gui/qml/components/controls/ChannelDelegate.qml new file mode 100644 index 000000000..b8aed2a51 --- /dev/null +++ b/electrum/gui/qml/components/controls/ChannelDelegate.qml @@ -0,0 +1,120 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.0 +import QtQuick.Controls.Material 2.0 + +ItemDelegate { + id: root + height: item.height + width: ListView.view.width + + font.pixelSize: constants.fontSizeSmall // set default font size for child controls + + GridLayout { + id: item + + anchors { + left: parent.left + right: parent.right + leftMargin: constants.paddingSmall + rightMargin: constants.paddingSmall + } + + columns: 2 + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } + + Image { + id: walleticon + source: "../../../icons/lightning.png" + fillMode: Image.PreserveAspectFit + Layout.rowSpan: 3 + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: model.node_alias + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + } + + Label { + text: model.state + } + } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: model.short_cid + color: constants.mutedForeground + } + + Label { + text: Config.formatSats(model.capacity) + font.family: FixedFont + } + + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Item { + id: chviz + Layout.fillWidth: true + height: 10 + onWidthChanged: { + var cap = model.capacity.satsInt * 1000 + var twocap = cap * 2 + b1.width = width * (cap - model.can_send.msatsInt) / twocap + b2.width = width * model.can_send.msatsInt / twocap + b3.width = width * model.can_receive.msatsInt / twocap + b4.width = width * (cap - model.can_receive.msatsInt) / twocap + } + Rectangle { + id: b1 + x: 0 + height: parent.height + color: 'gray' + } + Rectangle { + id: b2 + anchors.left: b1.right + height: parent.height + color: constants.colorLightningLocal + } + Rectangle { + id: b3 + anchors.left: b2.right + height: parent.height + color: constants.colorLightningRemote + } + Rectangle { + id: b4 + anchors.left: b3.right + height: parent.height + color: 'gray' + } + } + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } + + } +} diff --git a/electrum/gui/qml/components/controls/FiatField.qml b/electrum/gui/qml/components/controls/FiatField.qml new file mode 100644 index 000000000..3c2ed376c --- /dev/null +++ b/electrum/gui/qml/components/controls/FiatField.qml @@ -0,0 +1,30 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 + +import org.electrum 1.0 + +TextField { + id: amountFiat + + required property TextField btcfield + + font.family: FixedFont + placeholderText: qsTr('Amount') + inputMethodHints: Qt.ImhPreferNumbers + onTextChanged: { + if (amountFiat.activeFocus) + btcfield.text = text == '' + ? '' + : Config.satsToUnits(Daemon.fx.satoshiValue(amountFiat.text)) + } + + Connections { + target: Daemon.fx + function onQuotesUpdated() { + amountFiat.text = btcfield.text == '' + ? '' + : Daemon.fx.fiatValue(Config.unitsToSats(btcfield.text)) + } + } + +} diff --git a/electrum/gui/qml/components/controls/GenericShareDialog.qml b/electrum/gui/qml/components/controls/GenericShareDialog.qml new file mode 100644 index 000000000..1e008b7c6 --- /dev/null +++ b/electrum/gui/qml/components/controls/GenericShareDialog.qml @@ -0,0 +1,119 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.14 +import QtQuick.Controls.Material 2.0 + +Dialog { + id: dialog + + property string text + + title: '' + parent: Overlay.overlay + modal: true + standardButtons: Dialog.Ok + + width: parent.width + height: parent.height + + Overlay.modal: Rectangle { + color: "#aa000000" + } + + header: RowLayout { + width: dialog.width + Label { + Layout.fillWidth: true + text: dialog.title + visible: dialog.title + elide: Label.ElideRight + padding: constants.paddingXLarge + bottomPadding: 0 + font.bold: true + font.pixelSize: constants.fontSizeMedium + } + } + + Flickable { + anchors.fill: parent + contentHeight: rootLayout.height + clip:true + interactive: height < contentHeight + + ColumnLayout { + id: rootLayout + width: parent.width + spacing: constants.paddingMedium + + Rectangle { + height: 1 + Layout.fillWidth: true + color: Material.accentColor + } + + Image { + id: qr + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingSmall + Layout.bottomMargin: constants.paddingSmall + + Rectangle { + property int size: 57 // should be qr pixel multiple + color: 'white' + x: (parent.width - size) / 2 + y: (parent.height - size) / 2 + width: size + height: size + + Image { + source: '../../../icons/electrum.png' + x: 1 + y: 1 + width: parent.width - 2 + height: parent.height - 2 + scale: 0.9 + } + } + } + + Rectangle { + height: 1 + Layout.fillWidth: true + color: Material.accentColor + } + + TextHighlightPane { + Layout.fillWidth: true + Label { + width: parent.width + text: dialog.text + wrapMode: Text.Wrap + font.pixelSize: constants.fontSizeLarge + font.family: FixedFont + } + } + + RowLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + Button { + text: qsTr('Copy') + icon.source: '../../../icons/copy_bw.png' + onClicked: AppController.textToClipboard(dialog.text) + } + Button { + //enabled: false + text: qsTr('Share') + icon.source: '../../../icons/share.png' + onClicked: { + AppController.doShare(dialog.text, dialog.title) + } + } + } + } + } + + Component.onCompleted: { + qr.source = 'image://qrgen/' + dialog.text + } +} diff --git a/electrum/gui/qml/components/controls/InfoTextArea.qml b/electrum/gui/qml/components/controls/InfoTextArea.qml new file mode 100644 index 000000000..ba2848aac --- /dev/null +++ b/electrum/gui/qml/components/controls/InfoTextArea.qml @@ -0,0 +1,58 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.0 + +GridLayout { + property alias text: infotext.text + + enum IconStyle { + None, + Info, + Warn, + Error + } + + property int iconStyle: InfoTextArea.IconStyle.Info + + columns: 1 + rowSpacing: 0 + + Rectangle { + height: 2 + Layout.fillWidth: true + color: Qt.rgba(1,1,1,0.25) + } + + TextArea { + id: infotext + Layout.fillWidth: true + Layout.minimumHeight: constants.iconSizeLarge + 2*constants.paddingLarge + readOnly: true + rightPadding: constants.paddingLarge + leftPadding: 2*constants.iconSizeLarge + wrapMode: TextInput.WordWrap + textFormat: TextEdit.RichText + background: Rectangle { + color: Qt.rgba(1,1,1,0.05) // whiten 5% + } + + Image { + source: iconStyle == InfoTextArea.IconStyle.Info ? "../../../icons/info.png" : InfoTextArea.IconStyle.Warn ? "../../../icons/warning.png" : InfoTextArea.IconStyle.Error ? "../../../icons/expired.png" : "" + anchors.left: parent.left + anchors.top: parent.top + anchors.leftMargin: constants.paddingLarge + anchors.topMargin: constants.paddingLarge + height: constants.iconSizeLarge + width: constants.iconSizeLarge + fillMode: Image.PreserveAspectCrop + } + + } + + Rectangle { + height: 2 + Layout.fillWidth: true + color: Qt.rgba(0,0,0,0.25) + } +} diff --git a/electrum/gui/qml/components/controls/InvoiceDelegate.qml b/electrum/gui/qml/components/controls/InvoiceDelegate.qml new file mode 100644 index 000000000..4e6cd40f0 --- /dev/null +++ b/electrum/gui/qml/components/controls/InvoiceDelegate.qml @@ -0,0 +1,147 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.0 +import QtQuick.Controls.Material 2.0 + +ItemDelegate { + id: root + height: item.height + width: ListView.view.width + + font.pixelSize: constants.fontSizeSmall // set default font size for child controls + + GridLayout { + id: item + + anchors { + left: parent.left + right: parent.right + leftMargin: constants.paddingSmall + rightMargin: constants.paddingSmall + } + + columns: 2 + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } + + Image { + Layout.rowSpan: 2 + Layout.preferredWidth: constants.iconSizeLarge + Layout.preferredHeight: constants.iconSizeLarge + source: model.is_lightning + ? "../../../icons/lightning.png" + : "../../../icons/bitcoin.png" + + Image { + visible: model.onchain_fallback + z: -1 + source: "../../../icons/bitcoin.png" + anchors { + right: parent.right + bottom: parent.bottom + } + width: parent.width /2 + height: parent.height /2 + } + } + + RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: model.message + ? model.message + : model.type == 'request' + ? model.address + : '' + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + font.pixelSize: model.message ? constants.fontSizeMedium : constants.fontSizeSmall + } + + Label { + id: amount + text: model.amount.isEmpty ? '' : Config.formatSats(model.amount) + font.pixelSize: constants.fontSizeMedium + font.family: FixedFont + } + + Label { + text: model.amount.isEmpty ? '' : Config.baseUnit + font.pixelSize: constants.fontSizeMedium + color: Material.accentColor + } + } + + RowLayout { + Layout.fillWidth: true + Label { + text: model.status_str + color: Material.accentColor + } + Item { + Layout.fillWidth: true + Layout.preferredHeight: status_icon.height + Image { + id: status_icon + source: model.status == 0 + ? '../../../icons/unpaid.png' + : model.status == 1 + ? '../../../icons/expired.png' + : model.status == 3 + ? '../../../icons/confirmed.png' + : model.status == 7 + ? '../../../icons/unconfirmed.png' + : '' + width: constants.iconSizeSmall + height: constants.iconSizeSmall + } + } + Label { + id: fiatValue + visible: Daemon.fx.enabled + Layout.alignment: Qt.AlignRight + text: model.amount.isEmpty ? '' : Daemon.fx.fiatValue(model.amount, false) + font.family: FixedFont + font.pixelSize: constants.fontSizeSmall + } + Label { + visible: Daemon.fx.enabled + Layout.alignment: Qt.AlignRight + text: model.amount.isEmpty ? '' : Daemon.fx.fiatCurrency + font.pixelSize: constants.fontSizeSmall + color: Material.accentColor + } + } + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.preferredHeight: constants.paddingTiny + color: 'transparent' + } + } + + Connections { + target: Config + function onBaseUnitChanged() { + amount.text = model.amount.isEmpty ? '' : Config.formatSats(model.amount) + } + function onThousandsSeparatorChanged() { + amount.text = model.amount.isEmpty ? '' : Config.formatSats(model.amount) + } + } + Connections { + target: Daemon.fx + function onQuotesUpdated() { + fiatValue.text = model.amount.isEmpty ? '' : Daemon.fx.fiatValue(model.amount, false) + } + } + +} diff --git a/electrum/gui/qml/components/controls/MessageDialog.qml b/electrum/gui/qml/components/controls/MessageDialog.qml new file mode 100644 index 000000000..cf8d86772 --- /dev/null +++ b/electrum/gui/qml/components/controls/MessageDialog.qml @@ -0,0 +1,61 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +Dialog { + id: dialog + title: qsTr("Message") + + property bool yesno: false + property alias text: message.text + + signal yesClicked + signal noClicked + + parent: Overlay.overlay + modal: true + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + Overlay.modal: Rectangle { + color: "#aa000000" + } + + ColumnLayout { + TextArea { + id: message + Layout.preferredWidth: Overlay.overlay.width *2/3 + readOnly: true + wrapMode: TextInput.WordWrap + //textFormat: TextEdit.RichText // existing translations not richtext yet + background: Rectangle { + color: 'transparent' + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Button { + text: qsTr('Ok') + visible: !yesno + onClicked: dialog.close() + } + Button { + text: qsTr('Yes') + visible: yesno + onClicked: { + yesClicked() + dialog.close() + } + } + Button { + text: qsTr('No') + visible: yesno + onClicked: { + noClicked() + dialog.close() + } + } + } + } +} diff --git a/electrum/gui/qml/components/controls/MessagePane.qml b/electrum/gui/qml/components/controls/MessagePane.qml new file mode 100644 index 000000000..9be266327 --- /dev/null +++ b/electrum/gui/qml/components/controls/MessagePane.qml @@ -0,0 +1,30 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.0 + +Rectangle { + id: item + + property bool warning + property bool error + property string text + + color: "transparent" + border.color: error ? "red" : warning ? "yellow" : Material.accentColor + border.width: 1 + height: text.height + 2* 16 + radius: 8 + + Text { + id: text + width: item.width - 2* 16 + x: 16 + y: 16 + + color: item.border.color + text: item.text + wrapMode: Text.Wrap + } + +} diff --git a/electrum/gui/qml/components/controls/NotificationPopup.qml b/electrum/gui/qml/components/controls/NotificationPopup.qml new file mode 100644 index 000000000..adff81c09 --- /dev/null +++ b/electrum/gui/qml/components/controls/NotificationPopup.qml @@ -0,0 +1,61 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +Rectangle { + id: root + + property alias text: textItem.text + + property bool hide: true + + color: Qt.lighter(Material.background, 1.5) + radius: constants.paddingXLarge + + width: root.parent.width * 2/3 + height: layout.height + x: (root.parent.width - width) / 2 + y: -height + + states: [ + State { + name: 'expanded'; when: !hide + PropertyChanges { target: root; y: 100 } + } + ] + + transitions: [ + Transition { + from: ''; to: 'expanded'; reversible: true + NumberAnimation { properties: 'y'; duration: 300; easing.type: Easing.InOutQuad } + } + ] + + function show(message) { + root.text = message + root.hide = false + closetimer.start() + } + + RowLayout { + id: layout + width: parent.width + Text { + id: textItem + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + font.pixelSize: constants.fontSizeLarge + color: Material.foreground + wrapMode: Text.Wrap + } + } + + Timer { + id: closetimer + interval: 5000 + repeat: false + onTriggered: hide = true + } + +} diff --git a/electrum/gui/qml/components/controls/PaneInsetBackground.qml b/electrum/gui/qml/components/controls/PaneInsetBackground.qml new file mode 100644 index 000000000..8d4c316ac --- /dev/null +++ b/electrum/gui/qml/components/controls/PaneInsetBackground.qml @@ -0,0 +1,26 @@ +import QtQuick 2.6 +import QtQuick.Controls.Material 2.0 + +Rectangle { + Rectangle { + anchors { left: parent.left; top: parent.top; right: parent.right } + height: 1 + color: Qt.darker(Material.background, 1.50) + } + Rectangle { + anchors { left: parent.left; top: parent.top; bottom: parent.bottom } + width: 1 + color: Qt.darker(Material.background, 1.50) + } + Rectangle { + anchors { left: parent.left; bottom: parent.bottom; right: parent.right } + height: 1 + color: Qt.lighter(Material.background, 1.50) + } + Rectangle { + anchors { right: parent.right; top: parent.top; bottom: parent.bottom } + width: 1 + color: Qt.lighter(Material.background, 1.50) + } + color: Qt.darker(Material.background, 1.15) +} diff --git a/electrum/gui/qml/components/controls/PasswordDialog.qml b/electrum/gui/qml/components/controls/PasswordDialog.qml new file mode 100644 index 000000000..563f9c9a4 --- /dev/null +++ b/electrum/gui/qml/components/controls/PasswordDialog.qml @@ -0,0 +1,115 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +Dialog { + id: passworddialog + + title: qsTr("Enter Password") + + property bool confirmPassword: false + property string password + property string infotext + + parent: Overlay.overlay + modal: true + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + Overlay.modal: Rectangle { + color: "#aa000000" + } + + header: GridLayout { + columns: 2 + rowSpacing: 0 + + Image { + source: "../../../icons/lock.png" + Layout.preferredWidth: constants.iconSizeXLarge + Layout.preferredHeight: constants.iconSizeXLarge + Layout.leftMargin: constants.paddingMedium + Layout.topMargin: constants.paddingMedium + Layout.bottomMargin: constants.paddingMedium + } + + Label { + text: title + elide: Label.ElideRight + Layout.fillWidth: true + topPadding: constants.paddingXLarge + bottomPadding: constants.paddingXLarge + font.bold: true + font.pixelSize: constants.fontSizeMedium + } + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.leftMargin: constants.paddingXXSmall + Layout.rightMargin: constants.paddingXXSmall + height: 1 + color: Qt.rgba(0,0,0,0.5) + } + } + + ColumnLayout { + width: parent.width + + InfoTextArea { + visible: infotext + text: infotext + Layout.preferredWidth: password_layout.width + } + + GridLayout { + id: password_layout + columns: 2 + Layout.fillWidth: true + Layout.margins: constants.paddingXXLarge + + Label { + text: qsTr('Password') + } + + TextField { + id: pw_1 + echoMode: TextInput.Password + } + + Label { + text: qsTr('Password (again)') + visible: confirmPassword + } + + TextField { + id: pw_2 + echoMode: TextInput.Password + visible: confirmPassword + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingXXLarge + + Button { + text: qsTr("Ok") + enabled: confirmPassword ? pw_1.text == pw_2.text : true + onClicked: { + password = pw_1.text + passworddialog.accept() + } + } + Button { + text: qsTr("Cancel") + onClicked: { + passworddialog.reject() + } + } + } + } + +} diff --git a/electrum/gui/qml/components/controls/QRScan.qml b/electrum/gui/qml/components/controls/QRScan.qml new file mode 100644 index 000000000..514fd2591 --- /dev/null +++ b/electrum/gui/qml/components/controls/QRScan.qml @@ -0,0 +1,164 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.0 +import QtMultimedia 5.6 + +import org.electrum 1.0 + +Item { + id: scanner + + property bool active: false + property string url + property string scanData + + property bool _pointsVisible + + signal found + + VideoOutput { + id: vo + anchors.fill: parent + source: camera + fillMode: VideoOutput.PreserveAspectCrop + + Rectangle { + width: parent.width + height: (parent.height - parent.width) / 2 + visible: camera.cameraStatus == Camera.ActiveStatus + anchors.top: parent.top + color: Qt.rgba(0,0,0,0.5) + } + Rectangle { + width: parent.width + height: (parent.height - parent.width) / 2 + visible: camera.cameraStatus == Camera.ActiveStatus + anchors.bottom: parent.bottom + color: Qt.rgba(0,0,0,0.5) + } + } + + Image { + id: still + anchors.fill: vo + } + + SequentialAnimation { + id: foundAnimation + PropertyAction { target: scanner; property: '_pointsVisible'; value: true} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: false} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: true} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: false} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: true} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: false} + PauseAnimation { duration: 80 } + PropertyAction { target: scanner; property: '_pointsVisible'; value: true} + onFinished: found() + } + + Component { + id: r + Rectangle { + property int cx + property int cy + width: 15 + height: 15 + x: cx - width/2 + y: cy - height/2 + radius: 5 + visible: scanner._pointsVisible + } + } + + Connections { + target: qr + function onDataChanged() { + console.log(qr.data) + scanner.active = false + scanner.scanData = qr.data + still.source = scanner.url + + var sx = still.width/still.sourceSize.width + var sy = still.height/still.sourceSize.height + r.createObject(scanner, {cx: qr.points[0].x * sx, cy: qr.points[0].y * sy, color: 'yellow'}) + r.createObject(scanner, {cx: qr.points[1].x * sx, cy: qr.points[1].y * sy, color: 'yellow'}) + r.createObject(scanner, {cx: qr.points[2].x * sx, cy: qr.points[2].y * sy, color: 'yellow'}) + r.createObject(scanner, {cx: qr.points[3].x * sx, cy: qr.points[3].y * sy, color: 'yellow'}) + + foundAnimation.start() + } + } + + Camera { + id: camera + deviceId: QtMultimedia.defaultCamera.deviceId + viewfinder.resolution: "640x480" + + focus { + focusMode: Camera.FocusContinuous + focusPointMode: Camera.FocusPointCustom + customFocusPoint: Qt.point(0.5, 0.5) + } + + function dumpstats() { + console.log(camera.viewfinder.resolution) + console.log(camera.viewfinder.minimumFrameRate) + console.log(camera.viewfinder.maximumFrameRate) + var resolutions = camera.supportedViewfinderResolutions() + resolutions.forEach(function(item, i) { + console.log('' + item.width + 'x' + item.height) + }) + // TODO + // pick a suitable resolution from the available resolutions + // problem: some cameras have no supportedViewfinderResolutions + // but still error out when an invalid resolution is set. + // 640x480 seems to be universally available, but this needs to + // be checked across a range of phone models. + } + } + + Timer { + id: scanTimer + interval: 200 + repeat: true + running: scanner.active + onTriggered: { + if (qr.busy) + return + vo.grabToImage(function(result) { + if (result.image !== undefined) { + scanner.url = result.url + qr.scanImage(result.image) + } else { + console.log('image grab returned null') + } + }) + } + } + + QRParser { + id: qr + } + + Component.onCompleted: { + console.log('Scan page initialized') + QtMultimedia.availableCameras.forEach(function(item) { + console.log('cam found') + console.log(item.deviceId) + console.log(item.displayName) + console.log(item.position) + console.log(item.orientation) + if (QtMultimedia.defaultCamera.deviceId == item.deviceId) { + vo.orientation = item.orientation + } + + camera.dumpstats() + }) + + active = true + } +} diff --git a/electrum/gui/qml/components/controls/SeedTextArea.qml b/electrum/gui/qml/components/controls/SeedTextArea.qml new file mode 100644 index 000000000..34bd866f8 --- /dev/null +++ b/electrum/gui/qml/components/controls/SeedTextArea.qml @@ -0,0 +1,20 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.0 + +TextArea { + id: seedtext + Layout.fillWidth: true + Layout.minimumHeight: 80 + rightPadding: constants.paddingLarge + leftPadding: constants.paddingLarge + wrapMode: TextInput.WordWrap + font.bold: true + font.pixelSize: constants.fontSizeLarge + inputMethodHints: Qt.ImhSensitiveData | Qt.ImhPreferLowercase | Qt.ImhNoPredictiveText + background: Rectangle { + color: "transparent" + border.color: Material.accentColor + } +} diff --git a/electrum/gui/qml/components/controls/Tag.qml b/electrum/gui/qml/components/controls/Tag.qml new file mode 100644 index 000000000..510e059d0 --- /dev/null +++ b/electrum/gui/qml/components/controls/Tag.qml @@ -0,0 +1,29 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +Rectangle { + radius: constants.paddingXSmall + width: layout.width + height: layout.height + color: 'transparent' + border.color: Material.accentColor + + property alias text: label.text + property alias font: label.font + property alias labelcolor: label.color + + RowLayout { + id: layout + + Label { + id: label + Layout.leftMargin: constants.paddingSmall + Layout.rightMargin: constants.paddingSmall + Layout.topMargin: constants.paddingXXSmall + Layout.bottomMargin: constants.paddingXXSmall + font.pixelSize: constants.fontSizeXSmall + } + } +} diff --git a/electrum/gui/qml/components/controls/TextHighlightPane.qml b/electrum/gui/qml/components/controls/TextHighlightPane.qml new file mode 100644 index 000000000..7e38b327f --- /dev/null +++ b/electrum/gui/qml/components/controls/TextHighlightPane.qml @@ -0,0 +1,13 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Material 2.0 + +Pane { + topPadding: constants.paddingSmall + bottomPadding: constants.paddingSmall + background: Rectangle { + color: Qt.lighter(Material.background, 1.15) + radius: constants.paddingSmall + } +} diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml new file mode 100644 index 000000000..b496a4026 --- /dev/null +++ b/electrum/gui/qml/components/main.qml @@ -0,0 +1,315 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.0 + +import QtQml 2.6 +import QtMultimedia 5.6 + +import "controls" + +ApplicationWindow +{ + id: app + visible: true + + // dimensions ignored on android + width: 480 + height: 800 + + Material.theme: Material.Dark + Material.primary: Material.Indigo + Material.accent: Material.LightBlue + font.pixelSize: constants.fontSizeMedium + + property Item constants: appconstants + Constants { id: appconstants } + + property alias stack: mainStackView + + header: ToolBar { + id: toolbar + + RowLayout { + anchors.fill: parent + + ToolButton { + text: qsTr("‹") + enabled: stack.depth > 1 + onClicked: stack.pop() + } + + Label { + text: stack.currentItem.title + elide: Label.ElideRight + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + Layout.fillWidth: true + font.pixelSize: constants.fontSizeMedium + font.bold: true + } + + Item { + visible: Network.isTestNet + width: column.width + height: column.height + + ColumnLayout { + id: column + spacing: 0 + Image { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: constants.iconSizeSmall + Layout.preferredHeight: constants.iconSizeSmall + source: "../../icons/info.png" + } + + Label { + id: networkNameLabel + text: Network.networkName + color: Material.accentColor + font.pixelSize: constants.fontSizeXSmall + } + } + } + + Image { + Layout.preferredWidth: constants.iconSizeSmall + Layout.preferredHeight: constants.iconSizeSmall + visible: Daemon.currentWallet && Daemon.currentWallet.isWatchOnly + source: '../../icons/eye1.png' + scale: 1.5 + } + + Image { + Layout.preferredWidth: constants.iconSizeSmall + Layout.preferredHeight: constants.iconSizeSmall + source: Network.status == 'connecting' || Network.status == 'disconnected' + ? '../../icons/status_disconnected.png' + : Network.status == 'connected' + ? Daemon.currentWallet && !Daemon.currentWallet.isUptodate + ? '../../icons/status_lagging.png' + : '../../icons/status_connected.png' + : '../../icons/status_connected.png' + } + + Rectangle { + color: 'transparent' + Layout.preferredWidth: constants.paddingSmall + height: 1 + visible: !menuButton.visible + } + + ToolButton { + id: menuButton + enabled: stack.currentItem && stack.currentItem.menu ? stack.currentItem.menu.count > 0 : false + text: enabled ? qsTr("≡") : '' + font.pixelSize: constants.fontSizeXLarge + onClicked: { + stack.currentItem.menu.open() + // position the menu to the right + stack.currentItem.menu.x = toolbar.width - stack.currentItem.menu.width + } + } + } + } + + StackView { + id: mainStackView + anchors.fill: parent + + initialItem: Qt.resolvedUrl('WalletMainView.qml') + } + + Timer { + id: splashTimer + interval: 10 + onTriggered: { + splash.opacity = 0 + } + } + + Splash { + id: splash + anchors.top: header.top + anchors.bottom: app.contentItem.bottom + width: app.width + z: 1000 + + Behavior on opacity { + NumberAnimation { duration: 300 } + } + } + + property alias newWalletWizard: _newWalletWizard + Component { + id: _newWalletWizard + NewWalletWizard { + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + } + } + + property alias serverConnectWizard: _serverConnectWizard + Component { + id: _serverConnectWizard + ServerConnectWizard { + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + } + } + + property alias messageDialog: _messageDialog + Component { + id: _messageDialog + MessageDialog { + onClosed: destroy() + } + } + + property alias passwordDialog: _passwordDialog + Component { + id: _passwordDialog + PasswordDialog { + onClosed: destroy() + } + } + + property alias pinDialog: _pinDialog + Component { + id: _pinDialog + Pin { + onClosed: destroy() + } + } + + NotificationPopup { + id: notificationPopup + } + + Component.onCompleted: { + splashTimer.start() + + if (!Config.autoConnectDefined) { + var dialog = serverConnectWizard.createObject(app) + // without completed serverConnectWizard we can't start + dialog.rejected.connect(function() { + app.visible = false + Qt.callLater(Qt.quit) + }) + dialog.open() + } else { + Daemon.load_wallet() + } + } + + onClosing: { + if (stack.depth > 1) { + close.accepted = false + stack.pop() + } else { + // destroy most GUI components so that we don't dump so many null reference warnings on exit + if (closeMsgTimer.running) { + app.header.visible = false + mainStackView.clear() + } else { + notificationPopup.show('Press Back again to exit') + closeMsgTimer.start() + close.accepted = false + } + } + } + + Timer { + id: closeMsgTimer + interval: 5000 + repeat: false + } + + Connections { + target: Daemon + function onWalletRequiresPassword() { + console.log('wallet requires password') + app.stack.push(Qt.resolvedUrl("OpenWallet.qml"), {"path": Daemon.path}) + } + function onWalletOpenError(error) { + console.log('wallet open error') + var dialog = app.messageDialog.createObject(app, {'text': error}) + dialog.open() + } + function onAuthRequired(method) { + handleAuthRequired(Daemon, method) + } + } + + Connections { + target: AppController + function onUserNotify(message) { + notificationPopup.show(message) + } + } + + Connections { + target: Daemon.currentWallet + function onAuthRequired(method) { + handleAuthRequired(Daemon.currentWallet, method) + } + // TODO: add to notification queue instead of barging through + function onPaymentSucceeded(key) { + notificationPopup.show(qsTr('Payment Succeeded')) + } + function onPaymentFailed(key, reason) { + notificationPopup.show(qsTr('Payment Failed') + ': ' + reason) + } + } + + Connections { + target: Config + function onAuthRequired(method) { + handleAuthRequired(Config, method) + } + } + + function handleAuthRequired(qtobject, method) { + console.log('AUTHENTICATING USING METHOD ' + method) + if (method == 'wallet') { + if (Daemon.currentWallet.verify_password('')) { + // wallet has no password + qtobject.authProceed() + } else { + var dialog = app.passwordDialog.createObject(app, {'title': qsTr('Enter current password')}) + dialog.accepted.connect(function() { + if (Daemon.currentWallet.verify_password(dialog.password)) { + qtobject.authProceed() + } else { + qtobject.authCancel() + } + }) + dialog.rejected.connect(function() { + qtobject.authCancel() + }) + dialog.open() + } + } else if (method == 'pin') { + if (Config.pinCode == '') { + // no PIN configured + qtobject.authProceed() + } else { + var dialog = app.pinDialog.createObject(app, {mode: 'check', pincode: Config.pinCode}) + dialog.accepted.connect(function() { + qtobject.authProceed() + dialog.close() + }) + dialog.rejected.connect(function() { + qtobject.authCancel() + }) + dialog.open() + } + } + } + +} diff --git a/electrum/gui/qml/components/wizard/WCAutoConnect.qml b/electrum/gui/qml/components/wizard/WCAutoConnect.qml new file mode 100644 index 000000000..9b15d533c --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCAutoConnect.qml @@ -0,0 +1,41 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import ".." +import "../controls" + +WizardComponent { + valid: true + last: serverconnectgroup.checkedButton.connecttype === 'auto' + + onAccept: { + wizard_data['autoconnect'] = serverconnectgroup.checkedButton.connecttype === 'auto' + } + + ColumnLayout { + width: parent.width + + InfoTextArea { + text: qsTr('Electrum communicates with remote servers to get information about your transactions and addresses. The servers all fulfill the same purpose only differing in hardware. In most cases you simply want to let Electrum pick one at random. However if you prefer feel free to select a server manually.') + Layout.fillWidth: true + } + + ButtonGroup { + id: serverconnectgroup + } + + RadioButton { + ButtonGroup.group: serverconnectgroup + property string connecttype: 'auto' + text: qsTr('Auto connect') + } + RadioButton { + ButtonGroup.group: serverconnectgroup + property string connecttype: 'manual' + checked: true + text: qsTr('Select servers manually') + } + + } + +} diff --git a/electrum/gui/qml/components/wizard/WCBIP39Refine.qml b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml new file mode 100644 index 000000000..27cb36dd4 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCBIP39Refine.qml @@ -0,0 +1,99 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import ".." +import "../controls" + +WizardComponent { + valid: false + + onAccept: { + wizard_data['script_type'] = scripttypegroup.checkedButton.scripttype + wizard_data['derivation_path'] = derivationpathtext.text + } + function getScriptTypePurposeDict() { + return { + 'p2pkh': 44, + 'p2wpkh-p2sh': 49, + 'p2wpkh': 84 + } + } + + function validate() { + valid = false + if (!scripttypegroup.checkedButton.scripttype in getScriptTypePurposeDict()) + return + if (!bitcoin.verify_derivation_path(derivationpathtext.text)) + return + valid = true + } + + function setDerivationPath() { + var p = getScriptTypePurposeDict() + derivationpathtext.text = + "m/" + p[scripttypegroup.checkedButton.scripttype] + "'/" + + (Network.isTestNet ? 1 : 0) + "'/0'" + } + + ButtonGroup { + id: scripttypegroup + onCheckedButtonChanged: { + setDerivationPath() + } + } + + Flickable { + anchors.fill: parent + contentHeight: mainLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: mainLayout + width: parent.width + columns: 1 + + Label { text: qsTr('Script type and Derivation path') } + Button { + text: qsTr('Detect Existing Accounts') + enabled: false + } + Label { text: qsTr('Choose the type of addresses in your wallet.') } + RadioButton { + ButtonGroup.group: scripttypegroup + property string scripttype: 'p2pkh' + text: qsTr('legacy (p2pkh)') + } + RadioButton { + ButtonGroup.group: scripttypegroup + property string scripttype: 'p2wpkh-p2sh' + text: qsTr('wrapped segwit (p2wpkh-p2sh)') + } + RadioButton { + ButtonGroup.group: scripttypegroup + property string scripttype: 'p2wpkh' + checked: true + text: qsTr('native segwit (p2wpkh)') + } + InfoTextArea { + text: qsTr('You can override the suggested derivation path.') + ' ' + + qsTr('If you are not sure what this is, leave this field unchanged.') + } + TextField { + id: derivationpathtext + Layout.fillWidth: true + placeholderText: qsTr('Derivation path') + onTextChanged: validate() + } + } + } + + Bitcoin { + id: bitcoin + } + +} + diff --git a/electrum/gui/qml/components/wizard/WCConfirmSeed.qml b/electrum/gui/qml/components/wizard/WCConfirmSeed.qml new file mode 100644 index 000000000..2440291c9 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCConfirmSeed.qml @@ -0,0 +1,59 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import ".." +import "../controls" + +WizardComponent { + valid: false + + function checkValid() { + var seedvalid = confirm.text == wizard_data['seed'] + var customwordsvalid = customwordstext.text == wizard_data['seed_extra_words'] + valid = seedvalid && (wizard_data['seed_extend'] ? customwordsvalid : true) + } + + Flickable { + anchors.fill: parent + contentHeight: mainLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: mainLayout + width: parent.width + columns: 1 + + InfoTextArea { + Layout.fillWidth: true + text: qsTr('Your seed is important!') + ' ' + + qsTr('If you lose your seed, your money will be permanently lost.') + ' ' + + qsTr('To make sure that you have properly saved your seed, please retype it here.') + } + Label { text: qsTr('Confirm your seed (re-enter)') } + SeedTextArea { + id: confirm + Layout.fillWidth: true + onTextChanged: { + checkValid() + } + } + TextField { + id: customwordstext + Layout.fillWidth: true + placeholderText: qsTr('Enter your custom word(s)') + onTextChanged: { + checkValid() + } + } + } + } + + onReadyChanged: { + if (ready) + customwordstext.visible = wizard_data['seed_extend'] + } +} diff --git a/electrum/gui/qml/components/wizard/WCCreateSeed.qml b/electrum/gui/qml/components/wizard/WCCreateSeed.qml new file mode 100644 index 000000000..7c172842e --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCCreateSeed.qml @@ -0,0 +1,88 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import ".." +import "../controls" + +WizardComponent { + valid: seedtext.text != '' + + onAccept: { + wizard_data['seed'] = seedtext.text + wizard_data['seed_type'] = 'segwit' + wizard_data['seed_extend'] = extendcb.checked + wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' + } + + function setWarningText(numwords) { + var t = [ + '

', + qsTr('Please save these %1 words on paper (order is important).').arg(numwords), + qsTr('This seed will allow you to recover your wallet in case of computer failure.'), + '

', + '' + qsTr('WARNING') + ':', + '' + ] + warningtext.text = t.join(' ') + } + + Flickable { + anchors.fill: parent + contentHeight: mainLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: mainLayout + width: parent.width + columns: 1 + + InfoTextArea { + id: warningtext + Layout.fillWidth: true + iconStyle: InfoTextArea.IconStyle.Warn + } + Label { text: qsTr('Your wallet generation seed is:') } + SeedTextArea { + id: seedtext + readOnly: true + Layout.fillWidth: true + + BusyIndicator { + anchors.centerIn: parent + height: parent.height * 2/3 + visible: seedtext.text == '' + } + } + CheckBox { + id: extendcb + text: qsTr('Extend seed with custom words') + } + TextField { + id: customwordstext + visible: extendcb.checked + Layout.fillWidth: true + placeholderText: qsTr('Enter your custom word(s)') + } + Component.onCompleted : { + setWarningText(12) + bitcoin.generate_seed() + } + } + } + + Bitcoin { + id: bitcoin + onGeneratedSeedChanged: { + seedtext.text = generated_seed + setWarningText(generated_seed.split(' ').length) + } + } +} diff --git a/electrum/gui/qml/components/wizard/WCHaveSeed.qml b/electrum/gui/qml/components/wizard/WCHaveSeed.qml new file mode 100644 index 000000000..18599fee6 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCHaveSeed.qml @@ -0,0 +1,151 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import ".." +import "../controls" + +WizardComponent { + id: root + valid: false + + onAccept: { + wizard_data['seed'] = seedtext.text + wizard_data['seed_type'] = bitcoin.seed_type + wizard_data['seed_extend'] = extendcb.checked + wizard_data['seed_extra_words'] = extendcb.checked ? customwordstext.text : '' + wizard_data['seed_bip39'] = seed_type.getTypeCode() == 'BIP39' + wizard_data['seed_slip39'] = seed_type.getTypeCode() == 'SLIP39' + } + + function setSeedTypeHelpText() { + var t = { + 'Electrum': [ + qsTr('Electrum seeds are the default seed type.'), + qsTr('If you are restoring from a seed previously created by Electrum, choose this option') + ].join(' '), + 'BIP39': [ + qsTr('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), + '

', + qsTr('However, we do not generate BIP39 seeds, because they do not meet our safety standard.'), + qsTr('BIP39 seeds do not include a version number, which compromises compatibility with future software.') + ].join(' '), + 'SLIP39': [ + qsTr('SLIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'), + '

', + qsTr('However, we do not generate SLIP39 seeds.') + ].join(' ') + } + infotext.text = t[seed_type.currentText] + } + + function checkValid() { + bitcoin.verify_seed(seedtext.text, seed_type.getTypeCode() == 'BIP39', seed_type.getTypeCode() == 'SLIP39') + } + + Flickable { + anchors.fill: parent + contentHeight: mainLayout.height + clip:true + interactive: height < contentHeight + + GridLayout { + id: mainLayout + width: parent.width + columns: 2 + + Label { + text: qsTr('Seed Type') + Layout.fillWidth: true + } + ComboBox { + id: seed_type + model: ['Electrum', 'BIP39', 'SLIP39'] + onActivated: { + setSeedTypeHelpText() + checkValid() + } + function getTypeCode() { + return currentText + } + } + InfoTextArea { + id: infotext + Layout.fillWidth: true + Layout.columnSpan: 2 + } + Label { + text: qsTr('Enter your seed') + Layout.columnSpan: 2 + } + SeedTextArea { + id: seedtext + Layout.fillWidth: true + Layout.columnSpan: 2 + onTextChanged: { + validationTimer.restart() + } + + Rectangle { + anchors.fill: contentText + color: 'green' + border.color: Material.accentColor + radius: 2 + } + Label { + id: contentText + anchors.right: parent.right + anchors.bottom: parent.bottom + leftPadding: text != '' ? constants.paddingLarge : 0 + rightPadding: text != '' ? constants.paddingLarge : 0 + font.bold: false + font.pixelSize: constants.fontSizeSmall + } + } + TextArea { + id: validationtext + visible: text != '' + Layout.fillWidth: true + readOnly: true + wrapMode: TextInput.WordWrap + background: Rectangle { + color: 'transparent' + } + } + + CheckBox { + id: extendcb + Layout.columnSpan: 2 + text: qsTr('Extend seed with custom words') + } + TextField { + id: customwordstext + visible: extendcb.checked + Layout.fillWidth: true + Layout.columnSpan: 2 + placeholderText: qsTr('Enter your custom word(s)') + } + } + } + + Bitcoin { + id: bitcoin + onSeedTypeChanged: contentText.text = bitcoin.seed_type + onSeedValidChanged: root.valid = bitcoin.seed_valid + onValidationMessageChanged: validationtext.text = bitcoin.validation_message + } + + Timer { + id: validationTimer + interval: 500 + repeat: false + onTriggered: checkValid() + } + + Component.onCompleted: { + setSeedTypeHelpText() + } +} diff --git a/electrum/gui/qml/components/wizard/WCKeystoreType.qml b/electrum/gui/qml/components/wizard/WCKeystoreType.qml new file mode 100644 index 000000000..2e180a640 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCKeystoreType.qml @@ -0,0 +1,43 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +WizardComponent { + valid: keystoregroup.checkedButton !== null + + onAccept: { + wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype + } + + ButtonGroup { + id: keystoregroup + } + + GridLayout { + columns: 1 + Label { text: qsTr('What kind of wallet do you want to create?') } + RadioButton { + ButtonGroup.group: keystoregroup + property string keystoretype: 'createseed' + checked: true + text: qsTr('Create a new seed') + } + RadioButton { + ButtonGroup.group: keystoregroup + property string keystoretype: 'haveseed' + text: qsTr('I already have a seed') + } + RadioButton { + enabled: false + ButtonGroup.group: keystoregroup + property string keystoretype: 'masterkey' + text: qsTr('Use a master key') + } + RadioButton { + enabled: false + ButtonGroup.group: keystoregroup + property string keystoretype: 'hardware' + text: qsTr('Use a hardware device') + } + } +} + diff --git a/electrum/gui/qml/components/wizard/WCProxyConfig.qml b/electrum/gui/qml/components/wizard/WCProxyConfig.qml new file mode 100644 index 000000000..bf1c62b77 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCProxyConfig.qml @@ -0,0 +1,94 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +WizardComponent { + valid: true + + onAccept: { + var p = {} + p['enabled'] = proxy_enabled.checked + if (proxy_enabled.checked) { + var type = proxytype.currentValue.toLowerCase() + if (type == 'tor') + type = 'socks5' + p['mode'] = type + p['host'] = address.text + p['port'] = port.text + p['user'] = username.text + p['password'] = password.text + } + wizard_data['proxy'] = p + } + + ColumnLayout { + width: parent.width + + Label { + text: qsTr('Proxy settings') + } + + CheckBox { + id: proxy_enabled + text: qsTr('Enable Proxy') + } + + ComboBox { + id: proxytype + enabled: proxy_enabled.checked + model: ['TOR', 'SOCKS5', 'SOCKS4'] + onCurrentIndexChanged: { + if (currentIndex == 0) { + address.text = "127.0.0.1" + port.text = "9050" + } + } + } + + GridLayout { + columns: 4 + Layout.fillWidth: true + + Label { + text: qsTr("Address") + enabled: address.enabled + } + + TextField { + id: address + enabled: proxytype.enabled && proxytype.currentIndex > 0 + } + + Label { + text: qsTr("Port") + enabled: port.enabled + } + + TextField { + id: port + enabled: proxytype.enabled && proxytype.currentIndex > 0 + } + + Label { + text: qsTr("Username") + enabled: username.enabled + } + + TextField { + id: username + enabled: proxytype.enabled && proxytype.currentIndex > 0 + } + + Label { + text: qsTr("Password") + enabled: password.enabled + } + + TextField { + id: password + enabled: proxytype.enabled && proxytype.currentIndex > 0 + echoMode: TextInput.Password + } + } + } + +} diff --git a/electrum/gui/qml/components/wizard/WCServerConfig.qml b/electrum/gui/qml/components/wizard/WCServerConfig.qml new file mode 100644 index 000000000..8bad45136 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCServerConfig.qml @@ -0,0 +1,42 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +WizardComponent { + valid: true + last: true + + onAccept: { + wizard_data['oneserver'] = !auto_server.checked + wizard_data['server'] = address.text + } + + ColumnLayout { + width: parent.width + + Label { + text: qsTr('Server settings') + } + + CheckBox { + id: auto_server + text: qsTr('Select server automatically') + checked: true + } + + GridLayout { + columns: 2 + Layout.fillWidth: true + + Label { + text: qsTr("Server") + enabled: address.enabled + } + + TextField { + id: address + enabled: !auto_server.checked + } + } + } + +} diff --git a/electrum/gui/qml/components/wizard/WCWalletName.qml b/electrum/gui/qml/components/wizard/WCWalletName.qml new file mode 100644 index 000000000..6fd14dcfa --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCWalletName.qml @@ -0,0 +1,27 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +WizardComponent { + valid: wallet_name.text.length > 0 + + onAccept: { + wizard_data['wallet_name'] = wallet_name.text + } + + GridLayout { + columns: 1 + Label { text: qsTr('Wallet name') } + TextField { + id: wallet_name + focus: true + text: Daemon.suggestWalletName() + } + } + + Component.onCompleted: { + wallet_name.selectAll() + } +} diff --git a/electrum/gui/qml/components/wizard/WCWalletPassword.qml b/electrum/gui/qml/components/wizard/WCWalletPassword.qml new file mode 100644 index 000000000..51c5cc468 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCWalletPassword.qml @@ -0,0 +1,25 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +WizardComponent { + valid: password1.text === password2.text + + onAccept: { + wizard_data['password'] = password1.text + wizard_data['encrypt'] = password1.text != '' + } + + GridLayout { + columns: 1 + Label { text: qsTr('Password protect wallet?') } + TextField { + id: password1 + echoMode: TextInput.Password + } + TextField { + id: password2 + echoMode: TextInput.Password + } + } +} diff --git a/electrum/gui/qml/components/wizard/WCWalletType.qml b/electrum/gui/qml/components/wizard/WCWalletType.qml new file mode 100644 index 000000000..e7c02dd61 --- /dev/null +++ b/electrum/gui/qml/components/wizard/WCWalletType.qml @@ -0,0 +1,43 @@ +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +WizardComponent { + valid: wallettypegroup.checkedButton !== null + + onAccept: { + wizard_data['wallet_type'] = wallettypegroup.checkedButton.wallettype + } + + ButtonGroup { + id: wallettypegroup + } + + GridLayout { + columns: 1 + Label { text: qsTr('What kind of wallet do you want to create?') } + RadioButton { + ButtonGroup.group: wallettypegroup + property string wallettype: 'standard' + checked: true + text: qsTr('Standard Wallet') + } + RadioButton { + enabled: false + ButtonGroup.group: wallettypegroup + property string wallettype: '2fa' + text: qsTr('Wallet with two-factor authentication') + } + RadioButton { + enabled: false + ButtonGroup.group: wallettypegroup + property string wallettype: 'multisig' + text: qsTr('Multi-signature wallet') + } + RadioButton { + enabled: false + ButtonGroup.group: wallettypegroup + property string wallettype: 'import' + text: qsTr('Import Bitcoin addresses or private keys') + } + } +} diff --git a/electrum/gui/qml/components/wizard/Wizard.qml b/electrum/gui/qml/components/wizard/Wizard.qml new file mode 100644 index 000000000..2978ed6cf --- /dev/null +++ b/electrum/gui/qml/components/wizard/Wizard.qml @@ -0,0 +1,175 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +Dialog { + id: wizard + modal: true + + width: parent.width + height: parent.height + + property var wizard_data + property alias pages : pages + + function _setWizardData(wdata) { + wizard_data = {} + Object.assign(wizard_data, wdata) // deep copy + console.log('wizard data is now :' + JSON.stringify(wizard_data)) + } + + // helper function to dynamically load wizard page components + // and add them to the SwipeView + // Here we do some manual binding of page.valid -> pages.pagevalid and + // page.last -> pages.lastpage to propagate the state without the binding + // going stale. + function _loadNextComponent(comp, wdata={}) { + // remove any existing pages after current page + while (pages.contentChildren[pages.currentIndex+1]) { + pages.takeItem(pages.currentIndex+1).destroy() + } + + var page = comp.createObject(pages) + page.validChanged.connect(function() { + pages.pagevalid = page.valid + } ) + page.lastChanged.connect(function() { + pages.lastpage = page.last + } ) + Object.assign(page.wizard_data, wdata) // deep copy + page.ready = true // signal page it can access wizard_data + pages.pagevalid = page.valid + pages.lastpage = page.last + + return page + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + SwipeView { + id: pages + Layout.fillWidth: true + Layout.fillHeight: true + interactive: false + + clip:true + + function prev() { + currentIndex = currentIndex - 1 + _setWizardData(pages.contentChildren[currentIndex].wizard_data) + pages.pagevalid = pages.contentChildren[currentIndex].valid + pages.lastpage = pages.contentChildren[currentIndex].last + } + + function next() { + currentItem.accept() + _setWizardData(pages.contentChildren[currentIndex].wizard_data) + currentItem.next() + currentIndex = currentIndex + 1 + } + + function finish() { + currentItem.accept() + _setWizardData(pages.contentChildren[currentIndex].wizard_data) + wizard.accept() + } + + property bool pagevalid: false + property bool lastpage: false + + Component.onCompleted: { + _setWizardData({}) + } + + } + + ColumnLayout { + Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom + + PageIndicator { + id: indicator + + Layout.alignment: Qt.AlignHCenter + + count: pages.count + currentIndex: pages.currentIndex + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Button { + visible: pages.currentIndex == 0 + text: qsTr("Cancel") + onClicked: wizard.reject() + } + + Button { + visible: pages.currentIndex > 0 + text: qsTr('Back') + onClicked: pages.prev() + } + + Button { + text: qsTr("Next") + visible: !pages.lastpage + enabled: pages.pagevalid + onClicked: pages.next() + } + + Button { + text: qsTr("Finish") + visible: pages.lastpage + enabled: pages.pagevalid + onClicked: pages.finish() + } + + } + } + } + + header: GridLayout { + columns: 2 + rowSpacing: 0 + + Image { + source: "../../../icons/electrum.png" + Layout.preferredWidth: constants.iconSizeXLarge + Layout.preferredHeight: constants.iconSizeXLarge + Layout.leftMargin: constants.paddingMedium + Layout.topMargin: constants.paddingMedium + Layout.bottomMargin: constants.paddingMedium + } + + Label { + text: title + elide: Label.ElideRight + Layout.fillWidth: true + topPadding: constants.paddingXLarge + bottomPadding: constants.paddingXLarge + font.bold: true + font.pixelSize: constants.fontSizeMedium + } + + Rectangle { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.leftMargin: constants.paddingTiny + Layout.rightMargin: constants.paddingTiny + height: 1 + color: Qt.rgba(0,0,0,0.5) + } + } + + // make clicking the dialog background move the scope away from textedit fields + // so the keyboard goes away + // TODO: here it works on desktop, but not android. hmm. + MouseArea { + anchors.fill: parent + z: -1000 + onClicked: { parkFocus.focus = true } + FocusScope { id: parkFocus } + } + +} diff --git a/electrum/gui/qml/components/wizard/WizardComponent.qml b/electrum/gui/qml/components/wizard/WizardComponent.qml new file mode 100644 index 000000000..798b7ad8d --- /dev/null +++ b/electrum/gui/qml/components/wizard/WizardComponent.qml @@ -0,0 +1,10 @@ +import QtQuick 2.0 + +Item { + signal next + signal accept + property var wizard_data : ({}) + property bool valid + property bool last: false + property bool ready: false +} diff --git a/electrum/gui/qml/fonts/PTMono-Bold.ttf b/electrum/gui/qml/fonts/PTMono-Bold.ttf new file mode 100644 index 000000000..b1a145e0e Binary files /dev/null and b/electrum/gui/qml/fonts/PTMono-Bold.ttf differ diff --git a/electrum/gui/qml/fonts/PTMono-Regular.ttf b/electrum/gui/qml/fonts/PTMono-Regular.ttf new file mode 100644 index 000000000..b1983838c Binary files /dev/null and b/electrum/gui/qml/fonts/PTMono-Regular.ttf differ diff --git a/electrum/gui/qml/fonts/PTMono.LICENSE b/electrum/gui/qml/fonts/PTMono.LICENSE new file mode 100644 index 000000000..f3a682b22 --- /dev/null +++ b/electrum/gui/qml/fonts/PTMono.LICENSE @@ -0,0 +1,94 @@ +Copyright (c) 2011, ParaType Ltd. (http://www.paratype.com/public), +with Reserved Font Names "PT Sans", "PT Serif", "PT Mono" and "ParaType". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/electrum/gui/qml/qeaddressdetails.py b/electrum/gui/qml/qeaddressdetails.py new file mode 100644 index 000000000..67e67fdd9 --- /dev/null +++ b/electrum/gui/qml/qeaddressdetails.py @@ -0,0 +1,130 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from decimal import Decimal + +from electrum.logging import get_logger +from electrum.util import DECIMAL_POINT_DEFAULT + +from .qewallet import QEWallet +from .qetypes import QEAmount +from .qetransactionlistmodel import QETransactionListModel + +class QEAddressDetails(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + _logger = get_logger(__name__) + + _wallet = None + _address = None + + _label = None + _frozen = False + _scriptType = None + _status = None + _balance = QEAmount() + _pubkeys = None + _privkey = None + _derivationPath = None + _numtx = 0 + + _historyModel = None + + detailsChanged = pyqtSignal() + + walletChanged = pyqtSignal() + @pyqtProperty(QEWallet, notify=walletChanged) + def wallet(self): + return self._wallet + + @wallet.setter + def wallet(self, wallet: QEWallet): + if self._wallet != wallet: + self._wallet = wallet + self.walletChanged.emit() + + addressChanged = pyqtSignal() + @pyqtProperty(str, notify=addressChanged) + def address(self): + return self._address + + @address.setter + def address(self, address: str): + if self._address != address: + self._logger.debug('address changed') + self._address = address + self.addressChanged.emit() + self.update() + + @pyqtProperty(str, notify=detailsChanged) + def scriptType(self): + return self._scriptType + + @pyqtProperty(QEAmount, notify=detailsChanged) + def balance(self): + return self._balance + + @pyqtProperty('QStringList', notify=detailsChanged) + def pubkeys(self): + return self._pubkeys + + @pyqtProperty(str, notify=detailsChanged) + def derivationPath(self): + return self._derivationPath + + @pyqtProperty(int, notify=detailsChanged) + def numTx(self): + return self._numtx + + + frozenChanged = pyqtSignal() + @pyqtProperty(bool, notify=frozenChanged) + def isFrozen(self): + return self._frozen + + labelChanged = pyqtSignal() + @pyqtProperty(str, notify=labelChanged) + def label(self): + return self._label + + @pyqtSlot(bool) + def freeze(self, freeze: bool): + if freeze != self._frozen: + self._wallet.wallet.set_frozen_state_of_addresses([self._address], freeze=freeze) + self._frozen = freeze + self.frozenChanged.emit() + self._wallet.balanceChanged.emit() + + @pyqtSlot(str) + def set_label(self, label: str): + if label != self._label: + self._wallet.wallet.set_label(self._address, label) + self._label = label + self.labelChanged.emit() + + historyModelChanged = pyqtSignal() + @pyqtProperty(QETransactionListModel, notify=historyModelChanged) + def historyModel(self): + if self._historyModel is None: + self._historyModel = QETransactionListModel(self._wallet.wallet, + onchain_domain=[self._address], include_lightning=False) + return self._historyModel + + def update(self): + if self._wallet is None: + self._logger.error('wallet undefined') + return + + self._frozen = self._wallet.wallet.is_frozen_address(self._address) + self.frozenChanged.emit() + + self._scriptType = self._wallet.wallet.get_txin_type(self._address) + self._label = self._wallet.wallet.get_label(self._address) + c, u, x = self._wallet.wallet.get_addr_balance(self._address) + self._balance = QEAmount(amount_sat=c + u + x) + self._pubkeys = self._wallet.wallet.get_public_keys(self._address) + self._derivationPath = self._wallet.wallet.get_address_path_str(self._address) + self._derivationPath = self._derivationPath.replace('m', self._wallet.derivationPrefix) + self._numtx = self._wallet.wallet.adb.get_address_history_len(self._address) + assert(self._numtx == self.historyModel.rowCount(0)) + self.detailsChanged.emit() diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py new file mode 100644 index 000000000..094674f34 --- /dev/null +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -0,0 +1,101 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex + +from electrum.logging import get_logger +from electrum.util import Satoshis + +from .qetypes import QEAmount + +class QEAddressListModel(QAbstractListModel): + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + self.init_model() + + _logger = get_logger(__name__) + + # define listmodel rolemap + _ROLE_NAMES=('type','iaddr','address','label','balance','numtx', 'held') + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) + _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + + def rowCount(self, index): + return len(self.receive_addresses) + len(self.change_addresses) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + if index.row() > len(self.receive_addresses) - 1: + address = self.change_addresses[index.row() - len(self.receive_addresses)] + else: + address = self.receive_addresses[index.row()] + role_index = role - Qt.UserRole + value = address[self._ROLE_NAMES[role_index]] + if isinstance(value, (bool, list, int, str, QEAmount)) or value is None: + return value + if isinstance(value, Satoshis): + return value.value + return str(value) + + def clear(self): + self.beginResetModel() + self.receive_addresses = [] + self.change_addresses = [] + self.endResetModel() + + def addr_to_model(self, address): + item = {} + item['address'] = address + item['numtx'] = self.wallet.adb.get_address_history_len(address) + item['label'] = self.wallet.get_label(address) + c, u, x = self.wallet.get_addr_balance(address) + item['balance'] = QEAmount(amount_sat=c + u + x) + item['held'] = self.wallet.is_frozen_address(address) + return item + + # initial model data + @pyqtSlot() + def init_model(self): + r_addresses = self.wallet.get_receiving_addresses() + c_addresses = self.wallet.get_change_addresses() + n_addresses = len(r_addresses) + len(c_addresses) + + def insert_row(atype, alist, address, iaddr): + item = self.addr_to_model(address) + item['type'] = atype + item['iaddr'] = iaddr + alist.append(item) + + self.clear() + self.beginInsertRows(QModelIndex(), 0, n_addresses - 1) + i = 0 + for address in r_addresses: + insert_row('receive', self.receive_addresses, address, i) + i = i + 1 + i = 0 + for address in c_addresses: + insert_row('change', self.change_addresses, address, i) + i = i + 1 + self.endInsertRows() + + @pyqtSlot(str) + def update_address(self, address): + i = 0 + for a in self.receive_addresses: + if a['address'] == address: + self.do_update(i,a) + return + i = i + 1 + for a in self.change_addresses: + if a['address'] == address: + self.do_update(i,a) + return + i = i + 1 + + def do_update(self, modelindex, modelitem): + mi = self.createIndex(modelindex, 0) + self._logger.debug(repr(modelitem)) + modelitem.update(self.addr_to_model(modelitem['address'])) + self._logger.debug(repr(modelitem)) + self.dataChanged.emit(mi, mi, self._ROLE_KEYS) diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py new file mode 100644 index 000000000..5dfc5fa71 --- /dev/null +++ b/electrum/gui/qml/qeapp.py @@ -0,0 +1,211 @@ +import re +import queue +import time +import os + +from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QUrl, QLocale, qInstallMessageHandler, QTimer +from PyQt5.QtGui import QGuiApplication, QFontDatabase +from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType, QQmlApplicationEngine + +from electrum.logging import Logger, get_logger +from electrum import version + +from .qeconfig import QEConfig +from .qedaemon import QEDaemon, QEWalletListModel +from .qenetwork import QENetwork +from .qewallet import QEWallet +from .qeqr import QEQRParser, QEQRImageProvider +from .qewalletdb import QEWalletDB +from .qebitcoin import QEBitcoin +from .qefx import QEFX +from .qetxfinalizer import QETxFinalizer +from .qeinvoice import QEInvoice, QEInvoiceParser, QEUserEnteredPayment +from .qetypes import QEAmount +from .qeaddressdetails import QEAddressDetails +from .qetxdetails import QETxDetails +from .qechannelopener import QEChannelOpener +from .qelnpaymentdetails import QELnPaymentDetails +from .qechanneldetails import QEChannelDetails +from .qeswaphelper import QESwapHelper + +notification = None + +class QEAppController(QObject): + userNotify = pyqtSignal(str) + + def __init__(self, qedaemon): + super().__init__() + self.logger = get_logger(__name__) + + self._qedaemon = qedaemon + + # set up notification queue and notification_timer + self.user_notification_queue = queue.Queue() + self.user_notification_last_time = 0 + + self.notification_timer = QTimer(self) + self.notification_timer.setSingleShot(False) + self.notification_timer.setInterval(500) # msec + self.notification_timer.timeout.connect(self.on_notification_timer) + + self._qedaemon.walletLoaded.connect(self.on_wallet_loaded) + + self.userNotify.connect(self.notifyAndroid) + + def on_wallet_loaded(self): + qewallet = self._qedaemon.currentWallet + if not qewallet: + return + # attach to the wallet user notification events + # connect only once + try: + qewallet.userNotify.disconnect(self.on_wallet_usernotify) + except: + pass + qewallet.userNotify.connect(self.on_wallet_usernotify) + + def on_wallet_usernotify(self, wallet, message): + self.logger.debug(message) + self.user_notification_queue.put(message) + if not self.notification_timer.isActive(): + self.logger.debug('starting app notification timer') + self.notification_timer.start() + + def on_notification_timer(self): + if self.user_notification_queue.qsize() == 0: + self.logger.debug('queue empty, stopping app notification timer') + self.notification_timer.stop() + return + now = time.time() + rate_limit = 20 # seconds + if self.user_notification_last_time + rate_limit > now: + return + self.user_notification_last_time = now + self.logger.info("Notifying GUI about new user notifications") + try: + self.userNotify.emit(self.user_notification_queue.get_nowait()) + except queue.Empty: + pass + + def notifyAndroid(self, message): + try: + # TODO: lazy load not in UI thread please + global notification + if not notification: + from plyer import notification + icon = (os.path.dirname(os.path.realpath(__file__)) + + '/../icons/electrum.png') + notification.notify('Electrum', message, app_icon=icon, app_name='Electrum') + except ImportError: + self.logger.error('Notification: needs plyer; `sudo python3 -m pip install plyer`') + + @pyqtSlot(str, str) + def doShare(self, data, title): + #if platform != 'android': + #return + try: + from jnius import autoclass, cast + except ImportError: + self.logger.error('Share: needs jnius. Platform not Android?') + return + + JS = autoclass('java.lang.String') + Intent = autoclass('android.content.Intent') + sendIntent = Intent() + sendIntent.setAction(Intent.ACTION_SEND) + sendIntent.setType("text/plain") + sendIntent.putExtra(Intent.EXTRA_TEXT, JS(data)) + pythonActivity = autoclass('org.kivy.android.PythonActivity') + currentActivity = cast('android.app.Activity', pythonActivity.mActivity) + it = Intent.createChooser(sendIntent, cast('java.lang.CharSequence', JS(title))) + currentActivity.startActivity(it) + + @pyqtSlot('QString') + def textToClipboard(self, text): + QGuiApplication.clipboard().setText(text) + + @pyqtSlot(result='QString') + def clipboardToText(self): + return QGuiApplication.clipboard().text() + +class ElectrumQmlApplication(QGuiApplication): + + _valid = True + + def __init__(self, args, config, daemon): + super().__init__(args) + + self.logger = get_logger(__name__) + + ElectrumQmlApplication._daemon = daemon + + qmlRegisterType(QEWalletListModel, 'org.electrum', 1, 0, 'WalletListModel') + qmlRegisterType(QEWallet, 'org.electrum', 1, 0, 'Wallet') + qmlRegisterType(QEWalletDB, 'org.electrum', 1, 0, 'WalletDB') + qmlRegisterType(QEBitcoin, 'org.electrum', 1, 0, 'Bitcoin') + qmlRegisterType(QEQRParser, 'org.electrum', 1, 0, 'QRParser') + qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX') + qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer') + qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') + qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser') + qmlRegisterType(QEUserEnteredPayment, 'org.electrum', 1, 0, 'UserEnteredPayment') + qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') + qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails') + qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener') + qmlRegisterType(QELnPaymentDetails, 'org.electrum', 1, 0, 'LnPaymentDetails') + qmlRegisterType(QEChannelDetails, 'org.electrum', 1, 0, 'ChannelDetails') + qmlRegisterType(QESwapHelper, 'org.electrum', 1, 0, 'SwapHelper') + + 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() + + self.qr_ip = QEQRImageProvider((7/8)*min(screensize.width(), screensize.height())) + self.engine.addImageProvider('qrgen', self.qr_ip) + + # add a monospace font as we can't rely on device having one + self.fixedFont = 'PT Mono' + not_loaded = QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Regular.ttf') < 0 + not_loaded = QFontDatabase.addApplicationFont('electrum/gui/qml/fonts/PTMono-Bold.ttf') < 0 and not_loaded + if not_loaded: + self.logger.warning('Could not load font PT Mono') + self.fixedFont = 'Monospace' # hope for the best + + self.context = self.engine.rootContext() + self._qeconfig = QEConfig(config) + self._qenetwork = QENetwork(daemon.network) + self._qedaemon = QEDaemon(daemon) + self._appController = QEAppController(self._qedaemon) + self._maxAmount = QEAmount(is_max=True) + 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('FixedFont', self.fixedFont) + self.context.setContextProperty('MAX', self._maxAmount) + self.context.setContextProperty('BUILD', { + 'electrum_version': version.ELECTRUM_VERSION, + 'apk_version': version.APK_VERSION, + 'protocol_version': version.PROTOCOL_VERSION + }) + + qInstallMessageHandler(self.message_handler) + + # get notified whether root QML document loads or not + self.engine.objectCreated.connect(self.objectCreated) + + # slot is called after loading root QML. If object is None, it has failed. + @pyqtSlot('QObject*', 'QUrl') + def objectCreated(self, object, url): + if object is None: + self._valid = False + self.engine.objectCreated.disconnect(self.objectCreated) + + def message_handler(self, line, funct, file): + # filter out common harmless messages + if re.search('file:///.*TypeError: Cannot read property.*null$', file): + return + self.logger.warning(file) diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py new file mode 100644 index 000000000..eb9d1625f --- /dev/null +++ b/electrum/gui/qml/qebitcoin.py @@ -0,0 +1,132 @@ +import asyncio +from datetime import datetime + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.keystore import bip39_is_checksum_valid +from electrum.bip32 import is_bip32_derivation +from electrum.slip39 import decode_mnemonic, Slip39Error +from electrum import mnemonic +from electrum.util import parse_URI, create_bip21_uri, InvalidBitcoinURI, get_asyncio_loop + +from .qetypes import QEAmount + +class QEBitcoin(QObject): + def __init__(self, config, parent=None): + super().__init__(parent) + self.config = config + + _logger = get_logger(__name__) + + generatedSeedChanged = pyqtSignal() + generatedSeed = '' + + seedValidChanged = pyqtSignal() + seedValid = False + + seedTypeChanged = pyqtSignal() + seedType = '' + + validationMessageChanged = pyqtSignal() + validationMessage = '' + + @pyqtProperty('QString', notify=generatedSeedChanged) + def generated_seed(self): + return self.generatedSeed + + @pyqtProperty(bool, notify=seedValidChanged) + def seed_valid(self): + return self.seedValid + + @pyqtProperty('QString', notify=seedTypeChanged) + def seed_type(self): + return self.seedType + + @pyqtProperty('QString', notify=validationMessageChanged) + def validation_message(self): + return self.validationMessage + + @pyqtSlot() + @pyqtSlot(str) + @pyqtSlot(str,str) + def generate_seed(self, seed_type='segwit', language='en'): + self._logger.debug('generating seed of type ' + str(seed_type)) + + async def co_gen_seed(seed_type, language): + self.generatedSeed = mnemonic.Mnemonic(language).make_seed(seed_type=seed_type) + self._logger.debug('seed generated') + self.generatedSeedChanged.emit() + + asyncio.run_coroutine_threadsafe(co_gen_seed(seed_type, language), get_asyncio_loop()) + + @pyqtSlot(str) + @pyqtSlot(str,bool,bool) + @pyqtSlot(str,bool,bool,str,str,str) + def verify_seed(self, seed, bip39=False, slip39=False, wallet_type='standard', language='en'): + self._logger.debug('bip39 ' + str(bip39)) + self._logger.debug('slip39 ' + str(slip39)) + + seed_type = '' + seed_valid = False + validation_message = '' + + if not (bip39 or slip39): + seed_type = mnemonic.seed_type(seed) + if seed_type != '': + seed_valid = True + elif bip39: + is_checksum, is_wordlist = bip39_is_checksum_valid(seed) + status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist' + validation_message = 'BIP39 (%s)' % status + + if is_checksum: + seed_type = 'bip39' + seed_valid = True + + elif slip39: # TODO: incomplete impl, this code only validates a single share. + try: + share = decode_mnemonic(seed) + seed_type = 'slip39' + validation_message = 'SLIP39: share #%d in %dof%d scheme' % (share.group_index, share.group_threshold, share.group_count) + except Slip39Error as e: + validation_message = 'SLIP39: %s' % str(e) + seed_valid = False # for now + + # cosigning seed + if wallet_type != 'standard' and seed_type not in ['standard', 'segwit']: + seed_type = '' + seed_valid = False + + self.seedType = seed_type + self.seedTypeChanged.emit() + + if self.validationMessage != validation_message: + self.validationMessage = validation_message + self.validationMessageChanged.emit() + + if self.seedValid != seed_valid: + self.seedValid = seed_valid + self.seedValidChanged.emit() + + self._logger.debug('seed verified: ' + str(seed_valid)) + + @pyqtSlot(str, result=bool) + def verify_derivation_path(self, path): + return is_bip32_derivation(path) + + @pyqtSlot(str, result='QVariantMap') + def parse_uri(self, uri: str) -> dict: + try: + return parse_URI(uri) + except InvalidBitcoinURI as e: + return { 'error': str(e) } + + @pyqtSlot(str, QEAmount, str, int, int, result=str) + def create_bip21_uri(self, address, satoshis, message, timestamp, expiry): + extra_params = {} + if expiry: + extra_params['time'] = str(timestamp) + extra_params['exp'] = str(expiry) + + return create_bip21_uri(address, satoshis.satsInt, message, extra_query_params=extra_params) diff --git a/electrum/gui/qml/qechanneldetails.py b/electrum/gui/qml/qechanneldetails.py new file mode 100644 index 000000000..020a17568 --- /dev/null +++ b/electrum/gui/qml/qechanneldetails.py @@ -0,0 +1,182 @@ +import asyncio + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS + +from electrum.i18n import _ +from electrum.gui import messages +from electrum.logging import get_logger +from electrum.lnutil import LOCAL, REMOTE +from electrum.lnchannel import ChanCloseOption + +from .qewallet import QEWallet +from .qetypes import QEAmount +from .util import QtEventListener, qt_event_listener + +class QEChannelDetails(QObject, QtEventListener): + + _logger = get_logger(__name__) + _wallet = None + _channelid = None + _channel = None + + channelChanged = pyqtSignal() + channelCloseSuccess = pyqtSignal() + channelCloseFailed = pyqtSignal([str], arguments=['message']) + + def __init__(self, parent=None): + super().__init__(parent) + self.register_callbacks() + self.destroyed.connect(lambda: self.on_destroy()) + + @qt_event_listener + def on_event_channel(self, wallet, channel): + if wallet == self._wallet.wallet and self._channelid == channel.channel_id.hex(): + self.channelChanged.emit() + + def on_destroy(self): + self.unregister_callbacks() + + walletChanged = pyqtSignal() + @pyqtProperty(QEWallet, notify=walletChanged) + def wallet(self): + return self._wallet + + @wallet.setter + def wallet(self, wallet: QEWallet): + if self._wallet != wallet: + self._wallet = wallet + self.walletChanged.emit() + + channelidChanged = pyqtSignal() + @pyqtProperty(str, notify=channelidChanged) + def channelid(self): + return self._channelid + + @channelid.setter + def channelid(self, channelid: str): + if self._channelid != channelid: + self._channelid = channelid + if channelid: + self.load() + self.channelidChanged.emit() + + def load(self): + lnchannels = self._wallet.wallet.lnworker.channels + for channel in lnchannels.values(): + #self._logger.debug('%s == %s ?' % (self._channelid, channel.channel_id)) + if self._channelid == channel.channel_id.hex(): + self._channel = channel + self.channelChanged.emit() + + @pyqtProperty(str, notify=channelChanged) + def name(self): + if not self._channel: + return + return self._wallet.wallet.lnworker.get_node_alias(self._channel.node_id) or self._channel.node_id.hex() + + @pyqtProperty(str, notify=channelChanged) + def pubkey(self): + return self._channel.node_id.hex() #if self._channel else '' + + @pyqtProperty(str, notify=channelChanged) + def short_cid(self): + return self._channel.short_id_for_GUI() + + @pyqtProperty(str, notify=channelChanged) + def state(self): + return self._channel.get_state_for_GUI() + + @pyqtProperty(str, notify=channelChanged) + def initiator(self): + return 'Local' if self._channel.constraints.is_initiator else 'Remote' + + @pyqtProperty(QEAmount, notify=channelChanged) + def capacity(self): + self._capacity = QEAmount(amount_sat=self._channel.get_capacity()) + return self._capacity + + @pyqtProperty(QEAmount, notify=channelChanged) + def canSend(self): + self._can_send = QEAmount(amount_sat=self._channel.available_to_spend(LOCAL)/1000) + return self._can_send + + @pyqtProperty(QEAmount, notify=channelChanged) + def canReceive(self): + self._can_receive = QEAmount(amount_sat=self._channel.available_to_spend(REMOTE)/1000) + return self._can_receive + + @pyqtProperty(bool, notify=channelChanged) + def frozenForSending(self): + return self._channel.is_frozen_for_sending() + + @pyqtProperty(bool, notify=channelChanged) + def frozenForReceiving(self): + return self._channel.is_frozen_for_receiving() + + @pyqtProperty(str, notify=channelChanged) + def channelType(self): + return self._channel.storage['channel_type'].name_minimal + + @pyqtProperty(bool, notify=channelChanged) + def isOpen(self): + return self._channel.is_open() + + @pyqtProperty(bool, notify=channelChanged) + def canClose(self): + return self.canCoopClose or self.canForceClose + + @pyqtProperty(bool, notify=channelChanged) + def canCoopClose(self): + return ChanCloseOption.COOP_CLOSE in self._channel.get_close_options() + + @pyqtProperty(bool, notify=channelChanged) + def canForceClose(self): + return ChanCloseOption.LOCAL_FCLOSE in self._channel.get_close_options() + + @pyqtProperty(bool, notify=channelChanged) + def canDelete(self): + return self._channel.can_be_deleted() + + @pyqtProperty(str, notify=channelChanged) + def message_force_close(self, notify=channelChanged): + return _(messages.MSG_REQUEST_FORCE_CLOSE) + + @pyqtSlot() + def freezeForSending(self): + lnworker = self._channel.lnworker + if lnworker.channel_db or lnworker.is_trampoline_peer(self._channel.node_id): + self._channel.set_frozen_for_sending(not self.frozenForSending) + self.channelChanged.emit() + else: + self._logger.debug(messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP) + + @pyqtSlot() + def freezeForReceiving(self): + lnworker = self._channel.lnworker + if lnworker.channel_db or lnworker.is_trampoline_peer(self._channel.node_id): + self._channel.set_frozen_for_receiving(not self.frozenForReceiving) + self.channelChanged.emit() + else: + self._logger.debug(messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP) + + # this method assumes the qobject is not destroyed before the close either fails or succeeds + @pyqtSlot(str) + def close_channel(self, closetype): + async def do_close(closetype, channel_id): + try: + if closetype == 'force': + await self._wallet.wallet.lnworker.request_force_close(channel_id) + else: + await self._wallet.wallet.lnworker.close_channel(channel_id) + self.channelCloseSuccess.emit() + except Exception as e: + self._logger.exception("Could not close channel: " + repr(e)) + self.channelCloseFailed.emit(_('Could not close channel: ') + repr(e)) + + loop = self._wallet.wallet.network.asyncio_loop + coro = do_close(closetype, self._channel.channel_id) + asyncio.run_coroutine_threadsafe(coro, loop) + + @pyqtSlot() + def deleteChannel(self): + self._wallet.wallet.lnworker.remove_channel(self._channel.channel_id) diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py new file mode 100644 index 000000000..94bfcbb46 --- /dev/null +++ b/electrum/gui/qml/qechannellistmodel.py @@ -0,0 +1,154 @@ +from datetime import datetime, timedelta + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex + +from electrum.logging import get_logger +from electrum.util import Satoshis +from electrum.lnutil import LOCAL, REMOTE +from electrum.lnchannel import ChannelState + +from .qetypes import QEAmount +from .util import QtEventListener, qt_event_listener + +class QEChannelListModel(QAbstractListModel, QtEventListener): + _logger = get_logger(__name__) + + # define listmodel rolemap + _ROLE_NAMES=('cid','state','initiator','capacity','can_send','can_receive', + 'l_csv_delay','r_csv_delay','send_frozen','receive_frozen', + 'type','node_id','node_alias','short_cid','funding_tx') + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) + _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) + + _network_signal = pyqtSignal(str, object) + + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + self.init_model() + + # To avoid leaking references to "self" that prevent the + # window from being GC-ed when closed, callbacks should be + # methods of this class only, and specifically not be + # partials, lambdas or methods of subobjects. Hence... + self.register_callbacks() + self.destroyed.connect(lambda: self.on_destroy()) + + @qt_event_listener + def on_event_channel(self, wallet, channel): + if wallet == self.wallet: + self.on_channel_updated(channel) + + # elif event == 'channels_updated': + @qt_event_listener + def on_event_channels_updated(self, wallet): + if wallet == self.wallet: + self.init_model() # TODO: remove/add less crude than full re-init + + def on_destroy(self): + self.unregister_callbacks() + + def rowCount(self, index): + return len(self.channels) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + tx = self.channels[index.row()] + role_index = role - Qt.UserRole + value = tx[self._ROLE_NAMES[role_index]] + if isinstance(value, (bool, list, int, str, QEAmount)) or value is None: + return value + if isinstance(value, Satoshis): + return value.value + return str(value) + + def clear(self): + self.beginResetModel() + self.channels = [] + self.endResetModel() + + def channel_to_model(self, lnc): + lnworker = self.wallet.lnworker + item = {} + item['cid'] = lnc.channel_id.hex() + item['node_alias'] = lnworker.get_node_alias(lnc.node_id) or lnc.node_id.hex() + item['short_cid'] = lnc.short_id_for_GUI() + item['state'] = lnc.get_state_for_GUI() + item['state_code'] = lnc.get_state() + item['capacity'] = QEAmount(amount_sat=lnc.get_capacity()) + item['can_send'] = QEAmount(amount_msat=lnc.available_to_spend(LOCAL)) + item['can_receive'] = QEAmount(amount_msat=lnc.available_to_spend(REMOTE)) + self._logger.debug(repr(item)) + return item + + numOpenChannelsChanged = pyqtSignal() + @pyqtProperty(int, notify=numOpenChannelsChanged) + def numOpenChannels(self): + return sum([1 if x['state_code'] == ChannelState.OPEN else 0 for x in self.channels]) + + @pyqtSlot() + def init_model(self): + self._logger.debug('init_model') + if not self.wallet.lnworker: + self._logger.warning('lnworker should be defined') + return + + channels = [] + + lnchannels = self.wallet.lnworker.channels + for channel in lnchannels.values(): + self._logger.debug(repr(channel)) + item = self.channel_to_model(channel) + channels.append(item) + + self.clear() + self.beginInsertRows(QModelIndex(), 0, len(channels) - 1) + self.channels = channels + self.endInsertRows() + + def on_channel_updated(self, channel): + i = 0 + for c in self.channels: + if c['cid'] == channel.channel_id.hex(): + self.do_update(i,channel) + break + i = i + 1 + + def do_update(self, modelindex, channel): + modelitem = self.channels[modelindex] + #self._logger.debug(repr(modelitem)) + modelitem.update(self.channel_to_model(channel)) + + mi = self.createIndex(modelindex, 0) + self.dataChanged.emit(mi, mi, self._ROLE_KEYS) + self.numOpenChannelsChanged.emit() + + @pyqtSlot(str) + def new_channel(self, cid): + self._logger.debug('new channel with cid %s' % cid) + lnchannels = self.wallet.lnworker.channels + for channel in lnchannels.values(): + self._logger.debug(repr(channel)) + if cid == channel.channel_id.hex(): + item = self.channel_to_model(channel) + self._logger.debug(item) + self.beginInsertRows(QModelIndex(), 0, 0) + self.channels.insert(0,item) + self.endInsertRows() + + @pyqtSlot(str) + def remove_channel(self, cid): + self._logger.debug('remove channel with cid %s' % cid) + i = 0 + for channel in self.channels: + if cid == channel['cid']: + self._logger.debug(cid) + self.beginRemoveRows(QModelIndex(), i, i) + self.channels.remove(channel) + self.endRemoveRows() + return + i = i + 1 diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py new file mode 100644 index 000000000..8c5767380 --- /dev/null +++ b/electrum/gui/qml/qechannelopener.py @@ -0,0 +1,182 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.i18n import _ +from electrum.logging import get_logger +from electrum.lnutil import extract_nodeid, ConnStringFormatError, LNPeerAddr, ln_dummy_address +from electrum.lnworker import hardcoded_trampoline_nodes +from electrum.gui import messages + +from .qewallet import QEWallet +from .qetypes import QEAmount +from .qetxfinalizer import QETxFinalizer + +class QEChannelOpener(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + _logger = get_logger(__name__) + + _wallet = None + _nodeid = None + _amount = QEAmount() + _valid = False + _opentx = None + + validationError = pyqtSignal([str,str], arguments=['code','message']) + conflictingBackup = pyqtSignal([str], arguments=['message']) + channelOpenError = pyqtSignal([str], arguments=['message']) + channelOpenSuccess = pyqtSignal([str,bool], arguments=['cid','has_backup']) + + dataChanged = pyqtSignal() # generic notify signal + + walletChanged = pyqtSignal() + @pyqtProperty(QEWallet, notify=walletChanged) + def wallet(self): + return self._wallet + + @wallet.setter + def wallet(self, wallet: QEWallet): + if self._wallet != wallet: + self._wallet = wallet + self.walletChanged.emit() + + nodeidChanged = pyqtSignal() + @pyqtProperty(str, notify=nodeidChanged) + def nodeid(self): + return self._nodeid + + @nodeid.setter + def nodeid(self, nodeid: str): + if self._nodeid != nodeid: + self._logger.debug('nodeid set -> %s' % nodeid) + self._nodeid = nodeid + self.nodeidChanged.emit() + self.validate() + + amountChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=amountChanged) + def amount(self): + return self._amount + + @amount.setter + def amount(self, amount: QEAmount): + if self._amount != amount: + self._amount = amount + self.amountChanged.emit() + self.validate() + + validChanged = pyqtSignal() + @pyqtProperty(bool, notify=validChanged) + def valid(self): + return self._valid + + finalizerChanged = pyqtSignal() + @pyqtProperty(QETxFinalizer, notify=finalizerChanged) + def finalizer(self): + return self._finalizer + + @pyqtProperty(list, notify=dataChanged) + def trampolineNodeNames(self): + return list(hardcoded_trampoline_nodes().keys()) + + # FIXME min channel funding amount + # FIXME have requested funding amount + def validate(self): + nodeid_valid = False + if self._nodeid: + if not self._wallet.wallet.config.get('use_gossip', False): + self._peer = hardcoded_trampoline_nodes()[self._nodeid] + nodeid_valid = True + else: + try: + node_pubkey, host_port = extract_nodeid(self._nodeid) + host, port = host_port.split(':',1) + self._peer = LNPeerAddr(host, int(port), node_pubkey) + nodeid_valid = True + except ConnStringFormatError as e: + self.validationError.emit('invalid_nodeid', repr(e)) + except ValueError as e: + self.validationError.emit('invalid_nodeid', repr(e)) + + if not nodeid_valid: + self._valid = False + self.validChanged.emit() + return + + self._logger.debug('amount=%s' % str(self._amount)) + if not self._amount or not (self._amount.satsInt > 0 or self._amount.isMax): + self._valid = False + self.validChanged.emit() + return + + self._valid = True + self.validChanged.emit() + + # FIXME "max" button in amount_dialog should enforce LN_MAX_FUNDING_SAT + @pyqtSlot() + @pyqtSlot(bool) + def open_channel(self, confirm_backup_conflict=False): + if not self.valid: + return + + self._logger.debug('Connect String: %s' % str(self._peer)) + + lnworker = self._wallet.wallet.lnworker + if lnworker.has_conflicting_backup_with(self._peer.pubkey) and not confirm_backup_conflict: + self.conflictingBackup.emit(messages.MGS_CONFLICTING_BACKUP_INSTANCE) + return + + amount = '!' if self._amount.isMax else self._amount.satsInt + self._logger.debug('amount = %s' % str(amount)) + + coins = self._wallet.wallet.get_spendable_coins(None, nonlocal_only=True) + + mktx = lambda amt: lnworker.mktx_for_open_channel( + coins=coins, + funding_sat=amt, + node_id=self._peer.pubkey, + fee_est=None) + + acpt = lambda tx: self.do_open_channel(tx, str(self._peer), None) + + self._finalizer = QETxFinalizer(self, make_tx=mktx, accept=acpt) + self._finalizer.canRbf = False + self._finalizer.amount = self._amount + self._finalizer.wallet = self._wallet + self.finalizerChanged.emit() + + def do_open_channel(self, funding_tx, conn_str, password): + self._logger.debug('opening channel') + # read funding_sat from tx; converts '!' to int value + funding_sat = funding_tx.output_value_for_address(ln_dummy_address()) + lnworker = self._wallet.wallet.lnworker + try: + chan, funding_tx = lnworker.open_channel( + connect_str=conn_str, + funding_tx=funding_tx, + funding_sat=funding_sat, + push_amt_sat=0, + password=password) + except Exception as e: + self._logger.exception("Problem opening channel") + self.channelOpenError.emit(_('Problem opening channel: ') + '\n' + repr(e)) + return + + self._logger.debug('opening channel succeeded') + self.channelOpenSuccess.emit(chan.channel_id.hex(), chan.has_onchain_backup()) + + # TODO: it would be nice to show this before broadcasting + #if chan.has_onchain_backup(): + #self.maybe_show_funding_tx(chan, funding_tx) + #else: + #title = _('Save backup') + #help_text = _(messages.MSG_CREATED_NON_RECOVERABLE_CHANNEL) + #data = lnworker.export_channel_backup(chan.channel_id) + #popup = QRDialog( + #title, data, + #show_text=False, + #text_for_clipboard=data, + #help_text=help_text, + #close_button_text=_('OK'), + #on_close=lambda: self.maybe_show_funding_tx(chan, funding_tx)) + #popup.open() diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py new file mode 100644 index 000000000..cf1f28de6 --- /dev/null +++ b/electrum/gui/qml/qeconfig.py @@ -0,0 +1,168 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from decimal import Decimal + +from electrum.logging import get_logger +from electrum.util import DECIMAL_POINT_DEFAULT, format_satoshis + +from .qetypes import QEAmount +from .auth import AuthMixin, auth_protect + +class QEConfig(AuthMixin, QObject): + def __init__(self, config, parent=None): + super().__init__(parent) + self.config = config + + _logger = get_logger(__name__) + + autoConnectChanged = pyqtSignal() + @pyqtProperty(bool, notify=autoConnectChanged) + def autoConnect(self): + return self.config.get('auto_connect') + + @autoConnect.setter + def autoConnect(self, auto_connect): + self.config.set_key('auto_connect', auto_connect, True) + self.autoConnectChanged.emit() + + # auto_connect is actually a tri-state, expose the undefined case + @pyqtProperty(bool, notify=autoConnectChanged) + def autoConnectDefined(self): + return self.config.get('auto_connect') is not None + + serverStringChanged = pyqtSignal() + @pyqtProperty('QString', notify=serverStringChanged) + def serverString(self): + return self.config.get('server') + + @serverString.setter + def serverString(self, server): + self.config.set_key('server', server, True) + self.serverStringChanged.emit() + + manualServerChanged = pyqtSignal() + @pyqtProperty(bool, notify=manualServerChanged) + def manualServer(self): + return self.config.get('oneserver') + + @manualServer.setter + def manualServer(self, oneserver): + self.config.set_key('oneserver', oneserver, True) + self.manualServerChanged.emit() + + baseUnitChanged = pyqtSignal() + @pyqtProperty(str, notify=baseUnitChanged) + def baseUnit(self): + return self.config.get_base_unit() + + @baseUnit.setter + def baseUnit(self, unit): + self.config.set_base_unit(unit) + self.baseUnitChanged.emit() + + thousandsSeparatorChanged = pyqtSignal() + @pyqtProperty(bool, notify=thousandsSeparatorChanged) + def thousandsSeparator(self): + return self.config.get('amt_add_thousands_sep', False) + + @thousandsSeparator.setter + def thousandsSeparator(self, checked): + self.config.set_key('amt_add_thousands_sep', checked) + self.config.amt_add_thousands_sep = checked + self.thousandsSeparatorChanged.emit() + + spendUnconfirmedChanged = pyqtSignal() + @pyqtProperty(bool, notify=spendUnconfirmedChanged) + def spendUnconfirmed(self): + return not self.config.get('confirmed_only', False) + + @spendUnconfirmed.setter + def spendUnconfirmed(self, checked): + self.config.set_key('confirmed_only', not checked, True) + self.spendUnconfirmedChanged.emit() + + pinCodeChanged = pyqtSignal() + @pyqtProperty(str, notify=pinCodeChanged) + def pinCode(self): + return self.config.get('pin_code', '') + + @pinCode.setter + def pinCode(self, pin_code): + if pin_code == '': + self.pinCodeRemoveAuth() + self.config.set_key('pin_code', pin_code, True) + self.pinCodeChanged.emit() + + @auth_protect(method='wallet') + def pinCodeRemoveAuth(self): + pass # no-op + + useGossipChanged = pyqtSignal() + @pyqtProperty(bool, notify=useGossipChanged) + def useGossip(self): + return self.config.get('use_gossip', False) + + @useGossip.setter + def useGossip(self, gossip): + self.config.set_key('use_gossip', gossip) + self.useGossipChanged.emit() + + @pyqtSlot('qint64', result=str) + @pyqtSlot('qint64', bool, result=str) + @pyqtSlot(QEAmount, result=str) + @pyqtSlot(QEAmount, bool, result=str) + def formatSats(self, satoshis, with_unit=False): + if isinstance(satoshis, QEAmount): + satoshis = satoshis.satsInt + if with_unit: + return self.config.format_amount_and_units(satoshis) + else: + return self.config.format_amount(satoshis) + + @pyqtSlot(QEAmount, result=str) + @pyqtSlot(QEAmount, bool, result=str) + def formatMilliSats(self, amount, with_unit=False): + if isinstance(amount, QEAmount): + msats = amount.msatsInt + else: + return '---' + + s = format_satoshis(msats/1000, + decimal_point=self.decimal_point(), + precision=3) + return s + #if with_unit: + #return self.config.format_amount_and_units(msats) + #else: + #return self.config.format_amount(satoshis) + + # TODO delegate all this to config.py/util.py + def decimal_point(self): + return self.config.get('decimal_point', DECIMAL_POINT_DEFAULT) + + def max_precision(self): + return self.decimal_point() + 0 #self.extra_precision + + @pyqtSlot(str, result=QEAmount) + def unitsToSats(self, unitAmount): + self._amount = QEAmount() + try: + x = Decimal(unitAmount) + except: + return self._amount + + # scale it to max allowed precision, make it an int + max_prec_amount = int(pow(10, self.max_precision()) * x) + # if the max precision is simply what unit conversion allows, just return + if self.max_precision() == self.decimal_point(): + self._amount = QEAmount(amount_sat=max_prec_amount) + return self._amount + self._logger.debug('fallthrough') + # otherwise, scale it back to the expected unit + #amount = Decimal(max_prec_amount) / Decimal(pow(10, self.max_precision()-self.decimal_point())) + #return int(amount) #Decimal(amount) if not self.is_int else int(amount) + return self._amount + + @pyqtSlot('quint64', result=float) + def satsToUnits(self, satoshis): + return satoshis / pow(10,self.config.decimal_point) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py new file mode 100644 index 000000000..c5d5cddc6 --- /dev/null +++ b/electrum/gui/qml/qedaemon.py @@ -0,0 +1,218 @@ +import os +from decimal import Decimal + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex + +from electrum.util import register_callback, get_new_wallet_name, WalletFileException +from electrum.logging import get_logger +from electrum.wallet import Wallet, Abstract_Wallet +from electrum.storage import WalletStorage, StorageReadWriteError +from electrum.wallet_db import WalletDB + +from .qewallet import QEWallet +from .qewalletdb import QEWalletDB +from .qefx import QEFX +from .auth import AuthMixin, auth_protect + +# wallet list model. supports both wallet basenames (wallet file basenames) +# and whole Wallet instances (loaded wallets) +class QEWalletListModel(QAbstractListModel): + _logger = get_logger(__name__) + def __init__(self, parent=None): + QAbstractListModel.__init__(self, parent) + self.wallets = [] + + # define listmodel rolemap + _ROLE_NAMES= ('name','path','active') + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) + _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + + def rowCount(self, index): + return len(self.wallets) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + (wallet_name, wallet_path, wallet) = self.wallets[index.row()] + role_index = role - Qt.UserRole + role_name = self._ROLE_NAMES[role_index] + if role_name == 'name': + return wallet_name + if role_name == 'path': + return wallet_path + if role_name == 'active': + return wallet != None + + def add_wallet(self, wallet_path = None, wallet: Abstract_Wallet = None): + if wallet_path == None and wallet == None: + return + # only add wallet instance if instance not yet in model + if wallet: + for name,path,w in self.wallets: + if w == wallet: + return + self.beginInsertRows(QModelIndex(), len(self.wallets), len(self.wallets)); + if wallet == None: + wallet_name = os.path.basename(wallet_path) + else: + wallet_name = wallet.basename() + item = (wallet_name, wallet_path, wallet) + self.wallets.append(item); + self.endInsertRows(); + +class QEAvailableWalletListModel(QEWalletListModel): + def __init__(self, daemon, parent=None): + QEWalletListModel.__init__(self, parent) + self.daemon = daemon + self.reload() + + @pyqtSlot() + def reload(self): + if len(self.wallets) > 0: + self.beginRemoveRows(QModelIndex(), 0, len(self.wallets) - 1) + self.wallets = [] + self.endRemoveRows() + + available = [] + wallet_folder = os.path.dirname(self.daemon.config.get_wallet_path()) + with os.scandir(wallet_folder) as it: + for i in it: + if i.is_file() and not i.name.startswith('.'): + available.append(i.path) + for path in sorted(available): + wallet = self.daemon.get_wallet(path) + self.add_wallet(wallet_path = path, wallet = wallet) + + def wallet_name_exists(self, name): + for wallet_name, wallet_path, wallet in self.wallets: + if name == wallet_name: + return True + return False + +class QEDaemon(AuthMixin, QObject): + def __init__(self, daemon, parent=None): + super().__init__(parent) + self.daemon = daemon + self.qefx = QEFX(daemon.fx, daemon.config) + self._walletdb = QEWalletDB() + self._walletdb.validPasswordChanged.connect(self.passwordValidityCheck) + + _logger = get_logger(__name__) + _loaded_wallets = QEWalletListModel() + _available_wallets = None + _current_wallet = None + _path = None + _use_single_password = False + _password = None + + walletLoaded = pyqtSignal() + walletRequiresPassword = pyqtSignal() + activeWalletsChanged = pyqtSignal() + availableWalletsChanged = pyqtSignal() + walletOpenError = pyqtSignal([str], arguments=["error"]) + fxChanged = pyqtSignal() + + @pyqtSlot() + def passwordValidityCheck(self): + if not self._walletdb._validPassword: + self.walletRequiresPassword.emit() + + @pyqtSlot() + @pyqtSlot(str) + @pyqtSlot(str, str) + def load_wallet(self, path=None, password=None): + if path == None: + self._path = self.daemon.config.get('gui_last_wallet') + else: + self._path = path + if self._path is None: + return + + self._logger.debug('load wallet ' + str(self._path)) + + if path not in self.daemon._wallets: + # pre-checks, let walletdb trigger any necessary user interactions + self._walletdb.path = self._path + self._walletdb.password = password + if not self._walletdb.ready: + return + + try: + wallet = self.daemon.load_wallet(self._path, password) + if wallet != None: + self._loaded_wallets.add_wallet(wallet=wallet) + self._current_wallet = QEWallet.getInstanceFor(wallet) + self.walletLoaded.emit() + + if self.daemon.config.get('single_password'): + self._use_single_password = self.daemon.update_password_for_directory(old_password=password, new_password=password) + self._password = password + self._logger.info(f'use single password: {self._use_single_password}') + + self.daemon.config.save_last_wallet(wallet) + else: + self._logger.info('could not open wallet') + self.walletOpenError.emit('could not open wallet') + except WalletFileException as e: + self._logger.error(str(e)) + self.walletOpenError.emit(str(e)) + + @pyqtSlot(QEWallet) + @auth_protect + def delete_wallet(self, wallet): + path = wallet.wallet.storage.path + self._logger.debug('Ok to delete wallet with path %s' % path) + # TODO checks, e.g. existing LN channels, unpaid requests, etc + self._logger.debug('Not deleting yet, just unloading for now') + # TODO actually delete + # TODO walletLoaded signal is confusing + self.daemon.stop_wallet(path) + self._current_wallet = None + self.walletLoaded.emit() + + @pyqtProperty('QString') + def path(self): + return self._path + + @pyqtProperty(QEWallet, notify=walletLoaded) + def currentWallet(self): + return self._current_wallet + + @pyqtProperty(QEWalletListModel, notify=activeWalletsChanged) + def activeWallets(self): + return self._loaded_wallets + + @pyqtProperty(QEAvailableWalletListModel, notify=availableWalletsChanged) + def availableWallets(self): + if not self._available_wallets: + self._available_wallets = QEAvailableWalletListModel(self.daemon) + + return self._available_wallets + + @pyqtProperty(QEFX, notify=fxChanged) + def fx(self): + return self.qefx + + @pyqtSlot(result=str) + def suggestWalletName(self): + i = 1 + while self.availableWallets.wallet_name_exists(f'wallet_{i}'): + i = i + 1 + return f'wallet_{i}' + + requestNewPassword = pyqtSignal() + @pyqtSlot() + @auth_protect + def start_change_password(self): + if self._use_single_password: + self.requestNewPassword.emit() + else: + self.currentWallet.requestNewPassword.emit() + + @pyqtSlot(str) + def set_password(self, password): + assert self._use_single_password + self._logger.debug('about to set password to %s for ALL wallets' % password) + update_password_for_directory(self.daemon.config, self._password, password) diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py new file mode 100644 index 000000000..580910025 --- /dev/null +++ b/electrum/gui/qml/qefx.py @@ -0,0 +1,154 @@ +from decimal import Decimal +from datetime import datetime + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.exchange_rate import FxThread +from electrum.simple_config import SimpleConfig +from electrum.bitcoin import COIN + +from .qetypes import QEAmount +from .util import QtEventListener, qt_event_listener + +class QEFX(QObject, QtEventListener): + def __init__(self, fxthread: FxThread, config: SimpleConfig, parent=None): + super().__init__(parent) + self.fx = fxthread + self.config = config + self.register_callbacks() + self.destroyed.connect(lambda: self.on_destroy()) + + _logger = get_logger(__name__) + + quotesUpdated = pyqtSignal() + + def on_destroy(self): + self.unregister_callbacks() + + @qt_event_listener + def on_event_on_quotes(self, *args): + self._logger.debug('new quotes') + self.quotesUpdated.emit() + + historyUpdated = pyqtSignal() + @qt_event_listener + def on_event_on_history(self, *args): + self._logger.debug('new history') + self.historyUpdated.emit() + + currenciesChanged = pyqtSignal() + @pyqtProperty('QVariantList', notify=currenciesChanged) + def currencies(self): + return self.fx.get_currencies(self.historicRates) + + rateSourcesChanged = pyqtSignal() + @pyqtProperty('QVariantList', notify=rateSourcesChanged) + def rateSources(self): + return self.fx.get_exchanges_by_ccy(self.fiatCurrency, self.historicRates) + + fiatCurrencyChanged = pyqtSignal() + @pyqtProperty(str, notify=fiatCurrencyChanged) + def fiatCurrency(self): + return self.fx.get_currency() + + @fiatCurrency.setter + def fiatCurrency(self, currency): + if currency != self.fiatCurrency: + self.fx.set_currency(currency) + self.enabled = self.enabled and currency != '' + self.fiatCurrencyChanged.emit() + self.rateSourcesChanged.emit() + + historicRatesChanged = pyqtSignal() + @pyqtProperty(bool, notify=historicRatesChanged) + def historicRates(self): + return self.fx.get_history_config() + + @historicRates.setter + def historicRates(self, checked): + if checked != self.historicRates: + self.fx.set_history_config(checked) + self.historicRatesChanged.emit() + self.rateSourcesChanged.emit() + + rateSourceChanged = pyqtSignal() + @pyqtProperty(str, notify=rateSourceChanged) + def rateSource(self): + return self.fx.config_exchange() + + @rateSource.setter + def rateSource(self, source): + if source != self.rateSource: + self.fx.set_exchange(source) + self.rateSourceChanged.emit() + + enabledUpdated = pyqtSignal() # curiously, enabledChanged is clashing, so name it enabledUpdated + @pyqtProperty(bool, notify=enabledUpdated) + def enabled(self): + return self.fx.is_enabled() + + @enabled.setter + def enabled(self, enable): + if enable != self.enabled: + self.fx.set_enabled(enable) + self.enabledUpdated.emit() + + @pyqtSlot(str, result=str) + @pyqtSlot(str, bool, result=str) + @pyqtSlot(QEAmount, result=str) + @pyqtSlot(QEAmount, bool, result=str) + def fiatValue(self, satoshis, plain=True): + rate = self.fx.exchange_rate() + if isinstance(satoshis, QEAmount): + satoshis = satoshis.satsInt + else: + try: + sd = Decimal(satoshis) + except: + return '' + if plain: + return self.fx.ccy_amount_str(self.fx.fiat_value(satoshis, rate), False) + else: + return self.fx.value_str(satoshis, rate) + + @pyqtSlot(str, str, result=str) + @pyqtSlot(str, str, bool, result=str) + @pyqtSlot(QEAmount, str, result=str) + @pyqtSlot(QEAmount, str, bool, result=str) + def fiatValueHistoric(self, satoshis, timestamp, plain=True): + if isinstance(satoshis, QEAmount): + satoshis = satoshis.satsInt + else: + try: + sd = Decimal(satoshis) + except: + return '' + + try: + td = Decimal(timestamp) + if td == 0: + return '' + except: + return '' + dt = datetime.fromtimestamp(int(td)) + if plain: + return self.fx.ccy_amount_str(self.fx.historical_value(satoshis, dt), False) + else: + return self.fx.historical_value_str(satoshis, dt) + + @pyqtSlot(str, result=str) + @pyqtSlot(str, bool, result=str) + def satoshiValue(self, fiat, plain=True): + rate = self.fx.exchange_rate() + try: + fd = Decimal(fiat) + except: + return '' + v = fd / Decimal(rate) * COIN + if v.is_nan(): + return '' + if plain: + return str(v.to_integral_value()) + else: + return self.config.format_amount(v) diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py new file mode 100644 index 000000000..a0c57bf3f --- /dev/null +++ b/electrum/gui/qml/qeinvoice.py @@ -0,0 +1,491 @@ +import asyncio +from datetime import datetime + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Q_ENUMS + +from electrum.logging import get_logger +from electrum.i18n import _ +from electrum.util import (parse_URI, create_bip21_uri, InvalidBitcoinURI, InvoiceError, + maybe_extract_lightning_payment_identifier) +from electrum.invoices import Invoice +from electrum.invoices import (PR_UNPAID,PR_EXPIRED,PR_UNKNOWN,PR_PAID,PR_INFLIGHT, + PR_FAILED,PR_ROUTING,PR_UNCONFIRMED,LN_EXPIRY_NEVER) +from electrum.transaction import PartialTxOutput +from electrum.lnaddr import lndecode +from electrum import bitcoin + +from .qewallet import QEWallet +from .qetypes import QEAmount + +class QEInvoice(QObject): + class Type: + Invalid = -1 + OnchainOnlyAddress = 0 + OnchainInvoice = 1 + LightningInvoice = 2 + LightningAndOnchainInvoice = 3 + + class Status: + Unpaid = PR_UNPAID + Expired = PR_EXPIRED + Unknown = PR_UNKNOWN + Paid = PR_PAID + Inflight = PR_INFLIGHT + Failed = PR_FAILED + Routing = PR_ROUTING + Unconfirmed = PR_UNCONFIRMED + + Q_ENUMS(Type) + Q_ENUMS(Status) + + _logger = get_logger(__name__) + + _wallet = None + _canSave = False + _canPay = False + _key = None + + def __init__(self, parent=None): + super().__init__(parent) + + walletChanged = pyqtSignal() + @pyqtProperty(QEWallet, notify=walletChanged) + def wallet(self): + return self._wallet + + @wallet.setter + def wallet(self, wallet: QEWallet): + if self._wallet != wallet: + self._wallet = wallet + self.walletChanged.emit() + + canSaveChanged = pyqtSignal() + @pyqtProperty(bool, notify=canSaveChanged) + def canSave(self): + return self._canSave + + @canSave.setter + def canSave(self, canSave): + if self._canSave != canSave: + self._canSave = canSave + self.canSaveChanged.emit() + + canPayChanged = pyqtSignal() + @pyqtProperty(bool, notify=canPayChanged) + def canPay(self): + return self._canPay + + @canPay.setter + def canPay(self, canPay): + if self._canPay != canPay: + self._canPay = canPay + self.canPayChanged.emit() + + keyChanged = pyqtSignal() + @pyqtProperty(str, notify=keyChanged) + def key(self): + return self._key + + @key.setter + def key(self, key): + if self._key != key: + self._key = key + self.keyChanged.emit() + + userinfoChanged = pyqtSignal() + @pyqtProperty(str, notify=userinfoChanged) + def userinfo(self): + return self._userinfo + + @userinfo.setter + def userinfo(self, userinfo): + if self._userinfo != userinfo: + self._userinfo = userinfo + self.userinfoChanged.emit() + + def get_max_spendable_onchain(self): + c, u, x = self._wallet.wallet.get_balance() + #TODO determine real max + return c + + +class QEInvoiceParser(QEInvoice): + + _logger = get_logger(__name__) + + _invoiceType = QEInvoice.Type.Invalid + _recipient = '' + _effectiveInvoice = None + _amount = QEAmount() + _userinfo = '' + + invoiceChanged = pyqtSignal() + invoiceSaved = pyqtSignal() + + validationSuccess = pyqtSignal() + validationWarning = pyqtSignal([str,str], arguments=['code', 'message']) + validationError = pyqtSignal([str,str], arguments=['code', 'message']) + + invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message']) + + def __init__(self, parent=None): + super().__init__(parent) + self.clear() + + @pyqtProperty(int, notify=invoiceChanged) + def invoiceType(self): + return self._invoiceType + + # not a qt setter, don't let outside set state + def setInvoiceType(self, invoiceType: QEInvoice.Type): + self._invoiceType = invoiceType + + recipientChanged = pyqtSignal() + @pyqtProperty(str, notify=recipientChanged) + def recipient(self): + return self._recipient + + @recipient.setter + def recipient(self, recipient: str): + #if self._recipient != recipient: + self._recipient = recipient + if recipient: + self.validateRecipient(recipient) + self.recipientChanged.emit() + + @pyqtProperty(str, notify=invoiceChanged) + def message(self): + return self._effectiveInvoice.message if self._effectiveInvoice else '' + + @pyqtProperty(QEAmount, notify=invoiceChanged) + def amount(self): + # store ref to QEAmount on instance, otherwise we get destroyed when going out of scope + self._amount = QEAmount() + if not self._effectiveInvoice: + return self._amount + self._amount = QEAmount(from_invoice=self._effectiveInvoice) + return self._amount + + @pyqtProperty('quint64', notify=invoiceChanged) + def expiration(self): + return self._effectiveInvoice.exp if self._effectiveInvoice else 0 + + @pyqtProperty('quint64', notify=invoiceChanged) + def time(self): + return self._effectiveInvoice.time if self._effectiveInvoice else 0 + + statusChanged = pyqtSignal() + @pyqtProperty(int, notify=statusChanged) + def status(self): + if not self._effectiveInvoice: + return PR_UNKNOWN + return self._wallet.wallet.get_invoice_status(self._effectiveInvoice) + + @pyqtProperty(str, notify=statusChanged) + def status_str(self): + if not self._effectiveInvoice: + return '' + status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice) + return self._effectiveInvoice.get_status_str(status) + + # single address only, TODO: n outputs + @pyqtProperty(str, notify=invoiceChanged) + def address(self): + return self._effectiveInvoice.get_address() if self._effectiveInvoice else '' + + @pyqtProperty('QVariantMap', notify=invoiceChanged) + def lnprops(self): + if not self.invoiceType == QEInvoice.Type.LightningInvoice: + return {} + lnaddr = self._effectiveInvoice._lnaddr + self._logger.debug(str(lnaddr)) + self._logger.debug(str(lnaddr.get_routing_info('t'))) + return { + 'pubkey': lnaddr.pubkey.serialize().hex(), + 't': '', #lnaddr.get_routing_info('t')[0][0].hex(), + 'r': '' #lnaddr.get_routing_info('r')[0][0][0].hex() + } + + @pyqtSlot() + def clear(self): + self.recipient = '' + self.setInvoiceType(QEInvoice.Type.Invalid) + self._bip21 = None + self.canSave = False + self.canPay = False + self.userinfo = '' + self.invoiceChanged.emit() + + # don't parse the recipient string, but init qeinvoice from an invoice key + # this should not emit validation signals + @pyqtSlot(str) + def initFromKey(self, key): + self.clear() + invoice = self._wallet.wallet.get_invoice(key) + self._logger.debug(repr(invoice)) + if invoice: + self.set_effective_invoice(invoice) + self.key = key + + def set_effective_invoice(self, invoice: Invoice): + self._effectiveInvoice = invoice + + if invoice.is_lightning(): + self.setInvoiceType(QEInvoice.Type.LightningInvoice) + else: + self.setInvoiceType(QEInvoice.Type.OnchainInvoice) + + self.canSave = True + + self.determine_can_pay() + + self.invoiceChanged.emit() + self.statusChanged.emit() + + def determine_can_pay(self): + if self.invoiceType == QEInvoice.Type.LightningInvoice: + if self.status in [PR_UNPAID, PR_FAILED]: + if self.get_max_spendable_lightning() >= self.amount.satsInt: + self.canPay = True + else: + self.userinfo = _('Can\'t pay, insufficient balance') + else: + self.userinfo = { + PR_EXPIRED: _('Can\'t pay, invoice is expired'), + PR_PAID: _('Can\'t pay, invoice is already paid'), + PR_INFLIGHT: _('Can\'t pay, invoice is already being paid'), + PR_ROUTING: _('Can\'t pay, invoice is already being paid'), + PR_UNKNOWN: _('Can\'t pay, invoice has unknown status'), + }[self.status] + elif self.invoiceType == QEInvoice.Type.OnchainInvoice: + if self.status in [PR_UNPAID, PR_FAILED]: + if self.get_max_spendable_onchain() >= self.amount.satsInt: + self.canPay = True + else: + self.userinfo = _('Can\'t pay, insufficient balance') + else: + self.userinfo = { + PR_EXPIRED: _('Can\'t pay, invoice is expired'), + PR_PAID: _('Can\'t pay, invoice is already paid'), + PR_UNCONFIRMED: _('Can\'t pay, invoice is already paid'), + PR_UNKNOWN: _('Can\'t pay, invoice has unknown status'), + }[self.status] + + + def get_max_spendable_lightning(self): + return self._wallet.wallet.lnworker.num_sats_can_send() + + def setValidAddressOnly(self): + self._logger.debug('setValidAddressOnly') + self.setInvoiceType(QEInvoice.Type.OnchainOnlyAddress) + self._effectiveInvoice = None + self.invoiceChanged.emit() + + def setValidOnchainInvoice(self, invoice: Invoice): + self._logger.debug('setValidOnchainInvoice') + if invoice.is_lightning(): + raise Exception('unexpected LN invoice') + self.set_effective_invoice(invoice) + + def setValidLightningInvoice(self, invoice: Invoice): + self._logger.debug('setValidLightningInvoice') + if not invoice.is_lightning(): + raise Exception('unexpected Onchain invoice') + self.set_effective_invoice(invoice) + + def create_onchain_invoice(self, outputs, message, payment_request, uri): + return self._wallet.wallet.create_invoice( + outputs=outputs, + message=message, + pr=payment_request, + URI=uri + ) + + def validateRecipient(self, recipient): + if not recipient: + self.setInvoiceType(QEInvoice.Type.Invalid) + return + + maybe_lightning_invoice = recipient + + def _payment_request_resolved(request): + self._logger.debug('resolved payment request') + outputs = request.get_outputs() + invoice = self.create_onchain_invoice(outputs, None, request, None) + self.setValidOnchainInvoice(invoice) + + try: + self._bip21 = parse_URI(recipient, _payment_request_resolved) + if self._bip21: + if 'r' in self._bip21 or ('name' in self._bip21 and 'sig' in self._bip21): # TODO set flag in util? + # let callback handle state + return + if ':' not in recipient: + # address only + self.setValidAddressOnly() + self.validationSuccess.emit() + return + else: + # fallback lightning invoice? + if 'lightning' in self._bip21: + maybe_lightning_invoice = self._bip21['lightning'] + except InvalidBitcoinURI as e: + self._bip21 = None + self._logger.debug(repr(e)) + + lninvoice = None + try: + maybe_lightning_invoice = maybe_extract_lightning_payment_identifier(maybe_lightning_invoice) + lninvoice = Invoice.from_bech32(maybe_lightning_invoice) + except InvoiceError as e: + pass + + if not lninvoice and not self._bip21: + self.validationError.emit('unknown',_('Unknown invoice')) + self.clear() + return + + if lninvoice: + if not self._wallet.wallet.has_lightning(): + if not self._bip21: + # TODO: lightning onchain fallback in ln invoice + #self.validationError.emit('no_lightning',_('Detected valid Lightning invoice, but Lightning not enabled for wallet')) + self.setValidLightningInvoice(lninvoice) + self.clear() + return + else: + self._logger.debug('flow with LN but not LN enabled AND having bip21 uri') + self.setValidOnchainInvoice(self._bip21['address']) + else: + self.setValidLightningInvoice(lninvoice) + if not self._wallet.wallet.lnworker.channels: + self.validationWarning.emit('no_channels',_('Detected valid Lightning invoice, but there are no open channels')) + else: + self.validationSuccess.emit() + else: + self._logger.debug('flow without LN but having bip21 uri') + if 'amount' not in self._bip21: #TODO can we have amount-less invoices? + self.validationError.emit('no_amount', 'no amount in uri') + return + outputs = [PartialTxOutput.from_address_and_value(self._bip21['address'], self._bip21['amount'])] + self._logger.debug(outputs) + message = self._bip21['message'] if 'message' in self._bip21 else '' + invoice = self.create_onchain_invoice(outputs, message, None, self._bip21) + self._logger.debug(repr(invoice)) + self.setValidOnchainInvoice(invoice) + self.validationSuccess.emit() + + @pyqtSlot() + def save_invoice(self): + self.canSave = False + if not self._effectiveInvoice: + return + # TODO detect duplicate? + self.key = self._wallet.wallet.get_key_for_outgoing_invoice(self._effectiveInvoice) + self._wallet.wallet.save_invoice(self._effectiveInvoice) + self.invoiceSaved.emit() + + +class QEUserEnteredPayment(QEInvoice): + _logger = get_logger(__name__) + + _recipient = None + _message = None + _amount = QEAmount() + + validationError = pyqtSignal([str,str], arguments=['code','message']) + invoiceCreateError = pyqtSignal([str,str], arguments=['code', 'message']) + invoiceSaved = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.clear() + + recipientChanged = pyqtSignal() + @pyqtProperty(str, notify=recipientChanged) + def recipient(self): + return self._recipient + + @recipient.setter + def recipient(self, recipient: str): + if self._recipient != recipient: + self._recipient = recipient + self.validate() + self.recipientChanged.emit() + + messageChanged = pyqtSignal() + @pyqtProperty(str, notify=messageChanged) + def message(self): + return self._message + + @message.setter + def message(self, message): + if self._message != message: + self._message = message + self.messageChanged.emit() + + amountChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=amountChanged) + def amount(self): + return self._amount + + @amount.setter + def amount(self, amount): + if self._amount != amount: + self._amount = amount + self.validate() + self.amountChanged.emit() + + + def validate(self): + self.canPay = False + self.canSave = False + self._logger.debug('validate') + + if not self._recipient: + self.validationError.emit('recipient', _('Recipient not specified.')) + return + + if not bitcoin.is_address(self._recipient): + self.validationError.emit('recipient', _('Invalid Bitcoin address')) + return + + if self._amount.isEmpty: + self.validationError.emit('amount', _('Invalid amount')) + return + + if self._amount.isMax: + self.canPay = True + else: + self.canSave = True + if self.get_max_spendable_onchain() >= self._amount.satsInt: + self.canPay = True + + @pyqtSlot() + def save_invoice(self): + assert self.canSave + assert not self._amount.isMax + + self._logger.debug('saving invoice to %s, amount=%s, message=%s' % (self._recipient, repr(self._amount), self._message)) + + inv_amt = self._amount.satsInt + + try: + outputs = [PartialTxOutput.from_address_and_value(self._recipient, inv_amt)] + self._logger.debug(repr(outputs)) + invoice = self._wallet.wallet.create_invoice(outputs=outputs, message=self._message, pr=None, URI=None) + except InvoiceError as e: + self.invoiceCreateError.emit('fatal', _('Error creating payment') + ':\n' + str(e)) + return + + self.key = self._wallet.wallet.get_key_for_outgoing_invoice(invoice) + self._wallet.wallet.save_invoice(invoice) + self.invoiceSaved.emit() + + @pyqtSlot() + def clear(self): + self._recipient = None + self._amount = QEAmount() + self._message = None + self.canSave = False + self.canPay = False diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py new file mode 100644 index 000000000..0951c1291 --- /dev/null +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -0,0 +1,173 @@ +from abc import abstractmethod + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex + +from electrum.logging import get_logger +from electrum.util import Satoshis, format_time +from electrum.invoices import Invoice + +from .qetypes import QEAmount + +class QEAbstractInvoiceListModel(QAbstractListModel): + _logger = get_logger(__name__) + + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + self.init_model() + + # define listmodel rolemap + _ROLE_NAMES=('key', 'is_lightning', 'timestamp', 'date', 'message', 'amount', + 'status', 'status_str', 'address', 'expiration', 'type', 'onchain_fallback', + 'lightning_invoice') + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) + _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) + + def rowCount(self, index): + return len(self.invoices) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + invoice = self.invoices[index.row()] + role_index = role - Qt.UserRole + value = invoice[self._ROLE_NAMES[role_index]] + + if isinstance(value, (bool, list, int, str, QEAmount)) or value is None: + return value + if isinstance(value, Satoshis): + return value.value + return str(value) + + def clear(self): + self.beginResetModel() + self.invoices = [] + self.endResetModel() + + @pyqtSlot() + def init_model(self): + invoices = [] + for invoice in self.get_invoice_list(): + item = self.invoice_to_model(invoice) + #self._logger.debug(str(item)) + invoices.append(item) + + self.clear() + self.beginInsertRows(QModelIndex(), 0, len(invoices) - 1) + self.invoices = invoices + self.endInsertRows() + + def add_invoice(self, invoice: Invoice): + item = self.invoice_to_model(invoice) + self._logger.debug(str(item)) + + self.beginInsertRows(QModelIndex(), 0, 0) + self.invoices.insert(0, item) + self.endInsertRows() + + def delete_invoice(self, key: str): + i = 0 + for invoice in self.invoices: + if invoice['key'] == key: + self.beginRemoveRows(QModelIndex(), i, i) + self.invoices.pop(i) + self.endRemoveRows() + break + i = i + 1 + + def get_model_invoice(self, key: str): + for invoice in self.invoices: + if invoice['key'] == key: + return invoice + return None + + @pyqtSlot(str, int) + def updateInvoice(self, key, status): + self._logger.debug('updating invoice for %s to %d' % (key,status)) + i = 0 + for item in self.invoices: + if item['key'] == key: + invoice = self.get_invoice_for_key(key) + item['status'] = status + item['status_str'] = invoice.get_status_str(status) + index = self.index(i,0) + self.dataChanged.emit(index, index, [self._ROLE_RMAP['status'], self._ROLE_RMAP['status_str']]) + return + i = i + 1 + + def invoice_to_model(self, invoice: Invoice): + item = self.get_invoice_as_dict(invoice) + #item['key'] = invoice.get_id() + item['is_lightning'] = invoice.is_lightning() + if invoice.is_lightning() and 'address' not in item: + item['address'] = '' + item['date'] = format_time(item['timestamp']) + item['amount'] = QEAmount(from_invoice=invoice) + item['onchain_fallback'] = invoice.is_lightning() and invoice._lnaddr.get_fallback_address() + item['type'] = 'invoice' + + return item + + @abstractmethod + def get_invoice_for_key(self, key: str): + raise Exception('provide impl') + + @abstractmethod + def get_invoice_list(self): + raise Exception('provide impl') + + @abstractmethod + def get_invoice_as_dict(self, invoice: Invoice): + raise Exception('provide impl') + + +class QEInvoiceListModel(QEAbstractInvoiceListModel): + def __init__(self, wallet, parent=None): + super().__init__(wallet, parent) + + _logger = get_logger(__name__) + + def invoice_to_model(self, invoice: Invoice): + item = super().invoice_to_model(invoice) + item['type'] = 'invoice' + item['key'] = invoice.get_id() + + return item + + def get_invoice_list(self): + return self.wallet.get_unpaid_invoices() + + def get_invoice_for_key(self, key: str): + return self.wallet.get_invoice(key) + + def get_invoice_as_dict(self, invoice: Invoice): + return self.wallet.export_invoice(invoice) + +class QERequestListModel(QEAbstractInvoiceListModel): + def __init__(self, wallet, parent=None): + super().__init__(wallet, parent) + + _logger = get_logger(__name__) + + def invoice_to_model(self, invoice: Invoice): + item = super().invoice_to_model(invoice) + item['type'] = 'request' + item['key'] = invoice.get_id() if invoice.is_lightning() else invoice.get_address() + + return item + + def get_invoice_list(self): + return self.wallet.get_unpaid_requests() + + def get_invoice_for_key(self, key: str): + return self.wallet.get_request(key) + + def get_invoice_as_dict(self, invoice: Invoice): + return self.wallet.export_request(invoice) + + @pyqtSlot(str, int) + def updateRequest(self, key, status): + self.updateInvoice(key, status) diff --git a/electrum/gui/qml/qelnpaymentdetails.py b/electrum/gui/qml/qelnpaymentdetails.py new file mode 100644 index 000000000..81c391502 --- /dev/null +++ b/electrum/gui/qml/qelnpaymentdetails.py @@ -0,0 +1,114 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.util import format_time, bfh, format_time + +from .qewallet import QEWallet +from .qetypes import QEAmount + +class QELnPaymentDetails(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + _logger = get_logger(__name__) + + _wallet = None + _key = None + _date = None + + detailsChanged = pyqtSignal() + + walletChanged = pyqtSignal() + @pyqtProperty(QEWallet, notify=walletChanged) + def wallet(self): + return self._wallet + + @wallet.setter + def wallet(self, wallet: QEWallet): + if self._wallet != wallet: + self._wallet = wallet + self.walletChanged.emit() + + keyChanged = pyqtSignal() + @pyqtProperty(str, notify=keyChanged) + def key(self): + return self._key + + @key.setter + def key(self, key: str): + if self._key != key: + self._logger.debug('key set -> %s' % key) + self._key = key + self.keyChanged.emit() + self.update() + + labelChanged = pyqtSignal() + @pyqtProperty(str, notify=labelChanged) + def label(self): + return self._label + + @pyqtSlot(str) + def set_label(self, label: str): + if label != self._label: + self._wallet.wallet.set_label(self._key, label) + self._label = label + self.labelChanged.emit() + + @pyqtProperty(str, notify=detailsChanged) + def status(self): + return self._status + + @pyqtProperty(str, notify=detailsChanged) + def date(self): + return self._date + + @pyqtProperty(str, notify=detailsChanged) + def payment_hash(self): + return self._phash + + @pyqtProperty(str, notify=detailsChanged) + def preimage(self): + return self._preimage + + @pyqtProperty(str, notify=detailsChanged) + def invoice(self): + return self._invoice + + @pyqtProperty(QEAmount, notify=detailsChanged) + def amount(self): + return self._amount + + @pyqtProperty(QEAmount, notify=detailsChanged) + def fee(self): + return self._fee + + def update(self): + if self._wallet is None: + self._logger.error('wallet undefined') + return + + if self._key not in self._wallet.wallet.lnworker.payment_info: + self._logger.error('payment_hash not found') + return + + # TODO this is horribly inefficient. need a payment getter/query method + tx = self._wallet.wallet.lnworker.get_lightning_history()[bfh(self._key)] + self._logger.debug(str(tx)) + + self._fee = QEAmount() if not tx['fee_msat'] else QEAmount(amount_msat=tx['fee_msat']) + self._amount = QEAmount(amount_msat=tx['amount_msat']) + self._label = tx['label'] + self._date = format_time(tx['timestamp']) + self._status = 'settled' # TODO: other states? get_lightning_history is deciding the filter for us :( + self._phash = tx['payment_hash'] + self._preimage = tx['preimage'] + + invoice = (self._wallet.wallet.get_invoice(self._key) + or self._wallet.wallet.get_request(self._key)) + self._logger.debug(str(invoice)) + if invoice: + self._invoice = invoice.lightning_invoice or '' + else: + self._invoice = '' + + self.detailsChanged.emit() diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py new file mode 100644 index 000000000..190efc6a5 --- /dev/null +++ b/electrum/gui/qml/qenetwork.py @@ -0,0 +1,113 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum import constants +from electrum.interface import ServerAddr + +from .util import QtEventListener, qt_event_listener + +class QENetwork(QObject, QtEventListener): + def __init__(self, network, parent=None): + super().__init__(parent) + self.network = network + self.register_callbacks() + + _logger = get_logger(__name__) + + networkUpdated = pyqtSignal() + blockchainUpdated = pyqtSignal() + heightChanged = pyqtSignal([int], arguments=['height']) + defaultServerChanged = pyqtSignal() + proxySet = pyqtSignal() + proxyChanged = pyqtSignal() + statusChanged = pyqtSignal() + feeHistogramUpdated = pyqtSignal() + + # shared signal for static properties + dataChanged = pyqtSignal() + + _height = 0 + _status = "" + + @qt_event_listener + def on_event_network_updated(self, *args): + self.networkUpdated.emit() + + @qt_event_listener + def on_event_blockchain_updated(self, *args): + if self._height != self.network.get_local_height(): + self._height = self.network.get_local_height() + self._logger.debug('new height: %d' % self._height) + self.heightChanged.emit(self._height) + self.blockchainUpdated.emit() + + @qt_event_listener + def on_event_default_server_changed(self, *args): + self.defaultServerChanged.emit() + + @qt_event_listener + def on_event_proxy_set(self, *args): + self._logger.debug('proxy set') + self.proxySet.emit() + + @qt_event_listener + def on_event_status(self, *args): + self._logger.debug('status updated: %s' % self.network.connection_status) + if self._status != self.network.connection_status: + self._status = self.network.connection_status + self.statusChanged.emit() + + @qt_event_listener + def on_event_fee_histogram(self, *args): + self._logger.debug('fee histogram updated') + self.feeHistogramUpdated.emit() + + @pyqtProperty(int, notify=heightChanged) + def height(self): + return self._height + + @pyqtProperty('QString', notify=defaultServerChanged) + def server(self): + return str(self.network.get_parameters().server) + + @server.setter + def server(self, server): + net_params = self.network.get_parameters() + try: + server = ServerAddr.from_str_with_inference(server) + if not server: raise Exception("failed to parse") + except Exception: + return + net_params = net_params._replace(server=server) + self.network.run_from_another_thread(self.network.set_parameters(net_params)) + + @pyqtProperty('QString', notify=statusChanged) + def status(self): + return self._status + + @pyqtProperty(bool, notify=dataChanged) + def isTestNet(self): + return constants.net.TESTNET + + @pyqtProperty('QString', notify=dataChanged) + def networkName(self): + return constants.net.__name__.replace('Bitcoin','') + + @pyqtProperty('QVariantMap', notify=proxyChanged) + def proxy(self): + net_params = self.network.get_parameters() + return net_params + + @proxy.setter + def proxy(self, proxy_settings): + net_params = self.network.get_parameters() + if not proxy_settings['enabled']: + proxy_settings = None + net_params = net_params._replace(proxy=proxy_settings) + self.network.run_from_another_thread(self.network.set_parameters(net_params)) + self.proxyChanged.emit() + + @pyqtProperty('QVariant', notify=feeHistogramUpdated) + def feeHistogram(self): + return self.network.get_status_value('fee_histogram') + diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py new file mode 100644 index 000000000..0d1626850 --- /dev/null +++ b/electrum/gui/qml/qeqr.py @@ -0,0 +1,142 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QRect, QPoint +from PyQt5.QtGui import QImage,QColor +from PyQt5.QtQuick import QQuickImageProvider + +import asyncio +import qrcode +import math + +from PIL import Image, ImageQt + +from electrum.logging import get_logger +from electrum.qrreader import get_qr_reader +from electrum.i18n import _ +from electrum.util import profiler, get_asyncio_loop + +class QEQRParser(QObject): + def __init__(self, text=None, parent=None): + super().__init__(parent) + self._text = text + self.qrreader = get_qr_reader() + if not self.qrreader: + raise Exception(_("The platform QR detection library is not available.")) + + _logger = get_logger(__name__) + + busyChanged = pyqtSignal() + dataChanged = pyqtSignal() + imageChanged = pyqtSignal() + + _busy = False + _image = None + + @pyqtSlot('QImage') + def scanImage(self, image=None): + if self._busy: + self._logger.warning("Already processing an image. Check 'busy' property before calling scanImage") + return + + if image == None: + self._logger.warning("No image to decode") + return + + self._busy = True + self.busyChanged.emit() + + self.logImageStats(image) + self._parseQR(image) + + def logImageStats(self, image): + self._logger.info('width: ' + str(image.width())) + self._logger.info('height: ' + str(image.height())) + self._logger.info('depth: ' + str(image.depth())) + self._logger.info('format: ' + str(image.format())) + + def _parseQR(self, image): + self.w = image.width() + self.h = image.height() + img_crop_rect = self._get_crop(image, 360) + frame_cropped = image.copy(img_crop_rect) + + async def co_parse_qr(image): + # Convert to Y800 / GREY FourCC (single 8-bit channel) + # This creates a copy, so we don't need to keep the frame around anymore + frame_y800 = image.convertToFormat(QImage.Format_Grayscale8) + + self.frame_id = 0 + # Read the QR codes from the frame + self.qrreader_res = self.qrreader.read_qr_code( + frame_y800.constBits().__int__(), + frame_y800.byteCount(), + frame_y800.bytesPerLine(), + frame_y800.width(), + frame_y800.height(), + self.frame_id + ) + + if len(self.qrreader_res) > 0: + result = self.qrreader_res[0] + self._data = result + self.dataChanged.emit() + + self._busy = False + self.busyChanged.emit() + + asyncio.run_coroutine_threadsafe(co_parse_qr(frame_cropped), get_asyncio_loop()) + + def _get_crop(self, image: QImage, scan_size: int) -> QRect: + """ + Returns a QRect that is scan_size x scan_size in the middle of the resolution + """ + self.scan_pos_x = (image.width() - scan_size) // 2 + self.scan_pos_y = (image.height() - scan_size) // 2 + return QRect(self.scan_pos_x, self.scan_pos_y, scan_size, scan_size) + + @pyqtProperty(bool, notify=busyChanged) + def busy(self): + return self._busy + + @pyqtProperty('QImage', notify=imageChanged) + def image(self): + return self._image + + @pyqtProperty(str, notify=dataChanged) + def data(self): + return self._data.data + + @pyqtProperty('QPoint', notify=dataChanged) + def center(self): + (x,y) = self._data.center + return QPoint(x+self.scan_pos_x, y+self.scan_pos_y) + + @pyqtProperty('QVariant', notify=dataChanged) + def points(self): + result = [] + for item in self._data.points: + (x,y) = item + result.append(QPoint(x+self.scan_pos_x, y+self.scan_pos_y)) + return result + +class QEQRImageProvider(QQuickImageProvider): + def __init__(self, max_size, parent=None): + super().__init__(QQuickImageProvider.Image) + self._max_size = max_size + + _logger = get_logger(__name__) + + @profiler + def requestImage(self, qstr, size): + self._logger.debug('QR requested for %s' % qstr) + qr = qrcode.QRCode(version=1, border=2) + qr.add_data(qstr) + + # calculate best box_size + pixelsize = min(self._max_size, 400) + modules = 17 + 4 * qr.best_fit() + qr.box_size = math.floor(pixelsize/(modules+2*2)) + + qr.make(fit=True) + + pimg = qr.make_image(fill_color='black', back_color='white') + self.qimg = ImageQt.ImageQt(pimg) + return self.qimg, self.qimg.size() diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py new file mode 100644 index 000000000..e7cc22ae3 --- /dev/null +++ b/electrum/gui/qml/qeswaphelper.py @@ -0,0 +1,324 @@ +import asyncio +from typing import TYPE_CHECKING, Optional, Union + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.i18n import _ +from electrum.logging import get_logger +from electrum.lnutil import ln_dummy_address +from electrum.transaction import PartialTxOutput +from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, profiler + +from .qewallet import QEWallet +from .qetypes import QEAmount +from .auth import AuthMixin, auth_protect + +class QESwapHelper(AuthMixin, QObject): + _logger = get_logger(__name__) + + _wallet = None + _sliderPos = 0 + _rangeMin = 0 + _rangeMax = 0 + _tx = None + _valid = False + _userinfo = '' + _tosend = QEAmount() + _toreceive = QEAmount() + _serverfeeperc = '' + _serverfee = QEAmount() + _miningfee = QEAmount() + _isReverse = False + + _send_amount = 0 + _receive_amount = 0 + + error = pyqtSignal([str], arguments=['message']) + confirm = pyqtSignal([str], arguments=['message']) + + def __init__(self, parent=None): + super().__init__(parent) + + walletChanged = pyqtSignal() + @pyqtProperty(QEWallet, notify=walletChanged) + def wallet(self): + return self._wallet + + @wallet.setter + def wallet(self, wallet: QEWallet): + if self._wallet != wallet: + self._wallet = wallet + self.init_swap_slider_range() + self.walletChanged.emit() + + sliderPosChanged = pyqtSignal() + @pyqtProperty(float, notify=sliderPosChanged) + def sliderPos(self): + return self._sliderPos + + @sliderPos.setter + def sliderPos(self, sliderPos): + if self._sliderPos != sliderPos: + self._sliderPos = sliderPos + self.swap_slider_moved() + self.sliderPosChanged.emit() + + rangeMinChanged = pyqtSignal() + @pyqtProperty(float, notify=rangeMinChanged) + def rangeMin(self): + return self._rangeMin + + @rangeMin.setter + def rangeMin(self, rangeMin): + if self._rangeMin != rangeMin: + self._rangeMin = rangeMin + self.rangeMinChanged.emit() + + rangeMaxChanged = pyqtSignal() + @pyqtProperty(float, notify=rangeMaxChanged) + def rangeMax(self): + return self._rangeMax + + @rangeMax.setter + def rangeMax(self, rangeMax): + if self._rangeMax != rangeMax: + self._rangeMax = rangeMax + self.rangeMaxChanged.emit() + + validChanged = pyqtSignal() + @pyqtProperty(bool, notify=validChanged) + def valid(self): + return self._valid + + @valid.setter + def valid(self, valid): + if self._valid != valid: + self._valid = valid + self.validChanged.emit() + + userinfoChanged = pyqtSignal() + @pyqtProperty(str, notify=userinfoChanged) + def userinfo(self): + return self._userinfo + + @userinfo.setter + def userinfo(self, userinfo): + if self._userinfo != userinfo: + self._userinfo = userinfo + self.userinfoChanged.emit() + + tosendChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=tosendChanged) + def tosend(self): + return self._tosend + + @tosend.setter + def tosend(self, tosend): + if self._tosend != tosend: + self._tosend = tosend + self.tosendChanged.emit() + + toreceiveChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=toreceiveChanged) + def toreceive(self): + return self._toreceive + + @toreceive.setter + def toreceive(self, toreceive): + if self._toreceive != toreceive: + self._toreceive = toreceive + self.toreceiveChanged.emit() + + serverfeeChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=serverfeeChanged) + def serverfee(self): + return self._serverfee + + @serverfee.setter + def serverfee(self, serverfee): + if self._serverfee != serverfee: + self._serverfee = serverfee + self.serverfeeChanged.emit() + + serverfeepercChanged = pyqtSignal() + @pyqtProperty(str, notify=serverfeepercChanged) + def serverfeeperc(self): + return self._serverfeeperc + + @serverfeeperc.setter + def serverfeeperc(self, serverfeeperc): + if self._serverfeeperc != serverfeeperc: + self._serverfeeperc = serverfeeperc + self.serverfeepercChanged.emit() + + miningfeeChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=miningfeeChanged) + def miningfee(self): + return self._miningfee + + @miningfee.setter + def miningfee(self, miningfee): + if self._miningfee != miningfee: + self._miningfee = miningfee + self.miningfeeChanged.emit() + + isReverseChanged = pyqtSignal() + @pyqtProperty(bool, notify=isReverseChanged) + def isReverse(self): + return self._isReverse + + @isReverse.setter + def isReverse(self, isReverse): + if self._isReverse != isReverse: + self._isReverse = isReverse + self.isReverseChanged.emit() + + + def init_swap_slider_range(self): + lnworker = self._wallet.wallet.lnworker + swap_manager = lnworker.swap_manager + asyncio.run(swap_manager.get_pairs()) + """Sets the minimal and maximal amount that can be swapped for the swap + slider.""" + # tx is updated again afterwards with send_amount in case of normal swap + # this is just to estimate the maximal spendable onchain amount for HTLC + self.update_tx('!') + try: + max_onchain_spend = self._tx.output_value_for_address(ln_dummy_address()) + except AttributeError: # happens if there are no utxos + max_onchain_spend = 0 + reverse = int(min(lnworker.num_sats_can_send(), + swap_manager.get_max_amount())) + max_recv_amt_ln = int(swap_manager.num_sats_can_receive()) + max_recv_amt_oc = swap_manager.get_send_amount(max_recv_amt_ln, is_reverse=False) or float('inf') + forward = int(min(max_recv_amt_oc, + # maximally supported swap amount by provider + swap_manager.get_max_amount(), + max_onchain_spend)) + # we expect range to adjust the value of the swap slider to be in the + # correct range, i.e., to correct an overflow when reducing the limits + self._logger.debug(f'Slider range {-reverse} - {forward}') + self.rangeMin = -reverse + self.rangeMax = forward + + self.swap_slider_moved() + + @profiler + def update_tx(self, onchain_amount: Union[int, str]): + """Updates the transaction associated with a forward swap.""" + if onchain_amount is None: + self._tx = None + self.valid = False + return + outputs = [PartialTxOutput.from_address_and_value(ln_dummy_address(), onchain_amount)] + coins = self._wallet.wallet.get_spendable_coins(None) + try: + self._tx = self._wallet.wallet.make_unsigned_transaction( + coins=coins, + outputs=outputs) + except (NotEnoughFunds, NoDynamicFeeEstimates): + self._tx = None + self.valid = False + + def swap_slider_moved(self): + position = int(self._sliderPos) + + swap_manager = self._wallet.wallet.lnworker.swap_manager + + # pay_amount and receive_amounts are always with fees already included + # so they reflect the net balance change after the swap + if position < 0: # reverse swap + self.userinfo = _('Adds Lightning receiving capacity.') + self.isReverse = True + + pay_amount = abs(position) + self._send_amount = pay_amount + self.tosend = QEAmount(amount_sat=pay_amount) + + receive_amount = swap_manager.get_recv_amount( + send_amount=pay_amount, is_reverse=True) + self._receive_amount = receive_amount + self.toreceive = QEAmount(amount_sat=receive_amount) + + # fee breakdown + self.serverfeeperc = f'{swap_manager.percentage:0.1f}%' + self.serverfee = QEAmount(amount_sat=swap_manager.lockup_fee) + self.miningfee = QEAmount(amount_sat=swap_manager.get_claim_fee()) + + else: # forward (normal) swap + self.userinfo = _('Adds Lightning sending capacity.') + self.isReverse = False + self._send_amount = position + + self.update_tx(self._send_amount) + # add lockup fees, but the swap amount is position + pay_amount = position + self._tx.get_fee() if self._tx else 0 + self.tosend = QEAmount(amount_sat=pay_amount) + + receive_amount = swap_manager.get_recv_amount(send_amount=position, is_reverse=False) + self._receive_amount = receive_amount + self.toreceive = QEAmount(amount_sat=receive_amount) + + # fee breakdown + self.serverfeeperc = f'{swap_manager.percentage:0.1f}%' + self.serverfee = QEAmount(amount_sat=swap_manager.normal_fee) + self.miningfee = QEAmount(amount_sat=self._tx.get_fee()) + + if pay_amount and receive_amount: + self.valid = True + else: + # add more nuanced error reporting? + self.userinfo = _('Swap below minimal swap size, change the slider.') + self.valid = False + + def do_normal_swap(self, lightning_amount, onchain_amount, password): + assert self._tx + if lightning_amount is None or onchain_amount is None: + return + loop = self._wallet.wallet.network.asyncio_loop + coro = self._wallet.wallet.lnworker.swap_manager.normal_swap( + lightning_amount_sat=lightning_amount, + expected_onchain_amount_sat=onchain_amount, + password=password, + tx=self._tx, + ) + asyncio.run_coroutine_threadsafe(coro, loop) + + def do_reverse_swap(self, lightning_amount, onchain_amount, password): + if lightning_amount is None or onchain_amount is None: + return + swap_manager = self._wallet.wallet.lnworker.swap_manager + loop = self._wallet.wallet.network.asyncio_loop + coro = swap_manager.reverse_swap( + lightning_amount_sat=lightning_amount, + expected_onchain_amount_sat=onchain_amount + swap_manager.get_claim_fee(), + ) + asyncio.run_coroutine_threadsafe(coro, loop) + + @pyqtSlot() + @pyqtSlot(bool) + def executeSwap(self, confirm=False): + if not self._wallet.wallet.network: + self.error.emit(_("You are offline.")) + return + if confirm: + self._do_execute_swap() + return + + if self.isReverse: + self.confirm.emit(_('Do you want to do a reverse submarine swap?')) + else: + self.confirm.emit(_('Do you want to do a submarine swap? ' + 'You will need to wait for the swap transaction to confirm.' + )) + + @auth_protect + def _do_execute_swap(self): + if self.isReverse: + lightning_amount = self._send_amount + onchain_amount = self._receive_amount + self.do_reverse_swap(lightning_amount, onchain_amount, None) + else: + lightning_amount = self._receive_amount + onchain_amount = self._send_amount + self.do_normal_swap(lightning_amount, onchain_amount, None) diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py new file mode 100644 index 000000000..934a5ce31 --- /dev/null +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -0,0 +1,154 @@ +from datetime import datetime, timedelta + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject +from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex + +from electrum.logging import get_logger +from electrum.util import Satoshis, TxMinedInfo + +from .qetypes import QEAmount + +class QETransactionListModel(QAbstractListModel): + def __init__(self, wallet, parent=None, *, onchain_domain=None, include_lightning=True): + super().__init__(parent) + self.wallet = wallet + self.onchain_domain = onchain_domain + self.include_lightning = include_lightning + self.init_model() + + _logger = get_logger(__name__) + + # define listmodel rolemap + _ROLE_NAMES=('txid','fee_sat','height','confirmations','timestamp','monotonic_timestamp', + 'incoming','value','balance','date','label','txpos_in_block','fee', + 'inputs','outputs','section','type','lightning','payment_hash','key') + _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) + _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) + + def rowCount(self, index): + return len(self.tx_history) + + def roleNames(self): + return self._ROLE_MAP + + def data(self, index, role): + tx = self.tx_history[index.row()] + role_index = role - Qt.UserRole + value = tx[self._ROLE_NAMES[role_index]] + if isinstance(value, (bool, list, int, str, QEAmount)) or value is None: + return value + if isinstance(value, Satoshis): + return value.value + return str(value) + + def clear(self): + self.beginResetModel() + self.tx_history = [] + self.endResetModel() + + def tx_to_model(self, tx): + #self._logger.debug(str(tx)) + item = tx + + item['key'] = item['txid'] if 'txid' in item else item['payment_hash'] + + if not 'lightning' in item: + item['lightning'] = False + + if item['lightning']: + item['value'] = QEAmount(amount_sat=item['value'].value, amount_msat=item['amount_msat']) + item['balance'] = QEAmount(amount_sat=item['balance'].value, amount_msat=item['amount_msat']) + if item['type'] == 'payment': + item['incoming'] = True if item['direction'] == 'received' else False + item['confirmations'] = 0 + else: + item['value'] = QEAmount(amount_sat=item['value'].value) + item['balance'] = QEAmount(amount_sat=item['balance'].value) + + # newly arriving txs have no (block) timestamp + # TODO? + if not item['timestamp']: + item['timestamp'] = datetime.timestamp(datetime.now()) + + txts = datetime.fromtimestamp(item['timestamp']) + today = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0) + + if (txts > today): + item['section'] = 'today' + elif (txts > today - timedelta(days=1)): + item['section'] = 'yesterday' + elif (txts > today - timedelta(days=7)): + item['section'] = 'lastweek' + elif (txts > today - timedelta(days=31)): + item['section'] = 'lastmonth' + else: + item['section'] = 'older' + + item['date'] = self.format_date_by_section(item['section'], datetime.fromtimestamp(item['timestamp'])) + return item + + def format_date_by_section(self, section, date): + #TODO: l10n + dfmt = { + 'today': '%H:%M:%S', + 'yesterday': '%H:%M:%S', + 'lastweek': '%a, %H:%M:%S', + 'lastmonth': '%a %d, %H:%M:%S', + 'older': '%G-%m-%d %H:%M:%S' + } + if section not in dfmt: + section = 'older' + return date.strftime(dfmt[section]) + + # initial model data + @pyqtSlot() + def init_model(self): + history = self.wallet.get_full_history(onchain_domain=self.onchain_domain, + include_lightning=self.include_lightning) + txs = [] + for key, tx in history.items(): + txs.append(self.tx_to_model(tx)) + + self.clear() + self.beginInsertRows(QModelIndex(), 0, len(txs) - 1) + self.tx_history = txs + self.tx_history.reverse() + self.endInsertRows() + + def update_tx(self, txid, info): + i = 0 + for tx in self.tx_history: + if 'txid' in tx and tx['txid'] == txid: + tx['height'] = info.height + tx['confirmations'] = info.conf + tx['timestamp'] = info.timestamp + tx['date'] = self.format_date_by_section(tx['section'], datetime.fromtimestamp(info.timestamp)) + index = self.index(i,0) + roles = [self._ROLE_RMAP[x] for x in ['height','confirmations','timestamp','date']] + self.dataChanged.emit(index, index, roles) + return + i = i + 1 + + @pyqtSlot(str, str) + def update_tx_label(self, key, label): + i = 0 + for tx in self.tx_history: + if tx['key'] == key: + tx['label'] = label + index = self.index(i,0) + self.dataChanged.emit(index, index, [self._ROLE_RMAP['label']]) + return + i = i + 1 + + @pyqtSlot(int) + def updateBlockchainHeight(self, height): + self._logger.debug('updating height to %d' % height) + i = 0 + for tx in self.tx_history: + if 'height' in tx and tx['height'] > 0: + tx['confirmations'] = height - tx['height'] + 1 + index = self.index(i,0) + roles = [self._ROLE_RMAP['confirmations']] + self.dataChanged.emit(index, index, roles) + i = i + 1 diff --git a/electrum/gui/qml/qetxdetails.py b/electrum/gui/qml/qetxdetails.py new file mode 100644 index 000000000..01df0c216 --- /dev/null +++ b/electrum/gui/qml/qetxdetails.py @@ -0,0 +1,141 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.util import format_time + +from .qewallet import QEWallet +from .qetypes import QEAmount + +class QETxDetails(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + _logger = get_logger(__name__) + + _wallet = None + _txid = None + + _mempool_depth = None + _date = None + + detailsChanged = pyqtSignal() + + walletChanged = pyqtSignal() + @pyqtProperty(QEWallet, notify=walletChanged) + def wallet(self): + return self._wallet + + @wallet.setter + def wallet(self, wallet: QEWallet): + if self._wallet != wallet: + self._wallet = wallet + self.walletChanged.emit() + + txidChanged = pyqtSignal() + @pyqtProperty(str, notify=txidChanged) + def txid(self): + return self._txid + + @txid.setter + def txid(self, txid: str): + if self._txid != txid: + self._logger.debug('txid set -> %s' % txid) + self._txid = txid + self.txidChanged.emit() + self.update() + + labelChanged = pyqtSignal() + @pyqtProperty(str, notify=labelChanged) + def label(self): + return self._label + + @pyqtSlot(str) + def set_label(self, label: str): + if label != self._label: + self._wallet.wallet.set_label(self._txid, label) + self._label = label + self.labelChanged.emit() + + @pyqtProperty(str, notify=detailsChanged) + def status(self): + return self._status + + @pyqtProperty(str, notify=detailsChanged) + def date(self): + return self._date + + @pyqtProperty(str, notify=detailsChanged) + def mempoolDepth(self): + return self._mempool_depth + + @pyqtProperty(bool, notify=detailsChanged) + def isMined(self): + return self._is_mined + + @pyqtProperty(bool, notify=detailsChanged) + def isLightningFundingTx(self): + return self._is_lightning_funding_tx + + @pyqtProperty(bool, notify=detailsChanged) + def canBump(self): + return self._can_bump + + @pyqtProperty(bool, notify=detailsChanged) + def canCancel(self): + return self._can_dscancel + + @pyqtProperty(QEAmount, notify=detailsChanged) + def amount(self): + return self._amount + + @pyqtProperty(QEAmount, notify=detailsChanged) + def fee(self): + return self._fee + + @pyqtProperty('QVariantList', notify=detailsChanged) + def inputs(self): + return self._inputs + + @pyqtProperty('QVariantList', notify=detailsChanged) + def outputs(self): + return self._outputs + + def update(self): + if self._wallet is None: + self._logger.error('wallet undefined') + return + + # abusing get_input_tx to get tx from txid + tx = self._wallet.wallet.get_input_tx(self._txid) + + self._inputs = list(map(lambda x: x.to_json(), tx.inputs())) + self._outputs = list(map(lambda x: { + 'address': x.get_ui_address_str(), + 'value': QEAmount(amount_sat=x.value), + 'is_mine': self._wallet.wallet.is_mine(x.get_ui_address_str()) + }, tx.outputs())) + + txinfo = self._wallet.wallet.get_tx_info(tx) + self._status = txinfo.status + self._label = txinfo.label + self._amount = QEAmount(amount_sat=txinfo.amount) # can be None? + self._fee = QEAmount(amount_sat=txinfo.fee) + + self._is_mined = txinfo.tx_mined_status != None + if self._is_mined: + self._date = format_time(txinfo.tx_mined_status.timestamp) + else: + #TODO mempool_depth_bytes can be None? + self._mempool_depth = self._wallet.wallet.config.depth_tooltip(txinfo.mempool_depth_bytes) + + self._is_lightning_funding_tx = txinfo.is_lightning_funding_tx + self._can_bump = txinfo.can_bump + self._can_dscancel = txinfo.can_dscancel + + self._logger.debug(repr(txinfo.mempool_depth_bytes)) + self._logger.debug(repr(txinfo.can_broadcast)) + self._logger.debug(repr(txinfo.can_cpfp)) + self._logger.debug(repr(txinfo.can_save_as_local)) + self._logger.debug(repr(txinfo.can_remove)) + + self.detailsChanged.emit() diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py new file mode 100644 index 000000000..900abe3e2 --- /dev/null +++ b/electrum/gui/qml/qetxfinalizer.py @@ -0,0 +1,312 @@ +from decimal import Decimal + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.i18n import _ +from electrum.transaction import PartialTxOutput +from electrum.util import NotEnoughFunds, profiler + +from .qewallet import QEWallet +from .qetypes import QEAmount + +class QETxFinalizer(QObject): + def __init__(self, parent=None, *, make_tx=None, accept=None): + super().__init__(parent) + self.f_make_tx = make_tx + self.f_accept = accept + self._tx = None + + _logger = get_logger(__name__) + + _address = '' + _amount = QEAmount() + _effectiveAmount = QEAmount() + _fee = QEAmount() + _feeRate = '' + _wallet = None + _valid = False + _sliderSteps = 0 + _sliderPos = 0 + _method = -1 + _warning = '' + _target = '' + _rbf = False + _canRbf = False + _outputs = [] + config = None + + validChanged = pyqtSignal() + @pyqtProperty(bool, notify=validChanged) + def valid(self): + return self._valid + + walletChanged = pyqtSignal() + @pyqtProperty(QEWallet, notify=walletChanged) + def wallet(self): + return self._wallet + + @wallet.setter + def wallet(self, wallet: QEWallet): + if self._wallet != wallet: + self._wallet = wallet + self.config = self._wallet.wallet.config + self.read_config() + self.walletChanged.emit() + + addressChanged = pyqtSignal() + @pyqtProperty(str, notify=addressChanged) + def address(self): + return self._address + + @address.setter + def address(self, address): + if self._address != address: + self._address = address + self.addressChanged.emit() + + amountChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=amountChanged) + def amount(self): + return self._amount + + @amount.setter + def amount(self, amount): + if self._amount != amount: + self._logger.debug(str(amount)) + self._amount = amount + self.amountChanged.emit() + + effectiveAmountChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=effectiveAmountChanged) + def effectiveAmount(self): + return self._effectiveAmount + + feeChanged = pyqtSignal() + @pyqtProperty(QEAmount, notify=feeChanged) + def fee(self): + return self._fee + + @fee.setter + def fee(self, fee): + if self._fee != fee: + self._fee = fee + self.feeChanged.emit() + + feeRateChanged = pyqtSignal() + @pyqtProperty(str, notify=feeRateChanged) + def feeRate(self): + return self._feeRate + + @feeRate.setter + def feeRate(self, feeRate): + if self._feeRate != feeRate: + self._feeRate = feeRate + self.feeRateChanged.emit() + + targetChanged = pyqtSignal() + @pyqtProperty(str, notify=targetChanged) + def target(self): + return self._target + + @target.setter + def target(self, target): + if self._target != target: + self._target = target + self.targetChanged.emit() + + rbfChanged = pyqtSignal() + @pyqtProperty(bool, notify=rbfChanged) + def rbf(self): + return self._rbf + + @rbf.setter + def rbf(self, rbf): + if self._rbf != rbf: + self._rbf = rbf + self.update() + self.rbfChanged.emit() + + canRbfChanged = pyqtSignal() + @pyqtProperty(bool, notify=canRbfChanged) + def canRbf(self): + return self._canRbf + + @canRbf.setter + def canRbf(self, canRbf): + if self._canRbf != canRbf: + self._canRbf = canRbf + self.canRbfChanged.emit() + if not canRbf and self.rbf: + self.rbf = False + + outputsChanged = pyqtSignal() + @pyqtProperty('QVariantList', notify=outputsChanged) + def outputs(self): + return self._outputs + + @outputs.setter + def outputs(self, outputs): + if self._outputs != outputs: + self._outputs = outputs + self.outputsChanged.emit() + + warningChanged = pyqtSignal() + @pyqtProperty(str, notify=warningChanged) + def warning(self): + return self._warning + + @warning.setter + def warning(self, warning): + if self._warning != warning: + self._warning = warning + self.warningChanged.emit() + + sliderStepsChanged = pyqtSignal() + @pyqtProperty(int, notify=sliderStepsChanged) + def sliderSteps(self): + return self._sliderSteps + + sliderPosChanged = pyqtSignal() + @pyqtProperty(int, notify=sliderPosChanged) + def sliderPos(self): + return self._sliderPos + + @sliderPos.setter + def sliderPos(self, sliderPos): + if self._sliderPos != sliderPos: + self._sliderPos = sliderPos + self.save_config() + self.sliderPosChanged.emit() + + methodChanged = pyqtSignal() + @pyqtProperty(int, notify=methodChanged) + def method(self): + return self._method + + @method.setter + def method(self, method): + if self._method != method: + self._method = method + self.update_slider() + self.methodChanged.emit() + self.save_config() + + def get_method(self): + dynfees = self._method > 0 + mempool = self._method == 2 + return dynfees, mempool + + def update_slider(self): + dynfees, mempool = self.get_method() + maxp, pos, fee_rate = self.config.get_fee_slider(dynfees, mempool) + self._sliderSteps = maxp + self._sliderPos = pos + self.sliderStepsChanged.emit() + self.sliderPosChanged.emit() + + def read_config(self): + mempool = self.config.use_mempool_fees() + dynfees = self.config.is_dynfee() + self._method = (2 if mempool else 1) if dynfees else 0 + self.update_slider() + self.methodChanged.emit() + self.update() + + def save_config(self): + value = int(self._sliderPos) + dynfees, mempool = self.get_method() + self.config.set_key('dynamic_fees', dynfees, False) + self.config.set_key('mempool_fees', mempool, False) + if dynfees: + if mempool: + self.config.set_key('depth_level', value, True) + else: + self.config.set_key('fee_level', value, True) + else: + self.config.set_key('fee_per_kb', self.config.static_fee(value), True) + self.update() + + @profiler + def make_tx(self, amount): + self._logger.debug('make_tx amount = %s' % str(amount)) + + if self.f_make_tx: + tx = self.f_make_tx(amount) + else: + # default impl + coins = self._wallet.wallet.get_spendable_coins(None) + outputs = [PartialTxOutput.from_address_and_value(self.address, amount)] + tx = self._wallet.wallet.make_unsigned_transaction(coins=coins,outputs=outputs, fee=None,rbf=self._rbf) + + self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs()))) + + outputs = [] + for o in tx.outputs(): + outputs.append({ + 'address': o.get_ui_address_str(), + 'value_sats': o.value, + 'is_mine': self._wallet.wallet.is_mine(o.get_ui_address_str()) + }) + self.outputs = outputs + + return tx + + @pyqtSlot() + def update(self): + try: + # make unsigned transaction + tx = self.make_tx(amount = '!' if self._amount.isMax else self._amount.satsInt) + except NotEnoughFunds: + self.warning = _("Not enough funds") + self._valid = False + self.validChanged.emit() + return + except Exception as e: + self._logger.error(str(e)) + self.warning = repr(e) + self._valid = False + self.validChanged.emit() + return + + self._tx = tx + + amount = self._amount.satsInt if not self._amount.isMax else tx.output_value() + + self._effectiveAmount = QEAmount(amount_sat=amount) + self.effectiveAmountChanged.emit() + + tx_size = tx.estimated_size() + fee = tx.get_fee() + feerate = Decimal(fee) / tx_size # sat/byte + + self.fee = QEAmount(amount_sat=fee) + self.feeRate = f'{feerate:.1f}' + + #TODO + #x_fee = run_hook('get_tx_extra_fee', self._wallet.wallet, tx) + fee_warning_tuple = self._wallet.wallet.get_tx_fee_warning( + invoice_amt=amount, tx_size=tx_size, fee=fee) + if fee_warning_tuple: + allow_send, long_warning, short_warning = fee_warning_tuple + self.warning = long_warning + else: + self.warning = '' + + target, tooltip, dyn = self.config.get_fee_target() + self.target = target + + self._valid = True + self.validChanged.emit() + + @pyqtSlot() + def send_onchain(self): + if not self._valid or not self._tx: + self._logger.debug('no valid tx') + return + + if self.f_accept: + self.f_accept(self._tx) + return + + self._wallet.sign_and_broadcast(self._tx) diff --git a/electrum/gui/qml/qetypes.py b/electrum/gui/qml/qetypes.py new file mode 100644 index 000000000..b2390f052 --- /dev/null +++ b/electrum/gui/qml/qetypes.py @@ -0,0 +1,74 @@ +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.i18n import _ +from electrum.util import profiler + +# container for satoshi amounts that can be passed around more +# easily between python, QML-property and QML-javascript contexts +# QML 'int' is 32 bit signed, so overflows on satoshi amounts +# QML 'quint64' and 'qint64' can be used, but this breaks +# down when passing through property bindings +# should also capture millisats amounts and MAX/'!' indicators +# and (unformatted) string representations + +class QEAmount(QObject): + _logger = get_logger(__name__) + + def __init__(self, *, amount_sat: int = 0, amount_msat: int = 0, is_max: bool = False, from_invoice = None, parent=None): + super().__init__(parent) + self._amount_sat = int(amount_sat) if amount_sat is not None else None + self._amount_msat = int(amount_msat) if amount_msat is not None else None + self._is_max = is_max + if from_invoice: + inv_amt = from_invoice.get_amount_msat() + if inv_amt == '!': + self._is_max = True + elif inv_amt is not None: + self._amount_msat = int(inv_amt) + self._amount_sat = int(from_invoice.get_amount_sat()) + + valueChanged = pyqtSignal() + + @pyqtProperty('qint64', notify=valueChanged) + def satsInt(self): + return self._amount_sat + + @pyqtProperty('qint64', notify=valueChanged) + def msatsInt(self): + return self._amount_msat + + @pyqtProperty(str, notify=valueChanged) + def satsStr(self): + return str(self._amount_sat) + + @pyqtProperty(str, notify=valueChanged) + def msatsStr(self): + return str(self._amount_msat) + + @pyqtProperty(bool, notify=valueChanged) + def isMax(self): + return self._is_max + + @pyqtProperty(bool, notify=valueChanged) + def isEmpty(self): + return not(self._is_max or self._amount_sat or self._amount_msat) + + def __eq__(self, other): + if isinstance(other, QEAmount): + return self._amount_sat == other._amount_sat and self._amount_msat == other._amount_msat and self._is_max == other._is_max + elif isinstance(other, int): + return self._amount_sat == other + elif isinstance(other, str): + return self.satsStr == other + + return False + + def __str__(self): + s = _('Amount') + if self._is_max: + return '%s(MAX)' % s + return '%s(sats=%d, msats=%d)' % (s, self._amount_sat, self._amount_msat) + + def __repr__(self): + return f"" diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py new file mode 100644 index 000000000..39c0ae2e7 --- /dev/null +++ b/electrum/gui/qml/qewallet.py @@ -0,0 +1,552 @@ +from typing import Optional, TYPE_CHECKING, Sequence, List, Union +import queue +import time +import asyncio +import threading + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QTimer + +from electrum.i18n import _ +from electrum.util import (Satoshis, format_time, parse_max_spend, InvalidPassword, + event_listener) +from electrum.logging import get_logger +from electrum.wallet import Wallet, Abstract_Wallet +from electrum.storage import StorageEncryptionVersion +from electrum import bitcoin +from electrum.transaction import PartialTxOutput +from electrum.invoices import (Invoice, InvoiceError, + PR_DEFAULT_EXPIRATION_WHEN_CREATING, PR_PAID, + PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_UNCONFIRMED) + +from .qeinvoicelistmodel import QEInvoiceListModel, QERequestListModel +from .qetransactionlistmodel import QETransactionListModel +from .qeaddresslistmodel import QEAddressListModel +from .qechannellistmodel import QEChannelListModel +from .qetypes import QEAmount +from .auth import AuthMixin, auth_protect +from .util import QtEventListener, qt_event_listener + +class QEWallet(AuthMixin, QObject, QtEventListener): + __instances = [] + + # this factory method should be used to instantiate QEWallet + # so we have only one QEWallet for each electrum.wallet + @classmethod + def getInstanceFor(cls, wallet): + for i in cls.__instances: + if i.wallet == wallet: + return i + i = QEWallet(wallet) + cls.__instances.append(i) + return i + + _logger = get_logger(__name__) + + # emitted when wallet wants to display a user notification + # actual presentation should be handled on app or window level + userNotify = pyqtSignal(object, object) + + # shared signal for many static wallet properties + dataChanged = pyqtSignal() + + isUptodateChanged = pyqtSignal() + requestStatusChanged = pyqtSignal([str,int], arguments=['key','status']) + requestCreateSuccess = pyqtSignal() + requestCreateError = pyqtSignal([str,str], arguments=['code','error']) + invoiceStatusChanged = pyqtSignal([str,int], arguments=['key','status']) + invoiceCreateSuccess = pyqtSignal() + invoiceCreateError = pyqtSignal([str,str], arguments=['code','error']) + paymentSucceeded = pyqtSignal([str], arguments=['key']) + paymentFailed = pyqtSignal([str,str], arguments=['key','reason']) + requestNewPassword = pyqtSignal() + + _network_signal = pyqtSignal(str, object) + + def __init__(self, wallet, parent=None): + super().__init__(parent) + self.wallet = wallet + + self._historyModel = None + self._addressModel = None + self._requestModel = None + self._invoiceModel = None + self._channelModel = None + + self.tx_notification_queue = queue.Queue() + self.tx_notification_last_time = 0 + + self.notification_timer = QTimer(self) + self.notification_timer.setSingleShot(False) + self.notification_timer.setInterval(500) # msec + self.notification_timer.timeout.connect(self.notify_transactions) + + #self._network_signal.connect(self.on_network_qt) + interests = ['wallet_updated', 'new_transaction', 'status', 'verified', + 'on_history', 'channel', 'channels_updated', 'payment_failed', + 'payment_succeeded', 'invoice_status', 'request_status'] + # To avoid leaking references to "self" that prevent the + # window from being GC-ed when closed, callbacks should be + # methods of this class only, and specifically not be + # partials, lambdas or methods of subobjects. Hence... + #register_callback(self.on_network, interests) + self.register_callbacks() + self.destroyed.connect(lambda: self.on_destroy()) + + @pyqtProperty(bool, notify=isUptodateChanged) + def isUptodate(self): + return self.wallet.is_up_to_date() + + def on_network(self, event, *args): + if event in ['new_transaction', 'payment_succeeded']: + # Handle in GUI thread (_network_signal -> on_network_qt) + self._network_signal.emit(event, args) + else: + self.on_network_qt(event, args) + + def on_network_qt(self, event, args=None): + # note: we get events from all wallets! args are heterogenous so we can't + # shortcut here + if event != 'status': + wallet = args[0] + if wallet == self.wallet: + self._logger.debug('event %s' % event) + + @event_listener + def on_event_status(self, *args, **kwargs): + #if event == 'status': + self.isUptodateChanged.emit() + + + # elif event == 'request_status': + @event_listener + def on_event_request_status(self, wallet, key, status): + #wallet, key, status = args + if wallet == self.wallet: + self._logger.debug('request status %d for key %s' % (status, key)) + self.requestStatusChanged.emit(key, status) + # elif event == 'invoice_status': + @event_listener + def on_event_invoice_status(self, wallet, key): + #wallet, key = args + if wallet == self.wallet: + self._logger.debug('invoice status update for key %s' % key) + # FIXME event doesn't pass the new status, so we need to retrieve + invoice = self.wallet.get_invoice(key) + if invoice: + status = self.wallet.get_invoice_status(invoice) + self.invoiceStatusChanged.emit(key, status) + else: + self._logger.debug(f'No invoice found for key {key}') + + #elif event == 'new_transaction': + @qt_event_listener + def on_event_new_transaction(self, *args): + wallet, tx = args + if wallet == self.wallet: + self.add_tx_notification(tx) + self.historyModel.init_model() # TODO: be less dramatic + + + # elif event == 'verified': + @qt_event_listener + def on_event_verified(self, wallet, txid, info): + #wallet, txid, info = args + if wallet == self.wallet: + self.historyModel.update_tx(txid, info) + + + # elif event == 'wallet_updated': + @event_listener + def on_event_wallet_updated(self, wallet): + #wallet, = args + if wallet == self.wallet: + self._logger.debug('wallet %s updated' % str(wallet)) + self.balanceChanged.emit() + + # elif event == 'channel': + @event_listener + def on_event_channel(self, wallet, channel): + #wallet, channel = args + if wallet == self.wallet: + self.balanceChanged.emit() + + # elif event == 'channels_updated': + @event_listener + def on_event_channels_updated(self, wallet): + #wallet, = args + if wallet == self.wallet: + self.balanceChanged.emit() + # elif event == 'payment_succeeded': + + @qt_event_listener + def on_event_payment_succeeded(self, wallet, key): + #wallet, key = args + if wallet == self.wallet: + self.paymentSucceeded.emit(key) + self.historyModel.init_model() # TODO: be less dramatic + + # elif event == 'payment_failed': + @event_listener + def on_event_payment_failed(self, wallet, key, reason): + #wallet, key, reason = args + if wallet == self.wallet: + self.paymentFailed.emit(key, reason) + #else: + #self._logger.debug('unhandled event: %s %s' % (event, str(args))) + + def on_destroy(self): + #unregister_callback(self.on_network) + self.unregister_callbacks() + + def add_tx_notification(self, tx): + self._logger.debug('new transaction event') + self.tx_notification_queue.put(tx) + if not self.notification_timer.isActive(): + self._logger.debug('starting wallet notification timer') + self.notification_timer.start() + + def notify_transactions(self): + if self.tx_notification_queue.qsize() == 0: + self._logger.debug('queue empty, stopping wallet notification timer') + self.notification_timer.stop() + return + if not self.wallet.is_up_to_date(): + return # no notifications while syncing + now = time.time() + rate_limit = 20 # seconds + if self.tx_notification_last_time + rate_limit > now: + return + self.tx_notification_last_time = now + self._logger.info("Notifying app about new transactions") + txns = [] + while True: + try: + txns.append(self.tx_notification_queue.get_nowait()) + except queue.Empty: + break + + config = self.wallet.config + # Combine the transactions if there are at least three + if len(txns) >= 3: + total_amount = 0 + for tx in txns: + tx_wallet_delta = self.wallet.get_wallet_delta(tx) + if not tx_wallet_delta.is_relevant: + continue + total_amount += tx_wallet_delta.delta + self.userNotify.emit(self.wallet, _("{} new transactions: Total amount received in the new transactions {}").format(len(txns), config.format_amount_and_units(total_amount))) + else: + for tx in txns: + tx_wallet_delta = self.wallet.get_wallet_delta(tx) + if not tx_wallet_delta.is_relevant: + continue + self.userNotify.emit(self.wallet, + _("New transaction: {}").format(config.format_amount_and_units(tx_wallet_delta.delta))) + + historyModelChanged = pyqtSignal() + @pyqtProperty(QETransactionListModel, notify=historyModelChanged) + def historyModel(self): + if self._historyModel is None: + self._historyModel = QETransactionListModel(self.wallet) + return self._historyModel + + addressModelChanged = pyqtSignal() + @pyqtProperty(QEAddressListModel, notify=addressModelChanged) + def addressModel(self): + if self._addressModel is None: + self._addressModel = QEAddressListModel(self.wallet) + return self._addressModel + + requestModelChanged = pyqtSignal() + @pyqtProperty(QERequestListModel, notify=requestModelChanged) + def requestModel(self): + if self._requestModel is None: + self._requestModel = QERequestListModel(self.wallet) + return self._requestModel + + invoiceModelChanged = pyqtSignal() + @pyqtProperty(QEInvoiceListModel, notify=invoiceModelChanged) + def invoiceModel(self): + if self._invoiceModel is None: + self._invoiceModel = QEInvoiceListModel(self.wallet) + return self._invoiceModel + + channelModelChanged = pyqtSignal() + @pyqtProperty(QEChannelListModel, notify=channelModelChanged) + def channelModel(self): + if self._channelModel is None: + self._channelModel = QEChannelListModel(self.wallet) + return self._channelModel + + nameChanged = pyqtSignal() + @pyqtProperty(str, notify=nameChanged) + def name(self): + return self.wallet.basename() + + isLightningChanged = pyqtSignal() + @pyqtProperty(bool, notify=isLightningChanged) + def isLightning(self): + return bool(self.wallet.lnworker) + + @pyqtProperty(bool, notify=dataChanged) + def canHaveLightning(self): + return self.wallet.can_have_lightning() + + @pyqtProperty(bool, notify=dataChanged) + def hasSeed(self): + return self.wallet.has_seed() + + @pyqtProperty(str, notify=dataChanged) + def txinType(self): + return self.wallet.get_txin_type(self.wallet.dummy_address()) + + @pyqtProperty(bool, notify=dataChanged) + def isWatchOnly(self): + return self.wallet.is_watching_only() + + @pyqtProperty(bool, notify=dataChanged) + def isDeterministic(self): + return self.wallet.is_deterministic() + + @pyqtProperty(bool, notify=dataChanged) + def isEncrypted(self): + return self.wallet.storage.is_encrypted() + + @pyqtProperty(bool, notify=dataChanged) + def isHardware(self): + return self.wallet.storage.is_encrypted_with_hw_device() + + @pyqtProperty(str, notify=dataChanged) + def derivationPrefix(self): + keystores = self.wallet.get_keystores() + if len(keystores) > 1: + self._logger.debug('multiple keystores not supported yet') + return keystores[0].get_derivation_prefix() + + @pyqtProperty(str, notify=dataChanged) + def masterPubkey(self): + return self.wallet.get_master_public_key() + + balanceChanged = pyqtSignal() + + @pyqtProperty(QEAmount, notify=balanceChanged) + def frozenBalance(self): + c, u, x = self.wallet.get_frozen_balance() + self._frozenbalance = QEAmount(amount_sat=c+x) + return self._frozenbalance + + @pyqtProperty(QEAmount, notify=balanceChanged) + def unconfirmedBalance(self): + self._unconfirmedbalance = QEAmount(amount_sat=self.wallet.get_balance()[1]) + return self._unconfirmedbalance + + @pyqtProperty(QEAmount, notify=balanceChanged) + def confirmedBalance(self): + c, u, x = self.wallet.get_balance() + self._confirmedbalance = QEAmount(amount_sat=c+x) + return self._confirmedbalance + + @pyqtProperty(QEAmount, notify=balanceChanged) + def lightningBalance(self): + if not self.isLightning: + self._lightningbalance = QEAmount() + else: + self._lightningbalance = QEAmount(amount_sat=int(self.wallet.lnworker.get_balance())) + return self._lightningbalance + + @pyqtProperty(QEAmount, notify=balanceChanged) + def lightningCanSend(self): + if not self.isLightning: + self._lightningcansend = QEAmount() + else: + self._lightningcansend = QEAmount(amount_sat=int(self.wallet.lnworker.num_sats_can_send())) + return self._lightningcansend + + @pyqtProperty(QEAmount, notify=balanceChanged) + def lightningCanReceive(self): + if not self.isLightning: + self._lightningcanreceive = QEAmount() + else: + self._lightningcanreceive = QEAmount(amount_sat=int(self.wallet.lnworker.num_sats_can_receive())) + return self._lightningcanreceive + + @pyqtSlot() + def enableLightning(self): + self.wallet.init_lightning(password=None) # TODO pass password if needed + self.isLightningChanged.emit() + + @pyqtSlot(str, int, int, bool) + def send_onchain(self, address, amount, fee=None, rbf=False): + self._logger.info('send_onchain: %s %d' % (address,amount)) + coins = self.wallet.get_spendable_coins(None) + if not bitcoin.is_address(address): + self._logger.warning('Invalid Bitcoin Address: ' + address) + return False + + outputs = [PartialTxOutput.from_address_and_value(address, amount)] + self._logger.info(str(outputs)) + output_values = [x.value for x in outputs] + if any(parse_max_spend(outval) for outval in output_values): + output_value = '!' + else: + output_value = sum(output_values) + self._logger.info(str(output_value)) + # see qt/confirm_tx_dialog qt/main_window + tx = self.wallet.make_unsigned_transaction(coins=coins,outputs=outputs, fee=None) + self._logger.info(str(tx.to_json())) + + use_rbf = bool(self.wallet.config.get('use_rbf', True)) + tx.set_rbf(use_rbf) + self.sign_and_broadcast(tx) + + @auth_protect + def sign_and_broadcast(self, tx): + def cb(result): + self._logger.info('signing was succesful? %s' % str(result)) + tx = self.wallet.sign_transaction(tx, None) + if not tx.is_complete(): + self._logger.info('tx not complete') + return + + self.network = self.wallet.network # TODO not always defined? + + try: + self._logger.info('running broadcast in thread') + self.network.run_from_another_thread(self.network.broadcast_transaction(tx)) + self._logger.info('broadcast submit done') + except TxBroadcastError as e: + self._logger.info(e) + return + except BestEffortRequestFailed as e: + self._logger.info(e) + return + + return + + paymentAuthRejected = pyqtSignal() + def ln_auth_rejected(self): + self.paymentAuthRejected.emit() + + @pyqtSlot(str) + @auth_protect(reject='ln_auth_rejected') + def pay_lightning_invoice(self, invoice_key): + self._logger.debug('about to pay LN') + invoice = self.wallet.get_invoice(invoice_key) + assert(invoice) + assert(invoice.lightning_invoice) + + amount_msat = invoice.get_amount_msat() + + def pay_thread(): + try: + coro = self.wallet.lnworker.pay_invoice(invoice.lightning_invoice, amount_msat=amount_msat) + fut = asyncio.run_coroutine_threadsafe(coro, self.wallet.network.asyncio_loop) + fut.result() + except Exception as e: + self.userNotify.emit(repr(e)) + + threading.Thread(target=pay_thread).start() + + def create_bitcoin_request(self, amount: int, message: str, expiration: int, ignore_gap: bool) -> Optional[str]: + addr = self.wallet.get_unused_address() + if addr is None: + if not self.wallet.is_deterministic(): # imported wallet + # TODO implement + return + #msg = [ + #_('No more addresses in your wallet.'), ' ', + #_('You are using a non-deterministic wallet, which cannot create new addresses.'), ' ', + #_('If you want to create new addresses, use a deterministic wallet instead.'), '\n\n', + #_('Creating a new payment request will reuse one of your addresses and overwrite an existing request. Continue anyway?'), + #] + #if not self.question(''.join(msg)): + #return + #addr = self.wallet.get_receiving_address() + else: # deterministic wallet + if not ignore_gap: + self.requestCreateError.emit('gaplimit',_("Warning: The next address will not be recovered automatically if you restore your wallet from seed; you may need to add it manually.\n\nThis occurs because you have too many unused addresses in your wallet. To avoid this situation, use the existing addresses first.\n\nCreate anyway?")) + return + addr = self.wallet.create_new_address(False) + + req_key = self.wallet.create_request(amount, message, expiration, addr, False) + #try: + #self.wallet.add_payment_request(req) + #except Exception as e: + #self.logger.exception('Error adding payment request') + #self.requestCreateError.emit('fatal',_('Error adding payment request') + ':\n' + repr(e)) + #else: + ## TODO: check this flow. Only if alias is defined in config. OpenAlias? + #pass + ##self.sign_payment_request(addr) + + return req_key, addr + + @pyqtSlot(QEAmount, str, int) + @pyqtSlot(QEAmount, str, int, bool) + @pyqtSlot(QEAmount, str, int, bool, bool) + def create_request(self, amount: QEAmount, message: str, expiration: int, is_lightning: bool = False, ignore_gap: bool = False): + try: + if is_lightning: + if not self.wallet.lnworker.channels: + self.requestCreateError.emit('fatal',_("You need to open a Lightning channel first.")) + return + # TODO maybe show a warning if amount exceeds lnworker.num_sats_can_receive (as in kivy) + # TODO fallback address robustness + addr = self.wallet.get_unused_address() + key = self.wallet.create_request(amount.satsInt, message, expiration, addr, True) + else: + key, addr = self.create_bitcoin_request(amount.satsInt, message, expiration, ignore_gap) + if not key: + return + self.addressModel.init_model() + except InvoiceError as e: + self.requestCreateError.emit('fatal',_('Error creating payment request') + ':\n' + str(e)) + return + + assert key is not None + self._requestModel.add_invoice(self.wallet.get_request(key)) + self.requestCreateSuccess.emit() + + @pyqtSlot(str) + def delete_request(self, key: str): + self._logger.debug('delete req %s' % key) + self.wallet.delete_request(key) + self._requestModel.delete_invoice(key) + + @pyqtSlot(str, result='QVariant') + def get_request(self, key: str): + return self._requestModel.get_model_invoice(key) + + @pyqtSlot(str) + def delete_invoice(self, key: str): + self._logger.debug('delete inv %s' % key) + self.wallet.delete_invoice(key) + self._invoiceModel.delete_invoice(key) + + @pyqtSlot(str, result='QVariant') + def get_invoice(self, key: str): + return self._invoiceModel.get_model_invoice(key) + + @pyqtSlot(str, result=bool) + def verify_password(self, password): + try: + self.wallet.storage.check_password(password) + return True + except InvalidPassword as e: + return False + + @pyqtSlot(str) + def set_password(self, password): + storage = self.wallet.storage + + # HW wallet not supported yet + if storage.is_encrypted_with_hw_device(): + return + + self._logger.debug('Ok to set password for wallet with path %s' % storage.path) + if password: + enc_version = StorageEncryptionVersion.USER_PASSWORD + else: + enc_version = StorageEncryptionVersion.PLAINTEXT + storage.set_password(password, enc_version=enc_version) + self.wallet.save_db() diff --git a/electrum/gui/qml/qewalletdb.py b/electrum/gui/qml/qewalletdb.py new file mode 100644 index 000000000..d97a57f50 --- /dev/null +++ b/electrum/gui/qml/qewalletdb.py @@ -0,0 +1,226 @@ +import os + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import Logger, get_logger +from electrum.storage import WalletStorage, StorageEncryptionVersion +from electrum.wallet_db import WalletDB +from electrum.bip32 import normalize_bip32_derivation +from electrum.util import InvalidPassword +from electrum import keystore + +class QEWalletDB(QObject): + def __init__(self, parent=None): + super().__init__(parent) + + from .qeapp import ElectrumQmlApplication + self.daemon = ElectrumQmlApplication._daemon + + self.reset() + + _logger = get_logger(__name__) + + fileNotFound = pyqtSignal() + pathChanged = pyqtSignal([bool], arguments=["ready"]) + needsPasswordChanged = pyqtSignal() + needsHWDeviceChanged = pyqtSignal() + passwordChanged = pyqtSignal() + validPasswordChanged = pyqtSignal() + requiresSplitChanged = pyqtSignal() + splitFinished = pyqtSignal() + readyChanged = pyqtSignal() + createError = pyqtSignal([str], arguments=["error"]) + createSuccess = pyqtSignal() + invalidPassword = pyqtSignal() + + def reset(self): + self._path = None + self._needsPassword = False + self._needsHWDevice = False + self._password = '' + self._requiresSplit = False + self._validPassword = True + + self._storage = None + self._db = None + + self._ready = False + + @pyqtProperty('QString', notify=pathChanged) + def path(self): + return self._path + + @path.setter + def path(self, wallet_path): + if wallet_path == self._path: + return + + self._logger.info('setting path: ' + wallet_path) + self.reset() + self._path = wallet_path + + self.load_storage() + if self._storage: + self.load_db() + + self.pathChanged.emit(self._ready) + + @pyqtProperty(bool, notify=needsPasswordChanged) + def needsPassword(self): + return self._needsPassword + + @needsPassword.setter + def needsPassword(self, wallet_needs_password): + if wallet_needs_password == self._needsPassword: + return + + self._needsPassword = wallet_needs_password + self.needsPasswordChanged.emit() + + @pyqtProperty(bool, notify=needsHWDeviceChanged) + def needsHWDevice(self): + return self._needsHWDevice + + @needsHWDevice.setter + def needsHWDevice(self, wallet_needs_hw_device): + if wallet_needs_hw_device == self._needsHWDevice: + return + + self._needsHWDevice = wallet_needs_hw_device + self.needsHWDeviceChanged.emit() + + @pyqtProperty('QString', notify=passwordChanged) + def password(self): + return '' # no read access + + @password.setter + def password(self, wallet_password): + if wallet_password == self._password: + return + + self._password = wallet_password + self.passwordChanged.emit() + + self.load_storage() + + if self._storage: + self.needsPassword = False + self.load_db() + + @pyqtProperty(bool, notify=requiresSplitChanged) + def requiresSplit(self): + return self._requiresSplit + + @pyqtProperty(bool, notify=validPasswordChanged) + def validPassword(self): + return self._validPassword + + @validPassword.setter + def validPassword(self, validPassword): + if self._validPassword != validPassword: + self._validPassword = validPassword + self.validPasswordChanged.emit() + + @pyqtProperty(bool, notify=readyChanged) + def ready(self): + return self._ready + + + @pyqtSlot() + def doSplit(self): + self._logger.warning('doSplit') + if not self._requiresSplit: + return + + self._db.split_accounts(self._path) + + self.splitFinished.emit() + + def load_storage(self): + self._storage = WalletStorage(self._path) + if not self._storage.file_exists(): + self._logger.warning('file does not exist') + self.fileNotFound.emit() + self._storage = None + return + + if self._storage.is_encrypted(): + self.needsPassword = True + + try: + self._storage.decrypt('' if not self._password else self._password) + self.validPassword = True + except InvalidPassword as e: + self.validPassword = False + self.invalidPassword.emit() + + if not self._storage.is_past_initial_decryption(): + self._storage = None + + def load_db(self): + # needs storage accessible + self._db = WalletDB(self._storage.read(), manual_upgrades=True) + if self._db.requires_split(): + self._logger.warning('wallet requires split') + self._requiresSplit = True + self.requiresSplitChanged.emit() + return + if self._db.get_action(): + self._logger.warning('action pending. QML version doesn\'t support continuation of wizard') + return + + if self._db.requires_upgrade(): + self._logger.warning('wallet requires upgrade, upgrading') + self._db.upgrade() + self._db.write(self._storage) + + self._ready = True + self.readyChanged.emit() + + @pyqtSlot('QJSValue') + def create_storage(self, js_data): + self._logger.info('Creating wallet from wizard data') + data = js_data.toVariant() + self._logger.debug(str(data)) + + try: + path = os.path.join(os.path.dirname(self.daemon.config.get_wallet_path()), data['wallet_name']) + if os.path.exists(path): + raise Exception('file already exists at path') + storage = WalletStorage(path) + + if data['seed_type'] in ['old', 'standard', 'segwit']: #2fa, 2fa-segwit + self._logger.debug('creating keystore from electrum seed') + k = keystore.from_seed(data['seed'], data['seed_extra_words'], data['wallet_type'] == 'multisig') + elif data['seed_type'] == 'bip39': + self._logger.debug('creating keystore from bip39 seed') + root_seed = keystore.bip39_to_seed(data['seed'], data['seed_extra_words']) + derivation = normalize_bip32_derivation(data['derivation_path']) + script = data['script_type'] if data['script_type'] != 'p2pkh' else 'standard' + k = keystore.from_bip43_rootseed(root_seed, derivation, xtype=script) + + if data['encrypt']: + storage.set_password(data['password'], enc_version=StorageEncryptionVersion.USER_PASSWORD) + + db = WalletDB('', manual_upgrades=False) + db.set_keystore_encryption(bool(data['password']) and data['encrypt']) + + db.put('wallet_type', data['wallet_type']) + db.put('seed_type', data['seed_type']) + db.put('keystore', k.dump()) + if k.can_have_deterministic_lightning_xprv(): + db.put('lightning_xprv', k.get_lightning_xprv(None)) + + db.load_plugins() + db.write(storage) + + # minimally populate self after create + self._password = data['password'] + self.path = path + + self.createSuccess.emit() + except Exception as e: + self._logger.error(str(e)) + self.createError.emit(str(e)) + + diff --git a/electrum/gui/qml/util.py b/electrum/gui/qml/util.py new file mode 100644 index 000000000..79aff327a --- /dev/null +++ b/electrum/gui/qml/util.py @@ -0,0 +1,31 @@ +from functools import wraps + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.logging import get_logger +from electrum.i18n import _ +from electrum.util import EventListener, event_listener + +class QtEventListener(EventListener): + + qt_callback_signal = pyqtSignal(tuple) + + def register_callbacks(self): + self.qt_callback_signal.connect(self.on_qt_callback_signal) + EventListener.register_callbacks(self) + + def unregister_callbacks(self): + #self.qt_callback_signal.disconnect() + EventListener.unregister_callbacks(self) + + def on_qt_callback_signal(self, args): + func = args[0] + return func(self, *args[1:]) + +# decorator for members of the QtEventListener class +def qt_event_listener(func): + func = event_listener(func) + @wraps(func) + def decorator(self, *args): + self.qt_callback_signal.emit( (func,) + args) + return decorator diff --git a/electrum/logging.py b/electrum/logging.py index 316a5c83d..bf5d8caae 100644 --- a/electrum/logging.py +++ b/electrum/logging.py @@ -353,9 +353,9 @@ def get_logfile_path() -> Optional[pathlib.Path]: def describe_os_version() -> str: if 'ANDROID_DATA' in os.environ: - from kivy import utils - if utils.platform != "android": - return utils.platform + #from kivy import utils + #if utils.platform != "android": + # return utils.platform import jnius bv = jnius.autoclass('android.os.Build$VERSION') b = jnius.autoclass('android.os.Build') diff --git a/electrum/plugins/qml_test/__init__.py b/electrum/plugins/qml_test/__init__.py new file mode 100644 index 000000000..62e390176 --- /dev/null +++ b/electrum/plugins/qml_test/__init__.py @@ -0,0 +1,5 @@ +from electrum.i18n import _ + +fullname = 'QML Plugin Test' +description = '%s\n%s' % (_("Plugin to test QML integration from plugins."), _("Note: Used for development")) +available_for = ['qml'] diff --git a/electrum/plugins/qml_test/qml.py b/electrum/plugins/qml_test/qml.py new file mode 100644 index 000000000..bfe54506e --- /dev/null +++ b/electrum/plugins/qml_test/qml.py @@ -0,0 +1,17 @@ +from typing import TYPE_CHECKING +from PyQt5.QtQml import QQmlApplicationEngine +from electrum.plugin import hook, BasePlugin +from electrum.logging import get_logger + +if TYPE_CHECKING: + from electrum.gui.qml import ElectrumGui + +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') diff --git a/electrum/qrreader/zbar.py b/electrum/qrreader/zbar.py index 14df4e70f..8a3ef54df 100644 --- a/electrum/qrreader/zbar.py +++ b/electrum/qrreader/zbar.py @@ -37,8 +37,9 @@ from .abstract_base import AbstractQrCodeReader, QrCodeResult _logger = get_logger(__name__) - -if sys.platform == 'darwin': +if 'ANDROID_DATA' in os.environ: + LIBNAME = 'libzbar.so' +elif sys.platform == 'darwin': LIBNAME = 'libzbar.0.dylib' elif sys.platform in ('windows', 'win32'): LIBNAME = 'libzbar-0.dll' diff --git a/run_electrum b/run_electrum index 96da77c66..163eb6659 100755 --- a/run_electrum +++ b/run_electrum @@ -322,12 +322,14 @@ def main(): # config is an object passed to the various constructors (wallet, interface, gui) if is_android: + import importlib.util + android_gui = 'kivy' if importlib.util.find_spec('kivy') else 'qml' from jnius import autoclass build_config = autoclass("org.electrum.electrum.BuildConfig") config_options = { 'verbosity': '*' if build_config.DEBUG else '', 'cmd': 'gui', - 'gui': 'kivy', + 'gui': android_gui, 'single_password':True, } else: