From 212e0fef03409b6899c9c990192aa6ef4b70b6a8 Mon Sep 17 00:00:00 2001 From: gg Date: Mon, 19 Jan 2026 21:26:17 -0500 Subject: [PATCH] Feat: Smart Sync & Import Transaction Fixes - Implemented 'Smart Sync': scans only necessary blocks (tip-10) for unlocking funds. - Fixed 'Import Transaction' over-scanning: added Smart Restore to jump wallet height for fresh wallets. - UI: Removed obsolete 'Scan Mempool' setting; enabled ephemeral scan on connections. - Stability: Ensured 'Import Transaction' is non-blocking async. --- src/CoinsWidget.cpp | 5 +- src/HistoryWidget.cpp | 30 + src/HistoryWidget.h | 3 +- src/MainWindow.cpp | 595 ++++++++++++++++-- src/MainWindow.h | 15 + src/SendWidget.cpp | 28 +- src/SettingsDialog.cpp | 73 ++- src/SettingsDialog.ui | 30 + src/WindowManager.cpp | 72 ++- src/WindowManager.h | 1 + src/assets.qrc | 2 + src/assets/feather.desktop | 2 +- src/assets/images/status_idle.svg | 126 ++++ src/assets/images/status_idle_proxy.svg | 126 ++++ src/components.cpp | 5 +- src/constants.h | 1 + src/dialog/AboutDialog.cpp | 6 + src/dialog/AboutDialog.ui | 28 +- src/dialog/DebugInfoDialog.cpp | 6 +- src/dialog/PaymentRequestDialog.cpp | 6 +- src/dialog/QrCodeDialog.cpp | 5 +- src/dialog/SyncRangeDialog.cpp | 154 +++++ src/dialog/SyncRangeDialog.h | 49 ++ src/dialog/TxImportDialog.cpp | 117 +++- src/dialog/TxImportDialog.h | 7 +- src/libwalletqt/Wallet.cpp | 489 +++++++++++++- src/libwalletqt/Wallet.h | 53 +- src/main.cpp | 46 +- src/model/AddressBookProxyModel.h | 6 + src/model/CoinsProxyModel.cpp | 13 + src/model/HistoryView.cpp | 8 +- src/model/SubaddressProxyModel.h | 7 + src/model/TransactionHistoryProxyModel.h | 6 + src/model/WalletKeysFilesModel.cpp | 5 +- .../crowdfunding/CCSProgressDelegate.cpp | 2 +- src/plugins/tickers/TickersWidget.cpp | 1 + src/utils/Utils.cpp | 74 +++ src/utils/Utils.h | 7 + src/utils/WebsocketClient.cpp | 8 +- src/utils/config.cpp | 11 +- src/utils/config.h | 10 + src/utils/nodes.cpp | 67 +- src/utils/nodes.h | 3 + src/utils/prices.cpp | 8 +- src/utils/prices.h | 2 + src/widgets/NodeWidget.cpp | 10 +- src/widgets/PayToEdit.h | 4 +- src/widgets/TickerWidget.cpp | 28 +- src/wizard/PageOpenWallet.cpp | 4 +- 49 files changed, 2208 insertions(+), 156 deletions(-) create mode 100644 src/assets/images/status_idle.svg create mode 100644 src/assets/images/status_idle_proxy.svg create mode 100644 src/dialog/SyncRangeDialog.cpp create mode 100644 src/dialog/SyncRangeDialog.h diff --git a/src/CoinsWidget.cpp b/src/CoinsWidget.cpp index edee303b..f51f1d70 100644 --- a/src/CoinsWidget.cpp +++ b/src/CoinsWidget.cpp @@ -10,6 +10,7 @@ #include "dialog/OutputSweepDialog.h" #include "utils/Icons.h" #include "utils/Utils.h" +#include "utils/config.h" #ifdef WITH_SCANNER #include "wizard/offline_tx_signing/OfflineTxSigningWizard.h" @@ -222,14 +223,14 @@ void CoinsWidget::viewOutput() { } void CoinsWidget::onSweepOutputs() { - if (!m_wallet->isConnected()) { + if (!m_wallet->isConnected() && !conf()->get(Config::syncPaused).toBool()) { Utils::showError(this, "Unable to create transaction", "Wallet is not connected to a node.", {"Wait for the wallet to automatically connect to a node.", "Go to File -> Settings -> Network -> Node to manually connect to a node."}, "nodes"); return; } - if (!m_wallet->isSynchronized()) { + if (!m_wallet->isSynchronized() && !conf()->get(Config::syncPaused).toBool()) { Utils::showError(this, "Unable to create transaction", "Wallet is not synchronized", {"Wait for wallet synchronization to complete"}, "synchronization"); return; } diff --git a/src/HistoryWidget.cpp b/src/HistoryWidget.cpp index 6f2433b7..09aa843b 100644 --- a/src/HistoryWidget.cpp +++ b/src/HistoryWidget.cpp @@ -15,6 +15,11 @@ #include "utils/Icons.h" #include "WebsocketNotifier.h" +#include +#include +#include +#include + HistoryWidget::HistoryWidget(Wallet *wallet, QWidget *parent) : QWidget(parent) , ui(new Ui::HistoryWidget) @@ -33,6 +38,7 @@ HistoryWidget::HistoryWidget(Wallet *wallet, QWidget *parent) m_copyMenu->addAction("Date", this, [this]{copy(copyField::Date);}); m_copyMenu->addAction("Description", this, [this]{copy(copyField::Description);}); m_copyMenu->addAction("Amount", this, [this]{copy(copyField::Amount);}); + m_copyMenu->addAction("Row as JSON", this, [this]{copy(copyField::JSON);}); ui->history->setContextMenuPolicy(Qt::CustomContextMenu); connect(ui->history, &QTreeView::customContextMenuRequested, this, &HistoryWidget::showContextMenu); @@ -204,6 +210,30 @@ void HistoryWidget::copy(copyField field) { conf()->get(Config::timeFormat).toString())); case copyField::Amount: return WalletManager::displayAmount(abs(tx.balanceDelta)); + case copyField::JSON: { + QJsonObject obj; + obj.insert("txid", tx.hash); + obj.insert("amount", static_cast(tx.amount)); + obj.insert("fee", static_cast(tx.fee)); + obj.insert("height", static_cast(tx.blockHeight)); + obj.insert("timestamp", tx.timestamp.toSecsSinceEpoch()); + obj.insert("direction", tx.direction == TransactionRow::Direction_In ? "in" : "out"); + obj.insert("payment_id", tx.paymentId); + obj.insert("description", tx.description); + obj.insert("confirmations", static_cast(tx.confirmations)); + obj.insert("failed", tx.failed); + obj.insert("pending", tx.pending); + obj.insert("coinbase", tx.coinbase); + obj.insert("label", tx.label); + + QJsonArray subaddrIndices; + for (const auto &idx : tx.subaddrIndex) subaddrIndices.append(static_cast(idx)); + obj.insert("subaddr_index", subaddrIndices); + obj.insert("subaddr_account", static_cast(tx.subaddrAccount)); + + QJsonDocument doc(obj); + return QString::fromUtf8(doc.toJson(QJsonDocument::Indented)); + } default: return QString(""); } diff --git a/src/HistoryWidget.h b/src/HistoryWidget.h index d9d98263..f619a126 100644 --- a/src/HistoryWidget.h +++ b/src/HistoryWidget.h @@ -48,7 +48,8 @@ private: TxID = 0, Description, Date, - Amount + Amount, + JSON }; void copy(copyField field); diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 6a2d6b80..d7bf5dc1 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -7,7 +7,15 @@ #include #include #include +#include +#include +#include #include +#include +#include +#include +#include +#include #include "constants.h" #include "dialog/AddressCheckerIndexDialog.h" @@ -24,16 +32,19 @@ #include "dialog/ViewOnlyDialog.h" #include "dialog/WalletInfoDialog.h" #include "dialog/WalletCacheDebugDialog.h" +#include "dialog/SyncRangeDialog.h" #include "libwalletqt/AddressBook.h" #include "libwalletqt/rows/CoinsInfo.h" #include "libwalletqt/rows/Output.h" #include "libwalletqt/TransactionHistory.h" #include "model/AddressBookModel.h" #include "plugins/PluginRegistry.h" +#include "plugins/Plugin.h" #include "utils/AppData.h" #include "utils/AsyncTask.h" #include "utils/ColorScheme.h" #include "utils/Icons.h" +#include "utils/RestoreHeightLookup.h" #include "utils/TorManager.h" #include "utils/WebsocketNotifier.h" @@ -80,7 +91,7 @@ MainWindow::MainWindow(WindowManager *windowManager, Wallet *wallet, QWidget *pa this->onOfflineMode(conf()->get(Config::offlineMode).toBool()); conf()->set(Config::restartRequired, false); - + // Websocket notifier #ifdef CHECK_UPDATES connect(websocketNotifier(), &WebsocketNotifier::UpdatesReceived, m_updater.data(), &Updater::wsUpdatesReceived); @@ -93,6 +104,7 @@ MainWindow::MainWindow(WindowManager *windowManager, Wallet *wallet, QWidget *pa connect(m_windowManager, &WindowManager::proxySettingsChanged, this, &MainWindow::onProxySettingsChangedConnect); connect(m_windowManager, &WindowManager::updateBalance, m_wallet, &Wallet::updateBalance); + connect(m_windowManager, &WindowManager::websocketStatusChanged, m_wallet, &Wallet::updateBalance); connect(m_windowManager, &WindowManager::offlineMode, this, &MainWindow::onOfflineMode); connect(m_windowManager, &WindowManager::manualFeeSelectionEnabled, this, &MainWindow::onManualFeeSelectionEnabled); connect(m_windowManager, &WindowManager::subtractFeeFromAmountEnabled, this, &MainWindow::onSubtractFeeFromAmountEnabled); @@ -109,12 +121,20 @@ MainWindow::MainWindow(WindowManager *windowManager, Wallet *wallet, QWidget *pa // Timers connect(&m_updateBytes, &QTimer::timeout, this, &MainWindow::updateNetStats); + connect(&m_updateBytes, &QTimer::timeout, this, &MainWindow::updateStatusToolTip); connect(&m_txTimer, &QTimer::timeout, [this]{ - m_statusLabelStatus->setText("Constructing transaction" + this->statusDots()); + QString text = "Constructing transaction" + this->statusDots(); + m_statusLabelStatus->setText(text); }); conf()->set(Config::firstRun, false); + connect(conf(), &Config::changed, this, [this](Config::ConfigKey key){ + if (key == Config::syncPaused) { + this->setSyncPaused(conf()->get(Config::syncPaused).toBool()); + } + }); + this->onWalletOpened(); connect(&appData()->prices, &Prices::fiatPricesUpdated, this, &MainWindow::updateBalance); @@ -135,7 +155,7 @@ void MainWindow::initStatusBar() { this->statusBar()->setFixedHeight(30); m_statusLabelStatus = new QLabel("Idle", this); - m_statusLabelStatus->setTextInteractionFlags(Qt::TextSelectableByMouse); + m_statusLabelStatus->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse); this->statusBar()->addWidget(m_statusLabelStatus); m_statusLabelNetStats = new QLabel("", this); @@ -151,16 +171,32 @@ void MainWindow::initStatusBar() { m_statusLabelBalance = new ClickableLabel(this); m_statusLabelBalance->setText("Balance: 0 XMR"); - m_statusLabelBalance->setTextInteractionFlags(Qt::TextSelectableByMouse); - m_statusLabelBalance->setCursor(Qt::PointingHandCursor); + m_statusLabelBalance->setContextMenuPolicy(Qt::ActionsContextMenu); this->statusBar()->addPermanentWidget(m_statusLabelBalance); - connect(m_statusLabelBalance, &ClickableLabel::clicked, this, &MainWindow::showBalanceDialog); + + connect(m_statusLabelBalance, &ClickableLabel::clicked, this, [this](){ + QMenu menu; + menu.addActions(m_statusLabelBalance->actions()); + menu.exec(QCursor::pos()); + }); + + QAction *copyBalanceAction = new QAction(tr("Copy amount"), this); + connect(copyBalanceAction, &QAction::triggered, this, [this](){ + QApplication::clipboard()->setText(m_statusLabelBalance->property("copyableValue").toString()); + }); + m_statusLabelBalance->addAction(copyBalanceAction); + + QAction *showBalanceAction = new QAction(tr("Show details"), this); + connect(showBalanceAction, &QAction::triggered, this, &MainWindow::showBalanceDialog); + m_statusLabelBalance->addAction(showBalanceAction); m_statusBtnConnectionStatusIndicator = new StatusBarButton(icons()->icon("status_disconnected.svg"), "Connection status", this); connect(m_statusBtnConnectionStatusIndicator, &StatusBarButton::clicked, [this](){ this->onShowSettingsPage(Settings::Pages::NETWORK); }); this->statusBar()->addPermanentWidget(m_statusBtnConnectionStatusIndicator); + + // Initial status set this->onConnectionStatusChanged(Wallet::ConnectionStatus_Disconnected); m_statusAccountSwitcher = new StatusBarButton(icons()->icon("change_account.png"), "Account switcher", this); @@ -188,6 +224,130 @@ void MainWindow::initStatusBar() { connect(m_statusBtnHwDevice, &StatusBarButton::clicked, this, &MainWindow::menuHwDeviceClicked); this->statusBar()->addPermanentWidget(m_statusBtnHwDevice); m_statusBtnHwDevice->hide(); + + m_statusLabelStatus->setContextMenuPolicy(Qt::ActionsContextMenu); + + m_actionPauseSync = new QAction(tr("Pause Sync"), this); + m_actionPauseSync->setCheckable(true); + m_actionPauseSync->setChecked(conf()->get(Config::syncPaused).toBool()); + m_statusLabelStatus->addAction(m_actionPauseSync); + + m_actionEnableWebsocket = new QAction(tr("Enable Websocket"), this); + m_actionEnableWebsocket->setCheckable(true); + m_actionEnableWebsocket->setChecked(!conf()->get(Config::disableWebsocket).toBool()); + + connect(m_actionEnableWebsocket, &QAction::toggled, this, [](bool checked){ + conf()->set(Config::disableWebsocket, !checked); + if (checked) { + websocketNotifier()->websocketClient->restart(); + } else { + websocketNotifier()->websocketClient->stop(); + } + WindowManager::instance()->onWebsocketStatusChanged(checked); + }); + + + QAction *skipSyncAction = new QAction(tr("Skip Sync"), this); + m_statusLabelStatus->addAction(skipSyncAction); + + QAction *syncRangeAction = new QAction(tr("Sync Date Range..."), this); + m_statusLabelStatus->addAction(syncRangeAction); + + QAction *scanToTipAction = new QAction(tr("Sync Unconfirmed"), this); + m_statusLabelStatus->addAction(scanToTipAction); + + QAction *fullSyncAction = new QAction(tr("Full Sync"), this); + m_statusLabelStatus->addAction(fullSyncAction); + + QAction *scanTxAction = new QAction(tr("Import Transaction"), this); + m_statusLabelStatus->addAction(scanTxAction); + + m_updateNetworkInfoAction = new QAction(tr("Scan mempool when paused"), this); + m_statusLabelStatus->addAction(m_updateNetworkInfoAction); + + connect(m_actionPauseSync, &QAction::toggled, this, [this](bool checked) { + qInfo() << "Pause Sync toggled. Checked =" << checked; + conf()->set(Config::syncPaused, checked); + }); + + connect(skipSyncAction, &QAction::triggered, this, [this](){ + if (!m_wallet) return; + + QString msg = tr("Skip sync will set your wallet's restore height to the current network height.\n\n" + "Use this if you know you haven't received any transactions since your last sync.\n" + "You can always use 'Full Sync' to rescan from the beginning.\n\n" + "Continue?"); + + if (QMessageBox::question(this, tr("Skip Sync"), msg) == QMessageBox::Yes) { + m_wallet->skipToTip(); + this->setStatusText(tr("Skipped sync to tip.")); + } + }); + + connect(syncRangeAction, &QAction::triggered, this, [this](){ + if (!m_wallet) return; + + SyncRangeDialog dialog(this, m_wallet); + if (dialog.exec() == QDialog::Accepted) { + m_wallet->syncDateRange(dialog.fromDate(), dialog.toDate()); + + this->setStatusText(tr("Syncing range %1 - %2 (~%3 blocks)\nEst. download size: %4") + .arg(dialog.fromDate().toString("yyyy-MM-dd")) + .arg(dialog.toDate().toString("yyyy-MM-dd")) + .arg(QLocale().toString(dialog.estimatedBlocks())) + .arg(Utils::formatBytes(dialog.estimatedSize()))); + } + }); + + connect(scanToTipAction, &QAction::triggered, this, [this](){ + if (!m_wallet) return; + + QString msg = tr("Sync Unconfirmed (Smart Sync) will scan only the specific blocks required to unlock your pending funds (e.g. 10 confirmations).\n\n" + "This minimizes data usage by pausing immediately after verification.\n\n" + "Continue?"); + + if (QMessageBox::question(this, tr("Sync Unconfirmed"), msg) == QMessageBox::Yes) { + m_wallet->startSmartSync(); + this->setStatusText(tr("Scanning to tip...")); + } + }); + + connect(fullSyncAction, &QAction::triggered, this, [this](){ + if (m_wallet) { + QString estBlocks = "Unknown (waiting for node)"; + QString estSize = "Unknown"; + + quint64 walletCreationHeight = m_wallet->getWalletCreationHeight(); + quint64 daemonHeight = m_wallet->daemonBlockChainHeight(); + quint64 blocksBehind = 0; + + if (daemonHeight > 0) { + blocksBehind = (daemonHeight > walletCreationHeight) ? (daemonHeight - walletCreationHeight) : 0; + quint64 estimatedBytes = Utils::estimateSyncDataSize(blocksBehind); + estBlocks = QLocale().toString(blocksBehind); + estSize = QString("~%1").arg(Utils::formatBytes(estimatedBytes)); + } + + QString msg = tr("Full sync will rescan from your restore height.\n\n" + "Blocks to scan: %1\n" + "Estimated data: %2\n\n" + "Note: Cached blocks will be skipped.\n\n" + "Continue?") + .arg(estBlocks) + .arg(estSize); + + if (QMessageBox::question(this, tr("Full Sync"), msg) == QMessageBox::Yes) { + m_wallet->fullSync(); + if (estBlocks.startsWith("Unknown")) { + this->setStatusText(tr("Full sync started...")); + } else { + this->setStatusText(tr("Full sync started (%1 blocks)...").arg(estBlocks)); + } + } + } + }); + + connect(scanTxAction, &QAction::triggered, this, &MainWindow::importTransaction); } void MainWindow::initPlugins() { @@ -284,8 +444,15 @@ void MainWindow::initWidgets() { connect(m_walletUnlockWidget, &WalletUnlockWidget::closeWallet, this, &MainWindow::close); connect(m_walletUnlockWidget, &WalletUnlockWidget::unlockWallet, this, &MainWindow::unlockWallet); + ui->tabWidget->setCurrentIndex(0); ui->tabWidget->setCurrentIndex(0); ui->stackedWidget->setCurrentIndex(0); + + // Restore last network info update time + qulonglong lastNetInfoUpdate = conf()->get(Config::lastNetInfoUpdate).toULongLong(); + if (lastNetInfoUpdate > 0) { + m_lastNetInfoUpdate = QDateTime::fromSecsSinceEpoch(lastNetInfoUpdate); + } } void MainWindow::initMenu() { @@ -453,6 +620,22 @@ void MainWindow::initOffline() { ui->radio_airgapUR->setChecked(true); } + m_updateNetworkInfoAction->setCheckable(true); + connect(m_updateNetworkInfoAction, &QAction::toggled, this, [this](bool checked) { + if (!m_wallet) return; + + m_wallet->setScanMempoolWhenPaused(checked); + + if (checked) { + // Ensure we are connected if enabling + if (m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) { + m_nodes->connectToNode(); + } + } + }); + + + // We do NOT want to start syncing yet here, wait for wallet to be opened // We can't use rich text for radio buttons connect(ui->label_airgapUR, &ClickableLabel::clicked, [this] { ui->radio_airgapUR->setChecked(true); @@ -487,8 +670,21 @@ void MainWindow::initWalletContext() { connect(m_wallet, &Wallet::connectionStatusChanged, [this](int status){ // Order is important, first inform UI about a potential disconnect, then reconnect this->onConnectionStatusChanged(status); - m_nodes->autoConnect(); + + if (conf()->get(Config::syncPaused).toBool()) { + // Do not auto connect if paused + return; + } + + if (status == Wallet::ConnectionStatus_Disconnected) { + QTimer::singleShot(2000, m_nodes, [this]{ m_nodes->autoConnect(); }); + } else { + m_nodes->autoConnect(); + } + + this->updateBalance(); }); + connect(m_wallet, &Wallet::currentSubaddressAccountChanged, this, &MainWindow::updateTitle); connect(m_wallet, &Wallet::walletPassphraseNeeded, this, &MainWindow::onWalletPassphraseNeeded); @@ -509,7 +705,7 @@ void MainWindow::initWalletContext() { connect(m_wallet, &Wallet::deviceButtonRequest, this, &MainWindow::onDeviceButtonRequest); connect(m_wallet, &Wallet::deviceButtonPressed, this, &MainWindow::onDeviceButtonPressed); connect(m_wallet, &Wallet::deviceError, this, &MainWindow::onDeviceError); - + connect(m_wallet, &Wallet::multiBroadcast, this, &MainWindow::onMultiBroadcast); } @@ -554,6 +750,8 @@ void MainWindow::onWalletOpened() { m_wallet->setRingDatabase(Utils::ringDatabasePath()); + m_wallet->setRefreshInterval(constants::defaultRefreshInterval); + m_wallet->updateBalance(); if (m_wallet->isHwBacked()) { m_statusBtnHwDevice->show(); @@ -582,6 +780,16 @@ void MainWindow::onWalletOpened() { connect(m_wallet->coins(), &Coins::descriptionChanged, [this] { m_wallet->history()->refresh(); }); + + connect(m_wallet->coins(), &Coins::refreshStarted, [this]{ + m_coinsRefreshing = true; + this->updateNetStats(); + }); + + connect(m_wallet->coins(), &Coins::refreshFinished, [this]{ + m_coinsRefreshing = false; + this->updateNetStats(); + }); // Vice versa connect(m_wallet->transactionHistoryModel(), &TransactionHistoryModel::transactionDescriptionChanged, [this] { m_wallet->coins()->refresh(); @@ -590,7 +798,15 @@ void MainWindow::onWalletOpened() { this->updatePasswordIcon(); this->updateTitle(); m_nodes->allowConnection(); - m_nodes->connectToNode(); + if (!conf()->get(Config::disableAutoRefresh).toBool()) { + if (conf()->get(Config::syncPaused).toBool()) { + m_wallet->setSyncPaused(true); + // Manually set status to Disconnected/Paused so UI looks correct immediately + this->onConnectionStatusChanged(Wallet::ConnectionStatus_Disconnected); + } else { + m_nodes->connectToNode(); + } + } m_updateBytes.start(250); if (conf()->get(Config::writeRecentlyOpenedWallets).toBool()) { @@ -608,33 +824,104 @@ void MainWindow::onBalanceUpdated(quint64 balance, quint64 spendable) { int decimals = conf()->get(Config::amountPrecision).toInt(); QString balance_str = "Balance: "; + QString copyableVal; + if (hide) { balance_str += "HIDDEN"; + copyableVal = "HIDDEN"; } else if (displaySetting == Config::totalBalance) { - balance_str += QString("%1 XMR").arg(WalletManager::displayAmount(balance, false, decimals)); + QString amount = WalletManager::displayAmount(balance, false, decimals); + balance_str += QString("%1 XMR").arg(amount); + copyableVal = amount; } else if (displaySetting == Config::spendable || displaySetting == Config::spendablePlusUnconfirmed) { - balance_str += QString("%1 XMR").arg(WalletManager::displayAmount(spendable, false, decimals)); + QString amount = WalletManager::displayAmount(spendable, false, decimals); + balance_str += QString("%1 XMR").arg(amount); + copyableVal = amount; if (displaySetting == Config::spendablePlusUnconfirmed && balance > spendable) { - balance_str += QString(" (+%1 XMR unconfirmed)").arg(WalletManager::displayAmount(balance - spendable, false, decimals)); + balance_str += QString(" (+%1 XMR unconfirmed)").arg(WalletManager::displayAmount(balance - spendable, false, decimals)); } } + // Show fiat currency if configured and balance is not hidden or spendable only. if (conf()->get(Config::balanceShowFiat).toBool() && !hide) { QString fiatCurrency = conf()->get(Config::preferredFiatCurrency).toString(); double balanceFiatAmount = appData()->prices.convert("XMR", fiatCurrency, balance / constants::cdiv); - balance_str += QString(" (%1)").arg(Utils::amountToCurrencyString(balanceFiatAmount, fiatCurrency)); + bool isCacheValid = appData()->prices.lastUpdateTime.isValid(); + bool hasXmrPrice = appData()->prices.markets.contains("XMR"); + bool hasFiatRate = fiatCurrency == "USD" || appData()->prices.rates.contains(fiatCurrency); + + if (balance > 0 && (balanceFiatAmount == 0.0 || !isCacheValid)) { + if (conf()->get(Config::offlineMode).toBool() || conf()->get(Config::disableWebsocket).toBool() || m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) { + balance_str += " (offline)"; + } else if (!hasXmrPrice || !hasFiatRate) { + balance_str += " (connecting)"; + } else { + balance_str += " (unknown)"; + } + } else { + QString approx = !conf()->get(Config::disableWebsocket).toBool() ? "" : "~ "; + balance_str += QString(" (%1%2)").arg(approx, Utils::amountToCurrencyString(balanceFiatAmount, fiatCurrency)); + } } - m_statusLabelBalance->setToolTip("Click for details"); + this->updateStatusToolTip(); + + m_statusLabelBalance->setText(balance_str); + m_statusLabelBalance->setProperty("copyableValue", copyableVal); +} + +void MainWindow::updateStatusToolTip() { + QString toolTip = "Right-click for details"; + if (appData()->prices.lastUpdateTime.isValid()) { + toolTip += QString("\nFiat updated: %1").arg(Utils::timeAgo(appData()->prices.lastUpdateTime)); + } + + m_statusLabelBalance->setToolTip(toolTip); + + this->updateSyncStatusToolTip(); +} + +void MainWindow::updateSyncStatusToolTip() { + if (!m_wallet) return; + + // Throttle updates to 1s to prevent overwhelming the event loop (e.g. m_updateBytes timer) + static QDateTime lastUpdate = QDateTime::currentDateTime(); + if (lastUpdate.msecsTo(QDateTime::currentDateTime()) < 1000) { + return; + } + lastUpdate = QDateTime::currentDateTime(); + + bool isPaused = conf()->get(Config::syncPaused).toBool(); + + quint64 walletHeight = m_wallet->blockChainHeight(); + quint64 daemonHeight = m_wallet->daemonBlockChainHeight(); + quint64 blocksBehind = (daemonHeight > walletHeight) ? (daemonHeight - walletHeight) : 0; + + // Build tooltip + QString tooltip = tr("Daemon Height: %1").arg(QLocale().toString(daemonHeight)); + + if (conf()->get(Config::lastNetInfoUpdate).toULongLong() > 0) { + tooltip += tr("\nLast network update: %1").arg( + Utils::timeAgo(QDateTime::fromSecsSinceEpoch(conf()->get(Config::lastNetInfoUpdate).toULongLong())) + ); + } + + if (blocksBehind > 0) { + tooltip += tr("\n~%1 blocks behind").arg(QLocale().toString(blocksBehind)); + } + + m_statusLabelStatus->setToolTip(tooltip); } void MainWindow::setStatusText(const QString &text, bool override, int timeout) { + if (override) { m_statusOverrideActive = true; + // qDebug() << "STATUS (override):" << text; m_statusLabelStatus->setText(text); QTimer::singleShot(timeout, [this]{ m_statusOverrideActive = false; @@ -646,6 +933,7 @@ void MainWindow::setStatusText(const QString &text, bool override, int timeout) m_statusText = text; if (!m_statusOverrideActive && !m_constructingTransaction) { + // qDebug() << "STATUS:" << text; m_statusLabelStatus->setText(text); } } @@ -660,6 +948,9 @@ void MainWindow::tryStoreWallet() { } void MainWindow::onWebsocketStatusChanged(bool enabled) { + if (m_actionEnableWebsocket) { + m_actionEnableWebsocket->setChecked(enabled); + } ui->actionShow_Home->setVisible(enabled); QStringList enabledTabs = conf()->get(Config::enabledTabs).toStringList(); @@ -742,11 +1033,52 @@ void MainWindow::onMultiBroadcast(const QMap &txHexMap) { } void MainWindow::onSyncStatus(quint64 height, quint64 target, bool daemonSync) { - if (height >= (target - 1)) { + m_lastNetInfoUpdate = QDateTime::currentDateTime(); + + // Persist to global config (throttled to syncInterval) + static QDateTime lastConfigSave = QDateTime::currentDateTime(); + int interval = constants::defaultRefreshInterval; + if (lastConfigSave.secsTo(QDateTime::currentDateTime()) > interval) { + conf()->set(Config::lastNetInfoUpdate, static_cast(m_lastNetInfoUpdate.toSecsSinceEpoch())); + lastConfigSave = QDateTime::currentDateTime(); + } + + // qDebug() << "onSyncStatus: Height" << height << "Target" << target << "DaemonSync" << daemonSync; + + quint64 blocksBehind = Utils::blocksBehind(height, target); + + // Throttle UI updates to 10Hz to prevent spam during sync + static QDateTime lastThrottleTime = QDateTime::currentDateTime(); + if (height < (target - 1) && lastThrottleTime.msecsTo(QDateTime::currentDateTime()) < 100) { + return; + } + lastThrottleTime = QDateTime::currentDateTime(); + + if (height >= (target - 1) && target > 0) { + m_lastSyncStatusUpdate = QDateTime::currentDateTime(); + this->updateNetStats(); + // this->setStatusText(tr("Synchronized")); // TODO: do we need this? + + // Persist sync state for next boot + conf()->set(Config::lastKnownNetworkHeight, static_cast(target)); + m_wallet->setCacheAttribute("feather.lastSync", QString::number(QDateTime::currentSecsSinceEpoch())); + } else { + if (target == 0) { + this->setStatusText(tr("Connecting...")); + return; + } + + QString blocksStr = QLocale().toString(blocksBehind); + if (conf()->get(Config::syncPaused).toBool()) { + this->setStatusText(this->getPausedStatusText()); + } else { + QString type = daemonSync ? tr("Blockchain") : tr("Wallet"); + this->setStatusText(tr("%1 sync: %2 blocks behind").arg(type, blocksStr)); + } } - this->setStatusText(Utils::formatSyncStatus(height, target, daemonSync)); - m_statusLabelStatus->setToolTip(QString("Wallet height: %1").arg(QString::number(height))); + + this->updateSyncStatusToolTip(); } void MainWindow::onConnectionStatusChanged(int status) @@ -756,39 +1088,106 @@ void MainWindow::onConnectionStatusChanged(int status) qDebug() << "Wallet connection status changed " << Utils::QtEnumToString(static_cast(status)); + if (m_updateNetworkInfoAction) { // Maybe not initialized first call + m_updateNetworkInfoAction->setEnabled(true); + } + // Update connection info in status bar. QIcon icon; + QString statusStr; if (conf()->get(Config::offlineMode).toBool()) { icon = icons()->icon("status_offline.svg"); - this->setStatusText("Offline mode"); + statusStr = "Offline mode"; } else { switch(status){ + case Wallet::ConnectionStatus_Idle: + { + // If "Scan Mempool" is active, we show "Idle" (connected/active) + if (m_updateNetworkInfoAction->isChecked()) { + if (conf()->get(Config::proxy).toInt() == Config::Proxy::Tor) { + icon = icons()->icon("status_idle_proxy.svg"); + } else { + icon = icons()->icon("status_idle.svg"); + } + } else { + // "True Idle" - just waiting, no network activity + icon = icons()->icon("status_waiting.svg"); + } + statusStr = this->getPausedStatusText(); + m_statusLabelNetStats->hide(); + break; + } case Wallet::ConnectionStatus_Disconnected: - icon = icons()->icon("status_disconnected.svg"); - this->setStatusText("Disconnected"); + { + icon = icons()->icon("status_offline.svg"); + statusStr = "Disconnected"; + + // If we are waiting for a retry or scheduled sync, show that instead of "Disconnected" + if (m_wallet) { + qint64 seconds = m_wallet->secondsUntilNextRefresh(); + if (seconds > 0) { + QString timeStr; + if (seconds > 60) timeStr = QString("%1 min").arg((seconds + 59) / 60); + else timeStr = QString("%1s").arg(seconds); + statusStr = tr("Disconnected (Retry in %1)").arg(timeStr); + } else if (m_wallet->lastSyncTime().isValid()) { + // Fallback to estimation only if not waiting for retry + qint64 secsSinceLastSync = m_wallet->lastSyncTime().secsTo(QDateTime::currentDateTime()); + quint64 estimatedBlocksBehind = std::max(qint64(0), secsSinceLastSync) / 120; + if (estimatedBlocksBehind > 0) { + statusStr = tr("~%1 blocks behind").arg(QLocale().toString(estimatedBlocksBehind)); + } + } + } break; + } case Wallet::ConnectionStatus_Connecting: icon = icons()->icon("status_lagging.svg"); - this->setStatusText("Connecting to node"); + statusStr = "Connecting to node"; break; case Wallet::ConnectionStatus_WrongVersion: icon = icons()->icon("status_disconnected.svg"); - this->setStatusText("Incompatible node"); + statusStr = "Node Incompatible"; break; case Wallet::ConnectionStatus_Synchronizing: icon = icons()->icon("status_waiting.svg"); + statusStr = "Synchronizing"; break; case Wallet::ConnectionStatus_Synchronized: - icon = icons()->icon("status_connected.svg"); + if (conf()->get(Config::proxy).toInt() == Config::Proxy::Tor) { + icon = icons()->icon("status_connected_proxy.svg"); + } else { + icon = icons()->icon("status_connected.svg"); + } + statusStr = "Synchronized"; break; default: icon = icons()->icon("status_disconnected.svg"); + statusStr = "Disconnected"; break; } } + this->setStatusText(statusStr); + + this->updateSyncStatusToolTip(); + + if (m_wallet) { + quint64 walletHeight = m_wallet->blockChainHeight(); + quint64 daemonHeight = m_wallet->daemonBlockChainHeight(); + quint64 targetHeight = m_wallet->daemonBlockChainTargetHeight(); + + if (walletHeight > 0) { + statusStr += QString("\nWallet %1. Daemon %2. Network %3") + .arg(QLocale::system().toString(walletHeight)) + .arg(QLocale::system().toString(daemonHeight)) + .arg(QLocale::system().toString(targetHeight)); + } + } + // m_statusBtnConnectionStatusIndicator->setToolTip(statusStr); m_statusBtnConnectionStatusIndicator->setIcon(icon); + this->updateBalance(); } void MainWindow::onTransactionCreated(PendingTransaction *tx, const QVector &address) { @@ -967,7 +1366,7 @@ void MainWindow::onTransactionCreated(PendingTransaction *tx, const QVectordisconnect(); - this->disconnect(); this->saveGeo(); m_windowManager->closeWindow(this); @@ -1265,20 +1663,51 @@ void MainWindow::closeEvent(QCloseEvent *event) { event->accept(); } +void MainWindow::showEvent(QShowEvent *event) +{ + QMainWindow::showEvent(event); +} + void MainWindow::changeEvent(QEvent* event) { - if ((event->type() == QEvent::WindowStateChange) && this->isMinimized()) { - if (conf()->get(Config::lockOnMinimize).toBool()) { - this->lockWallet(); - } - if (conf()->get(Config::showTrayIcon).toBool() && conf()->get(Config::minimizeToTray).toBool()) { - this->hide(); + QMainWindow::changeEvent(event); + +// In changeEvent: + if (event->type() == QEvent::WindowStateChange) { + // qDebug() << "changeEvent: WindowStateChange. State:" << this->windowState() << " isMinimized:" << this->isMinimized(); + if (this->isMinimized()) { + if (conf()->get(Config::lockOnMinimize).toBool()) { + this->lockWallet(); + } + + bool showTray = conf()->get(Config::showTrayIcon).toBool(); + bool minimizeToTray = conf()->get(Config::minimizeToTray).toBool(); + if (showTray && minimizeToTray) + this->hide(); } - } else { - QMainWindow::changeEvent(event); + } else if (event->type() == QEvent::ActivationChange) { + // qDebug() << "changeEvent: ActivationChange. Active:" << this->isActiveWindow(); + // Workaround for some window managers (e.g. GNOME) where isExposed() or isMinimized() + // state doesn't update immediately upon minimization animation start. + QTimer::singleShot(350, this, [this]() { + auto handle = this->windowHandle(); + if (handle && !handle->isExposed()) { + if (conf()->get(Config::lockOnMinimize).toBool()) + this->lockWallet(); + + bool showTray = conf()->get(Config::showTrayIcon).toBool(); + bool minimizeToTray = conf()->get(Config::minimizeToTray).toBool(); + // TODO: Implement better logic here to hide all widgets and dialogs + if (showTray && minimizeToTray) + for (const auto &widget : QApplication::topLevelWidgets()) + widget->hide(); + } + }); } } +// Add logs to sync methods (need to locate them first, assuming onSyncStatus and setPausedSyncStatus) + void MainWindow::showHistoryTab() { this->raise(); ui->tabWidget->setCurrentIndex(this->findTab("History")); @@ -1427,7 +1856,12 @@ void MainWindow::importTransaction() { } } - TxImportDialog dialog(this, m_wallet); + // Ensure connection for Data Saving Mode + if (m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) { + m_nodes->connectToNode(); + } + + TxImportDialog dialog(this, m_wallet, m_nodes); dialog.exec(); } @@ -1460,7 +1894,6 @@ void MainWindow::onDeviceError(const QString &error, quint64 errorCode) { } } m_statusBtnHwDevice->setIcon(this->hardwareDevicePairedIcon()); - m_wallet->startRefresh(); m_showDeviceError = false; } @@ -1525,14 +1958,73 @@ void MainWindow::onWalletPassphraseNeeded(bool on_device) { } } +QString MainWindow::getPausedStatusText() { + if (!m_wallet) return tr("Sync Paused"); + + quint64 walletHeight = m_wallet->blockChainHeight(); + quint64 targetHeight = m_wallet->daemonBlockChainTargetHeight(); + + if (walletHeight > 0 && targetHeight > 0) { + quint64 blocksBehind = Utils::blocksBehind(walletHeight, targetHeight); + if (blocksBehind > 0) { + return tr("[SYNC PAUSED] - %1 blocks behind").arg(QLocale().toString(blocksBehind)); + } + } + return tr("[SYNC PAUSED]"); +} + void MainWindow::updateNetStats() { + static quint64 prevBytes = 0; + static int trafficCooldown = 0; + + quint64 currBytes = m_wallet ? m_wallet->getBytesReceived() : 0; + if (currBytes > prevBytes) { + trafficCooldown = 3; // Keep visible for 3 cycles (~3 seconds) + } else if (trafficCooldown > 0) { + trafficCooldown--; + } + prevBytes = currBytes; + + bool showTraffic = trafficCooldown > 0; + if (!m_wallet || m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected - || m_wallet->connectionStatus() == Wallet::ConnectionStatus_Synchronized) + || (m_wallet->connectionStatus() == Wallet::ConnectionStatus_Synchronized && !m_coinsRefreshing && !showTraffic)) { m_statusLabelNetStats->hide(); + + if (m_wallet && m_wallet->connectionStatus() == Wallet::ConnectionStatus_Synchronized) { + qint64 seconds = m_wallet->secondsUntilNextRefresh(); + this->setStatusText(tr("Synchronized")); + // if (seconds > 0) { ... } // Removed countdown display per user feedback + } + else if (m_wallet && m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) { + qint64 seconds = m_wallet->secondsUntilNextRefresh(); + if (seconds > 0) { + QString timeStr; + if (seconds > 60) timeStr = QString("%1 min").arg((seconds + 59) / 60); + else timeStr = QString("%1s").arg(seconds); + this->setStatusText(QString("Disconnected (Retry in %1)").arg(timeStr)); + } else { + if (conf()->get(Config::syncPaused).toBool()) { + this->setStatusText(this->getPausedStatusText()); + } else { + this->setStatusText(tr("Connecting...")); + } + } + } + return; + } + + if (conf()->get(Config::syncPaused).toBool()) { + m_statusLabelNetStats->hide(); + this->setStatusText(this->getPausedStatusText()); return; } + if (m_wallet && m_wallet->connectionStatus() == Wallet::ConnectionStatus_Synchronized) { + this->setStatusText(tr("Synchronized")); + } + m_statusLabelNetStats->show(); m_statusLabelNetStats->setText(QString("(D: %1)").arg(Utils::formatBytes(m_wallet->getBytesReceived()))); } @@ -1544,12 +2036,12 @@ void MainWindow::rescanSpent() { "Make sure you are connected to a trusted node.\n\n" "Do you want to proceed?"); warning.setStandardButtons(QMessageBox::Yes | QMessageBox::No); - + auto r = warning.exec(); if (r == QMessageBox::No) { return; } - + if (!m_wallet->rescanSpent()) { Utils::showError(this, "Failed to rescan spent outputs", m_wallet->errorString()); } else { @@ -1927,3 +2419,28 @@ int MainWindow::findTab(const QString &title) { MainWindow::~MainWindow() { qDebug() << "~MainWindow" << QThread::currentThreadId(); } + +void MainWindow::setSyncPaused(bool checked) { + if (m_actionPauseSync && m_actionPauseSync->isChecked() != checked) { + const QSignalBlocker blocker(m_actionPauseSync); + m_actionPauseSync->setChecked(checked); + } + + if (m_wallet) { + if (checked) { + qInfo() << "Pausing sync via setSyncPaused"; + m_wallet->setSyncPaused(true); + m_nodes->disconnectCurrentNode(); + this->onConnectionStatusChanged(Wallet::ConnectionStatus_Disconnected); + } else { + qInfo() << "Resuming sync via setSyncPaused"; + m_wallet->setSyncPaused(false); + m_nodes->connectToNode(); + + if (!conf()->get(Config::disableWebsocket).toBool()) { + websocketNotifier()->websocketClient->restart(); + } + this->setStatusText(tr("Resuming sync...")); + } + } +} diff --git a/src/MainWindow.h b/src/MainWindow.h index c44270d3..5164c147 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -6,6 +6,7 @@ #include #include +#include #include "components.h" #include "SettingsDialog.h" @@ -97,6 +98,7 @@ signals: protected: void changeEvent(QEvent* event) override; + void showEvent(QShowEvent *event) override; private slots: // TODO: use a consistent naming convention for slots @@ -205,9 +207,13 @@ private: void fillSendTab(const QString &address, const QString &description); void userActivity(); void checkUserActivity(); + void updateStatusToolTip(); + void updateSyncStatusToolTip(); void lockWallet(); void unlockWallet(const QString &password); void closeQDialogChildren(QObject *object); + void setSyncPaused(bool paused); + QString getPausedStatusText(); int findTab(const QString &title); QIcon hardwareDevicePairedIcon(); @@ -231,6 +237,12 @@ private: CoinsWidget *m_coinsWidget = nullptr; QPointer m_clearRecentlyOpenAction; + QPointer m_updateNetworkInfoAction; + QPointer m_actionEnableWebsocket; + QPointer m_actionPauseSync; + + QDateTime m_lastSyncStatusUpdate; + QDateTime m_lastNetInfoUpdate; // lower status bar QPushButton *m_statusUpdateAvailable; @@ -255,6 +267,7 @@ private: QString m_statusText; int m_statusDots; + bool m_coinsRefreshing = false; bool m_constructingTransaction = false; bool m_statusOverrideActive = false; bool m_showDeviceError = false; @@ -267,6 +280,8 @@ private: EventFilter *m_eventFilter = nullptr; qint64 m_userLastActive = QDateTime::currentSecsSinceEpoch(); + QMetaObject::Connection m_visibilityConnection; + #ifdef CHECK_UPDATES QSharedPointer m_updater = nullptr; #endif diff --git a/src/SendWidget.cpp b/src/SendWidget.cpp index e0ce2dea..de34fae7 100644 --- a/src/SendWidget.cpp +++ b/src/SendWidget.cpp @@ -4,6 +4,8 @@ #include "SendWidget.h" #include "ui_SendWidget.h" +#include + #include "ColorScheme.h" #include "constants.h" #include "utils/AppData.h" @@ -11,6 +13,7 @@ #include "Icons.h" #include "libwalletqt/Wallet.h" #include "libwalletqt/WalletManager.h" +#include "WindowManager.h" #if defined(WITH_SCANNER) #include "wizard/offline_tx_signing/OfflineTxSigningWizard.h" @@ -143,14 +146,35 @@ void SendWidget::scanClicked() { } void SendWidget::sendClicked() { - if (!m_wallet->isConnected()) { + if (conf()->get(Config::syncPaused).toBool()) { + QMessageBox msgBox(this); + msgBox.setIcon(QMessageBox::Warning); + msgBox.setWindowTitle("Are you sure? Create transaction?"); + msgBox.setText("Are you sure? Create transaction?"); + msgBox.setInformativeText("Wallet sync is paused. This may result in an invalid transaction or balance.\n\n• Go to File -> Settings -> Network -> Sync to resume sync."); + msgBox.setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel | QMessageBox::Help); + msgBox.setDefaultButton(QMessageBox::Cancel); + + auto ret = msgBox.exec(); + + if (ret == QMessageBox::Help) { + WindowManager::instance()->showDocs(this, "synchronization"); + return; + } + + if (ret != QMessageBox::Ok) { + return; + } + } + + if (!m_wallet->isConnected() && !conf()->get(Config::syncPaused).toBool()) { Utils::showError(this, "Unable to create transaction", "Wallet is not connected to a node.", {"Wait for the wallet to automatically connect to a node.", "Go to File -> Settings -> Network -> Node to manually connect to a node."}, "nodes"); return; } - if (!m_wallet->isSynchronized()) { + if (!m_wallet->isSynchronized() && !conf()->get(Config::syncPaused).toBool()) { Utils::showError(this, "Unable to create transaction", "Wallet is not synchronized", {"Wait for wallet synchronization to complete"}, "synchronization"); return; } diff --git a/src/SettingsDialog.cpp b/src/SettingsDialog.cpp index 982fef24..59940e45 100644 --- a/src/SettingsDialog.cpp +++ b/src/SettingsDialog.cpp @@ -165,19 +165,32 @@ void Settings::setupAppearanceTab() { void Settings::setupNetworkTab() { // Node - if (m_nodes) { - ui->nodeWidget->setupUI(m_nodes); - connect(ui->nodeWidget, &NodeWidget::nodeSourceChanged, m_nodes, &Nodes::onNodeSourceChanged); - connect(ui->nodeWidget, &NodeWidget::connectToNode, m_nodes, QOverload::of(&Nodes::connectToNode)); - } else { - m_nodes = new Nodes(this, nullptr); - ui->nodeWidget->setupUI(m_nodes); - ui->nodeWidget->setCanConnect(false); - } + std::function setupNodeWidget = [this]{ + if (m_nodes) { + ui->nodeWidget->setupUI(m_nodes); + connect(ui->nodeWidget, &NodeWidget::nodeSourceChanged, m_nodes, &Nodes::onNodeSourceChanged); + connect(ui->nodeWidget, &NodeWidget::connectToNode, m_nodes, QOverload::of(&Nodes::connectToNode)); + } else { + m_nodes = new Nodes(this, nullptr); + ui->nodeWidget->setupUI(m_nodes); + ui->nodeWidget->setCanConnect(false); + } + }; + setupNodeWidget(); + + // Proxy connect(ui->proxyWidget, &NetworkProxyWidget::proxySettingsChanged, this, &Settings::onProxySettingsChanged); + // Offline mode + ui->checkBox_offlineMode->setChecked(conf()->get(Config::offlineMode).toBool()); + connect(ui->checkBox_offlineMode, &QCheckBox::toggled, [this](bool checked){ + conf()->set(Config::offlineMode, checked); + this->enableWebsocket(!checked && !conf()->get(Config::disableWebsocket).toBool()); + emit offlineMode(checked); + }); + // Websocket // [Obtain third-party data] ui->checkBox_enableWebsocket->setChecked(!conf()->get(Config::disableWebsocket).toBool()); @@ -186,13 +199,19 @@ void Settings::setupNetworkTab() { this->enableWebsocket(checked); }); - // Overview - ui->checkBox_offlineMode->setChecked(conf()->get(Config::offlineMode).toBool()); - connect(ui->checkBox_offlineMode, &QCheckBox::toggled, [this](bool checked){ - conf()->set(Config::offlineMode, checked); - emit offlineMode(checked); - this->enableWebsocket(!checked); - }); + // Add to Node tab + if (auto *layout = qobject_cast(ui->Node->layout())) { + + // Sync (Data Saving Mode) + QCheckBox *cbDataSaver = new QCheckBox("Data Saving Mode (Pause Sync on startup)", this); + cbDataSaver->setChecked(conf()->get(Config::syncPaused).toBool()); + cbDataSaver->setToolTip("Prevents the wallet from automatically connecting to nodes on startup."); + + connect(cbDataSaver, &QCheckBox::toggled, [](bool checked){ + conf()->set(Config::syncPaused, checked); + }); + layout->addWidget(cbDataSaver); + } } void Settings::setupStorageTab() { @@ -234,10 +253,25 @@ void Settings::setupStorageTab() { WalletManager::instance()->setLogLevel(toggled ? conf()->get(Config::logLevel).toInt() : -1); }); + // [Disable terminal output] + QCheckBox *cbDisableStdout = new QCheckBox("Disable terminal output (silence most logs)", this); + cbDisableStdout->setChecked(conf()->get(Config::disableLoggingStdout).toBool()); + connect(cbDisableStdout, &QCheckBox::toggled, [](bool toggled){ + conf()->set(Config::disableLoggingStdout, toggled); + }); + // Insert into the logging layout (verticalLayout_2) + // We add it after checkBox_enableLogging + int index = ui->verticalLayout_2->indexOf(ui->checkBox_enableLogging); + ui->verticalLayout_2->insertWidget(index + 1, cbDisableStdout); + // [Log level] + ui->comboBox_logLevel->clear(); + ui->comboBox_logLevel->addItems({"Fatal", "Warning", "Info", "Debug"}); ui->comboBox_logLevel->setCurrentIndex(conf()->get(Config::logLevel).toInt()); + connect(ui->comboBox_logLevel, QOverload::of(&QComboBox::currentIndexChanged), [](int index){ conf()->set(Config::logLevel, index); + qInfo() << "Log level changed to:" << index; if (!conf()->get(Config::disableLogging).toBool()) { WalletManager::instance()->setLogLevel(index); } @@ -314,6 +348,7 @@ void Settings::setupDisplayTab() { connect(ui->checkBox_showTrayIcon, &QCheckBox::toggled, [this](bool toggled) { conf()->set(Config::showTrayIcon, toggled); ui->checkBox_minimizeToTray->setEnabled(toggled); + ui->checkBox_trayLeftClickTogglesFocus->setEnabled(toggled); emit showTrayIcon(toggled); }); @@ -323,6 +358,12 @@ void Settings::setupDisplayTab() { connect(ui->checkBox_minimizeToTray, &QCheckBox::toggled, [this](bool toggled) { conf()->set(Config::minimizeToTray, toggled); }); + + // [Left click system tray icon to toggle focus] + ui->checkBox_trayLeftClickTogglesFocus->setEnabled(ui->checkBox_showTrayIcon->isChecked()); + ui->checkBox_trayLeftClickTogglesFocus->setChecked(conf()->get(Config::trayLeftClickTogglesFocus).toBool()); + connect(ui->checkBox_trayLeftClickTogglesFocus, &QCheckBox::toggled, + [this](bool toggled) { conf()->set(Config::trayLeftClickTogglesFocus, toggled); }); } void Settings::setupMemoryTab() { diff --git a/src/SettingsDialog.ui b/src/SettingsDialog.ui index ccaad425..4479a235 100644 --- a/src/SettingsDialog.ui +++ b/src/SettingsDialog.ui @@ -838,6 +838,36 @@ + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 30 + 20 + + + + + + + + false + + + Left click system tray icon to toggle focus + + + + + diff --git a/src/WindowManager.cpp b/src/WindowManager.cpp index 5d6696e9..9fab17e5 100644 --- a/src/WindowManager.cpp +++ b/src/WindowManager.cpp @@ -47,6 +47,24 @@ WindowManager::WindowManager(QObject *parent) this->buildTrayMenu(); m_tray->setVisible(conf()->get(Config::showTrayIcon).toBool()); + connect(m_tray, &QSystemTrayIcon::activated, [this](QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::Trigger) { + if (conf()->get(Config::trayLeftClickTogglesFocus).toBool()) { + for (const auto &window : m_windows) { + if (window->isVisible() && window->isActiveWindow()) { + window->hide(); + } else { + window->show(); + window->raise(); + window->activateWindow(); + } + } + } else { + m_tray->contextMenu()->popup(QCursor::pos()); + } + } + }); + this->initSkins(); this->patchMacStylesheet(); @@ -71,9 +89,13 @@ void WindowManager::setEventFilter(EventFilter *ef) { WindowManager::~WindowManager() { qDebug() << "~WindowManager"; - m_cleanupThread->quit(); - m_cleanupThread->wait(); - qDebug() << "WindowManager: cleanup thread done" << QThread::currentThreadId(); + if (m_cleanupThread && m_cleanupThread->isRunning()) { + m_cleanupThread->quit(); + m_cleanupThread->wait(); + qDebug() << "WindowManager: cleanup thread done" << QThread::currentThreadId(); + } else { + qDebug() << "WindowManager: cleanup thread already stopped"; + } } // ######################## APPLICATION LIFECYCLE ######################## @@ -89,10 +111,37 @@ void WindowManager::quitAfterLastWindow() { void WindowManager::close() { qDebug() << Q_FUNC_INFO << QThread::currentThreadId(); - for (const auto &window: m_windows) { + + if (m_closing) { + return; + } + m_closing = true; + + + // Stop all threads before application shutdown to avoid QThreadStorage warnings + if (m_cleanupThread && m_cleanupThread->isRunning()) { + m_cleanupThread->quit(); + m_cleanupThread->wait(); + qDebug() << "WindowManager: cleanup thread stopped in close()"; + } + + // Close all windows first to ensure they cancel their tasks/connections + // Iterate over a copy because close() modifies m_windows + auto windows = m_windows; + for (const auto &window: windows) { window->close(); } + // Stop Tor manager threads + torManager()->stop(); + + // Wait for all threads in the global thread pool with timeout to prevent indefinite blocking + if (!QThreadPool::globalInstance()->waitForDone(15000)) { + qCritical() << "WindowManager: Thread pool tasks did not complete within 15s timeout. " + << "Forcing exit to prevent use-after-free."; + std::_Exit(1); // Fast exit without cleanup - threads may still hold resources + } + if (m_splashDialog) { m_splashDialog->deleteLater(); } @@ -106,8 +155,6 @@ void WindowManager::close() { m_docsDialog->deleteLater(); } - torManager()->stop(); - deleteLater(); qDebug() << "Calling QApplication::quit()"; @@ -117,6 +164,7 @@ void WindowManager::close() { void WindowManager::closeWindow(MainWindow *window) { qDebug() << "WindowManager: closing Window"; m_windows.removeOne(window); + this->buildTrayMenu(); // Move Wallet to a different thread for cleanup, so it doesn't block GUI thread window->m_wallet->moveToThread(m_cleanupThread); @@ -165,6 +213,11 @@ void WindowManager::raise() { m_wizard->raise(); m_wizard->activateWindow(); } + else if (m_openingWallet && m_splashDialog) { + m_splashDialog->show(); + m_splashDialog->raise(); + m_splashDialog->activateWindow(); + } else { // This shouldn't happen this->close(); @@ -269,6 +322,8 @@ void WindowManager::tryOpenWallet(const QString &path, const QString &password) } m_openingWallet = true; + m_splashDialog->setMessage("Opening wallet..."); + m_splashDialog->show(); m_walletManager->openWalletAsync(path, password, constants::networkType, constants::kdfRounds, Utils::ringDatabasePath()); } @@ -773,7 +828,10 @@ QString WindowManager::loadStylesheet(const QString &resource) { return ""; } - f.open(QFile::ReadOnly | QFile::Text); + if (!f.open(QFile::ReadOnly | QFile::Text)) { + qWarning() << "Failed to open stylesheet:" << resource; + return ""; + } QTextStream ts(&f); QString data = ts.readAll(); f.close(); diff --git a/src/WindowManager.h b/src/WindowManager.h index d0f3e460..54690e2e 100644 --- a/src/WindowManager.h +++ b/src/WindowManager.h @@ -113,6 +113,7 @@ private: bool m_openWalletTriedOnce = false; bool m_openingWallet = false; bool m_initialNetworkConfigured = false; + bool m_closing = false; QThread *m_cleanupThread; }; diff --git a/src/assets.qrc b/src/assets.qrc index de87ab63..54d30065 100644 --- a/src/assets.qrc +++ b/src/assets.qrc @@ -79,6 +79,8 @@ assets/images/status_connected_proxy.svg assets/images/status_connected.svg assets/images/status_disconnected.svg + assets/images/status_idle_proxy.svg + assets/images/status_idle.svg assets/images/status_lagging.svg assets/images/status_offline.svg assets/images/status_waiting.svg diff --git a/src/assets/feather.desktop b/src/assets/feather.desktop index 4f5737aa..9542b6a8 100644 --- a/src/assets/feather.desktop +++ b/src/assets/feather.desktop @@ -3,7 +3,7 @@ Type=Application Name=Feather Wallet GenericName=Monero Wallet Comment=A free Monero desktop wallet -Icon=feather +Icon=FeatherWallet Exec=feather Terminal=false Categories=Network; diff --git a/src/assets/images/status_idle.svg b/src/assets/images/status_idle.svg new file mode 100644 index 00000000..f22cb3b5 --- /dev/null +++ b/src/assets/images/status_idle.svg @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/status_idle_proxy.svg b/src/assets/images/status_idle_proxy.svg new file mode 100644 index 00000000..b4d614c3 --- /dev/null +++ b/src/assets/images/status_idle_proxy.svg @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components.cpp b/src/components.cpp index e6663ce3..43949431 100644 --- a/src/components.cpp +++ b/src/components.cpp @@ -75,7 +75,10 @@ ClickableLabel::ClickableLabel(QWidget* parent, Qt::WindowFlags f) ClickableLabel::~ClickableLabel() = default; void ClickableLabel::mousePressEvent(QMouseEvent* event) { - emit clicked(); + if (event->button() == Qt::LeftButton) { + emit clicked(); + } + QLabel::mousePressEvent(event); } WindowModalDialog::WindowModalDialog(QWidget *parent) diff --git a/src/constants.h b/src/constants.h index 567cc143..d4c666b1 100644 --- a/src/constants.h +++ b/src/constants.h @@ -19,6 +19,7 @@ namespace constants const quint64 kdfRounds = 1; const QString seedLanguage = "English"; // todo: move me + const int defaultRefreshInterval = 30; // seconds } #endif //FEATHER_CONSTANTS_H diff --git a/src/dialog/AboutDialog.cpp b/src/dialog/AboutDialog.cpp index bf8c7cf1..079b9cd4 100644 --- a/src/dialog/AboutDialog.cpp +++ b/src/dialog/AboutDialog.cpp @@ -29,6 +29,12 @@ AboutDialog::AboutDialog(QWidget *parent) ui->ackText->setText(ack_text); ui->label_featherVersion->setText(FEATHER_VERSION); +#ifdef FEATHER_BUILD_TAG + ui->label_buildTag->setText(FEATHER_BUILD_TAG); +#else + ui->label_buildTag->hide(); + ui->label_30->hide(); +#endif ui->label_moneroVersion->setText(MONERO_VERSION); ui->label_qtVersion->setText(QT_VERSION_STR); ui->label_torVersion->setText(TOR_VERSION); diff --git a/src/dialog/AboutDialog.ui b/src/dialog/AboutDialog.ui index 4cf20a25..f8ed38d8 100644 --- a/src/dialog/AboutDialog.ui +++ b/src/dialog/AboutDialog.ui @@ -234,55 +234,69 @@ + + + <html><head/><body><p><span style=" font-weight:700;">Build:</span></p></body></html> + + + + + + + TextLabel + + + + <html><head/><body><p><span style=" font-weight:700;">Monero:</span></p></body></html> - + TextLabel - + <html><head/><body><p><span style=" font-weight:700;">Qt:</span></p></body></html> - + TextLabel - + <html><head/><body><p><span style=" font-weight:700;">SSL:</span></p></body></html> - + TextLabel - + <html><head/><body><p><span style=" font-weight:700;">Tor:</span></p></body></html> - + TextLabel diff --git a/src/dialog/DebugInfoDialog.cpp b/src/dialog/DebugInfoDialog.cpp index 3d1ad59f..dd1d050b 100644 --- a/src/dialog/DebugInfoDialog.cpp +++ b/src/dialog/DebugInfoDialog.cpp @@ -57,7 +57,11 @@ void DebugInfoDialog::updateInfo() { auto node = m_nodes->connection(); ui->label_remoteNode->setText(node.toAddress()); - ui->label_walletStatus->setText(this->statusToString(m_wallet->connectionStatus())); + QString statusStr = this->statusToString(m_wallet->connectionStatus()); + if (conf()->get(Config::syncPaused).toBool()) { + statusStr += " (Paused)"; + } + ui->label_walletStatus->setText(statusStr); QString websocketStatus = Utils::QtEnumToString(websocketNotifier()->websocketClient->webSocket->state()).remove("State"); if (conf()->get(Config::disableWebsocket).toBool()) { websocketStatus = "Disabled"; diff --git a/src/dialog/PaymentRequestDialog.cpp b/src/dialog/PaymentRequestDialog.cpp index 9f4527de..f3b4f50b 100644 --- a/src/dialog/PaymentRequestDialog.cpp +++ b/src/dialog/PaymentRequestDialog.cpp @@ -118,7 +118,11 @@ void PaymentRequestDialog::saveImage() { } QFile file(filename); - file.open(QIODevice::WriteOnly); + if (!file.open(QIODevice::WriteOnly)) { + QMessageBox::warning(this, tr("Error"), tr("Could not save image to file: %1").arg(file.errorString())); + qWarning() << "Could not save image to file: " << file.errorString(); + return; + } m_qrCode->toPixmap(1).scaled(500, 500, Qt::KeepAspectRatio).save(&file, "PNG"); QMessageBox::information(this, "Information", "QR code saved to file"); } diff --git a/src/dialog/QrCodeDialog.cpp b/src/dialog/QrCodeDialog.cpp index 1710ae26..6025d6f4 100644 --- a/src/dialog/QrCodeDialog.cpp +++ b/src/dialog/QrCodeDialog.cpp @@ -40,7 +40,10 @@ void QrCodeDialog::saveImage() { } QFile file(filename); - file.open(QIODevice::WriteOnly); + if (!file.open(QIODevice::WriteOnly)) { + QMessageBox::critical(this, "Error", "Could not open file for writing."); + return; + } m_pixmap.save(&file, "PNG"); QMessageBox::information(this, "Information", "QR code saved to file"); } diff --git a/src/dialog/SyncRangeDialog.cpp b/src/dialog/SyncRangeDialog.cpp new file mode 100644 index 00000000..793d46c9 --- /dev/null +++ b/src/dialog/SyncRangeDialog.cpp @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: The Monero Project + +#include "SyncRangeDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils/Utils.h" +#include "utils/RestoreHeightLookup.h" + +SyncRangeDialog::SyncRangeDialog(QWidget *parent, Wallet *wallet) + : QDialog(parent) + , m_wallet(wallet) +{ + setWindowTitle(tr("Sync Date Range")); + setWindowIcon(QIcon(":/assets/images/appicons/64x64.png")); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + setWindowFlags(windowFlags() | Qt::MSWindowsFixedSizeDialogHint); + + auto *layout = new QVBoxLayout(this); + auto *formLayout = new QFormLayout(); + formLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + + m_toDateEdit = new QDateEdit(QDate::currentDate()); + m_toDateEdit->setCalendarPopup(true); + m_toDateEdit->setDisplayFormat("yyyy-MM-dd"); + + int defaultDays = 7; + + // Preset durations dropdown + m_presetCombo = new QComboBox; + m_presetCombo->addItem(tr("1 day"), 1); + m_presetCombo->addItem(tr("7 days"), 7); + m_presetCombo->addItem(tr("30 days"), 30); + m_presetCombo->addItem(tr("90 days"), 90); + m_presetCombo->addItem(tr("1 year"), 365); + m_presetCombo->addItem(tr("Custom..."), -1); + m_presetCombo->setCurrentIndex(1); // Default to 7 days + + m_daysSpinBox = new QSpinBox; + m_daysSpinBox->setRange(1, 3650); // 10 years + m_daysSpinBox->setValue(defaultDays); + m_daysSpinBox->setSuffix(tr(" days")); + m_daysSpinBox->setVisible(false); // Hidden until "Custom..." is selected + + // Layout for preset + custom spinbox + auto *daysLayout = new QHBoxLayout; + daysLayout->setContentsMargins(0, 0, 0, 0); + daysLayout->addWidget(m_presetCombo, 1); + daysLayout->addWidget(m_daysSpinBox, 0); + + m_fromDateEdit = new QDateEdit(QDate::currentDate().addDays(-defaultDays)); + m_fromDateEdit->setCalendarPopup(true); + m_fromDateEdit->setDisplayFormat("yyyy-MM-dd"); + m_fromDateEdit->setToolTip(tr("Calculated from 'End date' and day span.")); + + formLayout->addRow(tr("Day span:"), daysLayout); + formLayout->addRow(tr("Start date:"), m_fromDateEdit); + formLayout->addRow(tr("End date:"), m_toDateEdit); + + layout->addLayout(formLayout); + + m_infoLabel = new QLabel; + m_infoLabel->setWordWrap(true); + m_infoLabel->setStyleSheet("QLabel { color: #888; font-size: 11px; }"); + + layout->addWidget(m_infoLabel); + + connect(m_fromDateEdit, &QDateEdit::dateChanged, this, &SyncRangeDialog::updateToDate); + connect(m_toDateEdit, &QDateEdit::dateChanged, this, &SyncRangeDialog::updateFromDate); + connect(m_daysSpinBox, QOverload::of(&QSpinBox::valueChanged), this, &SyncRangeDialog::updateFromDate); + + // Connect preset dropdown + connect(m_presetCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { + int days = m_presetCombo->itemData(index).toInt(); + if (days == -1) { + // Custom mode: show spinbox, keep current value + m_daysSpinBox->setVisible(true); + } else { + // Preset mode: hide spinbox, set value + m_daysSpinBox->setVisible(false); + m_daysSpinBox->setValue(days); + } + }); + + // Init info + updateInfo(); + + auto *btnBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(btnBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(btnBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + layout->addWidget(btnBox); + + resize(320, height()); +} + +QDate SyncRangeDialog::fromDate() const { + return m_fromDateEdit->date(); +} + +QDate SyncRangeDialog::toDate() const { + return m_toDateEdit->date(); +} + +quint64 SyncRangeDialog::estimatedBlocks() const { + return m_estimatedBlocks; +} + +quint64 SyncRangeDialog::estimatedSize() const { + return m_estimatedSize; +} + +void SyncRangeDialog::updateInfo() { + NetworkType::Type nettype = m_wallet->nettype(); + QString filename = Utils::getRestoreHeightFilename(nettype); + std::unique_ptr lookup(RestoreHeightLookup::fromFile(filename, nettype)); + if (!lookup || lookup->data.isEmpty()) { + m_infoLabel->setText(tr("Unable to estimate - restore height data unavailable")); + m_estimatedBlocks = 0; + m_estimatedSize = 0; + return; + } + + QDate start = m_fromDateEdit->date(); + QDate end = m_toDateEdit->date(); + + uint64_t startHeight = lookup->dateToHeight(start.startOfDay().toSecsSinceEpoch()); + uint64_t endHeight = lookup->dateToHeight(end.endOfDay().toSecsSinceEpoch()); + + if (endHeight < startHeight) endHeight = startHeight; + m_estimatedBlocks = endHeight - startHeight; + m_estimatedSize = Utils::estimateSyncDataSize(m_estimatedBlocks); + + m_infoLabel->setText(tr("Scanning ~%1 blocks\nEst. download size: %2") + .arg(m_estimatedBlocks) + .arg(Utils::formatBytes(m_estimatedSize))); +} + +void SyncRangeDialog::updateFromDate() { + m_fromDateEdit->setDate(m_toDateEdit->date().addDays(-m_daysSpinBox->value())); + updateInfo(); +} + +void SyncRangeDialog::updateToDate() { + m_toDateEdit->setDate(m_fromDateEdit->date().addDays(m_daysSpinBox->value())); + updateInfo(); +} diff --git a/src/dialog/SyncRangeDialog.h b/src/dialog/SyncRangeDialog.h new file mode 100644 index 00000000..7a7ea1b9 --- /dev/null +++ b/src/dialog/SyncRangeDialog.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: The Monero Project + +#ifndef FEATHER_SYNCRANGEDIALOG_H +#define FEATHER_SYNCRANGEDIALOG_H + +#include +#include + +#include "libwalletqt/Wallet.h" + +class QComboBox; +class QSpinBox; +class QDateEdit; +class QLabel; + +class SyncRangeDialog : public QDialog +{ +Q_OBJECT + +public: + explicit SyncRangeDialog(QWidget *parent, Wallet *wallet); + ~SyncRangeDialog() override = default; + + QDate fromDate() const; + QDate toDate() const; + quint64 estimatedBlocks() const; + quint64 estimatedSize() const; + +private: + void updateInfo(); + void updateFromDate(); + void updateToDate(); + + Wallet *m_wallet; + + // Date/Time + QComboBox *m_presetCombo; + QSpinBox *m_daysSpinBox; + QDateEdit *m_fromDateEdit; + QDateEdit *m_toDateEdit; + + QLabel *m_infoLabel; + + quint64 m_estimatedBlocks = 0; + quint64 m_estimatedSize = 0; +}; + +#endif //FEATHER_SYNCRANGEDIALOG_H diff --git a/src/dialog/TxImportDialog.cpp b/src/dialog/TxImportDialog.cpp index 1473311f..842337bf 100644 --- a/src/dialog/TxImportDialog.cpp +++ b/src/dialog/TxImportDialog.cpp @@ -7,21 +7,44 @@ #include #include "utils/NetworkManager.h" +#include "utils/nodes.h" -TxImportDialog::TxImportDialog(QWidget *parent, Wallet *wallet) +#include +#include +#include +#include + + + +TxImportDialog::TxImportDialog(QWidget *parent, Wallet *wallet, Nodes *nodes) : WindowModalDialog(parent) , ui(new Ui::TxImportDialog) , m_wallet(wallet) + , m_nodes(nodes) { ui->setupUi(this); connect(ui->btn_import, &QPushButton::clicked, this, &TxImportDialog::onImport); + connect(m_wallet, &Wallet::connectionStatusChanged, this, &TxImportDialog::updateStatus); + ui->line_txid->setMinimumWidth(600); this->adjustSize(); + + this->layout()->setSizeConstraint(QLayout::SetFixedSize); + + this->updateStatus(m_wallet->connectionStatus()); } void TxImportDialog::onImport() { - QString txid = ui->line_txid->text(); + if (m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) { + m_nodes->connectToNode(); + m_wallet->setScanMempoolWhenPaused(true); + this->updateStatus(Wallet::ConnectionStatus_Connecting); + return; + } + + QString txid = ui->line_txid->text().trimmed(); + if (txid.isEmpty()) return; if (m_wallet->haveTransaction(txid)) { Utils::showWarning(this, "Transaction already exists in wallet", "If you can't find it in your history, " @@ -29,16 +52,92 @@ void TxImportDialog::onImport() { return; } - if (m_wallet->importTransaction(txid)) { - if (!m_wallet->haveTransaction(txid)) { - Utils::showError(this, "Unable to import transaction", "This transaction does not belong to the wallet"); - return; + // Async Import: Fetch height from daemon, then Smart Sync to it. + ui->btn_import->setEnabled(false); + ui->btn_import->setText("Checking..."); + + QNetworkAccessManager* nam = getNetwork(); // Use global network manager + QString url = m_nodes->connection().toURL() + "/get_transactions"; + + QJsonObject req; + req["txs_hashes"] = QJsonArray({txid}); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QNetworkReply* reply = nam->post(request, QJsonDocument(req).toJson()); + + connect(reply, &QNetworkReply::finished, this, [this, reply, txid]() { + reply->deleteLater(); + ui->btn_import->setEnabled(true); + ui->btn_import->setText("Import"); + + if (reply->error() != QNetworkReply::NoError) { + Utils::showError(this, "Connection error", reply->errorString()); + return; } - Utils::showInfo(this, "Transaction imported successfully", ""); + + QJsonObject json = QJsonDocument::fromJson(reply->readAll()).object(); + QJsonObject error = json.value("error").toObject(); + if (!error.isEmpty()) { + Utils::showError(this, "Node error", error.value("message").toString()); + return; + } + + QJsonArray txs = json.value("txs").toArray(); + bool found = false; + + for (const auto &val : txs) { + QJsonObject tx = val.toObject(); + if (tx.value("tx_hash").toString() == txid) { + found = true; + if (tx.value("in_pool").toBool()) { + Utils::showInfo(this, "Transaction is in mempool", "Feather will detect it automatically in a moment."); + this->accept(); + return; + } + + quint64 height = tx.value("block_height").toVariant().toULongLong(); + if (height > 0) { + // Check if wallet is far behind (fresh restore?) + quint64 currentHeight = m_wallet->blockChainHeight(); + + if (height > currentHeight + 100000) { + // Jump ahead to avoid full scan + quint64 restoreHeight = (height > 20000) ? height - 20000 : 0; + m_wallet->setWalletCreationHeight(restoreHeight); + m_wallet->rescanBlockchainAsync(); + Utils::showInfo(this, "Optimizing Sync", "Jumped to block " + QString::number(restoreHeight) + " to find transaction."); + } + + m_wallet->startSmartSync(height + 10); + Utils::showInfo(this, "Import started", "Scanning block " + QString::number(height) + " for transaction..."); + this->accept(); + return; + } + } + } + + if (!found) { + Utils::showError(this, "Transaction not found on node", "The connected node does not know this transaction."); + } else { + // Found but failed to get height? Fallback. + Utils::showError(this, "Failed to determine block height", "Could not read block height from node response."); + } + }); +} + +void TxImportDialog::updateStatus(int status) { + if (status == Wallet::ConnectionStatus_Disconnected) { + ui->btn_import->setText("Connect"); + ui->btn_import->setEnabled(true); + } else if (status == Wallet::ConnectionStatus_Connecting || status == Wallet::ConnectionStatus_WrongVersion) { + ui->btn_import->setText("Connecting..."); + ui->btn_import->setEnabled(false); } else { - Utils::showError(this, "Failed to import transaction", ""); + ui->btn_import->setText("Import"); + ui->btn_import->setEnabled(true); } - m_wallet->refreshModels(); } TxImportDialog::~TxImportDialog() = default; diff --git a/src/dialog/TxImportDialog.h b/src/dialog/TxImportDialog.h index 02b3441f..32a993a9 100644 --- a/src/dialog/TxImportDialog.h +++ b/src/dialog/TxImportDialog.h @@ -14,20 +14,25 @@ namespace Ui { class TxImportDialog; } +class Nodes; + class TxImportDialog : public WindowModalDialog { Q_OBJECT public: - explicit TxImportDialog(QWidget *parent, Wallet *wallet); + explicit TxImportDialog(QWidget *parent, Wallet *wallet, Nodes *nodes); ~TxImportDialog() override; private slots: void onImport(); private: + void updateStatus(int status); + QScopedPointer ui; Wallet *m_wallet; + Nodes *m_nodes; }; diff --git a/src/libwalletqt/Wallet.cpp b/src/libwalletqt/Wallet.cpp index c26e40d9..b4a517ef 100644 --- a/src/libwalletqt/Wallet.cpp +++ b/src/libwalletqt/Wallet.cpp @@ -3,8 +3,13 @@ #include "Wallet.h" + + #include #include +#include + +#include #include "AddressBook.h" #include "Coins.h" @@ -14,6 +19,7 @@ #include "WalletManager.h" #include "WalletListenerImpl.h" +#include "utils/config.h" #include "config.h" #include "constants.h" @@ -25,6 +31,8 @@ #include "model/CoinsModel.h" #include "utils/ScopeGuard.h" +#include "utils/RestoreHeightLookup.h" +#include "utils/Utils.h" #include "wallet/wallet2.h" @@ -52,6 +60,7 @@ Wallet::Wallet(Monero::Wallet *wallet, QObject *parent) , m_useSSL(true) , m_coins(new Coins(this, wallet->getWallet(), this)) , m_storeTimer(new QTimer(this)) + , m_lastRefreshTime(std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count()) { m_walletListener = new WalletListenerImpl(this); m_walletImpl->setListener(m_walletListener); @@ -63,7 +72,7 @@ Wallet::Wallet(Monero::Wallet *wallet, QObject *parent) m_coinsModel = new CoinsModel(this, m_coins); if (this->status() == Status_Ok) { - startRefreshThread(); + // startRefreshThread(); // Moved to startRefresh() // Store the wallet every 2 minutes m_storeTimer->start(2 * 60 * 1000); @@ -83,6 +92,21 @@ Wallet::Wallet(Monero::Wallet *wallet, QObject *parent) connect(m_subaddress, &Subaddress::corrupted, [this]{ emit keysCorrupted(); }); + + // Store original creation height if not already present + // This protects the restore height from being overwritten by "Skip Sync" or range syncs + if (!cacheAttributeExists("feather.creation_height")) { + quint64 height = m_wallet2->get_refresh_from_block_height(); + setCacheAttribute("feather.creation_height", QString::number(height)); + } + + QString lastSyncStr = getCacheAttribute("feather.lastSync"); + if (!lastSyncStr.isEmpty()) { + qint64 lastSync = lastSyncStr.toLongLong(); + if (lastSync > 0) { + m_lastSyncTime = QDateTime::fromSecsSinceEpoch(lastSync); + } + } } // #################### Status #################### @@ -406,20 +430,50 @@ void Wallet::setDaemonLogin(const QString &daemonUsername, const QString &daemon void Wallet::initAsync(const QString &daemonAddress, bool trustedDaemon, quint64 upperTransactionLimit, const QString &proxyAddress) { qDebug() << "initAsync: " + daemonAddress; + + if (daemonAddress.isEmpty()) { + m_scheduler.run([this] { + m_wallet2->set_offline(true); + }); + setConnectionStatus(Wallet::ConnectionStatus_Disconnected); + return; + } + const auto future = m_scheduler.run([this, daemonAddress, trustedDaemon, upperTransactionLimit, proxyAddress] { // Beware! This code does not run in the GUI thread. bool success; { QMutexLocker locker(&m_proxyMutex); - success = m_walletImpl->init(daemonAddress.toStdString(), upperTransactionLimit, m_daemonUsername.toStdString(), m_daemonPassword.toStdString(), m_useSSL, false, proxyAddress.toStdString()); + QString safeAddress = daemonAddress; + if (safeAddress.endsWith(".onion") || safeAddress.contains(".onion:")) { + if (!safeAddress.contains("://")) { + safeAddress.prepend("http://"); + } + } + qCritical() << "Refresher: Initializing wallet with daemon address:" << safeAddress; + qDebug() << "InitAsync: connecting to" << safeAddress; + m_wallet2->set_offline(false); + success = m_walletImpl->init(safeAddress.toStdString(), upperTransactionLimit, m_daemonUsername.toStdString(), m_daemonPassword.toStdString(), m_useSSL, false, proxyAddress.toStdString()); + } + + if (m_scheduler.stopping()) { + return; } setTrustedDaemon(trustedDaemon); if (success) { - qDebug() << "init async finished - starting refresh"; - startRefresh(); + qInfo() << "init async finished - starting refresh. Paused:" << m_syncPaused; + + // Fetch initial heights so UI can update even if paused + quint64 daemonHeight = m_walletImpl->daemonBlockChainHeight(); + quint64 targetHeight = m_walletImpl->daemonBlockChainTargetHeight(); + emit heightsRefreshed(daemonHeight > 0, daemonHeight, targetHeight); + + if (!m_syncPaused) { + startRefresh(); + } } }); if (future.first) @@ -430,22 +484,45 @@ void Wallet::initAsync(const QString &daemonAddress, bool trustedDaemon, quint64 // #################### Synchronization (Refresh) #################### -void Wallet::startRefresh() { - m_refreshEnabled = true; +void Wallet::startRefresh(bool force) { + startRefreshThread(); m_refreshEnabled = true; - m_refreshNow = true; + if (force || !m_syncPaused) { + m_refreshNow = true; + } } void Wallet::pauseRefresh() { m_refreshEnabled = false; } +void Wallet::updateNetworkStatus() { + const auto future = m_scheduler.run([this] { + if (!isHwBacked() || isDeviceConnected()) { + quint64 daemonHeight = m_walletImpl->daemonBlockChainHeight(); + bool success = daemonHeight > 0; + + quint64 targetHeight = 0; + if (success) { + targetHeight = m_walletImpl->daemonBlockChainTargetHeight(); + } + bool haveHeights = (daemonHeight > 0 && targetHeight > 0); + + emit heightsRefreshed(haveHeights, daemonHeight, targetHeight); + } + }); +} + void Wallet::startRefreshThread() { + bool expected = false; + if (!m_refreshThreadStarted.compare_exchange_strong(expected, true)) { + return; + } + const auto future = m_scheduler.run([this] { // Beware! This code does not run in the GUI thread. - constexpr const std::chrono::seconds refreshInterval{10}; constexpr const std::chrono::milliseconds intervalResolution{100}; auto last = std::chrono::steady_clock::now(); @@ -455,15 +532,56 @@ void Wallet::startRefreshThread() { const auto now = std::chrono::steady_clock::now(); const auto elapsed = now - last; - if (elapsed >= refreshInterval || m_refreshNow) + if (elapsed >= std::chrono::seconds(m_refreshInterval) || m_refreshNow) { - m_refreshNow = false; + if (m_syncPaused && !m_rangeSyncActive) { + bool shouldScanMempool = m_refreshNow || m_scanMempoolWhenPaused; + + if (shouldScanMempool) { + if (m_wallet2->get_daemon_address().empty()) { + qDebug() << "[SYNC PAUSED] Skipping mempool scan because daemon address is empty"; + } else { + qDebug() << "[SYNC PAUSED] Scanning mempool because scans are enabled"; + if (m_scheduler.stopping()) return; + scanMempool(); + } + } + + // Update network stats if we just scanned OR if we don't have stats yet (startup recovery) + if (shouldScanMempool || m_daemonBlockChainHeight == 0) { + quint64 daemonHeight = m_walletImpl->daemonBlockChainHeight(); + quint64 targetHeight = (daemonHeight > 0) ? m_walletImpl->daemonBlockChainTargetHeight() : 0; + emit heightsRefreshed(daemonHeight > 0, daemonHeight, targetHeight); + } + m_refreshNow = false; + last = std::chrono::steady_clock::now(); + continue; + } + + m_refreshNow = false; + auto loopStartTime = std::chrono::time_point_cast(std::chrono::steady_clock::now()); // get daemonHeight and targetHeight // daemonHeight and targetHeight will be 0 if call to get_info fails quint64 daemonHeight = m_walletImpl->daemonBlockChainHeight(); bool success = daemonHeight > 0; + if (success) { + m_lastRefreshTime = loopStartTime.time_since_epoch().count(); + last = loopStartTime; + } else { + // If sync failed, retry according to the interval (respects Data Saving) + auto retryDelay = std::chrono::seconds(m_refreshInterval); + qCritical() << "Refresher: Sync failed. Retry delay set to:" << retryDelay.count(); + auto nextTime = loopStartTime - std::chrono::seconds(m_refreshInterval) + retryDelay; + m_lastRefreshTime = nextTime.time_since_epoch().count(); + last = nextTime; + } + + qDebug() << "Refresher: Interval met. Elapsed:" << std::chrono::duration_cast(elapsed).count() + << "Interval:" << m_refreshInterval << "RefreshNow:" << m_refreshNow; + + quint64 targetHeight = 0; if (success) { targetHeight = m_walletImpl->daemonBlockChainTargetHeight(); @@ -475,6 +593,7 @@ void Wallet::startRefreshThread() // Don't call refresh function if we don't have the daemon and target height // We do this to prevent to UI from getting confused about the amount of blocks that are still remaining if (haveHeights) { + QMutexLocker locker(&m_asyncMutex); if (m_newWallet) { @@ -483,9 +602,28 @@ void Wallet::startRefreshThread() m_newWallet = false; } + quint64 walletHeight = m_walletImpl->blockChainHeight(); + + if (m_rangeSyncActive) { + uint64_t max_blocks = (m_stopHeight > walletHeight) ? (m_stopHeight - walletHeight) : 1; + uint64_t blocks_fetched = 0; + bool received_money = false; + + // Ensure we respect the wallet creation height (restore height) if it's set higher than current + uint64_t startHeight = std::max((uint64_t)walletHeight, m_wallet2->get_refresh_from_block_height()); + + m_wallet2->refresh(m_wallet2->is_trusted_daemon(), startHeight, blocks_fetched, received_money, true, true, max_blocks); + + if (m_walletImpl->blockChainHeight() >= m_stopHeight) { + m_rangeSyncActive = false; + if (m_syncPaused) { + setConnectionStatus(ConnectionStatus_Idle); + } + } + } else { m_walletImpl->refresh(); } - last = std::chrono::steady_clock::now(); + } } } @@ -507,12 +645,14 @@ void Wallet::onHeightsRefreshed(bool success, quint64 daemonHeight, quint64 targ if (daemonHeight < targetHeight) { emit syncStatus(daemonHeight, targetHeight, true); - } - else { + } else { this->syncStatusUpdated(walletHeight, daemonHeight); + emit syncStatus(daemonHeight, targetHeight, false); } - if (walletHeight < (targetHeight - 1)) { + if (m_syncPaused && !m_rangeSyncActive) { + setConnectionStatus(ConnectionStatus_Idle); + } else if (walletHeight < targetHeight) { setConnectionStatus(ConnectionStatus_Synchronizing); } else { setConnectionStatus(ConnectionStatus_Synchronized); @@ -520,6 +660,10 @@ void Wallet::onHeightsRefreshed(bool success, quint64 daemonHeight, quint64 targ } else { setConnectionStatus(ConnectionStatus_Disconnected); } + + if (success) { + m_lastSyncTime = QDateTime::currentDateTime(); + } } quint64 Wallet::blockChainHeight() const { @@ -527,6 +671,26 @@ quint64 Wallet::blockChainHeight() const { return m_wallet2->get_blockchain_current_height(); } +qint64 Wallet::secondsUntilNextRefresh() const { + if (m_syncPaused || !m_refreshEnabled) { + return -1; + } + + if (this->isHwBacked() && !this->isDeviceConnected()) { + return -2; + } + + auto now = std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); + auto elapsed = std::chrono::microseconds(now - m_lastRefreshTime.load()); + auto interval = std::chrono::seconds(m_refreshInterval); + + if (elapsed >= interval) { + return 0; + } + + return std::chrono::duration_cast(interval - elapsed).count(); +} + quint64 Wallet::daemonBlockChainHeight() const { return m_daemonBlockChainHeight; } @@ -535,16 +699,259 @@ quint64 Wallet::daemonBlockChainTargetHeight() const { return m_daemonBlockChainTargetHeight; } -void Wallet::syncStatusUpdated(quint64 height, quint64 target) { - if (height >= (target - 1)) { - // TODO: is this needed? +void Wallet::setSyncPaused(bool paused) { + m_syncPaused = paused; + if (paused) { + pauseRefresh(); + if (!m_scanMempoolWhenPaused) { + m_wallet2->set_offline(true); + } + } else { + m_wallet2->set_offline(false); + startRefresh(true); + } +} + +void Wallet::setScanMempoolWhenPaused(bool enabled) { + m_scanMempoolWhenPaused = enabled; + + // Immediately trigger a scan if enabled and paused + if (enabled && m_syncPaused) { + m_wallet2->set_offline(false); + startRefresh(true); + } + else if (!enabled && m_syncPaused) { + m_wallet2->set_offline(true); + } +} + +QDateTime Wallet::lastSyncTime() const { + return m_lastSyncTime; +} + +void Wallet::setRefreshInterval(int seconds) { + m_refreshInterval = seconds; +} + +void Wallet::skipToTip() { + if (!m_wallet2) return; + + uint64_t target = m_daemonBlockChainTargetHeight; + if (target == 0) { + qWarning() << "Cannot skip to tip: Network target unknown. Connect first."; + return; + } + + QMutexLocker locker(&m_asyncMutex); + m_stopHeight = target; + m_rangeSyncActive = true; + m_wallet2->set_refresh_from_block_height(target); + m_lastSyncTime = QDateTime::currentDateTime(); + + setConnectionStatus(ConnectionStatus_Synchronized); + startRefresh(true); + emit syncStatus(target, target, true); +} + +quint64 Wallet::getUnlockTargetHeight() const { + if (!m_wallet2) return 0; + + uint64_t current = blockChainHeight(); + uint64_t target = 0; + + // Check incoming transfers (last 1000 blocks) + uint64_t min_height = (current > 1000) ? current - 1000 : 0; + uint64_t max_height = (uint64_t)-1; + + std::list> in_payments; + m_wallet2->get_payments(in_payments, min_height, max_height); + + for (const auto &p : in_payments) { + // Standard unlock time is block_height + 10 + uint64_t unlock_height = p.second.m_block_height + 10; + // Explicit unlock_time override + if (p.second.m_unlock_time > 0) { + unlock_height = p.second.m_unlock_time; + } + + if (unlock_height > current) { + target = std::max(target, unlock_height); + } + } + + // Check outgoing transfers (change) + std::list> out_payments; + m_wallet2->get_payments_out(out_payments, min_height, max_height); + + for (const auto &p : out_payments) { + // Change is locked for 10 blocks + uint64_t unlock_height = p.second.m_block_height + 10; + if (unlock_height > current) { + target = std::max(target, unlock_height); + } + } + + return target; +} + +void Wallet::startSmartSync(quint64 requestedTarget) { + if (!m_wallet2) return; + + uint64_t tip = m_daemonBlockChainTargetHeight; + if (tip == 0) { + qWarning() << "Cannot start smart sync: Network target unknown. Connect first."; + return; + } + + uint64_t current = blockChainHeight(); + uint64_t target = tip; + uint64_t unlockTarget = getUnlockTargetHeight(); + + // "Smart Sync": Only scan what is needed to unlock funds + if (requestedTarget > 0) { + target = std::min((uint64_t)requestedTarget, tip); + qInfo() << "Smart Sync: Scanning to requested target:" << target; + } else if (unlockTarget > current) { + // If we have locked funds, scan to their unlock height (clamped to tip) + target = std::min(unlockTarget, tip); + qInfo() << "Smart Sync: Scanning to unlock target:" << target; + } else { + // No locked funds. + if (tip > current) { + // Minimal connectivity check + target = std::min(current + 10, tip); + qInfo() << "Smart Sync: No locked funds. Scanning small buffer to:" << target; + } else { + qInfo() << "Smart Sync: Already at tip."; + return; + } + } + + QMutexLocker locker(&m_asyncMutex); + m_stopHeight = target; + m_rangeSyncActive = true; + m_pauseAfterSync = true; + m_lastSyncTime = QDateTime::currentDateTime(); + + setConnectionStatus(ConnectionStatus_Synchronizing); + startRefresh(true); + emit syncStatus(target, target, true); +} + +void Wallet::syncDateRange(const QDate &start, const QDate &end) { + if (!m_wallet2) + return; + + // Convert dates to heights with internal table lookup + cryptonote::network_type nettype = m_wallet2->nettype(); + QString filename = Utils::getRestoreHeightFilename(static_cast(nettype)); + + std::unique_ptr lookup(RestoreHeightLookup::fromFile(filename, static_cast(nettype))); + uint64_t startHeight = lookup->dateToHeight(start.startOfDay().toSecsSinceEpoch()); + uint64_t endHeight = lookup->dateToHeight(end.startOfDay().toSecsSinceEpoch()); + + if (startHeight >= endHeight) + return; + + { + QMutexLocker locker(&m_asyncMutex); + m_stopHeight = endHeight; + m_rangeSyncActive = true; + m_wallet2->set_refresh_from_block_height(startHeight); + } + setConnectionStatus(ConnectionStatus_Synchronizing); + startRefresh(true); +} + + + +void Wallet::fullSync() { + if (!m_wallet2) + return; + + // Reset range sync just in case + m_rangeSyncActive = false; + + // Retrieve original creation height from persistent storage + uint64_t originalHeight = 0; + QString storedHeight = this->getCacheAttribute("feather.creation_height"); + if (!storedHeight.isEmpty()) { + originalHeight = storedHeight.toULongLong(); + } else { + // Fallback: if skipToTip() was used, this may be the current tip, missing all transactions + originalHeight = m_wallet2->get_refresh_from_block_height(); + qWarning() << "fullSync: No stored creation height found (feather.creation_height). " + << "Falling back to current refresh height:" << originalHeight + << ". This may miss transactions if skipToTip() was previously used."; + } + + { + QMutexLocker locker(&m_asyncMutex); + m_wallet2->set_refresh_from_block_height(originalHeight); + } + // Trigger rescan + setConnectionStatus(ConnectionStatus_Synchronizing); + startRefresh(true); + + qInfo() << "Full Sync triggered. Rescanning from original restore height:" << originalHeight; +} + +void Wallet::syncStatusUpdated(quint64 height, quint64 targetHeight) { + if (m_rangeSyncActive && height >= m_stopHeight) { + // At end of requested date range, jump to tip + m_rangeSyncActive = false; + + if (m_pauseAfterSync) { + m_pauseAfterSync = false; + // We reached the tip via scan. Just go back to paused/idle. + setSyncPaused(true); + } else { + // Normal date range sync behavior: skip the rest + this->skipToTip(); + } + return; + } + + if (height >= (targetHeight - 1)) { this->updateBalance(); } + emit syncStatus(height, targetHeight, false); +} - emit syncStatus(height, target, false); +bool Wallet::importTransaction(const QString &txid) { + if (!m_wallet2 || txid.isEmpty()) + return false; + + // If scanning a specific TX, we shouldn't be constrained by range sync + if (m_rangeSyncActive) { + m_rangeSyncActive = false; + } + + try { + std::unordered_set txids; + crypto::hash txid_hash; + if (!epee::string_tools::hex_to_pod(txid.toStdString(), txid_hash)) { + qWarning() << "Invalid transaction id: " << txid; + return false; + } + txids.insert(txid_hash); + m_wallet2->scan_tx(txids); + qInfo() << "Successfully imported transaction:" << txid; + this->updateBalance(); + this->history()->refresh(); + return true; + } catch (const std::exception &e) { + qWarning() << "Failed to import transaction: " << txid << ", error: " << e.what(); + } + return false; } + + void Wallet::onNewBlock(uint64_t walletHeight) { + if (m_syncPaused) { + return; + } // Called whenever a new block gets scanned by the wallet quint64 daemonHeight = m_daemonBlockChainTargetHeight; @@ -588,6 +995,11 @@ void Wallet::onRefreshed(bool success, const QString &message) { } } +void Wallet::rescanBlockchainAsync() { + m_wallet2->rescan_blockchain(false, false, false); + // After rescan, the wallet's local height is reset to the refresh-from height. +} + void Wallet::refreshModels() { m_history->refresh(); m_coins->refresh(); @@ -693,11 +1105,6 @@ bool Wallet::importOutputsFromStr(const std::string &outputs) { return m_walletImpl->importOutputsFromStr(outputs); } -bool Wallet::importTransaction(const QString& txid) { - std::vector txids = {txid.toStdString()}; - return m_walletImpl->scanTransactions(txids); -} - // #################### Wallet cache #################### void Wallet::store() { @@ -900,7 +1307,9 @@ void Wallet::createTransaction(const QString &address, quint64 amount, const QSt currentSubaddressAccount(), subaddr_indices, m_selectedInputs, subtractFeeFromAmount); QVector addresses{address}; - this->onTransactionCreated(ptImpl, addresses); + QMetaObject::invokeMethod(this, [this, ptImpl, addresses] { + this->onTransactionCreated(ptImpl, addresses); + }, Qt::QueuedConnection); }); } @@ -924,7 +1333,9 @@ void Wallet::createTransactionMultiDest(const QVector &addresses, const static_cast(feeLevel), currentSubaddressAccount(), subaddr_indices, m_selectedInputs, subtractFeeFromAmount); - this->onTransactionCreated(ptImpl, addresses); + QMetaObject::invokeMethod(this, [this, ptImpl, addresses] { + this->onTransactionCreated(ptImpl, addresses); + }, Qt::QueuedConnection); }); } @@ -945,7 +1356,9 @@ void Wallet::sweepOutputs(const QVector &keyImages, QString address, bo static_cast(feeLevel)); QVector addresses {address}; - this->onTransactionCreated(ptImpl, addresses); + QMetaObject::invokeMethod(this, [this, ptImpl, addresses] { + this->onTransactionCreated(ptImpl, addresses); + }, Qt::QueuedConnection); }); } @@ -953,7 +1366,6 @@ void Wallet::sweepOutputs(const QVector &keyImages, QString address, bo void Wallet::onTransactionCreated(Monero::PendingTransaction *mtx, const QVector &address) { qDebug() << Q_FUNC_INFO; - startRefresh(); PendingTransaction *tx = new PendingTransaction(mtx, this); @@ -1452,12 +1864,35 @@ void Wallet::getTxPoolStatsAsync() { }); } +void Wallet::scanMempool() { + QMutexLocker locker(&m_asyncMutex); + try { + std::vector> process_txs; + m_wallet2->update_pool_state(process_txs, false, false); + // Refresh models so the UI picks up the new transaction(s) + // We invoke this on the main thread to ensure signals (beginResetModel) are processed synchronously + // with the data update, preventing race conditions or ignored updates in the view. + QMetaObject::invokeMethod(this, [this]{ + if (m_history) m_history->refresh(); + if (m_coins) m_coins->refresh(); + if (m_subaddress) m_subaddress->refresh(); + }, Qt::QueuedConnection); + + emit updated(); + } catch (const std::exception &e) { + qWarning() << "Failed to scan mempool:" << e.what(); + } +} + Wallet::~Wallet() { qDebug() << "~Wallet: Closing wallet" << QThread::currentThreadId(); pauseRefresh(); m_walletImpl->stop(); + // Stop the wallet2 instance to interrupt any blocking network calls (e.g. init) + if (m_wallet2) + m_wallet2->stop(); m_scheduler.shutdownWaitForFinished(); diff --git a/src/libwalletqt/Wallet.h b/src/libwalletqt/Wallet.h index 20d21af4..1c85d9b3 100644 --- a/src/libwalletqt/Wallet.h +++ b/src/libwalletqt/Wallet.h @@ -15,6 +15,7 @@ #include "rows/TxBacklogEntry.h" #include +#include class WalletListenerImpl; @@ -111,7 +112,8 @@ public: ConnectionStatus_WrongVersion = 2, ConnectionStatus_Connecting = 9, ConnectionStatus_Synchronizing = 10, - ConnectionStatus_Synchronized = 11 + ConnectionStatus_Synchronized = 11, + ConnectionStatus_Idle = 12 }; Q_ENUM(ConnectionStatus) @@ -138,11 +140,15 @@ public: //! returns if view only wallet bool viewOnly() const; + QDateTime lastSyncTime() const; + void setRefreshInterval(int seconds); + qint64 secondsUntilNextRefresh() const; + //! return true if deterministic keys bool isDeterministic() const; QString walletName() const; - + // ##### Balance ##### //! returns balance quint64 balance() const; @@ -153,7 +159,7 @@ public: quint64 unlockedBalance() const; quint64 unlockedBalance(quint32 accountIndex) const; quint64 unlockedBalanceAll() const; - + quint64 viewOnlyBalance(quint32 accountIndex) const; void updateBalance(); @@ -215,8 +221,13 @@ public: const QString &proxyAddress = ""); // ##### Synchronization (Refresh) ##### - void startRefresh(); + void startRefresh(bool force = false); void pauseRefresh(); + Q_INVOKABLE void updateNetworkStatus(); + + bool syncPaused() const; + void setSyncPaused(bool paused); + void setScanMempoolWhenPaused(bool enabled); //! returns current wallet's block height //! (can be less than daemon's blockchain height when wallet sync in progress) @@ -229,6 +240,15 @@ public: quint64 daemonBlockChainTargetHeight() const; void syncStatusUpdated(quint64 height, quint64 target); + quint64 getUnlockTargetHeight() const; + Q_INVOKABLE void skipToTip(); + Q_INVOKABLE void startSmartSync(quint64 target = 0); + Q_INVOKABLE void syncDateRange(const QDate &start, const QDate &end); + + void fullSync(); // Rescans from wallet creation height, not genesis block + + Q_INVOKABLE void rescanBlockchainAsync(); + bool importTransaction(const QString &txid); void refreshModels(); @@ -250,25 +270,22 @@ public: void setForceKeyImageSync(bool enabled); bool hasUnknownKeyImages() const; bool keyImageSyncNeeded(quint64 amount, bool sendAll) const; - + //! export/import key images bool exportKeyImages(const QString& path, bool all = false); bool exportKeyImagesToStr(std::string &keyImages, bool all = false); bool exportKeyImagesForOutputsFromStr(const std::string &outputs, std::string &keyImages); - + bool importKeyImages(const QString& path); bool importKeyImagesFromStr(const std::string &keyImages); //! export/import outputs bool exportOutputs(const QString& path, bool all = false); bool exportOutputsToStr(std::string& outputs, bool all); - + bool importOutputs(const QString& path); bool importOutputsFromStr(const std::string &outputs); - //! import a transaction - bool importTransaction(const QString& txid); - // ##### Wallet cache ##### //! saves wallet to the file by given path //! empty path stores in current location @@ -342,7 +359,7 @@ public: //! Sign a transfer from file UnsignedTransaction * loadTxFile(const QString &fileName); UnsignedTransaction * loadUnsignedTransactionFromStr(const std::string &data); - + //! Load an unsigned transaction from a base64 encoded string UnsignedTransaction * loadTxFromBase64Str(const QString &unsigned_tx); @@ -455,7 +472,6 @@ signals: void connectionStatusChanged(int status) const; void currentSubaddressAccountChanged() const; - void syncStatus(quint64 height, quint64 target, bool daemonSync = false); void balanceUpdated(quint64 balance, quint64 spendable); @@ -486,6 +502,7 @@ private: void onTransactionCreated(Monero::PendingTransaction *mtx, const QVector &address); private: + void scanMempool(); friend class WalletManager; friend class WalletListenerImpl; @@ -501,6 +518,7 @@ private: quint64 m_daemonBlockChainHeight; quint64 m_daemonBlockChainTargetHeight; + QDateTime m_lastSyncTime; ConnectionStatus m_connectionStatus; @@ -513,6 +531,8 @@ private: Coins *m_coins; CoinsModel *m_coinsModel; + std::atomic m_refreshInterval{10}; + QMutex m_asyncMutex; QString m_daemonUsername; QString m_daemonPassword; @@ -529,6 +549,15 @@ private: QTimer *m_storeTimer = nullptr; std::set m_selectedInputs; + + std::atomic m_stopHeight{0}; + std::atomic m_rangeSyncActive{false}; + std::atomic m_syncPaused{false}; + std::atomic m_lastRefreshTime{0}; + std::atomic m_pauseAfterSync{false}; + std::atomic m_refreshThreadStarted{false}; + std::atomic m_scanMempoolWhenPaused{false}; }; #endif // FEATHER_WALLET_H + diff --git a/src/main.cpp b/src/main.cpp index 495479de..4b57478e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,9 @@ // SPDX-FileCopyrightText: The Monero Project #include +#include +#include +#include #include "Application.h" #include "constants.h" @@ -83,11 +86,33 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::Round); #endif + for (int i = 1; i < argc; i++) { + if (QString(argv[i]) == "--version" || QString(argv[i]) == "-v") { +#ifdef FEATHER_BUILD_TAG + QString buildTag = QString(FEATHER_BUILD_TAG).replace("\"", ""); + QString versionTag = QString(FEATHER_VERSION) + "-"; + if (buildTag.startsWith(versionTag)) { + buildTag.remove(0, versionTag.length()); + } + std::cout << "Feather Wallet " << FEATHER_VERSION << " (build " << buildTag.toStdString() << ")" << std::endl; +#else + std::cout << "Feather Wallet " << FEATHER_VERSION << std::endl; +#endif + return 0; + } + } + Application app(argc, argv); QApplication::setApplicationName("FeatherWallet"); QApplication::setApplicationVersion(FEATHER_VERSION); +#if defined(Q_OS_LINUX) + QGuiApplication::setDesktopFileName("feather"); +#endif + + QApplication::setWindowIcon(QIcon(":/assets/images/appicons/64x64.png")); + QCommandLineParser parser; parser.setApplicationDescription("Feather - a free Monero desktop wallet"); QCommandLineOption helpOption = parser.addHelpOption(); @@ -146,9 +171,11 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { } // Setup logging - QString logPath = QString("%1/libwallet.log").arg(configDir); + QString logPath = conf()->get(Config::disableLogging).toBool() ? "" : QString("%1/libwallet.log").arg(configDir); + bool consoleLogging = !conf()->get(Config::disableLoggingStdout).toBool(); + Monero::Utils::onStartup(); - Monero::Wallet::init("", "feather", logPath.toStdString(), true); + Monero::Wallet::init("", "feather", logPath.toStdString(), consoleLogging); bool logLevelFromEnv; int logLevel = qEnvironmentVariableIntValue("MONERO_LOG_LEVEL", &logLevelFromEnv); @@ -158,8 +185,13 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { logLevel = conf()->get(Config::logLevel).toInt(); } - if (parser.isSet("quiet") || conf()->get(Config::disableLogging).toBool()) { - qWarning() << "Logging is disabled"; + bool disableEverything = parser.isSet("quiet") || (conf()->get(Config::disableLogging).toBool() && conf()->get(Config::disableLoggingStdout).toBool()); + if (disableEverything) { + if (parser.isSet("quiet")) { + qWarning() << "Logging is disabled via --quiet flag"; + } else { + qWarning() << "Logging is disabled via configuration"; + } WalletManager::instance()->setLogLevel(-1); } else if (logLevel >= 0 && logLevel <= Monero::WalletManagerFactory::LogLevel_Max) { @@ -180,7 +212,7 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { conf()->set(Config::restartRequired, false); - if (!quiet) { + if (!quiet && !conf()->get(Config::disableLogging).toBool()) { QList> info; info.emplace_back("Feather", FEATHER_VERSION); info.emplace_back("Monero", MONERO_VERSION); @@ -188,6 +220,8 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { info.emplace_back("Tor", TOR_VERSION); info.emplace_back("SSL", QSslSocket::sslLibraryVersionString()); info.emplace_back("Mode", stagenet ? "Stagenet" : (testnet ? "Testnet" : "Mainnet")); + info.emplace_back("Network", conf()->get(Config::syncPaused).toBool() ? "PAUSED" : "ACTIVE"); + info.emplace_back("Config dir", configDir); for (const auto &k: info) { qWarning().nospace().noquote() << QString("%1: %2").arg(k.first, k.second); @@ -219,6 +253,6 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { wm->setEventFilter(&filter); int exitCode = Application::exec(); - qDebug() << "Application::exec() returned"; + qInstallMessageHandler(nullptr); return exitCode; } diff --git a/src/model/AddressBookProxyModel.h b/src/model/AddressBookProxyModel.h index 4ea5565a..3de09274 100644 --- a/src/model/AddressBookProxyModel.h +++ b/src/model/AddressBookProxyModel.h @@ -17,8 +17,14 @@ public: public slots: void setSearchFilter(const QString& searchString){ +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); + m_searchRegExp.setPattern(searchString); + endFilterChange(); +#else m_searchRegExp.setPattern(searchString); invalidateFilter(); +#endif } private: diff --git a/src/model/CoinsProxyModel.cpp b/src/model/CoinsProxyModel.cpp index 193f4280..bd9bfc45 100644 --- a/src/model/CoinsProxyModel.cpp +++ b/src/model/CoinsProxyModel.cpp @@ -4,6 +4,7 @@ #include "CoinsProxyModel.h" #include "CoinsModel.h" #include "libwalletqt/rows/CoinsInfo.h" +#include CoinsProxyModel::CoinsProxyModel(QObject *parent, Coins *coins) : QSortFilterProxyModel(parent) @@ -15,13 +16,25 @@ CoinsProxyModel::CoinsProxyModel(QObject *parent, Coins *coins) } void CoinsProxyModel::setShowSpent(const bool showSpent) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); + m_showSpent = showSpent; + endFilterChange(); +#else m_showSpent = showSpent; invalidateFilter(); +#endif } void CoinsProxyModel::setSearchFilter(const QString &searchString) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); + m_searchRegExp.setPattern(searchString); + endFilterChange(); +#else m_searchRegExp.setPattern(searchString); invalidateFilter(); +#endif } bool CoinsProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const diff --git a/src/model/HistoryView.cpp b/src/model/HistoryView.cpp index c9820971..eb10219e 100644 --- a/src/model/HistoryView.cpp +++ b/src/model/HistoryView.cpp @@ -115,8 +115,8 @@ void HistoryView::showHeaderMenu(const QPoint& position) { const QList actions = m_columnActions->actions(); for (auto& action : actions) { - Q_ASSERT(static_cast(action->data().type()) == QMetaType::Int); - if (static_cast(action->data().type()) != QMetaType::Int) { + Q_ASSERT(static_cast(action->data().typeId()) == QMetaType::Int); + if (static_cast(action->data().typeId()) != QMetaType::Int) { continue; } int columnIndex = action->data().toInt(); @@ -131,8 +131,8 @@ void HistoryView::toggleColumnVisibility(QAction* action) // Verify action carries a column index as data. Since QVariant.toInt() // below will accept anything that's interpretable as int, perform a type // check here to make sure data actually IS int - Q_ASSERT(static_cast(action->data().type()) == QMetaType::Int); - if (static_cast(action->data().type()) != QMetaType::Int) { + Q_ASSERT(static_cast(action->data().typeId()) == QMetaType::Int); + if (static_cast(action->data().typeId()) != QMetaType::Int) { return; } diff --git a/src/model/SubaddressProxyModel.h b/src/model/SubaddressProxyModel.h index 38069ed1..c54ff1bd 100644 --- a/src/model/SubaddressProxyModel.h +++ b/src/model/SubaddressProxyModel.h @@ -18,9 +18,16 @@ public: public slots: void setSearchFilter(const QString& searchString){ +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); + m_searchRegExp.setPattern(searchString); + m_searchCaseSensitiveRegExp.setPattern(searchString); + endFilterChange(); +#else m_searchRegExp.setPattern(searchString); m_searchCaseSensitiveRegExp.setPattern(searchString); invalidateFilter(); +#endif } private: diff --git a/src/model/TransactionHistoryProxyModel.h b/src/model/TransactionHistoryProxyModel.h index 8217efbe..f10f03af 100644 --- a/src/model/TransactionHistoryProxyModel.h +++ b/src/model/TransactionHistoryProxyModel.h @@ -19,8 +19,14 @@ public: public slots: void setSearchFilter(const QString& searchString){ +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); + m_searchRegExp.setPattern(searchString); + endFilterChange(); +#else m_searchRegExp.setPattern(searchString); invalidateFilter(); +#endif } private: diff --git a/src/model/WalletKeysFilesModel.cpp b/src/model/WalletKeysFilesModel.cpp index 343ad181..f0d0422f 100644 --- a/src/model/WalletKeysFilesModel.cpp +++ b/src/model/WalletKeysFilesModel.cpp @@ -47,7 +47,7 @@ void WalletKeysFilesModel::clear() { void WalletKeysFilesModel::refresh() { this->clear(); this->findWallets(); - endResetModel(); + } void WalletKeysFilesModel::updateDirectories() { @@ -93,6 +93,7 @@ void WalletKeysFilesModel::findWallets() { const QString baseName = fileInfo.baseName(); const QString basePath = QString("%1/%2").arg(path).arg(baseName); QString addr = QString(""); + // Assume mainnet by default, set otherwise if needed quint8 networkType = NetworkType::MAINNET; if (Utils::fileExists(basePath + ".address.txt")) { @@ -104,7 +105,7 @@ void WalletKeysFilesModel::findWallets() { addr = _address; if (addr.startsWith("5") || addr.startsWith("7")) networkType = NetworkType::STAGENET; - else if (addr.startsWith("9") || addr.startsWith("B")) + else if (addr.startsWith("9") || addr.startsWith("A") || addr.startsWith("B")) networkType = NetworkType::TESTNET; } file.close(); diff --git a/src/plugins/crowdfunding/CCSProgressDelegate.cpp b/src/plugins/crowdfunding/CCSProgressDelegate.cpp index c915d24d..021b36ea 100644 --- a/src/plugins/crowdfunding/CCSProgressDelegate.cpp +++ b/src/plugins/crowdfunding/CCSProgressDelegate.cpp @@ -23,7 +23,7 @@ void CCSProgressDelegate::paint(QPainter *painter, const QStyleOptionViewItem &o progressBarOption.state = QStyle::State_Enabled; progressBarOption.direction = QApplication::layoutDirection(); progressBarOption.rect = option.rect; - progressBarOption.fontMetrics = QApplication::fontMetrics(); + progressBarOption.fontMetrics = QFontMetrics(qApp->font()); progressBarOption.minimum = 0; progressBarOption.maximum = 100; progressBarOption.textAlignment = Qt::AlignCenter; diff --git a/src/plugins/tickers/TickersWidget.cpp b/src/plugins/tickers/TickersWidget.cpp index e55df61b..b9b3a138 100644 --- a/src/plugins/tickers/TickersWidget.cpp +++ b/src/plugins/tickers/TickersWidget.cpp @@ -23,6 +23,7 @@ TickersWidget::TickersWidget(QWidget *parent, Wallet *wallet) this->setup(); } }); + connect(windowManager(), &WindowManager::websocketStatusChanged, this, &TickersWidget::updateDisplay); this->updateBalance(); } diff --git a/src/utils/Utils.cpp b/src/utils/Utils.cpp index b468fed3..d9e42776 100644 --- a/src/utils/Utils.cpp +++ b/src/utils/Utils.cpp @@ -26,6 +26,7 @@ #include "utils/os/tails.h" #include "utils/os/whonix.h" #include "libwalletqt/Wallet.h" +#include "utils/nodes.h" #include "WindowManager.h" namespace Utils { @@ -558,6 +559,20 @@ QTextCharFormat addressTextFormat(const SubaddressIndex &index, quint64 amount) } void applicationLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) { + if (conf()->get(Config::disableLoggingStdout).toBool()) + return; + + int level = conf()->get(Config::logLevel).toInt(); + + // Mapping: + // 0: Critical/Fatal [always reported under below scheme] + // 1: + Warning + // 2: + Info + // 3+: + Debug + if (level < 3 && type == QtDebugMsg) return; + if (level < 2 && type == QtInfoMsg) return; + if (level < 1 && type == QtWarningMsg) return; + const QString fn = context.function ? QString::fromUtf8(context.function) : ""; const QString date = QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss"); QString line; @@ -604,6 +619,32 @@ QFont relativeFont(int delta) { return font; } +QString timeAgo(const QDateTime &dt) { + qint64 diff = dt.secsTo(QDateTime::currentDateTime()); + + if (diff < 0) return "in the future"; + if (diff < 60) return QString("%1 second%2 ago").arg(diff).arg(diff == 1 ? "" : "s"); + + diff /= 60; // minutes + if (diff < 60) return QString("%1 minute%2 ago").arg(diff).arg(diff == 1 ? "" : "s"); + + qint64 minutes = diff % 60; + diff /= 60; // hours + if (diff < 24) { + if (minutes > 0) + return QString("%1 hour%2 %3 minute%4 ago").arg(diff).arg(diff == 1 ? "" : "s").arg(minutes).arg(minutes == 1 ? "" : "s"); + return QString("%1 hour%2 ago").arg(diff).arg(diff == 1 ? "" : "s"); + } + + diff /= 24; // days + if (diff < 30) return QString("%1 day%2 ago").arg(diff).arg(diff == 1 ? "" : "s"); + + diff /= 30; // months (approx) + if (diff < 12) return QString("%1 month%2 ago").arg(diff).arg(diff == 1 ? "" : "s"); + + return QString("%1 year%2 ago").arg(diff / 12).arg((diff / 12) == 1 ? "" : "s"); +} + bool isLocalUrl(const QUrl &url) { QRegularExpression localNetwork(R"((^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.))"); return (localNetwork.match(url.host()).hasMatch() || url.host() == "localhost"); @@ -702,6 +743,10 @@ void clearLayout(QLayout* layout, bool deleteWidgets) } } +quint64 blocksBehind(quint64 height, quint64 target) { + return (target > height) ? (target - height) : 0; +} + QString formatSyncStatus(quint64 height, quint64 target, bool daemonSync) { if (height < (target - 1)) { QString blocks = (target >= height) ? QString::number(target - height) : "?"; @@ -712,6 +757,26 @@ QString formatSyncStatus(quint64 height, quint64 target, bool daemonSync) { return "Synchronized"; } +QString formatSyncTimeEstimate(quint64 blocks) { + quint64 minutes = blocks * 2; + quint64 days = minutes / (60 * 24); + + QString timeStr; + if (days > 0) { + timeStr = QObject::tr("~%1 days").arg(days); + } else if (minutes >= 60) { + timeStr = QObject::tr("~%1 hours").arg(minutes / 60); + } else { + timeStr = QObject::tr("< 1 hour"); + } + return timeStr; +} + +quint64 estimateSyncDataSize(quint64 blocks) { + // Estimate 30KB per block for wallet scanning. + return blocks * 30 * 1024; +} + QString formatRestoreHeight(quint64 height) { const QDateTime restoreDate = appData()->restoreHeights[constants::networkType]->heightToDate(height); return QString("%1 (%2)").arg(QString::number(height), restoreDate.toString("yyyy-MM-dd")); @@ -724,4 +789,13 @@ QString getVersion() { #endif return version; } + +QString getRestoreHeightFilename(NetworkType::Type nettype) { + if (nettype == NetworkType::MAINNET) + return ":/assets/restore_heights_monero_mainnet.txt"; + else if (nettype == NetworkType::TESTNET) + return ":/assets/restore_heights_monero_testnet.txt"; + else + return ":/assets/restore_heights_monero_stagenet.txt"; +} } diff --git a/src/utils/Utils.h b/src/utils/Utils.h index 22bba27f..f4aa6118 100644 --- a/src/utils/Utils.h +++ b/src/utils/Utils.h @@ -14,6 +14,8 @@ #include "networktype.h" class SubaddressIndex; +class Wallet; +class Nodes; namespace Utils { @@ -96,6 +98,7 @@ namespace Utils QFont getMonospaceFont(); QFont relativeFont(int delta); + QString timeAgo(const QDateTime &dt); void applicationLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg); QString barrayToString(const QByteArray &data); @@ -118,10 +121,14 @@ namespace Utils QWindow* windowForQObject(QObject* object); void clearLayout(QLayout *layout, bool deleteWidgets = true); + quint64 blocksBehind(quint64 height, quint64 target); QString formatSyncStatus(quint64 height, quint64 target, bool daemonSync = false); + QString formatSyncTimeEstimate(quint64 blocks); + quint64 estimateSyncDataSize(quint64 blocks); QString formatRestoreHeight(quint64 height); QString getVersion(); + QString getRestoreHeightFilename(NetworkType::Type nettype); } #endif //FEATHER_UTILS_H diff --git a/src/utils/WebsocketClient.cpp b/src/utils/WebsocketClient.cpp index 815666bf..360b3662 100644 --- a/src/utils/WebsocketClient.cpp +++ b/src/utils/WebsocketClient.cpp @@ -20,7 +20,13 @@ WebsocketClient::WebsocketClient(QObject *parent) connect(webSocket, &QWebSocket::stateChanged, this, &WebsocketClient::onStateChanged); connect(webSocket, &QWebSocket::connected, this, &WebsocketClient::onConnected); connect(webSocket, &QWebSocket::disconnected, this, &WebsocketClient::onDisconnected); + // one liner: for both Qt 6.4 (older) and Qt 6.5+ (newer) + // connect(webSocket, QOverload::of(&QWebSocket::error), this, &WebsocketClient::onError); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + connect(webSocket, &QWebSocket::errorOccurred, this, &WebsocketClient::onError); +#else connect(webSocket, QOverload::of(&QWebSocket::error), this, &WebsocketClient::onError); +#endif connect(webSocket, &QWebSocket::binaryMessageReceived, this, &WebsocketClient::onbinaryMessageReceived); @@ -73,7 +79,7 @@ void WebsocketClient::restart() { void WebsocketClient::stop() { qDebug() << Q_FUNC_INFO; m_stopped = true; - webSocket->close(); + webSocket->abort(); m_connectionTimeout.stop(); m_pingTimer.stop(); } diff --git a/src/utils/config.cpp b/src/utils/config.cpp index b5baa886..f11f89df 100644 --- a/src/utils/config.cpp +++ b/src/utils/config.cpp @@ -74,9 +74,17 @@ static const QHash configStrings = { {Config::inactivityLockTimeout, {QS("inactivityLockTimeout"), 10}}, {Config::lockOnMinimize, {QS("lockOnMinimize"), false}}, {Config::showTrayIcon, {QS("showTrayIcon"), true}}, - {Config::minimizeToTray, {QS("minimizeToTray"), false}}, + {Config::minimizeToTray, {QS("minimizeToTray"), true}}, + {Config::trayLeftClickTogglesFocus, {QS("trayLeftClickTogglesFocus"), true}}, {Config::disableWebsocket, {QS("disableWebsocket"), false}}, + {Config::disableAutoRefresh, {QS("disableAutoRefresh"), false}}, {Config::offlineMode, {QS("offlineMode"), false}}, + {Config::syncPaused, {QS("syncPaused"), false}}, + + {Config::lastKnownNetworkHeight, {QS("lastKnownNetworkHeight"), 0}}, + {Config::lastSyncTimestamp, {QS("lastSyncTimestamp"), 0}}, + {Config::lastPriceUpdateTimestamp, {QS("lastPriceUpdateTimestamp"), 0}}, + {Config::lastNetInfoUpdate, {QS("lastNetInfoUpdate"), 0}}, // Transactions {Config::multiBroadcast, {QS("multiBroadcast"), true}}, @@ -90,6 +98,7 @@ static const QHash configStrings = { {Config::hideNotifications, {QS("hideNotifications"), false}}, {Config::hideUpdateNotifications, {QS("hideUpdateNotifications"), false}}, {Config::disableLogging, {QS("disableLogging"), true}}, + {Config::disableLoggingStdout, {QS("disableLoggingStdout"), false}}, {Config::writeStackTraceToDisk, {QS("writeStackTraceToDisk"), true}}, {Config::writeRecentlyOpenedWallets, {QS("writeRecentlyOpenedWallets"), true}}, diff --git a/src/utils/config.h b/src/utils/config.h index 04d9d759..d12c8098 100644 --- a/src/utils/config.h +++ b/src/utils/config.h @@ -88,11 +88,13 @@ public: disableWebsocket, // Network -> Offline + disableAutoRefresh, offlineMode, // Storage -> Logging writeStackTraceToDisk, disableLogging, + disableLoggingStdout, logLevel, // Storage -> Misc @@ -108,6 +110,7 @@ public: lockOnMinimize, showTrayIcon, minimizeToTray, + trayLeftClickTogglesFocus, // Transactions multiBroadcast, @@ -139,6 +142,13 @@ public: // Tickers tickers, tickersShowFiatBalance, + + // Sync & data saver + syncPaused, + lastKnownNetworkHeight, + lastNetInfoUpdate, + lastSyncTimestamp, + lastPriceUpdateTimestamp, }; enum PrivacyLevel { diff --git a/src/utils/nodes.cpp b/src/utils/nodes.cpp index d9702f2d..4f33b4e7 100644 --- a/src/utils/nodes.cpp +++ b/src/utils/nodes.cpp @@ -109,6 +109,7 @@ Nodes::Nodes(QObject *parent, Wallet *wallet) if (m_wallet) { connect(m_wallet, &Wallet::walletRefreshed, this, &Nodes::onWalletRefreshed); + connect(m_wallet, &Wallet::connectionStatusChanged, this, &Nodes::onConnectionStatusChanged); } } @@ -238,6 +239,7 @@ void Nodes::connectToNode(const FeatherNode &node) { } } + qInfo() << "Nodes::connectToNode calling initAsync with:" << node.toAddress(); m_wallet->initAsync(node.toAddress(), true, 0, proxyAddress); m_connection = node; @@ -248,6 +250,20 @@ void Nodes::connectToNode(const FeatherNode &node) { this->updateModels(); } +void Nodes::disconnectCurrentNode() { + if (!m_wallet) return; + + // Stop any ongoing connection attempt + m_connection.isActive = false; + m_connection.isConnecting = false; + + // Connect to empty "node" effectively disconnects + m_wallet->initAsync("", false, 0); + + this->resetLocalState(); + this->updateModels(); +} + void Nodes::autoConnect(bool forceReconnect) { if (!m_wallet) { return; @@ -261,12 +277,20 @@ void Nodes::autoConnect(bool forceReconnect) { return; } + if (conf()->get(Config::syncPaused).toBool() && !forceReconnect) { + return; + } + // this function is responsible for automatically connecting to a daemon. if (m_wallet == nullptr || !m_enableAutoconnect) { return; } Wallet::ConnectionStatus status = m_wallet->connectionStatus(); + if (status == Wallet::ConnectionStatus_Connecting && !forceReconnect) { + return; + } + bool wsMode = (this->source() == NodeSource::websocket); if (wsMode && !m_wsNodesReceived && websocketNodes().count() == 0) { @@ -276,7 +300,17 @@ void Nodes::autoConnect(bool forceReconnect) { } if (status == Wallet::ConnectionStatus_Disconnected || forceReconnect) { + // If we had a working connection and it dropped (transient disconnect), + // try reconnecting to the same node instead of picking a new one + if (m_connection.isValid() && m_connection.isActive && !forceReconnect) { + qDebug() << "Transient disconnect, reconnecting to same node:" << m_connection.toAddress(); + this->connectToNode(m_connection); + return; + } + + // Otherwise, mark the failed node and pick a new one if (m_connection.isValid() && !forceReconnect) { + qInfo() << "Marking node as failed:" << m_connection.toAddress(); m_recentFailures << m_connection.toAddress(); } @@ -416,6 +450,10 @@ void Nodes::setCustomNodes(const QList &nodes) { void Nodes::onWalletRefreshed() { if (conf()->get(Config::proxy) == Config::Proxy::Tor && conf()->get(Config::torPrivacyLevel).toInt() == Config::allTorExceptInitSync) { + // Privacy switch already triggered this session, don't repeat + if (m_privacySwitchDone) + return; + // Don't reconnect if we're connected to a local node (traffic will not be routed through Tor) if (m_connection.isLocal()) return; @@ -424,7 +462,12 @@ void Nodes::onWalletRefreshed() { if (m_connection.isOnion()) return; - this->autoConnect(true); + // If want onion node but aren't connected to one, trigger the switch + if (this->useOnionNodes()) { + qInfo() << "Privacy switch: switching from clearnet to onion after initial sync"; + m_privacySwitchDone = true; + this->autoConnect(true); + } } } @@ -494,6 +537,11 @@ bool Nodes::useSocks5Proxy(const FeatherNode &node) { } if (config_proxy == Config::Proxy::Tor) { + // Always use proxy for onion addresses + if (node.isOnion()) { + return true; + } + // Don't use socks5 proxy if initial sync traffic is excluded. return this->useOnionNodes(); } @@ -525,7 +573,7 @@ void Nodes::resetLocalState() { } void Nodes::exhausted() { - // Do nothing + // Do nothing (matches upstream behavior) } QList Nodes::nodes() { @@ -605,6 +653,21 @@ int Nodes::modeHeight(const QList &nodes) { return mode_height; } +void Nodes::onConnectionStatusChanged(int status) { + if (status == Wallet::ConnectionStatus_Disconnected) { + if (!conf()->get(Config::syncPaused).toBool()) { + qInfo() << "Nodes: Wallet disconnected unexpectedly, triggering auto-connect to new node."; + // Fix J: Mark connection as inactive so autoConnect treats it as a failure, not a transient disconnect + m_connection.isActive = false; + QTimer::singleShot(1000, this, [this]{ + this->autoConnect(false); + }); + } + } +} + + + void Nodes::allowConnection() { m_allowConnection = true; } diff --git a/src/utils/nodes.h b/src/utils/nodes.h index 8bbf8b3a..018fb779 100644 --- a/src/utils/nodes.h +++ b/src/utils/nodes.h @@ -167,6 +167,7 @@ public: public slots: void connectToNode(); void connectToNode(const FeatherNode &node); + void disconnectCurrentNode(); void onWSNodesReceived(QList& nodes); void onNodeSourceChanged(NodeSource nodeSource); void setCustomNodes(const QList& nodes); @@ -174,6 +175,7 @@ public slots: private slots: void onWalletRefreshed(); + void onConnectionStatusChanged(int status); private: Wallet *m_wallet = nullptr; @@ -192,6 +194,7 @@ private: bool m_enableAutoconnect = true; bool m_allowConnection = false; + bool m_privacySwitchDone = false; // Tracks if allTorExceptInitSync switch has fired FeatherNode pickEligibleNode(); diff --git a/src/utils/prices.cpp b/src/utils/prices.cpp index 10ac77f1..635ed3af 100644 --- a/src/utils/prices.cpp +++ b/src/utils/prices.cpp @@ -12,6 +12,10 @@ Prices::Prices(QObject *parent) : QObject(parent) { + qint64 lastUpdate = conf()->get(Config::lastPriceUpdateTimestamp).toLongLong(); + if (lastUpdate > 0) { + this->lastUpdateTime = QDateTime::fromSecsSinceEpoch(lastUpdate); + } } void Prices::cryptoPricesReceived(const QJsonArray &data) { @@ -31,13 +35,15 @@ void Prices::cryptoPricesReceived(const QJsonArray &data) { this->markets.insert(ms.symbol.toUpper(), ms); } + this->lastUpdateTime = QDateTime::currentDateTime(); + conf()->set(Config::lastPriceUpdateTimestamp, this->lastUpdateTime.toSecsSinceEpoch()); emit cryptoPricesUpdated(); } void Prices::fiatPricesReceived(const QJsonObject &data) { QJsonObject ratesData = data.value("rates").toObject(); for (const auto ¤cy : ratesData.keys()) { - this->rates.insert(currency, ratesData.value(currency).toDouble()); + this->rates.insert(currency.toUpper(), ratesData.value(currency).toDouble()); } emit fiatPricesUpdated(); } diff --git a/src/utils/prices.h b/src/utils/prices.h index 8972c0e9..afee8b61 100644 --- a/src/utils/prices.h +++ b/src/utils/prices.h @@ -5,6 +5,7 @@ #define FEATHER_PRICES_H #include +#include #include "utils/Utils.h" @@ -24,6 +25,7 @@ public: explicit Prices(QObject *parent = nullptr); QMap rates; QMap markets; + QDateTime lastUpdateTime; public slots: void cryptoPricesReceived(const QJsonArray &data); diff --git a/src/widgets/NodeWidget.cpp b/src/widgets/NodeWidget.cpp index b2c94e77..49b2a6b4 100644 --- a/src/widgets/NodeWidget.cpp +++ b/src/widgets/NodeWidget.cpp @@ -3,6 +3,7 @@ #include "NodeWidget.h" #include "ui_NodeWidget.h" +#include #include #include @@ -21,8 +22,13 @@ NodeWidget::NodeWidget(QWidget *parent) connect(ui->btn_addCustomNodes, &QPushButton::clicked, this, &NodeWidget::onCustomAddClicked); - connect(ui->checkBox_websocketList, &QCheckBox::stateChanged, [this](int id){ - bool custom = (id == 0); +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + connect(ui->checkBox_websocketList, &QCheckBox::checkStateChanged, [this](Qt::CheckState state){ + bool custom = (state == Qt::Unchecked); +#else + connect(ui->checkBox_websocketList, &QCheckBox::stateChanged, [this](int state){ + bool custom = (state == Qt::Unchecked); +#endif ui->stackedWidget->setCurrentIndex(custom); ui->frame_addCustomNodes->setVisible(custom); conf()->set(Config::nodeSource, custom); diff --git a/src/widgets/PayToEdit.h b/src/widgets/PayToEdit.h index d8916496..2e9286bb 100644 --- a/src/widgets/PayToEdit.h +++ b/src/widgets/PayToEdit.h @@ -24,8 +24,8 @@ struct PayToLineError { QString lineContent; QString error; - int idx; - bool isMultiline; + int idx = 0; + bool isMultiline = false; }; class PayToEdit : public QPlainTextEdit diff --git a/src/widgets/TickerWidget.cpp b/src/widgets/TickerWidget.cpp index 75b5cfe6..11403b66 100644 --- a/src/widgets/TickerWidget.cpp +++ b/src/widgets/TickerWidget.cpp @@ -66,17 +66,35 @@ BalanceTickerWidget::BalanceTickerWidget(QWidget *parent, Wallet *wallet, bool t this->setPercentageVisible(false); connect(m_wallet, &Wallet::balanceUpdated, this, &BalanceTickerWidget::updateDisplay); + connect(m_wallet, &Wallet::connectionStatusChanged, this, &BalanceTickerWidget::updateDisplay); connect(&appData()->prices, &Prices::fiatPricesUpdated, this, &BalanceTickerWidget::updateDisplay); connect(&appData()->prices, &Prices::cryptoPricesUpdated, this, &BalanceTickerWidget::updateDisplay); } void BalanceTickerWidget::updateDisplay() { - double balance = (m_totalBalance ? m_wallet->balanceAll() : m_wallet->balance()) / constants::cdiv; + double balance = (m_totalBalance ? m_wallet->balanceAll() : m_wallet->balance()); + double balanceAmount = balance / constants::cdiv; QString fiatCurrency = conf()->get(Config::preferredFiatCurrency).toString(); - double balanceFiatAmount = appData()->prices.convert("XMR", fiatCurrency, balance); - if (balanceFiatAmount < 0) - return; - this->setFiatText(balanceFiatAmount, fiatCurrency); + double balanceFiatAmount = appData()->prices.convert("XMR", fiatCurrency, balanceAmount); + + bool isCacheValid = appData()->prices.lastUpdateTime.isValid(); + bool isCacheFresh = isCacheValid && appData()->prices.lastUpdateTime.secsTo(QDateTime::currentDateTime()) < 3600; + + bool hasXmrPrice = appData()->prices.markets.contains("XMR"); + bool hasFiatRate = fiatCurrency == "USD" || appData()->prices.rates.contains(fiatCurrency); + + if (balance > 0 && (balanceFiatAmount == 0.0 || !isCacheValid)) { + if (conf()->get(Config::offlineMode).toBool() || conf()->get(Config::disableWebsocket).toBool() || m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) { + this->setDisplayText("offline"); + } else if (!hasXmrPrice || !hasFiatRate) { + this->setDisplayText("connecting"); + } else { + this->setDisplayText("unknown"); + } + } else { + QString approx = isCacheFresh ? "" : "~ "; + this->setDisplayText(approx + Utils::amountToCurrencyString(balanceFiatAmount, fiatCurrency)); + } } // PriceTickerWidget diff --git a/src/wizard/PageOpenWallet.cpp b/src/wizard/PageOpenWallet.cpp index 90a961cd..2f16c71b 100644 --- a/src/wizard/PageOpenWallet.cpp +++ b/src/wizard/PageOpenWallet.cpp @@ -27,7 +27,9 @@ PageOpenWallet::PageOpenWallet(WalletKeysFilesModel *wallets, QWidget *parent) ui->walletTable->setSelectionBehavior(QAbstractItemView::SelectRows); ui->walletTable->setContextMenuPolicy(Qt::CustomContextMenu); ui->walletTable->setModel(m_keysProxy); - ui->walletTable->hideColumn(WalletKeysFilesModel::NetworkType); + // Only show 'wallet type' column in stagenet or testnet mode + if (constants::networkType == NetworkType::MAINNET) + ui->walletTable->hideColumn(WalletKeysFilesModel::NetworkType); ui->walletTable->hideColumn(WalletKeysFilesModel::Path); ui->walletTable->hideColumn(WalletKeysFilesModel::Modified); ui->walletTable->header()->setSectionResizeMode(WalletKeysFilesModel::FileName, QHeaderView::Stretch); -- 2.52.0