]> Nutra Git (v2) - gamesguru/feather.git/commitdiff
Implement Skip Sync and Data Saving features
authorgg <chown_tee@proton.me>
Wed, 7 Jan 2026 11:37:00 +0000 (06:37 -0500)
committergg <chown_tee@proton.me>
Wed, 7 Jan 2026 11:37:00 +0000 (06:37 -0500)
Logic:
- Add 'Skip to Tip', 'Date Range', and 'Full Sync' engine to libwalletqt
- Implement 'Scan Transaction' functionality for specific TXIDs

UI:
- Add context menu actions to bottom bar for selective sync
- Display block-depth count in status bar, courtesy of @masflam

bounty claimed here (as "mr_overquald")
https://bounties.monero.social/posts/79/1-230m-add-a-skip-sync-feature-to-a-monero-wallet

bigger scan Tx window

fix transaction diaglogue sizing

tidy estimatedBytes

use simple QDialog for transaction Scan window

rename to syncPause

properly dispose of QThreadStorage disposal

$ ./build/bin/feather --version
FeatherWallet 2.8.1-79-g16eec531
QThreadStorage: entry 2 destroyed before end of thread 0x562e3e2b3b90
QThreadStorage: entry 1 destroyed before end of thread 0x562e3e2b3b90

improvements in status bar

better sync status

trying to get status always updated

put into helper method

refactor bool importTransaction()

fix p1: full sync bug & mutex

fix file close cache bug

more lint fixes; start blocks instead of time

simplify status bar to block count, not download size/time

fix sync 1000 block even when paused bug

hide all widgets on minimize; respect log level

show "[PAUSED] x Blocks to go" on Connecting Status, too

Fetch initial heights so UI can update even if paused

info log: m_walletImpl->refresh() with walletHeight

fix: calc daemon height if 0 to show wallet depth

update icon for paused state

muzzle WebSocket when in pause mode

track and emit network/pause status

log block/daemon/wallet updates for ref

QPointer<QAction> m_updateNetworkInfoAction;

no background sync when paused; allow autoReconnect() on non-onion nodes

abstract out blocksBehind() method

show m_lastSyncStatusUpdate in label and debug log

drop minimize delay from 500 ms to 350 ms

m_actionDisconnectNodeOnPause, m_actionDisconnectWebSocketOnPause

log and special status for pauseSync disc node

disable Network Update Info when node disconnected

place disconnectCurrentNode outside connectToNode

parse cmdline args BEFORE initing UI libs

only disconnect WebSocket on pause when also selected

disconnect node when user explicitly requests

parse args start index=1, not 0 (skip script/exec name)

fix bug in m_updateNetworkInfoAction

disable context menu conditioanlly; conceal sus $0.00 bal

rename to FeatherWallet

better balance (based on wallet status and sync pause status)

fix translucent mis-match

make old policy conditional on configuration

fix: m_updateNetworkInfoAction was active when sync Disabled

show full amount when zero in balance label

fix annoying WebSocket close warning:

    WindowManager: cleanup thread done 0x7cca7efdbec0
    ~Application
    ~WebsocketNotifier 0x7cca7efdbec0
    QObject::startTimer: Timers can only be used with threads started with QThread

handle Calc page logic better now with new flags

add dedicated SyncRangeDialog

better reconnect logic/less network & log spam

track time since: last sync and last fiat update

tooltip simplifications/methods
some more simplifications to status/label/fiat updates

move sync interval to node page; add more config keys
status/balance and HW backed wallet fixes

fix autoConnect() spam/fail bug
update last refresh/sync pause logic status

fix data race/logic bug with refresh interval

get last sync
connect after scan Tx

38 files changed:
src/MainWindow.cpp
src/MainWindow.h
src/SettingsDialog.cpp
src/SettingsDialog.ui
src/WindowManager.cpp
src/WindowManager.h
src/assets/feather.desktop
src/components.cpp
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/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/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

