Files
purple-electrumwallet/electrum/gui/qml/components/main.qml
T
f321x 07f61ebd5a qml: PasswordDialog: show error on invalid password
Currently the PasswordDialog on QML would just close if the user enters
an incorrect password. This is confusing as the user doesn't know why
the dialog closed and if it initiated any action or not.

With the change the PasswordDialog will get the ability to show an error
message and will show "Invalid Password" if an incorrect password is
entered.
I also used it for the password unification warning ("Need to enter
similar password ...") instead of showing a separate popup.
2026-01-20 12:30:31 +01:00

901 lines
29 KiB
QML

import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Basic
import QtQuick.Controls.Material
import QtQuick.Controls.Material.impl
import QtQuick.Window
import QtQml
import QtMultimedia
import org.electrum 1.0
import "controls"
ApplicationWindow
{
id: app
visible: false // initial value
readonly property int statusBarHeight: AppController ? AppController.getStatusBarHeight() : 0
readonly property int navigationBarHeight: AppController ? AppController.getNavigationBarHeight() : 0
// 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 QtObject constants: appconstants
Constants { id: appconstants }
property alias stack: mainStackView
property alias keyboardFreeZone: _keyboardFreeZone
property alias infobanner: _infobanner
property string pendingIntent: ""
property variant activeDialogs: []
property var _exceptionDialog
property var pluginobjects: ({})
property QtObject appMenu: Menu {
id: menu
parent: Overlay.overlay
dim: true
modal: true
Overlay.modal: Rectangle {
color: "#44000000"
}
property int implicitChildrenWidth: 64
width: implicitChildrenWidth + 60 + constants.paddingLarge
MenuItem {
icon.color: action.enabled ? 'transparent' : Material.iconDisabledColor
icon.source: '../../icons/network.png'
action: Action {
text: qsTr('Network')
onTriggered: menu.openPage(Qt.resolvedUrl('NetworkOverview.qml'))
enabled: stack.currentItem.objectName != 'NetworkOverview'
}
}
MenuItem {
icon.color: action.enabled ? 'transparent' : Material.iconDisabledColor
icon.source: '../../icons/preferences.png'
action: Action {
text: qsTr('Preferences')
onTriggered: menu.openPage(Qt.resolvedUrl('Preferences.qml'))
enabled: stack.currentItem.objectName != 'Properties'
}
}
MenuItem {
icon.color: action.enabled ? 'transparent' : Material.iconDisabledColor
icon.source: '../../icons/electrum.png'
action: Action {
text: qsTr('About');
onTriggered: menu.openPage(Qt.resolvedUrl('About.qml'))
enabled: stack.currentItem.objectName != 'About'
}
}
function openPage(url) {
stack.pushOnRoot(url)
currentIndex = -1
}
// determine widest element and store in implicitChildrenWidth
function updateImplicitWidth() {
for (let i = 0; i < menu.count; i++) {
var item = menu.itemAt(i)
var txt = item.text
var txtwidth = fontMetrics.advanceWidth(txt)
if (txtwidth > menu.implicitChildrenWidth) {
menu.implicitChildrenWidth = txtwidth
}
}
}
FontMetrics {
id: fontMetrics
font: menu.font
}
Component.onCompleted: updateImplicitWidth()
}
function openAppMenu() {
appMenu.open()
appMenu.x = app.width - appMenu.width
appMenu.y = toolbar.height
}
header: ToolBar {
id: toolbar
// Add top margin for status bar on Android when using edge-to-edge
topPadding: app.statusBarHeight
background: Rectangle {
implicitHeight: 48
color: Material.dialogColor
layer.enabled: true
layer.effect: ElevationEffect {
elevation: 4
fullWidth: true
}
}
ColumnLayout {
spacing: 0
anchors.left: parent.left
anchors.right: parent.right
height: toolbar.availableHeight
RowLayout {
id: toolbarTopLayout
Layout.fillWidth: true
Layout.rightMargin: constants.paddingMedium
Layout.alignment: Qt.AlignVCenter
Item {
Layout.fillWidth: true
Layout.preferredHeight: Math.max(implicitHeight, toolbarTopLayout.height)
MouseArea {
anchors.fill: parent
enabled: Daemon.currentWallet &&
(!stack.currentItem || !stack.currentItem.title || stack.currentItem.title == Daemon.currentWallet.name)
onClicked: {
stack.getRoot().menu.open() // open wallet-menu
stack.getRoot().menu.y = toolbar.height
}
}
RowLayout {
width: parent.width
Item {
Layout.preferredWidth: constants.paddingXLarge
Layout.preferredHeight: 1
}
Image {
Layout.preferredWidth: constants.iconSizeSmall
Layout.preferredHeight: constants.iconSizeSmall
visible: Daemon.currentWallet &&
(!stack.currentItem || !stack.currentItem.title || stack.currentItem.title == Daemon.currentWallet.name)
source: '../../icons/wallet.png'
}
Label {
Layout.fillWidth: true
Layout.preferredHeight: Math.max(implicitHeight, toolbarTopLayout.height)
text: stack.currentItem && stack.currentItem.title
? stack.currentItem.title
: Daemon.currentWallet.name
elide: Label.ElideRight
verticalAlignment: Qt.AlignVCenter
font.pixelSize: constants.fontSizeMedium
font.bold: true
}
}
}
Item {
implicitHeight: 48
implicitWidth: statusIconsLayout.width
MouseArea {
anchors.fill: parent
onClicked: openAppMenu() // open global-app-menu
}
RowLayout {
id: statusIconsLayout
anchors.verticalCenter: parent.verticalCenter
Item {
Layout.preferredWidth: constants.paddingLarge
Layout.preferredHeight: 1
}
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
}
}
}
LightningNetworkStatusIndicator {
id: lnnsi
}
OnchainNetworkStatusIndicator { }
}
}
}
// hack to force relayout of toolbar
// since qt6 LightningNetworkStatusIndicator.visible doesn't trigger relayout(?)
Item {
Layout.preferredHeight: 1
Layout.topMargin: -1
Layout.preferredWidth: lnnsi.visible
? 1
: 2
}
}
}
ColumnLayout {
width: parent.width
height: _keyboardFreeZone.height - header.height
spacing: 0
InfoBanner {
id: _infobanner
Layout.fillWidth: true
}
StackView {
id: mainStackView
Layout.fillHeight: true
Layout.fillWidth: true
initialItem: Component {
Wallets {}
}
function getRoot() {
return mainStackView.get(0)
}
function pushOnRoot(item) {
if (mainStackView.depth > 1) {
mainStackView.replace(mainStackView.get(1), item)
} else {
mainStackView.push(item)
}
}
function replaceRoot(item_url) {
mainStackView.clear()
mainStackView.push(Qt.resolvedUrl(item_url))
}
}
// Add bottom padding for navigation bar on Android when UI is edge-to-edge
Item {
visible: app.navigationBarHeight > 0
Layout.fillWidth: true
Layout.preferredHeight: app.navigationBarHeight
}
}
Timer {
id: coverTimer
interval: 10
onTriggered: {
app.visible = true
cover.opacity = 0
}
}
Rectangle {
id: cover
parent: Overlay.overlay
anchors.fill: parent
z: 1000
color: 'black'
Behavior on opacity {
enabled: AppController ? AppController.isAndroid() : false
NumberAnimation {
duration: 1000
easing.type: Easing.OutQuad;
}
}
}
Item {
id: _keyboardFreeZone
// Item as first child in Overlay that adjusts its size to the available
// screen space minus the virtual keyboard (e.g. to center dialogs in)
// see also ElDialog.resizeWithKeyboard property
parent: Overlay.overlay
width: parent.width
height: parent.height
states: [
State {
name: 'visible'
when: Qt.inputMethod.keyboardRectangle.y
PropertyChanges {
target: _keyboardFreeZone
height: _keyboardFreeZone.parent.height - (Screen.desktopAvailableHeight - (Qt.inputMethod.keyboardRectangle.y/Screen.devicePixelRatio))
}
}
]
transitions: [
Transition {
from: ''
to: 'visible'
NumberAnimation {
properties: 'height'
duration: 100
easing.type: Easing.OutQuad
}
},
Transition {
from: 'visible'
to: ''
SequentialAnimation {
PauseAnimation {
duration: 200
}
NumberAnimation {
properties: 'height'
duration: 50
easing.type: Easing.OutQuad
}
}
}
]
}
property alias newWalletWizard: _newWalletWizard
Component {
id: _newWalletWizard
NewWalletWizard {
onClosed: destroy()
}
}
property alias termsOfUseWizard: _termsOfUseWizard
Component {
id: _termsOfUseWizard
TermsOfUseWizard {
onClosed: destroy()
}
}
property alias serverConnectWizard: _serverConnectWizard
Component {
id: _serverConnectWizard
ServerConnectWizard {
onClosed: destroy()
}
}
property alias messageDialog: _messageDialog
Component {
id: _messageDialog
MessageDialog {
onClosed: destroy()
}
}
property alias helpDialog: _helpDialog
Component {
id: _helpDialog
HelpDialog {
onClosed: destroy()
}
}
property alias passwordDialog: _passwordDialog
Component {
id: _passwordDialog
PasswordDialog {
onClosed: destroy()
}
}
property alias genericShareDialog: _genericShareDialog
Component {
id: _genericShareDialog
GenericShareDialog {
onClosed: destroy()
}
}
property alias openWalletDialog: _openWalletDialog
Component {
id: _openWalletDialog
OpenWalletDialog {
onClosed: destroy()
}
}
property alias loadingWalletDialog: _loadingWalletDialog
Component {
id: _loadingWalletDialog
LoadingWalletDialog {
onClosed: destroy()
}
}
property Component scanDialog // set in Component.onCompleted
Component {
id: _scanDialog
QRScanner {
onFinished: destroy()
}
}
Component {
id: _qtScanDialog
ScanDialog {
onClosed: destroy()
}
}
Component {
id: crashDialog
ExceptionDialog {
onClosed: destroy()
}
}
property alias channelOpenProgressDialog: _channelOpenProgressDialog
ChannelOpenProgressDialog {
id: _channelOpenProgressDialog
}
property alias signVerifyMessageDialog: _signVerifyMessageDialog
Component {
id: _signVerifyMessageDialog
SignVerifyMessageDialog {
onClosed: destroy()
}
}
property alias nostrSwapServersDialog: _nostrSwapServersDialog
Component {
id: _nostrSwapServersDialog
NostrSwapServersDialog {
onClosed: destroy()
}
}
Component {
id: swapDialog
SwapDialog {
id: _swapdialog
onClosed: destroy()
swaphelper: SwapHelper {
id: _swaphelper
wallet: Daemon.currentWallet
onAuthRequired: (method, authMessage) => {
app.handleAuthRequired(_swaphelper, method, authMessage)
}
onError: (message) => {
var dialog = app.messageDialog.createObject(app, {
title: qsTr('Error'),
iconSource: Qt.resolvedUrl('../../icons/warning.png'),
text: message
})
dialog.open()
}
onUndefinedNPub: {
var dialog = app.nostrSwapServersDialog.createObject(app, {
swaphelper: _swaphelper,
selectedPubkey: Config.swapServerNPub
})
dialog.accepted.connect(function() {
Config.swapServerNPub = dialog.selectedPubkey
_swaphelper.setReadyState()
})
dialog.rejected.connect(function() {
_swaphelper.npubSelectionCancelled()
})
dialog.open()
}
}
}
}
NotificationPopup {
id: notificationPopup
width: parent.width
}
Component.onCompleted: {
coverTimer.start()
if (AppController.isAndroid()) {
app.scanDialog = _scanDialog
} else {
app.scanDialog = _qtScanDialog
}
function continueWithServerConnection() {
if (!Network.autoConnectDefined) {
var dialog = serverConnectWizard.createObject(app)
// without completed serverConnectWizard we can't start
dialog.rejected.connect(function() {
app.visible = false
AppController.wantClose = true
Qt.callLater(Qt.quit)
})
dialog.accepted.connect(function() {
Daemon.startNetwork()
var newww = app.newWalletWizard.createObject(app)
newww.walletCreated.connect(function() {
Daemon.availableWallets.reload()
// and load the new wallet
Daemon.loadWallet(newww.path, newww.wizard_data['password'])
})
newww.open()
})
dialog.open()
} else {
Daemon.startNetwork()
if (Daemon.availableWallets.rowCount() > 0) {
Daemon.loadWallet()
} else {
var newww = app.newWalletWizard.createObject(app)
newww.walletCreated.connect(function() {
Daemon.availableWallets.reload()
// and load the new wallet
Daemon.loadWallet(newww.path, newww.wizard_data['password'])
})
newww.open()
}
}
}
if (!Config.termsOfUseAccepted) {
var dialog = termsOfUseWizard.createObject(app)
dialog.rejected.connect(function() {
app.visible = false
AppController.wantClose = true
Qt.callLater(Qt.quit)
})
dialog.accepted.connect(function() {
Config.termsOfUseAccepted = true
continueWithServerConnection()
})
dialog.open()
} else {
continueWithServerConnection()
}
}
onClosing: (close) => {
if (AppController.wantClose) {
// destroy most GUI components so that we don't dump so many null reference warnings on exit
app.header.visible = false
mainStackView.clear()
return
}
if (activeDialogs.length > 0) {
var activeDialog = activeDialogs[activeDialogs.length - 1]
if (activeDialog.allowClose) {
console.log('main: dialog.doClose')
activeDialog.doClose()
} else {
console.log('dialog disallowed close')
}
close.accepted = false
return
}
if (stack.depth > 1) {
close.accepted = false
stack.pop()
} else {
var dialog = app.messageDialog.createObject(app, {
title: qsTr('Close Electrum?'),
yesno: true
})
dialog.accepted.connect(function() {
AppController.wantClose = true
app.close()
})
dialog.open()
close.accepted = false
}
}
property var _pendingBiometricAuth: null
property var _loadingWalletContext: null
Connections {
target: Biometrics
function onUnlockSuccess(password) {
if (app._pendingBiometricAuth) {
if (app._pendingBiometricAuth.action === 'load_wallet') {
app._loadingWalletContext = _pendingBiometricAuth
Daemon.loadWallet(app._pendingBiometricAuth.path, password)
app._pendingBiometricAuth = null
return
}
let qtobject = app._pendingBiometricAuth.qtobject
let method = app._pendingBiometricAuth.method
if (Daemon.currentWallet.verifyPassword(password)) {
qtobject.authProceed()
} else {
console.warn("Biometric password invalid falling back to manual input")
// this shouldn't really happen so we better disable biometric auth
Biometrics.disable()
handleManualAuth(qtobject, method, app._pendingBiometricAuth.authMessage)
}
app._pendingBiometricAuth = null
}
}
function onUnlockError(error) {
console.log("Biometric auth failed: " + error)
// we end up here if QEBiometrics fails to give us the decrypted password. The user might
// have cancelled the biometric auth popup or the key got invalidated because a new fingerprint got registered.
if (app._pendingBiometricAuth) {
if (app._pendingBiometricAuth.action === 'load_wallet') {
// set loadingWalletContext to disable biometric auth until the OpenWalletDialog is closed
app._loadingWalletContext = app._pendingBiometricAuth
showOpenWalletDialog(app._pendingBiometricAuth.name, app._pendingBiometricAuth.path)
} else {
console.log('biometric auth failed, not falling back to passwordDialog')
app._pendingBiometricAuth.qtobject.authCancel() // no fallback to password dialog
}
app._pendingBiometricAuth = null
}
}
function onAuthRequired(method, authMessage) {
handleAuthRequired(Biometrics, method, authMessage)
}
}
property var _opendialog: null
property var _opendialog_startup: true
function showOpenWalletDialog(name, path) {
if (!_opendialog) {
_opendialog = openWalletDialog.createObject(app, {
name: name,
path: path,
isStartup: _opendialog_startup,
})
_opendialog.closed.connect(function() {
_opendialog = null
app._loadingWalletContext = null // dialog closed, we can allow trying biometric auth again
_opendialog_startup = false
})
_opendialog.open()
}
}
Connections {
target: Daemon
function onWalletRequiresPassword(name, path) {
console.log('wallet requires password')
if (Biometrics.isAvailable && Biometrics.isEnabled && !app._loadingWalletContext) {
app._pendingBiometricAuth = {
action: 'load_wallet',
name: name,
path: path
}
Biometrics.unlock()
} else {
showOpenWalletDialog(name, path)
}
}
function onWalletOpenError(error) {
console.log('wallet open error')
var dialog = app.messageDialog.createObject(app, {
title: qsTr('Error'),
iconSource: Qt.resolvedUrl('../../icons/warning.png'),
text: error
})
dialog.open()
}
function onAuthRequired(method, authMessage) {
handleAuthRequired(Daemon, method, authMessage)
}
function onLoadingChanged() {
if (!Daemon.loading)
return
console.log('wallet loading')
var dialog = loadingWalletDialog.createObject(app, { allowClose: false } )
dialog.open()
}
function onWalletLoaded() {
app._loadingWalletContext = null // either biometric auth or manual auth was successful
}
}
Connections {
target: AppController
function onUserNotify(wallet_name, message) {
notificationPopup.show(wallet_name, message)
}
function onShowException(crash_data) {
if (app._exceptionDialog)
return
app._exceptionDialog = crashDialog.createObject(app, {
crashData: crash_data
})
app._exceptionDialog.onClosed.connect(function() {
app._exceptionDialog = null
})
app._exceptionDialog.open()
}
function onPluginLoaded(name) {
console.log('plugin ' + name + ' loaded')
var loader = AppController.plugin(name).loader
if (loader == undefined)
return
var url = Qt.resolvedUrl('../../../plugins/' + name + '/qml/' + loader)
var comp = Qt.createComponent(url)
if (comp.status == Component.Error) {
console.log('Could not find/parse PluginLoader for plugin ' + name)
console.log(comp.errorString())
return
}
var obj = comp.createObject(app)
if (obj != null)
app.pluginobjects[name] = obj
}
function onUriReceived(uri) {
console.log('uri received (main): ' + uri)
app.pendingIntent = uri
}
}
function pluginsComponentsByName(comp_name) {
// return named QML components from plugins
var plugins = AppController.plugins
var result = []
for (var i=0; i < plugins.length; i++) {
if (!plugins[i].enabled)
continue
var pluginobject = app.pluginobjects[plugins[i].name]
if (!pluginobject)
continue
if (!(comp_name in pluginobject))
continue
var comp = pluginobject[comp_name]
if (!comp)
continue
result.push(comp)
}
return result
}
Connections {
target: Daemon.currentWallet
function onAuthRequired(method, authMessage) {
handleAuthRequired(Daemon.currentWallet, method, authMessage)
}
// TODO: add to notification queue instead of barging through
function onPaymentSucceeded(key) {
notificationPopup.show(Daemon.currentWallet.name, qsTr('Payment succeeded'))
}
function onPaymentFailed(key, reason) {
notificationPopup.show(Daemon.currentWallet.name, qsTr('Payment failed') + ': ' + reason)
}
}
Connections {
target: Config
function onAuthRequired(method, authMessage) {
handleAuthRequired(Config, method, authMessage)
}
}
function handleAuthRequired(qtobject, method, authMessage) {
console.log('auth using method ' + method)
if (method === 'payment_auth') {
if (Config.paymentAuthentication) {
// treat like a wallet auth request
method = 'wallet'
} else {
handleAuthConfirmationOnly(qtobject, authMessage)
return
}
}
if (Daemon.currentWallet.verifyPassword('')) {
// wallet has no password
qtobject.authProceed()
return
}
if (method !== 'wallet_password_only') {
if (Biometrics.isAvailable && Biometrics.isEnabled) {
app._pendingBiometricAuth = {
qtobject: qtobject,
method: method,
authMessage: authMessage
}
Biometrics.unlock(authMessage)
return
}
}
handleManualAuth(qtobject, method, authMessage)
}
function handleManualAuth(qtobject, method, authMessage) {
// 'payment_auth' should have been converted to 'wallet' at this point
if (method === 'wallet' || method === 'wallet_password_only') {
var dialog = app.passwordDialog.createObject(app, authMessage ? {'title': authMessage} : {})
dialog.passwordEntered.connect(function(password) {
if (Daemon.currentWallet.verifyPassword(password)) {
dialog.close()
qtobject.authProceed()
} else {
dialog.clearPassword()
dialog.errorMessage = qsTr("Invalid Password")
}
})
dialog.rejected.connect(function() {
qtobject.authCancel()
})
dialog.open()
} else {
console.log('unknown auth method ' + method)
qtobject.authCancel()
}
}
function handleAuthConfirmationOnly(qtobject, authMessage) {
if (!authMessage) {
qtobject.authProceed()
return
}
var dialog = app.messageDialog.createObject(app, {
title: authMessage,
yesno: true
})
dialog.accepted.connect(function() {
qtobject.authProceed()
})
dialog.rejected.connect(function() {
qtobject.authCancel()
})
dialog.open()
}
function startSwap() {
var swapdialog = swapDialog.createObject(app)
swapdialog.open()
}
property var _lastActive: 0 // record time of last activity
property bool _lockDialogShown: false
}