]> Nutra Git (v1) - gamesguru/feather.git/commitdiff
Feat: Smart Sync & Import Transaction Fixes
authorgg <chown_tee@proton.me>
Tue, 20 Jan 2026 02:26:17 +0000 (21:26 -0500)
committergg <chown_tee@proton.me>
Tue, 20 Jan 2026 02:26:17 +0000 (21:26 -0500)
- 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.

49 files changed:
src/CoinsWidget.cpp
src/HistoryWidget.cpp
src/HistoryWidget.h
src/MainWindow.cpp
src/MainWindow.h
src/SendWidget.cpp
src/SettingsDialog.cpp
src/SettingsDialog.ui
src/WindowManager.cpp
src/WindowManager.h
src/assets.qrc
src/assets/feather.desktop
src/assets/images/status_idle.svg [new file with mode: 0644]
src/assets/images/status_idle_proxy.svg [new file with mode: 0644]
src/components.cpp
src/constants.h
src/dialog/AboutDialog.cpp
src/dialog/AboutDialog.ui
src/dialog/DebugInfoDialog.cpp
src/dialog/PaymentRequestDialog.cpp
src/dialog/QrCodeDialog.cpp
src/dialog/SyncRangeDialog.cpp [new file with mode: 0644]
src/dialog/SyncRangeDialog.h [new file with mode: 0644]
src/dialog/TxImportDialog.cpp
src/dialog/TxImportDialog.h
src/libwalletqt/Wallet.cpp
src/libwalletqt/Wallet.h
src/main.cpp
src/model/AddressBookProxyModel.h
src/model/CoinsProxyModel.cpp
src/model/HistoryView.cpp
src/model/SubaddressProxyModel.h
src/model/TransactionHistoryProxyModel.h
src/model/WalletKeysFilesModel.cpp
src/plugins/crowdfunding/CCSProgressDelegate.cpp
src/plugins/tickers/TickersWidget.cpp
src/utils/Utils.cpp
src/utils/Utils.h
src/utils/WebsocketClient.cpp
src/utils/config.cpp
src/utils/config.h
src/utils/nodes.cpp
src/utils/nodes.h
src/utils/prices.cpp
src/utils/prices.h
src/widgets/NodeWidget.cpp
src/widgets/PayToEdit.h
src/widgets/TickerWidget.cpp
src/wizard/PageOpenWallet.cpp

index edee303bc263bc74e8a524f9bec5f2bcc904509e..f51f1d70422d811390c05cee208ed5a025c3e267 100644 (file)
@@ -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;
     }
index 6f2433b77a9b20d5077d28956b02400178c2b049..09aa843b326ad1a89c777806e3004585779a82e3 100644 (file)
 #include "utils/Icons.h"
 #include "WebsocketNotifier.h"
 
+#include <QClipboard>
+#include <QJsonObject>
+#include <QJsonDocument>
+#include <QJsonArray>
+
 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<double>(tx.amount));
+                obj.insert("fee", static_cast<double>(tx.fee));
+                obj.insert("height", static_cast<double>(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<double>(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<int>(idx));
+                obj.insert("subaddr_index", subaddrIndices);
+                obj.insert("subaddr_account", static_cast<int>(tx.subaddrAccount));
+
+                QJsonDocument doc(obj);
+                return QString::fromUtf8(doc.toJson(QJsonDocument::Indented));
+            }
             default:
                 return QString("");
         }
index d9d982632505b2a6dd7fa7e48ed7a73f22ee605f..f619a1262da2c790f63db83c2880370371be2434 100644 (file)
@@ -48,7 +48,8 @@ private:
         TxID = 0,
         Description,
         Date,
-        Amount
+        Amount,
+        JSON
     };
 
     void copy(copyField field);
index 6a2d6b80368eeea58d95b0e776fce0173e06af23..d7bf5dc17c86ed92cbc6b74937707c83a9de1674 100644 (file)
@@ -7,7 +7,15 @@
 #include <QFileDialog>
 #include <QInputDialog>
 #include <QMessageBox>
+#include <QClipboard>
+#include <QLocale>
+#include <QHBoxLayout>
 #include <QCheckBox>