index 6a2d6b80368eeea58d95b0e776fce0173e06af23..e457221290bb755e71837f87c184edcada48092b 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::syncInterval && m_wallet) {
+            m_wallet->setRefreshInterval(conf()->get(Config::syncInterval).toInt());
+        }
+    });
+
     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,133 @@ void MainWindow::initStatusBar() {
     connect(m_statusBtnHwDevice, &StatusBarButton::clicked, this, &MainWindow::menuHwDeviceClicked);
     this->statusBar()->addPermanentWidget(m_statusBtnHwDevice);
     m_statusBtnHwDevice->hide();
+
+    m_statusLabelStatus->setContextMenuPolicy(Qt::ActionsContextMenu);
+
+    QAction *pauseSyncAction = new QAction(tr("Pause Sync"), this);
+    pauseSyncAction->setCheckable(true);
+    pauseSyncAction->setChecked(conf()->get(Config::syncPaused).toBool());
+    m_statusLabelStatus->addAction(pauseSyncAction);
+
+    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 *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 Now"), this);
+    m_statusLabelStatus->addAction(m_updateNetworkInfoAction);
+
+    connect(pauseSyncAction, &QAction::toggled, this, [this](bool checked) {
+        qInfo() << "Pause Sync toggled. Checked =" << checked;
+        conf()->set(Config::syncPaused, checked);
+
+        if (m_wallet) {
+            if (checked) {
+                m_wallet->setSyncPaused(true);
+                m_nodes->disconnectCurrentNode();
+                this->onConnectionStatusChanged(Wallet::ConnectionStatus_Disconnected);
+            } else {
+                // Ensure we reconnect everything when unpausing
+                m_wallet->setSyncPaused(false);
+                m_nodes->connectToNode();
+                websocketNotifier()->websocketClient->restart();
+                this->setStatusText(tr("Resuming sync..."));
+            }
+        }
+    });
+
+    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(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, [this](){
+        if (m_wallet) {
+            TxImportDialog dialog(this, m_wallet);
+            dialog.exec();
+        }
+    });
 }
 
 void MainWindow::initPlugins() {
@@ -453,6 +616,22 @@ void MainWindow::initOffline() {
             ui->radio_airgapUR->setChecked(true);
     }
 
+    connect(m_updateNetworkInfoAction, &QAction::triggered, this, [this]() {
+        if (!m_wallet) return;
+
+        this->setStatusText(tr("Scanning..."));
+        
+        // FIX: Temporarily connect if we are disconnected/paused
+        if (m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) {
+             m_nodes->connectToNode();
+        }
+        
+        // Trigger the refresh (sets m_refreshNow = true, bypassing the pause check)
+        m_wallet->startRefresh();
+    });
+
+
+    // 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 +666,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 +701,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 +746,18 @@ void MainWindow::onWalletOpened() {
 
     m_wallet->setRingDatabase(Utils::ringDatabasePath());
 
+    // Load persisted sync state
+    // Load persisted sync state
+    QString lastSyncStr = m_wallet->getCacheAttribute("feather.lastSync");
+    if (!lastSyncStr.isEmpty()) {
+        qint64 lastSync = lastSyncStr.toLongLong();
+        if (lastSync > 0) {
+            m_lastSyncStatusUpdate = QDateTime::fromSecsSinceEpoch(lastSync);
+        }
+    }
+
+    m_wallet->setRefreshInterval(conf()->get(Config::syncInterval).toInt());
+
     m_wallet->updateBalance();
     if (m_wallet->isHwBacked()) {
         m_statusBtnHwDevice->show();
@@ -582,6 +786,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 +804,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 +830,142 @@ 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));
         }
     }
 
