diff --git a/components/Dialog.qml b/components/Dialog.qml new file mode 100644 index 00000000..7853788f --- /dev/null +++ b/components/Dialog.qml @@ -0,0 +1,66 @@ +// Copyright (c) 2021, The Monero Project +// +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import QtQuick 2.9 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.1 + +import "." as MoneroComponents + +Popup { + id: dialog + + default property alias content: mainLayout.children + property alias title: header.text + + background: Rectangle { + border.color: MoneroComponents.Style.blackTheme ? Qt.rgba(255, 255, 255, 0.25) : Qt.rgba(0, 0, 0, 0.25) + border.width: 1 + color: MoneroComponents.Style.blackTheme ? "black" : "white" + radius: 10 + } + closePolicy: Popup.CloseOnEscape + focus: true + padding: 20 + x: (appWindow.width - width) / 2 + y: (appWindow.height - height) / 2 + + ColumnLayout { + id: mainLayout + spacing: dialog.padding + + Text { + id: header + color: MoneroComponents.Style.defaultFontColor + font.bold: true + font.family: MoneroComponents.Style.fontRegular.name + font.pixelSize: 18 + visible: text != "" + } + } +} diff --git a/components/RemoteNodeDialog.qml b/components/RemoteNodeDialog.qml new file mode 100644 index 00000000..30383d44 --- /dev/null +++ b/components/RemoteNodeDialog.qml @@ -0,0 +1,153 @@ +// Copyright (c) 2021, The Monero Project +// +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without modification, are +// permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this list of +// conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, this list +// of conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import QtQuick 2.9 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.1 + +import "." as MoneroComponents + +MoneroComponents.Dialog { + id: root + title: (editMode ? qsTr("Edit remote node") : qsTr("Add remote node")) + translationManager.emptyString + + property var callbackOnSuccess: null + property bool editMode: false + property bool success: false + + onActiveFocusChanged: activeFocus && remoteNodeAddress.forceActiveFocus() + + function add(callbackOnSuccess) { + root.editMode = false; + root.callbackOnSuccess = callbackOnSuccess; + + open(); + } + + function edit(remoteNode, callbackOnSuccess) { + const hostPort = remoteNode.address.match(/^(.*?)(?:\:?(\d*))$/); + if (hostPort) { + remoteNodeAddress.daemonAddrText = hostPort[1]; + remoteNodeAddress.daemonPortText = hostPort[2]; + } + daemonUsername.text = remoteNode.username; + daemonPassword.text = remoteNode.password; + setTrustedDaemonCheckBox.checked = remoteNode.trusted; + root.callbackOnSuccess = callbackOnSuccess; + root.editMode = true; + + open(); + } + + onClosed: { + if (root.success && callbackOnSuccess) { + callbackOnSuccess({ + address: remoteNodeAddress.getAddress(), + username: daemonUsername.text, + password: daemonPassword.text, + trusted: setTrustedDaemonCheckBox.checked, + }); + } + + remoteNodeAddress.daemonAddrText = ""; + remoteNodeAddress.daemonPortText = ""; + daemonUsername.text = ""; + daemonPassword.text = ""; + setTrustedDaemonCheckBox.checked = false; + root.success = false; + } + + MoneroComponents.RemoteNodeEdit { + id: remoteNodeAddress + Layout.fillWidth: true + placeholderFontSize: 15 + + daemonAddrLabelText: qsTr("Address") + translationManager.emptyString + daemonPortLabelText: qsTr("Port") + translationManager.emptyString + } + + RowLayout { + Layout.fillWidth: true + spacing: 32 + + MoneroComponents.LineEdit { + id: daemonUsername + Layout.fillWidth: true + Layout.minimumWidth: 220 + labelText: qsTr("Daemon username") + translationManager.emptyString + placeholderText: qsTr("(optional)") + translationManager.emptyString + placeholderFontSize: 15 + labelFontSize: 14 + fontSize: 15 + } + + MoneroComponents.LineEdit { + id: daemonPassword + Layout.fillWidth: true + Layout.minimumWidth: 220 + labelText: qsTr("Daemon password") + translationManager.emptyString + placeholderText: qsTr("Password") + translationManager.emptyString + password: true + placeholderFontSize: 15 + labelFontSize: 14 + fontSize: 15 + } + } + + MoneroComponents.CheckBox { + id: setTrustedDaemonCheckBox + activeFocusOnTab: true + text: qsTr("Mark as Trusted Daemon") + translationManager.emptyString + } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: parent.spacing + + MoneroComponents.StandardButton { + activeFocusOnTab: true + fontBold: false + primary: false + text: qsTr("Cancel") + translationManager.emptyString + + onClicked: root.close() + } + + MoneroComponents.StandardButton { + activeFocusOnTab: true + fontBold: false + enabled: remoteNodeAddress.getAddress() != "" + text: qsTr("Ok") + translationManager.emptyString + + onClicked: { + root.success = true; + root.close(); + } + } + } +} diff --git a/main.qml b/main.qml index 54cdbdab..02bdc259 100644 --- a/main.qml +++ b/main.qml @@ -26,6 +26,7 @@ // STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF // THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import QtQml.Models 2.12 import QtQuick 2.9 import QtQuick.Window 2.0 import QtQuick.Controls 1.1 @@ -374,13 +375,13 @@ ApplicationWindow { console.log("Recovering from seed: ", persistentSettings.is_recovering) console.log("restore Height", persistentSettings.restore_height) - // Use saved daemon rpc login settings - currentWallet.setDaemonLogin(persistentSettings.daemonUsername, persistentSettings.daemonPassword) - - if(persistentSettings.useRemoteNode) - currentDaemonAddress = persistentSettings.remoteNodeAddress - else - currentDaemonAddress = localDaemonAddress + if (persistentSettings.useRemoteNode) { + const remoteNode = remoteNodesModel.currentRemoteNode(); + currentDaemonAddress = remoteNode.address; + currentWallet.setDaemonLogin(remoteNode.username, remoteNode.password); + } else { + currentDaemonAddress = localDaemonAddress; + } console.log("initializing with daemon address: ", currentDaemonAddress) currentWallet.initAsync( @@ -397,7 +398,7 @@ ApplicationWindow { } function isTrustedDaemon() { - return !persistentSettings.useRemoteNode || persistentSettings.is_trusted_daemon; + return !persistentSettings.useRemoteNode || remoteNodesModel.currentRemoteNode().trusted; } function usefulName(path) { @@ -626,7 +627,9 @@ ApplicationWindow { const callback = function() { persistentSettings.useRemoteNode = true; - currentDaemonAddress = persistentSettings.remoteNodeAddress; + const remoteNode = remoteNodesModel.currentRemoteNode(); + currentDaemonAddress = remoteNode.address; + currentWallet.setDaemonLogin(remoteNode.username, remoteNode.password); currentWallet.initAsync( currentDaemonAddress, isTrustedDaemon(), @@ -652,6 +655,7 @@ ApplicationWindow { console.log("disconnecting remote node"); persistentSettings.useRemoteNode = false; currentDaemonAddress = localDaemonAddress + currentWallet.setDaemonLogin("", ""); currentWallet.initAsync( currentDaemonAddress, isTrustedDaemon(), @@ -1360,6 +1364,8 @@ ApplicationWindow { confirmationDialog.open(); } } + + remoteNodesModel.initialize(); } MoneroSettings { @@ -1380,22 +1386,33 @@ ApplicationWindow { property bool miningIgnoreBattery : true property var nettype: NetworkType.MAINNET property int restore_height : 0 - property bool is_trusted_daemon : false + property bool is_trusted_daemon : false // TODO: drop after v0.17.2.0 release property bool is_recovering : false property bool is_recovering_from_device : false property bool customDecorations : true property string daemonFlags property int logLevel: 0 property string logCategories: "" - property string daemonUsername: "" - property string daemonPassword: "" + property string daemonUsername: "" // TODO: drop after v0.17.2.0 release + property string daemonPassword: "" // TODO: drop after v0.17.2.0 release property bool transferShowAdvanced: false property bool receiveShowAdvanced: false property bool historyShowAdvanced: false property bool historyHumanDates: true property string blockchainDataDir: "" property bool useRemoteNode: false - property string remoteNodeAddress: "" + property string remoteNodeAddress: "" // TODO: drop after v0.17.2.0 release + property string remoteNodesSerialized: JSON.stringify({ + selected: 0, + nodes: remoteNodeAddress != "" + ? [{ + address: remoteNodeAddress, + username: daemonUsername, + password: daemonPassword, + trusted: is_trusted_daemon, + }] + : [], + }) property string bootstrapNodeAddress: "" property bool segregatePreForkOutputs: true property bool keyReuseMitigation2: true @@ -1441,6 +1458,88 @@ ApplicationWindow { } } + ListModel { + id: remoteNodesModel + + property int selected: 0 + + signal store() + + function initialize() { + try { + const remoteNodes = JSON.parse(persistentSettings.remoteNodesSerialized); + for (var index = 0; index < remoteNodes.nodes.length; ++index) { + const remoteNode = remoteNodes.nodes[index]; + remoteNodesModel.append(remoteNode); + } + selected = remoteNodes.selected % remoteNodesModel.count || 0; + } catch (e) { + console.error('failed to parse remoteNodesSerialized', e); + } + + store.connect(function() { + var remoteNodes = []; + for (var index = 0; index < remoteNodesModel.count; ++index) { + remoteNodes.push(remoteNodesModel.get(index)); + } + persistentSettings.remoteNodesSerialized = JSON.stringify({ + selected: selected, + nodes: remoteNodes + }); + }); + } + + function appendIfNotExists(newRemoteNode) { + for (var index = 0; index < remoteNodesModel.count; ++index) { + const remoteNode = remoteNodesModel.get(index); + if (remoteNode.address == newRemoteNode.address && + remoteNode.username == newRemoteNode.username && + remoteNode.password == newRemoteNode.password && + remoteNode.trusted == newRemoteNode.trusted) { + return index; + } + } + remoteNodesModel.append(newRemoteNode); + return remoteNodesModel.count - 1; + } + + function applyRemoteNode(index) { + selected = index; + const remoteNode = currentRemoteNode(); + persistentSettings.useRemoteNode = true; + if (currentWallet) { + currentWallet.setDaemonLogin(remoteNode.username, remoteNode.password); + currentWallet.setTrustedDaemon(remoteNode.trusted); + appWindow.connectRemoteNode(); + } + } + + function currentRemoteNode() { + if (selected < remoteNodesModel.count) { + return remoteNodesModel.get(selected); + } + return { + address: "", + username: "", + password: "", + trusted: false, + }; + } + + function removeSelectNextIfNeeded(index) { + remoteNodesModel.remove(index); + if (selected == index) { + applyRemoteNode(selected % remoteNodesModel.count || 0); + } else if (selected > index) { + selected = selected - 1; + } + } + + onCountChanged: store() + onDataChanged: store() + onSelectedChanged: store() + } + // Information dialog StandardDialog { // dynamically change onclose handler @@ -1521,6 +1620,10 @@ ApplicationWindow { y: (parent.height - height) / 2 } + MoneroComponents.RemoteNodeDialog { + id: remoteNodeDialog + } + // Choose blockchain folder FileDialog { id: blockchainFileDialog @@ -1766,7 +1869,9 @@ ApplicationWindow { anchors.fill: blurredArea source: blurredArea radius: 64 - visible: passwordDialog.visible || inputDialog.visible || splash.visible || updateDialog.visible || devicePassphraseDialog.visible || txConfirmationPopup.visible || successfulTxPopup.visible + visible: passwordDialog.visible || inputDialog.visible || splash.visible || updateDialog.visible || + devicePassphraseDialog.visible || txConfirmationPopup.visible || successfulTxPopup.visible || + remoteNodeDialog.visible } @@ -2156,6 +2261,7 @@ ApplicationWindow { passwordDialog.onRejectedCallback = function() { appWindow.showWizard(); } if (inputDialogVisible) inputDialog.close() + remoteNodeDialog.close(); passwordDialog.open(); } diff --git a/pages/settings/SettingsNode.qml b/pages/settings/SettingsNode.qml index 9da1452e..a786ea1c 100644 --- a/pages/settings/SettingsNode.qml +++ b/pages/settings/SettingsNode.qml @@ -130,7 +130,7 @@ Rectangle{ topPadding: 0 text: qsTr("The blockchain is downloaded to your computer. Provides higher security and requires more local storage.") + translationManager.emptyString width: parent.width - (localNodeIcon.width + localNodeIcon.anchors.leftMargin + anchors.leftMargin) - } + } } MouseArea { @@ -262,80 +262,93 @@ Rectangle{ text: qsTr("To find a remote node, type 'Monero remote node' into your favorite search engine. Please ensure the node is run by a trusted third-party.") + translationManager.emptyString } - MoneroComponents.RemoteNodeEdit { - id: remoteNodeEdit - Layout.minimumWidth: 100 - placeholderFontSize: 15 - - daemonAddrLabelText: qsTr("Address") + translationManager.emptyString - daemonPortLabelText: qsTr("Port") + translationManager.emptyString - - initialAddress: persistentSettings.remoteNodeAddress - onEditingFinished: { - persistentSettings.remoteNodeAddress = remoteNodeEdit.getAddress(); - console.log("setting remote node to " + persistentSettings.remoteNodeAddress); - if (persistentSettings.is_trusted_daemon) { - persistentSettings.is_trusted_daemon = !persistentSettings.is_trusted_daemon - currentWallet.setTrustedDaemon(persistentSettings.is_trusted_daemon) - setTrustedDaemonCheckBox.checked = !setTrustedDaemonCheckBox.checked - appWindow.showStatusMessage(qsTr("Remote node updated. Trusted daemon has been reset. Mark again, if desired."), 8); - } - } - } - - GridLayout { - columns: 2 - columnSpacing: 32 - - MoneroComponents.LineEdit { - id: daemonUsername - Layout.fillWidth: true - labelText: qsTr("Daemon username") + translationManager.emptyString - text: persistentSettings.daemonUsername - placeholderText: qsTr("(optional)") + translationManager.emptyString - placeholderFontSize: 15 - labelFontSize: 14 - fontSize: 15 - } - - MoneroComponents.LineEdit { - id: daemonPassword - Layout.fillWidth: true - labelText: qsTr("Daemon password") + translationManager.emptyString - text: persistentSettings.daemonPassword - placeholderText: qsTr("Password") + translationManager.emptyString - password: true - placeholderFontSize: 15 - labelFontSize: 14 - fontSize: 15 - } - } - MoneroComponents.CheckBox { - id: setTrustedDaemonCheckBox - checked: persistentSettings.is_trusted_daemon - onClicked: { - persistentSettings.is_trusted_daemon = !persistentSettings.is_trusted_daemon - currentWallet.setTrustedDaemon(persistentSettings.is_trusted_daemon) - } - text: qsTr("Mark as Trusted Daemon") + translationManager.emptyString + border: false + checkedIcon: FontAwesome.minusCircle + uncheckedIcon: FontAwesome.plusCircle + fontAwesomeIcons: true + fontSize: 16 + iconOnTheLeft: true + text: qsTr("Add remote node") + translationManager.emptyString + toggleOnClick: false + onClicked: remoteNodeDialog.add(remoteNodesModel.append) } - MoneroComponents.StandardButton { - id: btnConnectRemote - enabled: remoteNodeEdit.isValid() - small: true - text: qsTr("Connect") + translationManager.emptyString - onClicked: { - // Update daemon login - persistentSettings.remoteNodeAddress = remoteNodeEdit.getAddress(); - persistentSettings.daemonUsername = daemonUsername.text; - persistentSettings.daemonPassword = daemonPassword.text; - persistentSettings.useRemoteNode = true + ColumnLayout { + spacing: 0 - currentWallet.setDaemonLogin(persistentSettings.daemonUsername, persistentSettings.daemonPassword); + Repeater { + model: remoteNodesModel - appWindow.connectRemoteNode() + Rectangle { + height: 30 + Layout.fillWidth: true + color: itemMouseArea.containsMouse || index === remoteNodesModel.selected ? MoneroComponents.Style.titleBarButtonHoverColor : "transparent" + + Rectangle { + color: MoneroComponents.Style.appWindowBorderColor + anchors.right: parent.right + anchors.left: parent.left + anchors.top: parent.top + height: 1 + visible: index > 0 + + MoneroEffects.ColorTransition { + targetObj: parent + blackColor: MoneroComponents.Style._b_appWindowBorderColor + whiteColor: MoneroComponents.Style._w_appWindowBorderColor + } + } + + Rectangle { + anchors.fill: parent + anchors.rightMargin: 80 + color: "transparent" + + MoneroComponents.TextPlain { + color: index === remoteNodesModel.selected ? MoneroComponents.Style.defaultFontColor : MoneroComponents.Style.dimmedFontColor + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 6 + font.pixelSize: 16 + text: address + themeTransition: false + } + + MouseArea { + id: itemMouseArea + cursorShape: Qt.PointingHandCursor + anchors.fill: parent + hoverEnabled: true + onClicked: remoteNodesModel.applyRemoteNode(index) + } + } + + RowLayout { + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: 6 + height: 30 + spacing: 10 + + MoneroComponents.InlineButton { + buttonColor: "transparent" + fontFamily: FontAwesome.fontFamily + text: FontAwesome.edit + onClicked: remoteNodeDialog.edit(remoteNodesModel.get(index), function (remoteNode) { + remoteNodesModel.set(index, remoteNode) + }) + } + + MoneroComponents.InlineButton { + buttonColor: "transparent" + fontFamily: FontAwesome.fontFamily + text: FontAwesome.times + visible: remoteNodesModel.count > 1 + onClicked: remoteNodesModel.removeSelectNextIfNeeded(index) + } + } + } } } } @@ -431,7 +444,7 @@ Rectangle{ } } } - } + } } } diff --git a/qml.qrc b/qml.qrc index 625da33f..1680e64a 100644 --- a/qml.qrc +++ b/qml.qrc @@ -3,10 +3,12 @@ main.qml LeftPanel.qml MiddlePanel.qml + components/Dialog.qml components/Label.qml components/LanguageButton.qml components/Navbar.qml components/NavbarItem.qml + components/RemoteNodeDialog.qml components/SettingsListItem.qml components/Slider.qml components/UpdateDialog.qml diff --git a/wizard/WizardDaemonSettings.qml b/wizard/WizardDaemonSettings.qml index 3f1f2204..bd54f7e1 100644 --- a/wizard/WizardDaemonSettings.qml +++ b/wizard/WizardDaemonSettings.qml @@ -42,7 +42,13 @@ ColumnLayout { function save(){ persistentSettings.useRemoteNode = remoteNode.checked - persistentSettings.remoteNodeAddress = remoteNodeEdit.getAddress(); + const index = remoteNodesModel.appendIfNotExists({ + address: remoteNodeEdit.getAddress(), + username: "", + password: "", + trusted: false, + }); + remoteNodesModel.applyRemoteNode(index); if (bootstrapNodeEdit.daemonAddrText == "auto") { persistentSettings.bootstrapNodeAddress = "auto"; } else { @@ -179,7 +185,7 @@ ColumnLayout { id: remoteNodeEdit Layout.fillWidth: true - initialAddress: persistentSettings.remoteNodeAddress + initialAddress: remoteNodesModel.currentRemoteNode().address } } } diff --git a/wizard/WizardSummary.qml b/wizard/WizardSummary.qml index 840ee971..de3c60ec 100644 --- a/wizard/WizardSummary.qml +++ b/wizard/WizardSummary.qml @@ -65,10 +65,10 @@ ColumnLayout { } WizardSummaryItem { - visible: persistentSettings.remoteNodeAddress !== "" && appWindow.walletMode == 0 + visible: remoteNodesModel.currentRemoteNode().address !== "" && appWindow.walletMode == 0 Layout.fillWidth: true header: qsTr("Daemon address") + translationManager.emptyString - value: persistentSettings.remoteNodeAddress + value: remoteNodesModel.currentRemoteNode().address } WizardSummaryItem {