From 7c2d9ab271ca0053b38429d94c83736989adb561 Mon Sep 17 00:00:00 2001 From: gg Date: Wed, 7 Jan 2026 06:37:00 -0500 Subject: [PATCH] Implement Skip Sync and Data Saving features 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 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 --- src/MainWindow.cpp | 574 ++++++++++++++++-- src/MainWindow.h | 11 + src/SettingsDialog.cpp | 143 ++++- src/SettingsDialog.ui | 30 + src/WindowManager.cpp | 65 +- src/WindowManager.h | 1 + src/assets/feather.desktop | 2 +- src/components.cpp | 5 +- src/dialog/AboutDialog.cpp | 6 + src/dialog/AboutDialog.ui | 28 +- src/dialog/DebugInfoDialog.cpp | 6 +- src/dialog/PaymentRequestDialog.cpp | 6 +- src/dialog/QrCodeDialog.cpp | 5 +- src/dialog/SyncRangeDialog.cpp | 148 +++++ src/dialog/SyncRangeDialog.h | 45 ++ src/dialog/TxImportDialog.cpp | 3 + src/libwalletqt/Wallet.cpp | 273 ++++++++- src/libwalletqt/Wallet.h | 37 +- src/main.cpp | 46 +- src/model/AddressBookProxyModel.h | 6 + src/model/CoinsProxyModel.cpp | 13 + src/model/HistoryView.cpp | 8 +- src/model/SubaddressProxyModel.h | 7 + src/model/TransactionHistoryProxyModel.h | 6 + .../crowdfunding/CCSProgressDelegate.cpp | 2 +- src/plugins/tickers/TickersWidget.cpp | 1 + src/utils/Utils.cpp | 74 +++ src/utils/Utils.h | 7 + src/utils/WebsocketClient.cpp | 12 +- src/utils/config.cpp | 11 +- src/utils/config.h | 11 + src/utils/nodes.cpp | 65 +- src/utils/nodes.h | 3 + src/utils/prices.cpp | 8 +- src/utils/prices.h | 2 + src/widgets/NodeWidget.cpp | 10 +- src/widgets/PayToEdit.h | 4 +- src/widgets/TickerWidget.cpp | 28 +- 38 files changed, 1587 insertions(+), 125 deletions(-) create mode 100644 src/dialog/SyncRangeDialog.cpp create mode 100644 src/dialog/SyncRangeDialog.h diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 6a2d6b80..e4572212 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -7,7 +7,15 @@ #include #include #include +#include +#include +#include #include +#include +#include +#include +#include +#include #include "constants.h" #include "dialog/AddressCheckerIndexDialog.h" @@ -24,16 +32,19 @@ #include "dialog/ViewOnlyDialog.h" #include "dialog/WalletInfoDialog.h" #include "dialog/WalletCacheDebugDialog.h" +#include "dialog/SyncRangeDialog.h" #include "libwalletqt/AddressBook.h" #include "libwalletqt/rows/CoinsInfo.h" #include "libwalletqt/rows/Output.h" #include "libwalletqt/TransactionHistory.h" #include "model/AddressBookModel.h" #include "plugins/PluginRegistry.h" +#include "plugins/Plugin.h" #include "utils/AppData.h" #include "utils/AsyncTask.h" #include "utils/ColorScheme.h" #include "utils/Icons.h" +#include "utils/RestoreHeightLookup.h" #include "utils/TorManager.h" #include "utils/WebsocketNotifier.h" @@ -80,7 +91,7 @@ MainWindow::MainWindow(WindowManager *windowManager, Wallet *wallet, QWidget *pa this->onOfflineMode(conf()->get(Config::offlineMode).toBool()); conf()->set(Config::restartRequired, false); - + // Websocket notifier #ifdef CHECK_UPDATES connect(websocketNotifier(), &WebsocketNotifier::UpdatesReceived, m_updater.data(), &Updater::wsUpdatesReceived); @@ -93,6 +104,7 @@ MainWindow::MainWindow(WindowManager *windowManager, Wallet *wallet, QWidget *pa connect(m_windowManager, &WindowManager::proxySettingsChanged, this, &MainWindow::onProxySettingsChangedConnect); connect(m_windowManager, &WindowManager::updateBalance, m_wallet, &Wallet::updateBalance); + connect(m_windowManager, &WindowManager::websocketStatusChanged, m_wallet, &Wallet::updateBalance); connect(m_windowManager, &WindowManager::offlineMode, this, &MainWindow::onOfflineMode); connect(m_windowManager, &WindowManager::manualFeeSelectionEnabled, this, &MainWindow::onManualFeeSelectionEnabled); connect(m_windowManager, &WindowManager::subtractFeeFromAmountEnabled, this, &MainWindow::onSubtractFeeFromAmountEnabled); @@ -109,12 +121,20 @@ MainWindow::MainWindow(WindowManager *windowManager, Wallet *wallet, QWidget *pa // Timers connect(&m_updateBytes, &QTimer::timeout, this, &MainWindow::updateNetStats); + connect(&m_updateBytes, &QTimer::timeout, this, &MainWindow::updateStatusToolTip); connect(&m_txTimer, &QTimer::timeout, [this]{ - m_statusLabelStatus->setText("Constructing transaction" + this->statusDots()); + QString text = "Constructing transaction" + this->statusDots(); + m_statusLabelStatus->setText(text); }); conf()->set(Config::firstRun, false); + connect(conf(), &Config::changed, this, [this](Config::ConfigKey key){ + if (key == Config::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 &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(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(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 &address) { @@ -967,7 +1391,7 @@ void MainWindow::onTransactionCreated(PendingTransaction *tx, const QVectordisconnect(); - 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 { diff --git a/src/MainWindow.h b/src/MainWindow.h index c44270d3..4df66991 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -6,6 +6,7 @@ #include #include +#include #include "components.h" #include "SettingsDialog.h" @@ -97,6 +98,7 @@ signals: protected: void changeEvent(QEvent* event) override; + void showEvent(QShowEvent *event) override; private slots: // TODO: use a consistent naming convention for slots @@ -205,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 m_clearRecentlyOpenAction; + QPointer m_updateNetworkInfoAction; + QPointer 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 m_updater = nullptr; #endif diff --git a/src/SettingsDialog.cpp b/src/SettingsDialog.cpp index 982fef24..9f51fabf 100644 --- a/src/SettingsDialog.cpp +++ b/src/SettingsDialog.cpp @@ -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::of(&Nodes::connectToNode)); - } else { - m_nodes = new Nodes(this, nullptr); - ui->nodeWidget->setupUI(m_nodes); - ui->nodeWidget->setCanConnect(false); + std::function setupNodeWidget = [this]{ + if (m_nodes) { + ui->nodeWidget->setupUI(m_nodes); + connect(ui->nodeWidget, &NodeWidget::nodeSourceChanged, m_nodes, &Nodes::onNodeSourceChanged); + connect(ui->nodeWidget, &NodeWidget::connectToNode, m_nodes, QOverload::of(&Nodes::connectToNode)); + } else { + m_nodes = new Nodes(this, nullptr); + ui->nodeWidget->setupUI(m_nodes); + ui->nodeWidget->setCanConnect(false); + } + }; + setupNodeWidget(); + + // 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(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 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(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::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() { diff --git a/src/SettingsDialog.ui b/src/SettingsDialog.ui index ccaad425..4479a235 100644 --- a/src/SettingsDialog.ui +++ b/src/SettingsDialog.ui @@ -838,6 +838,36 @@ + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 30 + 20 + + + + + + + + false + + + Left click system tray icon to toggle focus + + + + + diff --git a/src/WindowManager.cpp b/src/WindowManager.cpp index 5d6696e9..344b6969 100644 --- a/src/WindowManager.cpp +++ b/src/WindowManager.cpp @@ -47,6 +47,24 @@ WindowManager::WindowManager(QObject *parent) this->buildTrayMenu(); m_tray->setVisible(conf()->get(Config::showTrayIcon).toBool()); + connect(m_tray, &QSystemTrayIcon::activated, [this](QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::Trigger) { + if (conf()->get(Config::trayLeftClickTogglesFocus).toBool()) { + for (const auto &window : m_windows) { + if (window->isVisible() && window->isActiveWindow()) { + window->hide(); + } else { + window->show(); + window->raise(); + window->activateWindow(); + } + } + } else { + m_tray->contextMenu()->popup(QCursor::pos()); + } + } + }); + this->initSkins(); this->patchMacStylesheet(); @@ -71,9 +89,13 @@ void WindowManager::setEventFilter(EventFilter *ef) { WindowManager::~WindowManager() { qDebug() << "~WindowManager"; - m_cleanupThread->quit(); - m_cleanupThread->wait(); - qDebug() << "WindowManager: cleanup thread done" << QThread::currentThreadId(); + if (m_cleanupThread && m_cleanupThread->isRunning()) { + m_cleanupThread->quit(); + m_cleanupThread->wait(); + qDebug() << "WindowManager: cleanup thread done" << QThread::currentThreadId(); + } else { + qDebug() << "WindowManager: cleanup thread already stopped"; + } } // ######################## APPLICATION LIFECYCLE ######################## @@ -89,10 +111,37 @@ void WindowManager::quitAfterLastWindow() { void WindowManager::close() { qDebug() << Q_FUNC_INFO << QThread::currentThreadId(); - for (const auto &window: m_windows) { + + if (m_closing) { + return; + } + m_closing = true; + + + // Stop all threads before application shutdown to avoid QThreadStorage warnings + if (m_cleanupThread && m_cleanupThread->isRunning()) { + m_cleanupThread->quit(); + m_cleanupThread->wait(); + qDebug() << "WindowManager: cleanup thread stopped in close()"; + } + + // Close all windows first to ensure they cancel their tasks/connections + // Iterate over a copy because close() modifies m_windows + auto windows = m_windows; + for (const auto &window: windows) { window->close(); } + // Stop Tor manager threads + torManager()->stop(); + + // Wait for all threads in the global thread pool with timeout to prevent indefinite blocking + if (!QThreadPool::globalInstance()->waitForDone(15000)) { + qCritical() << "WindowManager: Thread pool tasks did not complete within 15s timeout. " + << "Forcing exit to prevent use-after-free."; + std::_Exit(1); // Fast exit without cleanup - threads may still hold resources + } + if (m_splashDialog) { m_splashDialog->deleteLater(); } @@ -106,8 +155,6 @@ void WindowManager::close() { m_docsDialog->deleteLater(); } - torManager()->stop(); - deleteLater(); qDebug() << "Calling QApplication::quit()"; @@ -117,6 +164,7 @@ void WindowManager::close() { void WindowManager::closeWindow(MainWindow *window) { qDebug() << "WindowManager: closing Window"; m_windows.removeOne(window); + this->buildTrayMenu(); // Move Wallet to a different thread for cleanup, so it doesn't block GUI thread window->m_wallet->moveToThread(m_cleanupThread); @@ -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(); diff --git a/src/WindowManager.h b/src/WindowManager.h index d0f3e460..54690e2e 100644 --- a/src/WindowManager.h +++ b/src/WindowManager.h @@ -113,6 +113,7 @@ private: bool m_openWalletTriedOnce = false; bool m_openingWallet = false; bool m_initialNetworkConfigured = false; + bool m_closing = false; QThread *m_cleanupThread; }; diff --git a/src/assets/feather.desktop b/src/assets/feather.desktop index 4f5737aa..9542b6a8 100644 --- a/src/assets/feather.desktop +++ b/src/assets/feather.desktop @@ -3,7 +3,7 @@ Type=Application Name=Feather Wallet GenericName=Monero Wallet Comment=A free Monero desktop wallet -Icon=feather +Icon=FeatherWallet Exec=feather Terminal=false Categories=Network; diff --git a/src/components.cpp b/src/components.cpp index e6663ce3..43949431 100644 --- a/src/components.cpp +++ b/src/components.cpp @@ -75,7 +75,10 @@ ClickableLabel::ClickableLabel(QWidget* parent, Qt::WindowFlags f) ClickableLabel::~ClickableLabel() = default; void ClickableLabel::mousePressEvent(QMouseEvent* event) { - emit clicked(); + if (event->button() == Qt::LeftButton) { + emit clicked(); + } + QLabel::mousePressEvent(event); } WindowModalDialog::WindowModalDialog(QWidget *parent) diff --git a/src/dialog/AboutDialog.cpp b/src/dialog/AboutDialog.cpp index bf8c7cf1..079b9cd4 100644 --- a/src/dialog/AboutDialog.cpp +++ b/src/dialog/AboutDialog.cpp @@ -29,6 +29,12 @@ AboutDialog::AboutDialog(QWidget *parent) ui->ackText->setText(ack_text); ui->label_featherVersion->setText(FEATHER_VERSION); +#ifdef FEATHER_BUILD_TAG + ui->label_buildTag->setText(FEATHER_BUILD_TAG); +#else + ui->label_buildTag->hide(); + ui->label_30->hide(); +#endif ui->label_moneroVersion->setText(MONERO_VERSION); ui->label_qtVersion->setText(QT_VERSION_STR); ui->label_torVersion->setText(TOR_VERSION); diff --git a/src/dialog/AboutDialog.ui b/src/dialog/AboutDialog.ui index 4cf20a25..f8ed38d8 100644 --- a/src/dialog/AboutDialog.ui +++ b/src/dialog/AboutDialog.ui @@ -234,55 +234,69 @@ + + + <html><head/><body><p><span style=" font-weight:700;">Build:</span></p></body></html> + + + + + + + TextLabel + + + + <html><head/><body><p><span style=" font-weight:700;">Monero:</span></p></body></html> - + TextLabel - + <html><head/><body><p><span style=" font-weight:700;">Qt:</span></p></body></html> - + TextLabel - + <html><head/><body><p><span style=" font-weight:700;">SSL:</span></p></body></html> - + TextLabel - + <html><head/><body><p><span style=" font-weight:700;">Tor:</span></p></body></html> - + TextLabel diff --git a/src/dialog/DebugInfoDialog.cpp b/src/dialog/DebugInfoDialog.cpp index 3d1ad59f..dd1d050b 100644 --- a/src/dialog/DebugInfoDialog.cpp +++ b/src/dialog/DebugInfoDialog.cpp @@ -57,7 +57,11 @@ void DebugInfoDialog::updateInfo() { auto node = m_nodes->connection(); ui->label_remoteNode->setText(node.toAddress()); - ui->label_walletStatus->setText(this->statusToString(m_wallet->connectionStatus())); + QString statusStr = this->statusToString(m_wallet->connectionStatus()); + if (conf()->get(Config::syncPaused).toBool()) { + statusStr += " (Paused)"; + } + ui->label_walletStatus->setText(statusStr); QString websocketStatus = Utils::QtEnumToString(websocketNotifier()->websocketClient->webSocket->state()).remove("State"); if (conf()->get(Config::disableWebsocket).toBool()) { websocketStatus = "Disabled"; diff --git a/src/dialog/PaymentRequestDialog.cpp b/src/dialog/PaymentRequestDialog.cpp index 9f4527de..f3b4f50b 100644 --- a/src/dialog/PaymentRequestDialog.cpp +++ b/src/dialog/PaymentRequestDialog.cpp @@ -118,7 +118,11 @@ void PaymentRequestDialog::saveImage() { } QFile file(filename); - file.open(QIODevice::WriteOnly); + if (!file.open(QIODevice::WriteOnly)) { + QMessageBox::warning(this, tr("Error"), tr("Could not save image to file: %1").arg(file.errorString())); + qWarning() << "Could not save image to file: " << file.errorString(); + return; + } m_qrCode->toPixmap(1).scaled(500, 500, Qt::KeepAspectRatio).save(&file, "PNG"); QMessageBox::information(this, "Information", "QR code saved to file"); } diff --git a/src/dialog/QrCodeDialog.cpp b/src/dialog/QrCodeDialog.cpp index 1710ae26..6025d6f4 100644 --- a/src/dialog/QrCodeDialog.cpp +++ b/src/dialog/QrCodeDialog.cpp @@ -40,7 +40,10 @@ void QrCodeDialog::saveImage() { } QFile file(filename); - file.open(QIODevice::WriteOnly); + if (!file.open(QIODevice::WriteOnly)) { + QMessageBox::critical(this, "Error", "Could not open file for writing."); + return; + } m_pixmap.save(&file, "PNG"); QMessageBox::information(this, "Information", "QR code saved to file"); } diff --git a/src/dialog/SyncRangeDialog.cpp b/src/dialog/SyncRangeDialog.cpp new file mode 100644 index 00000000..73966fa4 --- /dev/null +++ b/src/dialog/SyncRangeDialog.cpp @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: The Monero Project + +#include "SyncRangeDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils/Utils.h" +#include "utils/RestoreHeightLookup.h" + +SyncRangeDialog::SyncRangeDialog(QWidget *parent, Wallet *wallet) + : QDialog(parent) + , m_wallet(wallet) +{ + setWindowTitle(tr("Sync Date Range")); + setWindowIcon(QIcon(":/assets/images/appicons/64x64.png")); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + setWindowFlags(windowFlags() | Qt::MSWindowsFixedSizeDialogHint); + + auto *layout = new QVBoxLayout(this); + auto *formLayout = new QFormLayout; + formLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + + m_toDateEdit = new QDateEdit(QDate::currentDate()); + m_toDateEdit->setCalendarPopup(true); + m_toDateEdit->setDisplayFormat("yyyy-MM-dd"); + + int defaultDays = 7; + + // Preset durations dropdown + m_presetCombo = new QComboBox; + m_presetCombo->addItem(tr("1 day"), 1); + m_presetCombo->addItem(tr("7 days"), 7); + m_presetCombo->addItem(tr("30 days"), 30); + m_presetCombo->addItem(tr("90 days"), 90); + m_presetCombo->addItem(tr("1 year"), 365); + m_presetCombo->addItem(tr("Custom..."), -1); + m_presetCombo->setCurrentIndex(1); // Default to 7 days + + m_daysSpinBox = new QSpinBox; + m_daysSpinBox->setRange(1, 3650); // 10 years + m_daysSpinBox->setValue(defaultDays); + m_daysSpinBox->setSuffix(tr(" days")); + m_daysSpinBox->setVisible(false); // Hidden until "Custom..." is selected + + // Layout for preset + custom spinbox + auto *daysLayout = new QHBoxLayout; + daysLayout->setContentsMargins(0, 0, 0, 0); + daysLayout->addWidget(m_presetCombo, 1); + daysLayout->addWidget(m_daysSpinBox, 0); + + m_fromDateEdit = new QDateEdit(QDate::currentDate().addDays(-defaultDays)); + m_fromDateEdit->setCalendarPopup(true); + m_fromDateEdit->setDisplayFormat("yyyy-MM-dd"); + m_fromDateEdit->setToolTip(tr("Calculated from 'End date' and day span.")); + + 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::of(&QSpinBox::valueChanged), this, &SyncRangeDialog::updateFromDate); + + // Connect preset dropdown + connect(m_presetCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { + int days = m_presetCombo->itemData(index).toInt(); + if (days == -1) { + // Custom mode: show spinbox, keep current value + m_daysSpinBox->setVisible(true); + } else { + // Preset mode: hide spinbox, set value + m_daysSpinBox->setVisible(false); + m_daysSpinBox->setValue(days); + } + }); + + // Init info + updateInfo(); + + auto *btnBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(btnBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(btnBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + layout->addWidget(btnBox); + + resize(320, height()); +} + +QDate SyncRangeDialog::fromDate() const { + return m_fromDateEdit->date(); +} + +QDate SyncRangeDialog::toDate() const { + return m_toDateEdit->date(); +} + +quint64 SyncRangeDialog::estimatedBlocks() const { + return m_estimatedBlocks; +} + +quint64 SyncRangeDialog::estimatedSize() const { + return m_estimatedSize; +} + +void SyncRangeDialog::updateInfo() { + NetworkType::Type nettype = m_wallet->nettype(); + QString filename = Utils::getRestoreHeightFilename(nettype); + std::unique_ptr lookup(RestoreHeightLookup::fromFile(filename, nettype)); + if (!lookup || lookup->data.isEmpty()) { + m_infoLabel->setText(tr("Unable to estimate - restore height data unavailable")); + m_estimatedBlocks = 0; + m_estimatedSize = 0; + return; + } + + QDate start = m_fromDateEdit->date(); + QDate end = m_toDateEdit->date(); + + uint64_t startHeight = lookup->dateToHeight(start.startOfDay().toSecsSinceEpoch()); + uint64_t endHeight = lookup->dateToHeight(end.endOfDay().toSecsSinceEpoch()); + + if (endHeight < startHeight) endHeight = startHeight; + m_estimatedBlocks = endHeight - startHeight; + m_estimatedSize = Utils::estimateSyncDataSize(m_estimatedBlocks); + + m_infoLabel->setText(tr("Scanning ~%1 blocks\nEst. download size: %2") + .arg(m_estimatedBlocks) + .arg(Utils::formatBytes(m_estimatedSize))); +} + +void SyncRangeDialog::updateFromDate() { + m_fromDateEdit->setDate(m_toDateEdit->date().addDays(-m_daysSpinBox->value())); + updateInfo(); +} diff --git a/src/dialog/SyncRangeDialog.h b/src/dialog/SyncRangeDialog.h new file mode 100644 index 00000000..baa9294d --- /dev/null +++ b/src/dialog/SyncRangeDialog.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: The Monero Project + +#ifndef FEATHER_SYNCRANGEDIALOG_H +#define FEATHER_SYNCRANGEDIALOG_H + +#include +#include + +#include "libwalletqt/Wallet.h" + +class QComboBox; +class QSpinBox; +class QDateEdit; +class QLabel; + +class SyncRangeDialog : public QDialog +{ +Q_OBJECT + +public: + explicit SyncRangeDialog(QWidget *parent, Wallet *wallet); + ~SyncRangeDialog() override = default; + + QDate fromDate() const; + QDate toDate() const; + quint64 estimatedBlocks() const; + quint64 estimatedSize() const; + +private: + void updateInfo(); + void updateFromDate(); + + 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 diff --git a/src/dialog/TxImportDialog.cpp b/src/dialog/TxImportDialog.cpp index 1473311f..a2e49deb 100644 --- a/src/dialog/TxImportDialog.cpp +++ b/src/dialog/TxImportDialog.cpp @@ -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() { diff --git a/src/libwalletqt/Wallet.cpp b/src/libwalletqt/Wallet.cpp index c26e40d9..436be139 100644 --- a/src/libwalletqt/Wallet.cpp +++ b/src/libwalletqt/Wallet.cpp @@ -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::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::steady_clock::now()); // get daemonHeight and targetHeight // daemonHeight and targetHeight will be 0 if call to get_info fails quint64 daemonHeight = m_walletImpl->daemonBlockChainHeight(); bool success = daemonHeight > 0; + if (success) { + m_lastRefreshTime = loopStartTime.time_since_epoch().count(); + last = loopStartTime; + } else { + // If sync failed, retry according to the interval (respects Data Saving) + auto retryDelay = std::chrono::seconds(m_refreshInterval); + qCritical() << "Refresher: Sync failed. Retry delay set to:" << retryDelay.count(); + auto nextTime = loopStartTime - std::chrono::seconds(m_refreshInterval) + retryDelay; + m_lastRefreshTime = nextTime.time_since_epoch().count(); + last = nextTime; + } + + qDebug() << "Refresher: Interval met. Elapsed:" << std::chrono::duration_cast(elapsed).count() + << "Interval:" << m_refreshInterval << "RefreshNow:" << m_refreshNow; + + quint64 targetHeight = 0; if (success) { targetHeight = m_walletImpl->daemonBlockChainTargetHeight(); @@ -475,6 +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::steady_clock::now().time_since_epoch()).count(); + auto elapsed = std::chrono::microseconds(now - m_lastRefreshTime.load()); + auto interval = std::chrono::seconds(m_refreshInterval); + + if (elapsed >= interval) { + return 0; + } + + return std::chrono::duration_cast(interval - elapsed).count(); +} + quint64 Wallet::daemonBlockChainHeight() const { return m_daemonBlockChainHeight; } @@ -535,16 +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(nettype)); + + std::unique_ptr lookup(RestoreHeightLookup::fromFile(filename, static_cast(nettype))); + uint64_t startHeight = lookup->dateToHeight(start.startOfDay().toSecsSinceEpoch()); + uint64_t endHeight = lookup->dateToHeight(end.startOfDay().toSecsSinceEpoch()); + + if (startHeight >= endHeight) + return; + + { + QMutexLocker locker(&m_asyncMutex); + m_stopHeight = endHeight; + m_rangeSyncActive = true; + m_wallet2->set_refresh_from_block_height(startHeight); + } + 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 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 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(); diff --git a/src/libwalletqt/Wallet.h b/src/libwalletqt/Wallet.h index 20d21af4..366dd885 100644 --- a/src/libwalletqt/Wallet.h +++ b/src/libwalletqt/Wallet.h @@ -15,6 +15,7 @@ #include "rows/TxBacklogEntry.h" #include +#include 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 m_refreshInterval{10}; + QMutex m_asyncMutex; QString m_daemonUsername; QString m_daemonPassword; @@ -529,6 +540,12 @@ private: QTimer *m_storeTimer = nullptr; std::set m_selectedInputs; + + std::atomic m_stopHeight{0}; + std::atomic m_rangeSyncActive{false}; + std::atomic m_syncPaused{false}; + std::atomic m_lastRefreshTime{0}; }; #endif // FEATHER_WALLET_H + diff --git a/src/main.cpp b/src/main.cpp index 495479de..4b57478e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,9 @@ // SPDX-FileCopyrightText: The Monero Project #include +#include +#include +#include #include "Application.h" #include "constants.h" @@ -83,11 +86,33 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::Round); #endif + for (int i = 1; i < argc; i++) { + if (QString(argv[i]) == "--version" || QString(argv[i]) == "-v") { +#ifdef FEATHER_BUILD_TAG + QString buildTag = QString(FEATHER_BUILD_TAG).replace("\"", ""); + QString versionTag = QString(FEATHER_VERSION) + "-"; + if (buildTag.startsWith(versionTag)) { + buildTag.remove(0, versionTag.length()); + } + std::cout << "Feather Wallet " << FEATHER_VERSION << " (build " << buildTag.toStdString() << ")" << std::endl; +#else + std::cout << "Feather Wallet " << FEATHER_VERSION << std::endl; +#endif + return 0; + } + } + Application app(argc, argv); QApplication::setApplicationName("FeatherWallet"); QApplication::setApplicationVersion(FEATHER_VERSION); +#if defined(Q_OS_LINUX) + QGuiApplication::setDesktopFileName("feather"); +#endif + + QApplication::setWindowIcon(QIcon(":/assets/images/appicons/64x64.png")); + QCommandLineParser parser; parser.setApplicationDescription("Feather - a free Monero desktop wallet"); QCommandLineOption helpOption = parser.addHelpOption(); @@ -146,9 +171,11 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { } // Setup logging - QString logPath = QString("%1/libwallet.log").arg(configDir); + QString logPath = conf()->get(Config::disableLogging).toBool() ? "" : QString("%1/libwallet.log").arg(configDir); + bool consoleLogging = !conf()->get(Config::disableLoggingStdout).toBool(); + Monero::Utils::onStartup(); - Monero::Wallet::init("", "feather", logPath.toStdString(), true); + Monero::Wallet::init("", "feather", logPath.toStdString(), consoleLogging); bool logLevelFromEnv; int logLevel = qEnvironmentVariableIntValue("MONERO_LOG_LEVEL", &logLevelFromEnv); @@ -158,8 +185,13 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { logLevel = conf()->get(Config::logLevel).toInt(); } - if (parser.isSet("quiet") || conf()->get(Config::disableLogging).toBool()) { - qWarning() << "Logging is disabled"; + bool disableEverything = parser.isSet("quiet") || (conf()->get(Config::disableLogging).toBool() && conf()->get(Config::disableLoggingStdout).toBool()); + if (disableEverything) { + if (parser.isSet("quiet")) { + qWarning() << "Logging is disabled via --quiet flag"; + } else { + qWarning() << "Logging is disabled via configuration"; + } WalletManager::instance()->setLogLevel(-1); } else if (logLevel >= 0 && logLevel <= Monero::WalletManagerFactory::LogLevel_Max) { @@ -180,7 +212,7 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { conf()->set(Config::restartRequired, false); - if (!quiet) { + if (!quiet && !conf()->get(Config::disableLogging).toBool()) { QList> info; info.emplace_back("Feather", FEATHER_VERSION); info.emplace_back("Monero", MONERO_VERSION); @@ -188,6 +220,8 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { info.emplace_back("Tor", TOR_VERSION); info.emplace_back("SSL", QSslSocket::sslLibraryVersionString()); info.emplace_back("Mode", stagenet ? "Stagenet" : (testnet ? "Testnet" : "Mainnet")); + info.emplace_back("Network", conf()->get(Config::syncPaused).toBool() ? "PAUSED" : "ACTIVE"); + info.emplace_back("Config dir", configDir); for (const auto &k: info) { qWarning().nospace().noquote() << QString("%1: %2").arg(k.first, k.second); @@ -219,6 +253,6 @@ if (AttachConsole(ATTACH_PARENT_PROCESS)) { wm->setEventFilter(&filter); int exitCode = Application::exec(); - qDebug() << "Application::exec() returned"; + qInstallMessageHandler(nullptr); return exitCode; } diff --git a/src/model/AddressBookProxyModel.h b/src/model/AddressBookProxyModel.h index 4ea5565a..3de09274 100644 --- a/src/model/AddressBookProxyModel.h +++ b/src/model/AddressBookProxyModel.h @@ -17,8 +17,14 @@ public: public slots: void setSearchFilter(const QString& searchString){ +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); + m_searchRegExp.setPattern(searchString); + endFilterChange(); +#else m_searchRegExp.setPattern(searchString); invalidateFilter(); +#endif } private: diff --git a/src/model/CoinsProxyModel.cpp b/src/model/CoinsProxyModel.cpp index 193f4280..bd9bfc45 100644 --- a/src/model/CoinsProxyModel.cpp +++ b/src/model/CoinsProxyModel.cpp @@ -4,6 +4,7 @@ #include "CoinsProxyModel.h" #include "CoinsModel.h" #include "libwalletqt/rows/CoinsInfo.h" +#include CoinsProxyModel::CoinsProxyModel(QObject *parent, Coins *coins) : QSortFilterProxyModel(parent) @@ -15,13 +16,25 @@ CoinsProxyModel::CoinsProxyModel(QObject *parent, Coins *coins) } void CoinsProxyModel::setShowSpent(const bool showSpent) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); + m_showSpent = showSpent; + endFilterChange(); +#else m_showSpent = showSpent; invalidateFilter(); +#endif } void CoinsProxyModel::setSearchFilter(const QString &searchString) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); + m_searchRegExp.setPattern(searchString); + endFilterChange(); +#else m_searchRegExp.setPattern(searchString); invalidateFilter(); +#endif } bool CoinsProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const diff --git a/src/model/HistoryView.cpp b/src/model/HistoryView.cpp index c9820971..eb10219e 100644 --- a/src/model/HistoryView.cpp +++ b/src/model/HistoryView.cpp @@ -115,8 +115,8 @@ void HistoryView::showHeaderMenu(const QPoint& position) { const QList actions = m_columnActions->actions(); for (auto& action : actions) { - Q_ASSERT(static_cast(action->data().type()) == QMetaType::Int); - if (static_cast(action->data().type()) != QMetaType::Int) { + Q_ASSERT(static_cast(action->data().typeId()) == QMetaType::Int); + if (static_cast(action->data().typeId()) != QMetaType::Int) { continue; } int columnIndex = action->data().toInt(); @@ -131,8 +131,8 @@ void HistoryView::toggleColumnVisibility(QAction* action) // Verify action carries a column index as data. Since QVariant.toInt() // below will accept anything that's interpretable as int, perform a type // check here to make sure data actually IS int - Q_ASSERT(static_cast(action->data().type()) == QMetaType::Int); - if (static_cast(action->data().type()) != QMetaType::Int) { + Q_ASSERT(static_cast(action->data().typeId()) == QMetaType::Int); + if (static_cast(action->data().typeId()) != QMetaType::Int) { return; } diff --git a/src/model/SubaddressProxyModel.h b/src/model/SubaddressProxyModel.h index 38069ed1..c54ff1bd 100644 --- a/src/model/SubaddressProxyModel.h +++ b/src/model/SubaddressProxyModel.h @@ -18,9 +18,16 @@ public: public slots: void setSearchFilter(const QString& searchString){ +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); + m_searchRegExp.setPattern(searchString); + m_searchCaseSensitiveRegExp.setPattern(searchString); + endFilterChange(); +#else m_searchRegExp.setPattern(searchString); m_searchCaseSensitiveRegExp.setPattern(searchString); invalidateFilter(); +#endif } private: diff --git a/src/model/TransactionHistoryProxyModel.h b/src/model/TransactionHistoryProxyModel.h index 8217efbe..f10f03af 100644 --- a/src/model/TransactionHistoryProxyModel.h +++ b/src/model/TransactionHistoryProxyModel.h @@ -19,8 +19,14 @@ public: public slots: void setSearchFilter(const QString& searchString){ +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); + m_searchRegExp.setPattern(searchString); + endFilterChange(); +#else m_searchRegExp.setPattern(searchString); invalidateFilter(); +#endif } private: diff --git a/src/plugins/crowdfunding/CCSProgressDelegate.cpp b/src/plugins/crowdfunding/CCSProgressDelegate.cpp index c915d24d..021b36ea 100644 --- a/src/plugins/crowdfunding/CCSProgressDelegate.cpp +++ b/src/plugins/crowdfunding/CCSProgressDelegate.cpp @@ -23,7 +23,7 @@ void CCSProgressDelegate::paint(QPainter *painter, const QStyleOptionViewItem &o progressBarOption.state = QStyle::State_Enabled; progressBarOption.direction = QApplication::layoutDirection(); progressBarOption.rect = option.rect; - progressBarOption.fontMetrics = QApplication::fontMetrics(); + progressBarOption.fontMetrics = QFontMetrics(qApp->font()); progressBarOption.minimum = 0; progressBarOption.maximum = 100; progressBarOption.textAlignment = Qt::AlignCenter; diff --git a/src/plugins/tickers/TickersWidget.cpp b/src/plugins/tickers/TickersWidget.cpp index e55df61b..b9b3a138 100644 --- a/src/plugins/tickers/TickersWidget.cpp +++ b/src/plugins/tickers/TickersWidget.cpp @@ -23,6 +23,7 @@ TickersWidget::TickersWidget(QWidget *parent, Wallet *wallet) this->setup(); } }); + connect(windowManager(), &WindowManager::websocketStatusChanged, this, &TickersWidget::updateDisplay); this->updateBalance(); } diff --git a/src/utils/Utils.cpp b/src/utils/Utils.cpp index b468fed3..d9e42776 100644 --- a/src/utils/Utils.cpp +++ b/src/utils/Utils.cpp @@ -26,6 +26,7 @@ #include "utils/os/tails.h" #include "utils/os/whonix.h" #include "libwalletqt/Wallet.h" +#include "utils/nodes.h" #include "WindowManager.h" namespace Utils { @@ -558,6 +559,20 @@ QTextCharFormat addressTextFormat(const SubaddressIndex &index, quint64 amount) } void applicationLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) { + if (conf()->get(Config::disableLoggingStdout).toBool()) + return; + + int level = conf()->get(Config::logLevel).toInt(); + + // Mapping: + // 0: Critical/Fatal [always reported under below scheme] + // 1: + Warning + // 2: + Info + // 3+: + Debug + if (level < 3 && type == QtDebugMsg) return; + if (level < 2 && type == QtInfoMsg) return; + if (level < 1 && type == QtWarningMsg) return; + const QString fn = context.function ? QString::fromUtf8(context.function) : ""; const QString date = QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss"); QString line; @@ -604,6 +619,32 @@ QFont relativeFont(int delta) { return font; } +QString timeAgo(const QDateTime &dt) { + qint64 diff = dt.secsTo(QDateTime::currentDateTime()); + + if (diff < 0) return "in the future"; + if (diff < 60) return QString("%1 second%2 ago").arg(diff).arg(diff == 1 ? "" : "s"); + + diff /= 60; // minutes + if (diff < 60) return QString("%1 minute%2 ago").arg(diff).arg(diff == 1 ? "" : "s"); + + qint64 minutes = diff % 60; + diff /= 60; // hours + if (diff < 24) { + if (minutes > 0) + return QString("%1 hour%2 %3 minute%4 ago").arg(diff).arg(diff == 1 ? "" : "s").arg(minutes).arg(minutes == 1 ? "" : "s"); + return QString("%1 hour%2 ago").arg(diff).arg(diff == 1 ? "" : "s"); + } + + diff /= 24; // days + if (diff < 30) return QString("%1 day%2 ago").arg(diff).arg(diff == 1 ? "" : "s"); + + diff /= 30; // months (approx) + if (diff < 12) return QString("%1 month%2 ago").arg(diff).arg(diff == 1 ? "" : "s"); + + return QString("%1 year%2 ago").arg(diff / 12).arg((diff / 12) == 1 ? "" : "s"); +} + bool isLocalUrl(const QUrl &url) { QRegularExpression localNetwork(R"((^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.))"); return (localNetwork.match(url.host()).hasMatch() || url.host() == "localhost"); @@ -702,6 +743,10 @@ void clearLayout(QLayout* layout, bool deleteWidgets) } } +quint64 blocksBehind(quint64 height, quint64 target) { + return (target > height) ? (target - height) : 0; +} + QString formatSyncStatus(quint64 height, quint64 target, bool daemonSync) { if (height < (target - 1)) { QString blocks = (target >= height) ? QString::number(target - height) : "?"; @@ -712,6 +757,26 @@ QString formatSyncStatus(quint64 height, quint64 target, bool daemonSync) { return "Synchronized"; } +QString formatSyncTimeEstimate(quint64 blocks) { + quint64 minutes = blocks * 2; + quint64 days = minutes / (60 * 24); + + QString timeStr; + if (days > 0) { + timeStr = QObject::tr("~%1 days").arg(days); + } else if (minutes >= 60) { + timeStr = QObject::tr("~%1 hours").arg(minutes / 60); + } else { + timeStr = QObject::tr("< 1 hour"); + } + return timeStr; +} + +quint64 estimateSyncDataSize(quint64 blocks) { + // Estimate 30KB per block for wallet scanning. + return blocks * 30 * 1024; +} + QString formatRestoreHeight(quint64 height) { const QDateTime restoreDate = appData()->restoreHeights[constants::networkType]->heightToDate(height); return QString("%1 (%2)").arg(QString::number(height), restoreDate.toString("yyyy-MM-dd")); @@ -724,4 +789,13 @@ QString getVersion() { #endif return version; } + +QString getRestoreHeightFilename(NetworkType::Type nettype) { + if (nettype == NetworkType::MAINNET) + return ":/assets/restore_heights_monero_mainnet.txt"; + else if (nettype == NetworkType::TESTNET) + return ":/assets/restore_heights_monero_testnet.txt"; + else + return ":/assets/restore_heights_monero_stagenet.txt"; +} } diff --git a/src/utils/Utils.h b/src/utils/Utils.h index 22bba27f..f4aa6118 100644 --- a/src/utils/Utils.h +++ b/src/utils/Utils.h @@ -14,6 +14,8 @@ #include "networktype.h" class SubaddressIndex; +class Wallet; +class Nodes; namespace Utils { @@ -96,6 +98,7 @@ namespace Utils QFont getMonospaceFont(); QFont relativeFont(int delta); + QString timeAgo(const QDateTime &dt); void applicationLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg); QString barrayToString(const QByteArray &data); @@ -118,10 +121,14 @@ namespace Utils QWindow* windowForQObject(QObject* object); void clearLayout(QLayout *layout, bool deleteWidgets = true); + quint64 blocksBehind(quint64 height, quint64 target); QString formatSyncStatus(quint64 height, quint64 target, bool daemonSync = false); + QString formatSyncTimeEstimate(quint64 blocks); + quint64 estimateSyncDataSize(quint64 blocks); QString formatRestoreHeight(quint64 height); QString getVersion(); + QString getRestoreHeightFilename(NetworkType::Type nettype); } #endif //FEATHER_UTILS_H diff --git a/src/utils/WebsocketClient.cpp b/src/utils/WebsocketClient.cpp index 815666bf..a894e177 100644 --- a/src/utils/WebsocketClient.cpp +++ b/src/utils/WebsocketClient.cpp @@ -20,7 +20,13 @@ WebsocketClient::WebsocketClient(QObject *parent) connect(webSocket, &QWebSocket::stateChanged, this, &WebsocketClient::onStateChanged); connect(webSocket, &QWebSocket::connected, this, &WebsocketClient::onConnected); connect(webSocket, &QWebSocket::disconnected, this, &WebsocketClient::onDisconnected); + // one liner: for both Qt 6.4 (older) and Qt 6.5+ (newer) + // connect(webSocket, QOverload::of(&QWebSocket::error), this, &WebsocketClient::onError); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + connect(webSocket, &QWebSocket::errorOccurred, this, &WebsocketClient::onError); +#else connect(webSocket, QOverload::of(&QWebSocket::error), this, &WebsocketClient::onError); +#endif connect(webSocket, &QWebSocket::binaryMessageReceived, this, &WebsocketClient::onbinaryMessageReceived); @@ -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(); } diff --git a/src/utils/config.cpp b/src/utils/config.cpp index b5baa886..bfb5c421 100644 --- a/src/utils/config.cpp +++ b/src/utils/config.cpp @@ -74,9 +74,17 @@ static const QHash configStrings = { {Config::inactivityLockTimeout, {QS("inactivityLockTimeout"), 10}}, {Config::lockOnMinimize, {QS("lockOnMinimize"), false}}, {Config::showTrayIcon, {QS("showTrayIcon"), true}}, - {Config::minimizeToTray, {QS("minimizeToTray"), false}}, + {Config::minimizeToTray, {QS("minimizeToTray"), true}}, + {Config::trayLeftClickTogglesFocus, {QS("trayLeftClickTogglesFocus"), true}}, {Config::disableWebsocket, {QS("disableWebsocket"), false}}, + {Config::disableAutoRefresh, {QS("disableAutoRefresh"), false}}, {Config::offlineMode, {QS("offlineMode"), false}}, + {Config::syncPaused, {QS("syncPaused"), false}}, + {Config::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 configStrings = { {Config::hideNotifications, {QS("hideNotifications"), false}}, {Config::hideUpdateNotifications, {QS("hideUpdateNotifications"), false}}, {Config::disableLogging, {QS("disableLogging"), true}}, + {Config::disableLoggingStdout, {QS("disableLoggingStdout"), false}}, {Config::writeStackTraceToDisk, {QS("writeStackTraceToDisk"), true}}, {Config::writeRecentlyOpenedWallets, {QS("writeRecentlyOpenedWallets"), true}}, diff --git a/src/utils/config.h b/src/utils/config.h index 04d9d759..f86d6b7d 100644 --- a/src/utils/config.h +++ b/src/utils/config.h @@ -88,11 +88,13 @@ public: disableWebsocket, // Network -> Offline + disableAutoRefresh, offlineMode, // Storage -> Logging writeStackTraceToDisk, disableLogging, + disableLoggingStdout, logLevel, // Storage -> Misc @@ -108,6 +110,7 @@ public: lockOnMinimize, showTrayIcon, minimizeToTray, + trayLeftClickTogglesFocus, // Transactions multiBroadcast, @@ -139,6 +142,14 @@ public: // Tickers tickers, tickersShowFiatBalance, + + // Sync & data saver + syncPaused, + syncPausedAlsoDisconnectWebSocket, + syncInterval, + lastKnownNetworkHeight, + lastSyncTimestamp, + lastPriceUpdateTimestamp, }; enum PrivacyLevel { diff --git a/src/utils/nodes.cpp b/src/utils/nodes.cpp index d9702f2d..26c16647 100644 --- a/src/utils/nodes.cpp +++ b/src/utils/nodes.cpp @@ -109,6 +109,7 @@ Nodes::Nodes(QObject *parent, Wallet *wallet) if (m_wallet) { connect(m_wallet, &Wallet::walletRefreshed, this, &Nodes::onWalletRefreshed); + connect(m_wallet, &Wallet::connectionStatusChanged, this, &Nodes::onConnectionStatusChanged); } } @@ -238,6 +239,7 @@ void Nodes::connectToNode(const FeatherNode &node) { } } + qInfo() << "Nodes::connectToNode calling initAsync with:" << node.toAddress(); m_wallet->initAsync(node.toAddress(), true, 0, proxyAddress); m_connection = node; @@ -248,6 +250,20 @@ void Nodes::connectToNode(const FeatherNode &node) { this->updateModels(); } +void Nodes::disconnectCurrentNode() { + if (!m_wallet) return; + + // Stop any ongoing connection attempt + m_connection.isActive = false; + m_connection.isConnecting = false; + + // Connect to empty "node" effectively disconnects + m_wallet->initAsync("", false, 0); + + this->resetLocalState(); + this->updateModels(); +} + void Nodes::autoConnect(bool forceReconnect) { if (!m_wallet) { return; @@ -261,12 +277,20 @@ void Nodes::autoConnect(bool forceReconnect) { return; } + if (conf()->get(Config::syncPaused).toBool()) { + return; + } + // this function is responsible for automatically connecting to a daemon. if (m_wallet == nullptr || !m_enableAutoconnect) { return; } Wallet::ConnectionStatus status = m_wallet->connectionStatus(); + if (status == Wallet::ConnectionStatus_Connecting && !forceReconnect) { + return; + } + bool wsMode = (this->source() == NodeSource::websocket); if (wsMode && !m_wsNodesReceived && websocketNodes().count() == 0) { @@ -276,7 +300,17 @@ void Nodes::autoConnect(bool forceReconnect) { } if (status == Wallet::ConnectionStatus_Disconnected || forceReconnect) { + // If we had a working connection and it dropped (transient disconnect), + // try reconnecting to the same node instead of picking a new one + if (m_connection.isValid() && m_connection.isActive && !forceReconnect) { + qDebug() << "Transient disconnect, reconnecting to same node:" << m_connection.toAddress(); + this->connectToNode(m_connection); + return; + } + + // Otherwise, mark the failed node and pick a new one if (m_connection.isValid() && !forceReconnect) { + qInfo() << "Marking node as failed:" << m_connection.toAddress(); m_recentFailures << m_connection.toAddress(); } @@ -416,6 +450,10 @@ void Nodes::setCustomNodes(const QList &nodes) { void Nodes::onWalletRefreshed() { if (conf()->get(Config::proxy) == Config::Proxy::Tor && conf()->get(Config::torPrivacyLevel).toInt() == Config::allTorExceptInitSync) { + // Privacy switch already triggered this session, don't repeat + if (m_privacySwitchDone) + return; + // Don't reconnect if we're connected to a local node (traffic will not be routed through Tor) if (m_connection.isLocal()) return; @@ -424,7 +462,12 @@ void Nodes::onWalletRefreshed() { if (m_connection.isOnion()) return; - this->autoConnect(true); + // If want onion node but aren't connected to one, trigger the switch + if (this->useOnionNodes()) { + qInfo() << "Privacy switch: switching from clearnet to onion after initial sync"; + m_privacySwitchDone = true; + this->autoConnect(true); + } } } @@ -494,6 +537,11 @@ bool Nodes::useSocks5Proxy(const FeatherNode &node) { } if (config_proxy == Config::Proxy::Tor) { + // Always use proxy for onion addresses + if (node.isOnion()) { + return true; + } + // Don't use socks5 proxy if initial sync traffic is excluded. return this->useOnionNodes(); } @@ -525,7 +573,7 @@ void Nodes::resetLocalState() { } void Nodes::exhausted() { - // Do nothing + // Do nothing (matches upstream behavior) } QList Nodes::nodes() { @@ -605,6 +653,19 @@ int Nodes::modeHeight(const QList &nodes) { return mode_height; } +void Nodes::onConnectionStatusChanged(int status) { + if (status == Wallet::ConnectionStatus_Disconnected) { + if (!conf()->get(Config::syncPaused).toBool()) { + qInfo() << "Nodes: Wallet disconnected unexpectedly, triggering auto-connect to new node."; + // Fix J: Mark connection as inactive so autoConnect treats it as a failure, not a transient disconnect + m_connection.isActive = false; + this->autoConnect(false); + } + } +} + + + void Nodes::allowConnection() { m_allowConnection = true; } diff --git a/src/utils/nodes.h b/src/utils/nodes.h index 8bbf8b3a..018fb779 100644 --- a/src/utils/nodes.h +++ b/src/utils/nodes.h @@ -167,6 +167,7 @@ public: public slots: void connectToNode(); void connectToNode(const FeatherNode &node); + void disconnectCurrentNode(); void onWSNodesReceived(QList& nodes); void onNodeSourceChanged(NodeSource nodeSource); void setCustomNodes(const QList& nodes); @@ -174,6 +175,7 @@ public slots: private slots: void onWalletRefreshed(); + void onConnectionStatusChanged(int status); private: Wallet *m_wallet = nullptr; @@ -192,6 +194,7 @@ private: bool m_enableAutoconnect = true; bool m_allowConnection = false; + bool m_privacySwitchDone = false; // Tracks if allTorExceptInitSync switch has fired FeatherNode pickEligibleNode(); diff --git a/src/utils/prices.cpp b/src/utils/prices.cpp index 10ac77f1..635ed3af 100644 --- a/src/utils/prices.cpp +++ b/src/utils/prices.cpp @@ -12,6 +12,10 @@ Prices::Prices(QObject *parent) : QObject(parent) { + qint64 lastUpdate = conf()->get(Config::lastPriceUpdateTimestamp).toLongLong(); + if (lastUpdate > 0) { + this->lastUpdateTime = QDateTime::fromSecsSinceEpoch(lastUpdate); + } } void Prices::cryptoPricesReceived(const QJsonArray &data) { @@ -31,13 +35,15 @@ void Prices::cryptoPricesReceived(const QJsonArray &data) { this->markets.insert(ms.symbol.toUpper(), ms); } + this->lastUpdateTime = QDateTime::currentDateTime(); + conf()->set(Config::lastPriceUpdateTimestamp, this->lastUpdateTime.toSecsSinceEpoch()); emit cryptoPricesUpdated(); } void Prices::fiatPricesReceived(const QJsonObject &data) { QJsonObject ratesData = data.value("rates").toObject(); for (const auto ¤cy : ratesData.keys()) { - this->rates.insert(currency, ratesData.value(currency).toDouble()); + this->rates.insert(currency.toUpper(), ratesData.value(currency).toDouble()); } emit fiatPricesUpdated(); } diff --git a/src/utils/prices.h b/src/utils/prices.h index 8972c0e9..afee8b61 100644 --- a/src/utils/prices.h +++ b/src/utils/prices.h @@ -5,6 +5,7 @@ #define FEATHER_PRICES_H #include +#include #include "utils/Utils.h" @@ -24,6 +25,7 @@ public: explicit Prices(QObject *parent = nullptr); QMap rates; QMap markets; + QDateTime lastUpdateTime; public slots: void cryptoPricesReceived(const QJsonArray &data); diff --git a/src/widgets/NodeWidget.cpp b/src/widgets/NodeWidget.cpp index b2c94e77..49b2a6b4 100644 --- a/src/widgets/NodeWidget.cpp +++ b/src/widgets/NodeWidget.cpp @@ -3,6 +3,7 @@ #include "NodeWidget.h" #include "ui_NodeWidget.h" +#include #include #include @@ -21,8 +22,13 @@ NodeWidget::NodeWidget(QWidget *parent) connect(ui->btn_addCustomNodes, &QPushButton::clicked, this, &NodeWidget::onCustomAddClicked); - connect(ui->checkBox_websocketList, &QCheckBox::stateChanged, [this](int id){ - bool custom = (id == 0); +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + connect(ui->checkBox_websocketList, &QCheckBox::checkStateChanged, [this](Qt::CheckState state){ + bool custom = (state == Qt::Unchecked); +#else + connect(ui->checkBox_websocketList, &QCheckBox::stateChanged, [this](int state){ + bool custom = (state == Qt::Unchecked); +#endif ui->stackedWidget->setCurrentIndex(custom); ui->frame_addCustomNodes->setVisible(custom); conf()->set(Config::nodeSource, custom); diff --git a/src/widgets/PayToEdit.h b/src/widgets/PayToEdit.h index d8916496..2e9286bb 100644 --- a/src/widgets/PayToEdit.h +++ b/src/widgets/PayToEdit.h @@ -24,8 +24,8 @@ struct PayToLineError { QString lineContent; QString error; - int idx; - bool isMultiline; + int idx = 0; + bool isMultiline = false; }; class PayToEdit : public QPlainTextEdit diff --git a/src/widgets/TickerWidget.cpp b/src/widgets/TickerWidget.cpp index 75b5cfe6..11403b66 100644 --- a/src/widgets/TickerWidget.cpp +++ b/src/widgets/TickerWidget.cpp @@ -66,17 +66,35 @@ BalanceTickerWidget::BalanceTickerWidget(QWidget *parent, Wallet *wallet, bool t this->setPercentageVisible(false); connect(m_wallet, &Wallet::balanceUpdated, this, &BalanceTickerWidget::updateDisplay); + connect(m_wallet, &Wallet::connectionStatusChanged, this, &BalanceTickerWidget::updateDisplay); connect(&appData()->prices, &Prices::fiatPricesUpdated, this, &BalanceTickerWidget::updateDisplay); connect(&appData()->prices, &Prices::cryptoPricesUpdated, this, &BalanceTickerWidget::updateDisplay); } void BalanceTickerWidget::updateDisplay() { - double balance = (m_totalBalance ? m_wallet->balanceAll() : m_wallet->balance()) / constants::cdiv; + double balance = (m_totalBalance ? m_wallet->balanceAll() : m_wallet->balance()); + double balanceAmount = balance / constants::cdiv; QString fiatCurrency = conf()->get(Config::preferredFiatCurrency).toString(); - double balanceFiatAmount = appData()->prices.convert("XMR", fiatCurrency, balance); - if (balanceFiatAmount < 0) - return; - this->setFiatText(balanceFiatAmount, fiatCurrency); + double balanceFiatAmount = appData()->prices.convert("XMR", fiatCurrency, balanceAmount); + + bool isCacheValid = appData()->prices.lastUpdateTime.isValid(); + bool isCacheFresh = isCacheValid && appData()->prices.lastUpdateTime.secsTo(QDateTime::currentDateTime()) < 3600; + + bool hasXmrPrice = appData()->prices.markets.contains("XMR"); + bool hasFiatRate = fiatCurrency == "USD" || appData()->prices.rates.contains(fiatCurrency); + + if (balance > 0 && (balanceFiatAmount == 0.0 || !isCacheValid)) { + if (conf()->get(Config::offlineMode).toBool() || conf()->get(Config::disableWebsocket).toBool() || m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) { + this->setDisplayText("offline"); + } else if (!hasXmrPrice || !hasFiatRate) { + this->setDisplayText("connecting"); + } else { + this->setDisplayText("unknown"); + } + } else { + QString approx = isCacheFresh ? "" : "~ "; + this->setDisplayText(approx + Utils::amountToCurrencyString(balanceFiatAmount, fiatCurrency)); + } } // PriceTickerWidget -- 2.52.0