+    // 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("\nPrice updated: %1").arg(Utils::timeAgo(appData()->prices.lastUpdateTime));
+    }
+    if (m_wallet && m_wallet->lastSyncTime().isValid()) {
+        toolTip += QString("\nWallet synced: %1").arg(Utils::timeAgo(m_wallet->lastSyncTime()));
+    }
+
+    if (m_wallet) {
+        qint64 nextRefresh = m_wallet->secondsUntilNextRefresh();
+        if (nextRefresh > 0) {
+            toolTip += QString("\nNext sync attempt in: %1s").arg(nextRefresh);
+        } else if (nextRefresh == 0) {
+            toolTip += "\nSync attempt in progress...";
+        } else if (nextRefresh == -2) {
+            toolTip += "\nHardware wallet disconnected";
+        }
+    }
+
+    m_statusLabelBalance->setToolTip(toolTip);
+
+    this->updateSyncStatusToolTip();
+}
+
+void MainWindow::updateSyncStatusToolTip() {
+    if (!m_wallet) return;
+
+    quint64 walletHeight = m_wallet->blockChainHeight();
+    quint64 targetHeight = m_wallet->daemonBlockChainTargetHeight();
+
+    // Fall back to persisted network height if current is 0
+    if (targetHeight == 0) {
+        targetHeight = conf()->get(Config::lastKnownNetworkHeight).toULongLong();
+    }
+
+    // 1. Calculate Real Lag (If connected)
+    quint64 blocksBehind = 0;
+    if (targetHeight > walletHeight) {
+        blocksBehind = targetHeight - walletHeight;
+    } 
+    
+    // 2. Calculate Estimated Lag (If Paused/Offline)
+    // Only use time-estimation if we don't have a live connection
+    bool isPaused = conf()->get(Config::syncPaused).toBool();
+    if (isPaused || m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) {
+        // Only estimate if we deviate from the cached target, OR if target is unknown
+        if (targetHeight == 0 || targetHeight > walletHeight) {
+            QDateTime lastSync = m_wallet->lastSyncTime();
+            if (lastSync.isValid()) {
+                qint64 secs = lastSync.secsTo(QDateTime::currentDateTime());
+                if (secs > 0) blocksBehind = secs / 120; // 120s per block
+            }
+        }
+    }
+
+    // 3. Build the String
+    QString tooltip = tr("Wallet Height: %1").arg(QLocale().toString(walletHeight));
+
+    // Only show Network Tip if we are actually connected
+    if (!isPaused && m_wallet->connectionStatus() == Wallet::ConnectionStatus_Synchronized) {
+        if (targetHeight > 0)
+            tooltip += tr(" | Network Tip: %1").arg(QLocale().toString(targetHeight));
+    }
+
+    if (m_wallet->lastSyncTime().isValid()) {
+        tooltip += tr("\nLast synchronized: %1").arg(Utils::timeAgo(m_wallet->lastSyncTime()));
+    }
+
+    if (blocksBehind > 0) {
+        // Show estimate if significant or if explicitly paused
+        if (blocksBehind > 2 || isPaused) {
+             tooltip += tr("\n~%1 blocks behind").arg(QLocale().toString(blocksBehind));
+        }
+    } else if (isPaused) {
+         tooltip += tr("\n(Up to date)");
+    }
+
+    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 +977,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 +992,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,53 +1077,142 @@ void MainWindow::onMultiBroadcast(const QMap<QString, QString> &txHexMap) {
 }
 
 void MainWindow::onSyncStatus(quint64 height, quint64 target, bool daemonSync) {
-    if (height >= (target - 1)) {
+    // 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"));
+
+        // 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 type = daemonSync ? tr("Blockchain") : tr("Wallet");
+        QString blocksStr = QLocale().toString(blocksBehind);
+        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)
 {
+    // Fix B: Override status when paused
+    if (conf()->get(Config::syncPaused).toBool()) {
+        QIcon icon = icons()->icon("status_offline.svg");
+        QString statusStr = tr("Sync Paused");
+        
+        m_statusBtnConnectionStatusIndicator->setIcon(icon);
+        this->setStatusText(statusStr);
+        
+        // Hide the "Net Stats" (D: 0.0 B) label since we aren't downloading
+        m_statusLabelNetStats->hide();
+        
+        // Update tooltip to ensure it doesn't show "Synchronized"
+        this->updateSyncStatusToolTip();
+        return; // STOP EXECUTION HERE
+    }
+
     // Note: Wallet does not emit this signal unless status is changed, so calling this function from MainWindow may
     // result in the wrong connection status being displayed.
 
     qDebug() << "Wallet connection status changed " << Utils::QtEnumToString(static_cast<Wallet::ConnectionStatus>(status));
 
+    if (m_updateNetworkInfoAction) {  // Maybe not initialized on first function call
+        m_updateNetworkInfoAction->setEnabled(status != Wallet::ConnectionStatus_Disconnected && !conf()->get(Config::syncPaused).toBool());
+    }
+
     // 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_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");
+                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 +1391,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 +1566,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 +1680,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 +1688,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,6 +1881,11 @@ void MainWindow::importTransaction() {
         }
     }
 
+    // Ensure connection for Data Saving Mode
+    if (m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) {
+         m_nodes->connectToNode();
+    }
+
     TxImportDialog dialog(this, m_wallet);
     dialog.exec();
 }
@@ -1460,7 +1919,6 @@ void MainWindow::onDeviceError(const QString &error, quint64 errorCode) {
         }
     }
     m_statusBtnHwDevice->setIcon(this->hardwareDevicePairedIcon());
-    m_wallet->startRefresh();
     m_showDeviceError = false;
 }
 