+#include <QFormLayout>
+#include <QSpinBox>
+#include <QDateEdit>
+#include <QComboBox>
+#include <QDialogButtonBox>
 
 #include "constants.h"
 #include "dialog/AddressCheckerIndexDialog.h"
 #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(" <font color='#ffd60a'>(+%1 XMR unconfirmed)</font>").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<QString, QString> &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<qulonglong>(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<qulonglong>(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<Wallet::ConnectionStatus>(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<QString> &address) {
@@ -967,7 +1366,7 @@ void MainWindow::onTransactionCreated(PendingTransaction *tx, const QVector<QStr
 #ifdef WITH_SCANNER
         OfflineTxSigningWizard wizard(this, m_wallet, tx);
         wizard.exec();
-        
+
         if (!wizard.readyToCommit()) {
             return;
         } else {
@@ -1142,7 +1541,7 @@ void MainWindow::showKeyImageSyncWizard() {
 #ifdef WITH_SCANNER
     OfflineTxSigningWizard wizard{this, m_wallet};
     wizard.exec();
-    
+
     if (wizard.readyToSign()) {
         TxConfAdvDialog dialog{m_wallet, "", this, true};
         dialog.setUnsignedTransaction(wizard.unsignedTransaction());
@@ -1256,7 +1655,6 @@ void MainWindow::closeEvent(QCloseEvent *event) {
 
         // Wallet signal may fire after AppContext is gone, causing segv
         m_wallet->disconnect();
-        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..."));
+        }
+    }
+}
index c44270d32dd8a91bdf243ddbc57a68875913dd7f..5164c147486d233e1d5c9d755d98286126e51d43 100644 (file)
@@ -6,6 +6,7 @@
 
 #include <QMainWindow>
 #include <QSystemTrayIcon>
+#include <QWindow>
 
 #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<QAction> m_clearRecentlyOpenAction;
+    QPointer<QAction> m_updateNetworkInfoAction;
+    QPointer<QAction> m_actionEnableWebsocket;
+    QPointer<QAction> 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<Updater> m_updater = nullptr;
 #endif
index e0ce2dea4c55f593c5852ba275398cf01ac7db0b..de34fae7f96ac509680b7e9bc79d0c6da5b0df17 100644 (file)
@@ -4,6 +4,8 @@
 #include "SendWidget.h"
 #include "ui_SendWidget.h"
 
+#include <QMessageBox>
+
 #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("<b>Are you sure? Create transaction?</b>");
+        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;
     }
index 982fef249e9ff5ccb8836e791c8750f2c29f7d47..59940e4523d38e83d0edf9130ac07b44a458fba5 100644 (file)
@@ -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<const FeatherNode&>::of(&Nodes::connectToNode));
-    } else {
-        m_nodes = new Nodes(this, nullptr);
-        ui->nodeWidget->setupUI(m_nodes);
-        ui->nodeWidget->setCanConnect(false);
-    }
+    std::function<void()> 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<const FeatherNode&>::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<QVBoxLayout*>(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<int>::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() {
index ccaad42534e9ed6e7b2eb377fde572652ce169e3..4479a2359c9aafd25720022db0e9c76eabc22002 100644 (file)
                 </item>
                </layout>
               </item>
+              <item>
+               <layout class="QHBoxLayout" name="horizontalLayout_17">
+                <item>
+                 <spacer name="horizontalSpacer_3">
+                  <property name="orientation">
+                   <enum>Qt::Orientation::Horizontal</enum>
+                  </property>
+                  <property name="sizeType">
+                   <enum>QSizePolicy::Policy::Fixed</enum>
+                  </property>
+                  <property name="sizeHint" stdset="0">
+                   <size>
+                    <width>30</width>
+                    <height>20</height>
+                   </size>
+                  </property>
+                 </spacer>
+                </item>
+                <item>
+                 <widget class="QCheckBox" name="checkBox_trayLeftClickTogglesFocus">
+                  <property name="enabled">
+                   <bool>false</bool>
+                  </property>
+                  <property name="text">
+                   <string>Left click system tray icon to toggle focus</string>
+                  </property>
+                 </widget>
+                </item>
+               </layout>
+              </item>
              </layout>
             </item>
             <item>
index 5d6696e9abd1cbdc275c2a2d26726726f5398f1e..9fab17e559e3419a524d7a875c3ffd0dc1c74f06 100644 (file)
@@ -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();
index d0f3e46073a969d9a9854b24df47ba0fb45d01c2..54690e2e5a1ec35e4e7b642755a001cc5d273aa0 100644 (file)
@@ -113,6 +113,7 @@ private:
     bool m_openWalletTriedOnce = false;
     bool m_openingWallet = false;
     bool m_initialNetworkConfigured = false;
+    bool m_closing = false;
 
     QThread *m_cleanupThread;
 };
index de87ab63a51fecc1008d7c77731eae865aeec098..54d300651f488fd912a542a28a86a145e395c05b 100644 (file)
@@ -79,6 +79,8 @@
     <file>assets/images/status_connected_proxy.svg</file>
     <file>assets/images/status_connected.svg</file>
     <file>assets/images/status_disconnected.svg</file>
+    <file>assets/images/status_idle_proxy.svg</file>
+    <file>assets/images/status_idle.svg</file>
     <file>assets/images/status_lagging.svg</file>
     <file>assets/images/status_offline.svg</file>
     <file>assets/images/status_waiting.svg</file>
index 4f5737aae43c842a24a8c8d8d0b5bc88c55f6004..9542b6a8ac1d032c5397104f1f3e38fed242c4e9 100644 (file)
@@ -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 (file)
index 0000000..f22cb3b
--- /dev/null
@@ -0,0 +1,126 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   version="1.0"
+   id="svg7854"
+   height="512"
+   width="512"
+   viewBox="9 9 30 30">
+  <defs
+     id="defs7856">
+    <linearGradient
+       id="linearGradient860">
+      <stop
+         id="stop856"
+         offset="0"
+         style="stop-color:#90bb65;stop-opacity:1" />
+      <stop
+         id="stop858"
+         offset="1"
+         style="stop-color:#6ac017;stop-opacity:1" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient7577">
+      <stop
+         id="stop7579"
+         offset="0"
+         style="stop-color:#000000;stop-opacity:0.3137255;" />
+      <stop
+         id="stop7581"
+         offset="1"
+         style="stop-color:#ffffff;stop-opacity:1" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient5167">
+      <stop
+         style="stop-color:#76d717;stop-opacity:1"
+         offset="0"
+         id="stop5169" />
+      <stop
+         style="stop-color:#509e07;stop-opacity:1"
+         offset="1"
+         id="stop5171" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient5184">
+      <stop
+         style="stop-color:white;stop-opacity:1;"
+         offset="0"
+         id="stop5186" />
+      <stop
+         style="stop-color:white;stop-opacity:0;"
+         offset="1"
+         id="stop5188" />
+    </linearGradient>
+    <linearGradient
+       gradientTransform="matrix(2.1074616,0,0,2.1078593,-9.43551,-10.006786)"
+       y2="17.024479"
+       x2="16.657505"
+       y1="10.883683"
+       x1="15.011773"
+       gradientUnits="userSpaceOnUse"
+       id="linearGradient8317"
+       xlink:href="#linearGradient7577" />
+    <radialGradient
+       gradientTransform="matrix(1.897257,0,0,1.897615,-6.10046,-6.6146433)"
+       r="7.5896134"
+       fy="20.410854"
+       fx="15.865708"
+       cy="20.410854"
+       cx="15.865708"
+       gradientUnits="userSpaceOnUse"
+       id="radialGradient8319"
+       xlink:href="#linearGradient5167" />
+    <radialGradient
+       r="5.96875"
+       fy="11.308558"
+       fx="14.05685"
+       cy="11.308558"
+       cx="14.05685"
+       gradientTransform="matrix(-4.2002315,0.5953403,0.2958442,2.0989386,75.31118,-18.732928)"
+       gradientUnits="userSpaceOnUse"
+       id="radialGradient8321"
+       xlink:href="#linearGradient5184" />
+    <linearGradient
+       gradientTransform="matrix(1.7591324,0,0,1.7580929,-3.90899,-4.3562887)"
+       y2="26.431587"
+       x2="13.458839"
+       y1="2.0178134"
+       x1="8.9317284"
+       gradientUnits="userSpaceOnUse"
+       id="linearGradient8323"
+       xlink:href="#linearGradient860" />
+  </defs>
+  <g id="layer1">
+    <ellipse
+       ry="14.997972"
+       rx="14.995141"
+       style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.4;fill:url(#linearGradient8317);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.11079514;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none"
+       id="path7691"
+       cx="24.00086"
+       cy="24.002029" />
+    <ellipse
+       ry="13.502028"
+       rx="13.49948"
+       cy="24.002029"
+       cx="24.000866"
+       id="path7968"
+       style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:url(#radialGradient8319);fill-opacity:0.15;fill-rule:nonzero;stroke:#336402;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none" />
+    <path
+       id="path7970"
+       d="M 25.3861,13.485003 C 20.31979,12.724926 15.45183,15.857848 14,20.764516 c 1.18871,3.18039 3.90811,5.70993 7.46677,6.47724 5.29459,1.141602 10.50115,-2.027543 12.01505,-7.143895 -1.18869,-3.180413 -3.90812,-5.709952 -7.46675,-6.477239 -0.217,-0.04678 -0.41248,-0.103152 -0.62897,-0.135619 z"
+       style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.2;fill:url(#radialGradient8321);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.09465754;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none" />
+    <ellipse
+       ry="12.509292"
+       rx="12.516688"
+       cy="24.009293"
+       cx="24.000891"
+       style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.54494413;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:url(#linearGradient8323);stroke-width:1.00000215;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none"
+       id="path7972" />
+  </g>
+</svg>
diff --git a/src/assets/images/status_idle_proxy.svg b/src/assets/images/status_idle_proxy.svg
new file mode 100644 (file)
index 0000000..b4d614c
--- /dev/null
@@ -0,0 +1,126 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   version="1.0"
+   id="svg7854"
+   height="512"
+   width="512"
+   viewBox="9 9 30 30">
+  <defs
+     id="defs7856">
+    <linearGradient
+       id="linearGradient860">
+      <stop
+         id="stop856"
+         offset="0"
+         style="stop-color:#479fc6;stop-opacity:1" />
+      <stop
+         id="stop858"
+         offset="1"
+         style="stop-color:#0c89c1;stop-opacity:1" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient7577">
+      <stop
+         id="stop7579"
+         offset="0"
+         style="stop-color:#000000;stop-opacity:0.3137255;" />
+      <stop
+         id="stop7581"
+         offset="1"
+         style="stop-color:#ffffff;stop-opacity:1" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient5167">
+      <stop
+         style="stop-color:#0090ef;stop-opacity:1"
+         offset="0"
+         id="stop5169" />
+      <stop
+         style="stop-color:#0062b2;stop-opacity:1"
+         offset="1"
+         id="stop5171" />
+    </linearGradient>
+    <linearGradient
+       id="linearGradient5184">
+      <stop
+         style="stop-color:white;stop-opacity:1;"
+         offset="0"
+         id="stop5186" />
+      <stop
+         style="stop-color:white;stop-opacity:0;"
+         offset="1"
+         id="stop5188" />
+    </linearGradient>
+    <linearGradient
+       gradientTransform="matrix(2.1074616,0,0,2.1078593,-9.43551,-10.006786)"
+       y2="17.024479"
+       x2="16.657505"
+       y1="10.883683"
+       x1="15.011773"
+       gradientUnits="userSpaceOnUse"
+       id="linearGradient8317"
+       xlink:href="#linearGradient7577" />
+    <radialGradient
+       gradientTransform="matrix(1.897257,0,0,1.897615,-6.10046,-6.6146433)"
+       r="7.5896134"
+       fy="20.410854"
+       fx="15.865708"
+       cy="20.410854"
+       cx="15.865708"
+       gradientUnits="userSpaceOnUse"
+       id="radialGradient8319"
+       xlink:href="#linearGradient5167" />
+    <radialGradient
+       r="5.96875"
+       fy="11.308558"
+       fx="14.05685"
+       cy="11.308558"
+       cx="14.05685"
+       gradientTransform="matrix(-4.2002315,0.5953403,0.2958442,2.0989386,75.31118,-18.732928)"
+       gradientUnits="userSpaceOnUse"
+       id="radialGradient8321"
+       xlink:href="#linearGradient5184" />
+    <linearGradient
+       gradientTransform="matrix(1.7591324,0,0,1.7580929,-3.90899,-4.3562887)"
+       y2="26.431587"
+       x2="13.458839"
+       y1="2.0178134"
+       x1="8.9317284"
+       gradientUnits="userSpaceOnUse"
+       id="linearGradient8323"
+       xlink:href="#linearGradient860" />
+  </defs>
+  <g id="layer1">
+    <ellipse
+       ry="14.997972"
+       rx="14.995141"
+       style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.4;fill:url(#linearGradient8317);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.11079514;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none"
+       id="path7691"
+       cx="24.00086"
+       cy="24.002029" />
+    <ellipse
+       ry="13.502028"
+       rx="13.49948"
+       cy="24.002029"
+       cx="24.000866"
+       id="path7968"
+       style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:url(#radialGradient8319);fill-opacity:0.15;fill-rule:nonzero;stroke:#003f70;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none" />
+    <path
+       id="path7970"
+       d="M 25.3861,13.485003 C 20.31979,12.724926 15.45183,15.857848 14,20.764516 c 1.18871,3.18039 3.90811,5.70993 7.46677,6.47724 5.29459,1.141602 10.50115,-2.027543 12.01505,-7.143895 -1.18869,-3.180413 -3.90812,-5.709952 -7.46675,-6.477239 -0.217,-0.04678 -0.41248,-0.103152 -0.62897,-0.135619 z"
+       style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.2;fill:url(#radialGradient8321);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.09465754;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none" />
+    <ellipse
+       ry="12.509292"
+       rx="12.516688"
+       cy="24.009293"
+       cx="24.000891"
+       style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.54494413;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:url(#linearGradient8323);stroke-width:1.00000215;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none"
+       id="path7972" />
+  </g>
+</svg>
index e6663ce345992ea41d12986b4f8795fa6a63e30a..439494316f38c4eeaae83c71eaefe3c0457edd49 100644 (file)
@@ -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)
index 567cc143c3a98c2555124fd7f66443e07a5cc7ec..d4c666b13cf23cbcc77889f501ee9468268b4e0a 100644 (file)
@@ -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
index bf8c7cf1d3fe087d8f8a94d473a72547cfa8adf7..079b9cd42475d4596cb29b54cd9f6316ae2c3311 100644 (file)
@@ -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);
index 4cf20a2539a16cfc9a632f50edf4ab3ab51d1560..f8ed38d89cb31364a87ac92f697b0ead28508924 100644 (file)
         </widget>
        </item>
        <item row="1" column="0">
+        <widget class="QLabel" name="label_30">
+         <property name="text">
+          <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-weight:700;&quot;&gt;Build:&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+         </property>
+        </widget>
+       </item>
+       <item row="1" column="1">
+        <widget class="QLabel" name="label_buildTag">
+         <property name="text">
+          <string>TextLabel</string>
+         </property>
+        </widget>
+       </item>
+       <item row="2" column="0">
         <widget class="QLabel" name="label_20">
          <property name="text">
           <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-weight:700;&quot;&gt;Monero:&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
          </property>
         </widget>
        </item>
-       <item row="1" column="1">
+       <item row="2" column="1">
         <widget class="QLabel" name="label_moneroVersion">
          <property name="text">
           <string>TextLabel</string>
          </property>
         </widget>
        </item>
-       <item row="2" column="0">
+       <item row="3" column="0">
         <widget class="QLabel" name="label_22">
          <property name="text">
           <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-weight:700;&quot;&gt;Qt:&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
          </property>
         </widget>
        </item>
-       <item row="2" column="1">
+       <item row="3" column="1">
         <widget class="QLabel" name="label_qtVersion">
          <property name="text">
           <string>TextLabel</string>
          </property>
         </widget>
        </item>
-       <item row="4" column="0">
+       <item row="5" column="0">
         <widget class="QLabel" name="label_24">
          <property name="text">
           <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-weight:700;&quot;&gt;SSL:&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
          </property>
         </widget>
        </item>
-       <item row="4" column="1">
+       <item row="5" column="1">
         <widget class="QLabel" name="label_sslVersion">
          <property name="text">
           <string>TextLabel</string>
          </property>
         </widget>
        </item>
-       <item row="3" column="0">
+       <item row="4" column="0">
         <widget class="QLabel" name="label_26">
          <property name="text">
           <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-weight:700;&quot;&gt;Tor:&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
          </property>
         </widget>
        </item>
-       <item row="3" column="1">
+       <item row="4" column="1">
         <widget class="QLabel" name="label_torVersion">
          <property name="text">
           <string>TextLabel</string>
index 3d1ad59f66632c8a38b8105b6ce81bb5ea8bacfa..dd1d050b3b6c1e915c635c65868ca12f1d1d73cf 100644 (file)
@@ -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";
index 9f4527de1d18874301aa11be621f19c40e232f5e..f3b4f50b6d082e2a8d7422e4a51a3ec029f5aa94 100644 (file)
@@ -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");
 }
index 1710ae26cc87cd25972701b1ee7367bba9271662..6025d6f4728416005bf6cb38cd1f7c330d92bf0f 100644 (file)
@@ -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 (file)
index 0000000..793d46c
--- /dev/null
@@ -0,0 +1,154 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// SPDX-FileCopyrightText: The Monero Project
+
+#include "SyncRangeDialog.h"
+
+#include <QVBoxLayout>
+#include <QHBoxLayout>
+#include <QFormLayout>
+#include <QComboBox>
+#include <QSpinBox>
+#include <QDateEdit>
+#include <QLabel>
+#include <QDialogButtonBox>
+
+#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<int>::of(&QSpinBox::valueChanged), this, &SyncRangeDialog::updateFromDate);
+
+    // Connect preset dropdown
+    connect(m_presetCombo, QOverload<int>::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<RestoreHeightLookup> 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 (file)
index 0000000..7a7ea1b
--- /dev/null
@@ -0,0 +1,49 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// SPDX-FileCopyrightText: The Monero Project
+
+#ifndef FEATHER_SYNCRANGEDIALOG_H
+#define FEATHER_SYNCRANGEDIALOG_H
+
+#include <QDialog>
+#include <QDate>
+
+#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
index 1473311f23124eb8e77e69bac4d5afd6b86fda8d..842337bfedea87e7d113c4d21e02a4a82fa229b2 100644 (file)
@@ -7,21 +7,44 @@
 #include <QMessageBox>
 
 #include "utils/NetworkManager.h"
+#include "utils/nodes.h"
 
-TxImportDialog::TxImportDialog(QWidget *parent, Wallet *wallet)
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QNetworkReply>
+
+
+
+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;
index 02b3441f3c2746af4a35c0839711ba98dc532a11..32a993a9ca89d90a7d1d248b2d0cd4efb3597b76 100644 (file)
@@ -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::TxImportDialog> ui;
     Wallet *m_wallet;
+    Nodes *m_nodes;
 };
 
 
index c26e40d99a8e6aca7dc12d305a046c5914ebabe1..b4a517efe379f3681b554294ced09ba32344ecaa 100644 (file)
@@ -3,8 +3,13 @@
 
 #include "Wallet.h"
 
+
+
 #include <chrono>
 #include <thread>
+#include <tuple>
+
+#include <QMetaObject>
 
 #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::microseconds>(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::microseconds>(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<std::chrono::seconds>(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::microseconds>(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<std::chrono::seconds>(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<std::pair<crypto::hash, tools::wallet2::payment_details>> 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<std::pair<crypto::hash, tools::wallet2::confirmed_transfer_details>> 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<NetworkType::Type>(nettype));
+
+    std::unique_ptr<RestoreHeightLookup> lookup(RestoreHeightLookup::fromFile(filename, static_cast<NetworkType::Type>(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<crypto::hash> 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<std::string> 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<QString> 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<QString> &addresses, const
                                                                                      static_cast<Monero::PendingTransaction::Priority>(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<QString> &keyImages, QString address, bo
                                                                                      static_cast<Monero::PendingTransaction::Priority>(feeLevel));
 
         QVector<QString> 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<QString> &keyImages, QString address, bo
 
 void Wallet::onTransactionCreated(Monero::PendingTransaction *mtx, const QVector<QString> &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<std::tuple<cryptonote::transaction, crypto::hash, bool>> 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();
 
index 20d21af496352baf59fd8d88530ad23c43d0c5a9..1c85d9b3248fad8213db5902765b753abc2292ef 100644 (file)
@@ -15,6 +15,7 @@
 #include "rows/TxBacklogEntry.h"
 
 #include <set>
+#include <atomic>
 
 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<QString> &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<int> m_refreshInterval{10};
+
     QMutex m_asyncMutex;
     QString m_daemonUsername;
     QString m_daemonPassword;
@@ -529,6 +549,15 @@ private:
 
     QTimer *m_storeTimer = nullptr;
     std::set<std::string> m_selectedInputs;
+
+    std::atomic<quint64> m_stopHeight{0};
+    std::atomic<bool> m_rangeSyncActive{false};
+    std::atomic<bool> m_syncPaused{false};
+    std::atomic<bool> m_lastRefreshTime{0};
+    std::atomic<bool> m_pauseAfterSync{false};
+    std::atomic<bool> m_refreshThreadStarted{false};
+    std::atomic<bool> m_scanMempoolWhenPaused{false};
 };
 
 #endif // FEATHER_WALLET_H
+
index 495479de7beef05b4d0ef3794a2a4270ea6e10bf..4b57478ec969c358c89f17ed5658eb7e225f8c4e 100644 (file)
@@ -2,6 +2,9 @@
 // SPDX-FileCopyrightText: The Monero Project
 
 #include <QSslSocket>
+#include <iostream>
+#include <QIcon>
+#include <QGuiApplication>
 
 #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<QPair<QString, QString>> 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;
 }
index 4ea5565ae50ea77943eb69d86b718fac4f29b58b..3de09274f2ce6605b73ac88b253e5a8bb01f4eaf 100644 (file)
@@ -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:
index 193f428081cca1792b1ae5ffd3a10a18f709185d..bd9bfc45d286f2b4f77d3bf90ef0c5c11e1e8a40 100644 (file)
@@ -4,6 +4,7 @@
 #include "CoinsProxyModel.h"
 #include "CoinsModel.h"
 #include "libwalletqt/rows/CoinsInfo.h"
+#include <QtGlobal>
 
 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
index c9820971f8e5bceba19be9bab4d9c57e2840f118..eb10219e728fa1d57376b667a349f346735bee79 100644 (file)
@@ -115,8 +115,8 @@ void HistoryView::showHeaderMenu(const QPoint& position)
 {
     const QList<QAction*> actions = m_columnActions->actions();
     for (auto& action : actions) {
-        Q_ASSERT(static_cast<QMetaType::Type>(action->data().type()) == QMetaType::Int);
-        if (static_cast<QMetaType::Type>(action->data().type()) != QMetaType::Int) {
+        Q_ASSERT(static_cast<QMetaType::Type>(action->data().typeId()) == QMetaType::Int);
+        if (static_cast<QMetaType::Type>(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<QMetaType::Type>(action->data().type()) == QMetaType::Int);
-    if (static_cast<QMetaType::Type>(action->data().type()) != QMetaType::Int) {
+    Q_ASSERT(static_cast<QMetaType::Type>(action->data().typeId()) == QMetaType::Int);
+    if (static_cast<QMetaType::Type>(action->data().typeId()) != QMetaType::Int) {
         return;
     }
 
index 38069ed1e41b96651d8346d70ea2530835576fae..c54ff1bddb6f9e82226138c359a08e624cc7eaa5 100644 (file)
@@ -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:
index 8217efbe569eb87aed8e96e0aa2add555ad07b6f..f10f03af1a019032a3207d990ffe485375097611 100644 (file)
@@ -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:
index 343ad1812b674c6f229854be64653433c77b5245..f0d0422f494c87f75902e8f14002b5d6c3015827 100644 (file)
@@ -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();
index c915d24d445a78d97e09311779dd3e33391624b5..021b36eaa6f45a4adf04ef2e763091b3be2950db 100644 (file)
@@ -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;
index e55df61b0c2552cc00edae2fb0eab8936e646c96..b9b3a138670fa6f2645bbdd00d4452a0ae47313b 100644 (file)
@@ -23,6 +23,7 @@ TickersWidget::TickersWidget(QWidget *parent, Wallet *wallet)
            this->setup();
        }
     });
+    connect(windowManager(), &WindowManager::websocketStatusChanged, this, &TickersWidget::updateDisplay);
     this->updateBalance();
 }
 
index b468fed39f8b01aa119921fffdaf0e42019f8031..d9e42776f4893f2994c133420d912e2ec2ce2da2 100644 (file)
@@ -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";
+}
 }
index 22bba27fd829a61c7d8497a0f8a4bce9233fddd8..f4aa611827637baa0555d5da502eccb276f80b5a 100644 (file)
@@ -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
index 815666bfdc64808601ffe5f5835e5aaf7fe0bb53..360b3662e368f0ec1bcdc8947884fd9bd8a41ada 100644 (file)
@@ -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<QAbstractSocket::SocketError>::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<QAbstractSocket::SocketError>::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();
 }
index b5baa886d797ece9c12bcef280d41dd9bd6dab06..f11f89df72bb481a2c8d230c2d7e889421b4ad58 100644 (file)
@@ -74,9 +74,17 @@ static const QHash<Config::ConfigKey, ConfigDirective> 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<Config::ConfigKey, ConfigDirective> 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}},
 
index 04d9d7590c7ccf7b4155d5b886fa541771c97eb0..d12c80987be3b4d20dda4cab0d75946120eeb20d 100644 (file)
@@ -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 {
index d9702f2dae00a21b3a8e9943b155f6a9b8357ec1..4f33b4e72de09b6483e273d6091afdaf266426f0 100644 (file)
@@ -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<FeatherNode> &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<FeatherNode> Nodes::nodes() {
@@ -605,6 +653,21 @@ int Nodes::modeHeight(const QList<FeatherNode> &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;
 }
index 8bbf8b3a1c2cc7b9da6cd531c5b73e29e61247ca..018fb77969d4df6f6d0c1edccb70b873036dfb45 100644 (file)
@@ -167,6 +167,7 @@ public:
 public slots:
     void connectToNode();
     void connectToNode(const FeatherNode &node);
+    void disconnectCurrentNode();
     void onWSNodesReceived(QList<FeatherNode>& nodes);
     void onNodeSourceChanged(NodeSource nodeSource);
     void setCustomNodes(const QList<FeatherNode>& 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();
 
index 10ac77f1e2a985cf13ddbaf965a210f390bba516..635ed3af4b5c1ce5000f39647a78ad479c2bed95 100644 (file)
 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 &currency : ratesData.keys()) {
-        this->rates.insert(currency, ratesData.value(currency).toDouble());
+        this->rates.insert(currency.toUpper(), ratesData.value(currency).toDouble());
     }
     emit fiatPricesUpdated();
 }
index 8972c0e97f72ac93f7b4f29ebccde3cf2f3680d2..afee8b61be9b5618a04b536b5f1923b2d7b3fdc0 100644 (file)
@@ -5,6 +5,7 @@
 #define FEATHER_PRICES_H
 
 #include <QObject>
+#include <QDateTime>
 
 #include "utils/Utils.h"
 
@@ -24,6 +25,7 @@ public:
     explicit Prices(QObject *parent = nullptr);
     QMap<QString, double> rates;
     QMap<QString, marketStruct> markets;
+    QDateTime lastUpdateTime;
 
 public slots:
     void cryptoPricesReceived(const QJsonArray &data);
index b2c94e775284e3bdefb5ee1e68cd65a1cdfefddc..49b2a6b4a1e77022dcb8dc3b388ab5a243d06947 100644 (file)
@@ -3,6 +3,7 @@
 
 #include "NodeWidget.h"
 #include "ui_NodeWidget.h"
+#include <QtGlobal>
 
 #include <QAction>
 #include <QDesktopServices>
@@ -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);
index d8916496766860bff3ae8c76d35ddd99518e8e7b..2e9286bb4ba0114e25c4a869af007ecb5c144821 100644 (file)
@@ -24,8 +24,8 @@ struct PayToLineError {
 
     QString lineContent;
     QString error;
-    int idx;
-    bool isMultiline;
+    int idx = 0;
+    bool isMultiline = false;
 };
 
 class PayToEdit : public QPlainTextEdit
index 75b5cfe66d52ba1f874dcd258df3895c8d5612e5..11403b666d0ac5dee04f611f9ababa33564e5546 100644 (file)
@@ -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
index 90a961cd8577b938d7a895f7713822102367dbc4..2f16c71b2c1ea20dd9ea22dde81f0d3498285337 100644 (file)
@@ -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);