@@ -1526,10 +1984,54 @@ void MainWindow::onWalletPassphraseNeeded(bool on_device) {
 }
 
 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();
+             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("Synchronized (Sync in %1)").arg(timeStr));
+             }
+        }
+        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(tr("Paused"));
+                 } else {
+                     this->setStatusText(tr("Connecting..."));
+                 }
+             }
+        }
+
+        return;
+    }
+
+    if (conf()->get(Config::syncPaused).toBool()) {
+        m_statusLabelNetStats->hide();
         return;
     }
 
@@ -1544,12 +2046,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 {
index c44270d32dd8a91bdf243ddbc57a68875913dd7f..4df669919da23973138b4a51cadee54960627c14 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,6 +207,8 @@ 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);
@@ -231,6 +235,10 @@ private:
     CoinsWidget *m_coinsWidget = nullptr;
 
     QPointer<QAction> m_clearRecentlyOpenAction;
+    QPointer<QAction> m_updateNetworkInfoAction;
+    QPointer<QAction> m_actionEnableWebsocket;
+
+    QDateTime m_lastSyncStatusUpdate;
 
     // lower status bar
     QPushButton *m_statusUpdateAvailable;
@@ -255,6 +263,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 +276,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 982fef249e9ff5ccb8836e791c8750f2c29f7d47..9f51fabfe3551a378c5a85e0a803ed971364d409 100644 (file)
@@ -165,19 +165,44 @@ 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();
+
+    // 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);
+    });
+
+    // Add to Node tab layout
+    if (auto *layout = qobject_cast<QVBoxLayout*>(ui->Node->layout())) {
+        layout->insertWidget(0, cbDataSaver);
     }
 
     // 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 +211,79 @@ 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);
-    });
+    // Sync
+    QComboBox *comboSyncInterval = new QComboBox(this);
+    comboSyncInterval->setEditable(true);
+
+    struct IntervalPreset {
+        QString label;
+        int seconds;
+    };
+
+    QList<IntervalPreset> presets = {
+        {"30 seconds", 30},
+        {"1 minute",   60},
+        {"2 minutes",  120},
+        {"5 minutes",  300},
+        {"10 minutes", 600},
+        {"15 minutes", 900},
+        {"20 minutes", 1200},
+        {"30 minutes", 1800},
+        {"45 minutes", 2700},
+        {"1 hour",     3600},
+        {"1.5 hours",  5400},
+        {"3 hours",    10800},
+        {"5 hours",    18000},
+        {"10 hours",   36000},
+        {"1 day",      86400},
+        {"1 week",     604800},
+        {"1 month",    2592000}
+    };
+
+    for (const auto &preset : presets) {
+        comboSyncInterval->addItem(preset.label, preset.seconds);
+    }
+
+    int currentInterval = conf()->get(Config::syncInterval).toInt();
+    bool found = false;
+    for (int i = 0; i < comboSyncInterval->count(); ++i) {
+        if (comboSyncInterval->itemData(i).toInt() == currentInterval) {
+            comboSyncInterval->setCurrentIndex(i);
+            found = true;
+            break;
+        }
+    }
+    if (!found) {
+        comboSyncInterval->setCurrentText(QString("%1 seconds").arg(currentInterval));
+    }
+
+    auto updateConfig = [comboSyncInterval](const QString &text){
+        int seconds = 0;
+        if (comboSyncInterval->currentIndex() != -1 && comboSyncInterval->currentText() == comboSyncInterval->itemText(comboSyncInterval->currentIndex())) {
+            seconds = comboSyncInterval->currentData().toInt();
+        } else {
+            // Try to parse simple number as seconds
+            bool ok;
+            seconds = text.split(" ").first().toInt(&ok);
+            if (!ok) return;
+        }
+        if (seconds < 30) {
+            seconds = 30;
+        }
+        conf()->set(Config::syncInterval, seconds);
+    };
+
+    connect(comboSyncInterval, &QComboBox::currentTextChanged, updateConfig);
+
+    QHBoxLayout *hLayoutSync = new QHBoxLayout();
+    hLayoutSync->addWidget(new QLabel("Time between syncs:", this));
+    hLayoutSync->addWidget(comboSyncInterval);
+    hLayoutSync->addStretch();
+
+    // Add to Node tab
+    if (auto *layout = qobject_cast<QVBoxLayout*>(ui->Node->layout())) {
+        layout->addLayout(hLayoutSync);
+    }
 }
 
 void Settings::setupStorageTab() {
@@ -234,10 +325,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 +420,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 +430,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..344b69698ca6a2621706ea4cd0b928ff2febe00e 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);
@@ -773,7 +821,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 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;
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 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..73966fa
--- /dev/null
@@ -0,0 +1,148 @@
+// 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."));
+
+    m_infoLabel = new QLabel;
+    m_infoLabel->setWordWrap(true);
+    m_infoLabel->setStyleSheet("QLabel { color: #888; font-size: 11px; }");
+
+    formLayout->addRow(tr("Day span:"), daysLayout);
+    formLayout->addRow(tr("Start date:"), m_fromDateEdit);
+    formLayout->addRow(tr("End date:"), m_toDateEdit);
+
+    layout->addLayout(formLayout);
+    layout->addWidget(m_infoLabel);
+
+    connect(m_fromDateEdit, &QDateEdit::dateChanged, this, &SyncRangeDialog::updateInfo);
+    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();
+}
diff --git a/src/dialog/SyncRangeDialog.h b/src/dialog/SyncRangeDialog.h
new file mode 100644 (file)
index 0000000..baa9294
--- /dev/null
@@ -0,0 +1,45 @@
+// 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();
+
+    Wallet *m_wallet;
+    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..a2e49deb61405537bc729e21146adda397829f1b 100644 (file)
@@ -17,7 +17,10 @@ TxImportDialog::TxImportDialog(QWidget *parent, Wallet *wallet)
 
     connect(ui->btn_import, &QPushButton::clicked, this, &TxImportDialog::onImport);
 
+    ui->line_txid->setMinimumWidth(600);
     this->adjustSize();
+
+    this->layout()->setSizeConstraint(QLayout::SetFixedSize);
 }
 
 void TxImportDialog::onImport() {
index c26e40d99a8e6aca7dc12d305a046c5914ebabe1..436be13985fb4bca245ca1ca4b4b792468990a71 100644 (file)
@@ -14,6 +14,7 @@
 #include "WalletManager.h"
 #include "WalletListenerImpl.h"
 
+#include "utils/config.h"
 #include "config.h"
 #include "constants.h"
 
@@ -25,6 +26,8 @@
 #include "model/CoinsModel.h"
 
 #include "utils/ScopeGuard.h"
+#include "utils/RestoreHeightLookup.h"
+#include "utils/Utils.h"
 
 #include "wallet/wallet2.h"
 
@@ -52,6 +55,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);
@@ -83,6 +87,15 @@ 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();
+        if (height > 0) {
+            setCacheAttribute("feather.creation_height", QString::number(height));
+        }
+    }
 }
 
 // #################### Status ####################
@@ -406,20 +419,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)
@@ -431,7 +474,6 @@ void Wallet::initAsync(const QString &daemonAddress, bool trustedDaemon, quint64
 // #################### Synchronization (Refresh) ####################
 
 void Wallet::startRefresh() {
-    m_refreshEnabled = true;
     m_refreshEnabled = true;
     m_refreshNow = true;
 }
@@ -440,12 +482,28 @@ 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()
 {
     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 +513,37 @@ 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_refreshNow) {
+                        last = std::chrono::steady_clock::now();
+                        std::this_thread::sleep_for(std::chrono::milliseconds(250));
+                        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 +555,10 @@ 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) {
+                        // Prevent background network usage when sync is paused
+                        if (m_syncPaused)
+                            continue;
+
                         QMutexLocker locker(&m_asyncMutex);
 
                         if (m_newWallet) {
@@ -483,9 +567,9 @@ void Wallet::startRefreshThread()
                             m_newWallet = false;
                         }
 
+                        quint64 walletHeight = m_walletImpl->blockChainHeight();
                         m_walletImpl->refresh();
                     }
-                    last = std::chrono::steady_clock::now();
                 }
             }
 
@@ -507,12 +591,11 @@ void Wallet::onHeightsRefreshed(bool success, quint64 daemonHeight, quint64 targ
 
         if (daemonHeight < targetHeight) {
             emit syncStatus(daemonHeight, targetHeight, true);
-        }
-        else {
+        } else {
             this->syncStatusUpdated(walletHeight, daemonHeight);
         }
 
-        if (walletHeight < (targetHeight - 1)) {
+        if (walletHeight < targetHeight) {
             setConnectionStatus(ConnectionStatus_Synchronizing);
         } else {
             setConnectionStatus(ConnectionStatus_Synchronized);
@@ -520,6 +603,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 +614,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 +642,144 @@ 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();
+    } else {
+        m_refreshNow = true;
+        m_wallet2->set_offline(false);
+        startRefresh();
+    }
+}
+
+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_wallet2->set_refresh_from_block_height(target);
+    m_lastSyncTime = QDateTime::currentDateTime();
+
+    pauseRefresh();
+    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);
+    }
+    pauseRefresh();
+    startRefresh();
+}
+
+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
+    pauseRefresh();
+    startRefresh();
+
+    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;
+        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;
 
@@ -693,11 +928,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() {
@@ -1458,6 +1688,9 @@ Wallet::~Wallet()
 
     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..366dd8858742f7769f44745d7eb2afa8c0b0cd81 100644 (file)
@@ -15,6 +15,7 @@
 #include "rows/TxBacklogEntry.h"
 
 #include <set>
+#include <atomic>
 
 class WalletListenerImpl;
 
@@ -138,11 +139,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 +158,7 @@ public:
     quint64 unlockedBalance() const;
     quint64 unlockedBalance(quint32 accountIndex) const;
     quint64 unlockedBalanceAll() const;
-    
+
     quint64 viewOnlyBalance(quint32 accountIndex) const;
 
     void updateBalance();
@@ -217,6 +222,7 @@ public:
     // ##### Synchronization (Refresh) #####
     void startRefresh();
     void pauseRefresh();
+    Q_INVOKABLE void updateNetworkStatus();
 
     //! returns current wallet's block height
     //! (can be less than daemon's blockchain height when wallet sync in progress)
@@ -229,6 +235,12 @@ public:
     quint64 daemonBlockChainTargetHeight() const;
 
     void syncStatusUpdated(quint64 height, quint64 target);
+    void setSyncPaused(bool paused);
+    Q_INVOKABLE void skipToTip();
+    Q_INVOKABLE void syncDateRange(const QDate &start, const QDate &end);
+    void fullSync(); // Rescans from wallet creation height, not genesis block
+
+    bool importTransaction(const QString &txid);
 
     void refreshModels();
 
@@ -250,25 +262,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 +351,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 +464,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);
@@ -501,6 +509,7 @@ private:
 
     quint64 m_daemonBlockChainHeight;
     quint64 m_daemonBlockChainTargetHeight;
+    QDateTime m_lastSyncTime;
 
     ConnectionStatus m_connectionStatus;
 
@@ -513,6 +522,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 +540,12 @@ 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<int64_t> m_lastRefreshTime{0};
 };
 
 #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 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..a894e177f4bd646f50f5b13f91a9c2bd60a19670 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);
 
@@ -57,6 +63,10 @@ void WebsocketClient::start() {
         return;
     }
 
+    if (conf()->get(Config::syncPaused).toBool() && conf()->get(Config::syncPausedAlsoDisconnectWebSocket).toBool()) {
+        return;
+    }
+
     // connect & reconnect on errors/close
     auto state = webSocket->state();
     if (state != QAbstractSocket::ConnectedState && state != QAbstractSocket::ConnectingState) {
@@ -73,7 +83,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..bfb5c42123a422fe1cd79d5f7a7610906777cc7a 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::syncPausedAlsoDisconnectWebSocket, {QS("syncPausedAlsoDisconnectWebSocket"), false}},
+        {Config::syncInterval, {QS("syncInterval"), 30}},
+        {Config::lastKnownNetworkHeight, {QS("lastKnownNetworkHeight"), 0}},
+        {Config::lastSyncTimestamp, {QS("lastSyncTimestamp"), 0}},
+        {Config::lastPriceUpdateTimestamp, {QS("lastPriceUpdateTimestamp"), 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..f86d6b7d58e49a968eb7eaabd7581e619e67f1a0 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,14 @@ public:
         // Tickers
         tickers,
         tickersShowFiatBalance,
+
+        // Sync & data saver
+        syncPaused,
+        syncPausedAlsoDisconnectWebSocket,
+        syncInterval,
+        lastKnownNetworkHeight,
+        lastSyncTimestamp,
+        lastPriceUpdateTimestamp,
     };
 
     enum PrivacyLevel {
index d9702f2dae00a21b3a8e9943b155f6a9b8357ec1..26c1664713f69ef1f07f96cdac2de84a823c7cfe 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()) {
+        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,19 @@ 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;
+            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