#include <QFileDialog>
#include <QInputDialog>
#include <QMessageBox>
+#include <QClipboard>
+#include <QLocale>
+#include <QHBoxLayout>
#include <QCheckBox>
+#include <QFormLayout>
+#include <QSpinBox>
+#include <QDateEdit>
+#include <QComboBox>
+#include <QDialogButtonBox>
#include "constants.h"
#include "dialog/AddressCheckerIndexDialog.h"
#include "dialog/ViewOnlyDialog.h"
#include "dialog/WalletInfoDialog.h"
#include "dialog/WalletCacheDebugDialog.h"
+#include "dialog/SyncRangeDialog.h"
#include "libwalletqt/AddressBook.h"
#include "libwalletqt/rows/CoinsInfo.h"
#include "libwalletqt/rows/Output.h"
#include "libwalletqt/TransactionHistory.h"
#include "model/AddressBookModel.h"
#include "plugins/PluginRegistry.h"
+#include "plugins/Plugin.h"
#include "utils/AppData.h"
#include "utils/AsyncTask.h"
#include "utils/ColorScheme.h"
#include "utils/Icons.h"
+#include "utils/RestoreHeightLookup.h"
#include "utils/TorManager.h"
#include "utils/WebsocketNotifier.h"
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);
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);
// 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);
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);
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);
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() {
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);
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);
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);
}
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();
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();
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()) {
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;
m_statusText = text;
if (!m_statusOverrideActive && !m_constructingTransaction) {
+ // qDebug() << "STATUS:" << text;
m_statusLabelStatus->setText(text);
}
}
}
void MainWindow::onWebsocketStatusChanged(bool enabled) {
+ if (m_actionEnableWebsocket) {
+ m_actionEnableWebsocket->setChecked(enabled);
+ }
ui->actionShow_Home->setVisible(enabled);
QStringList enabledTabs = conf()->get(Config::enabledTabs).toStringList();
}
void MainWindow::onSyncStatus(quint64 height, quint64 target, bool daemonSync) {
- if (height >= (target - 1)) {
+ // qDebug() << "onSyncStatus: Height" << height << "Target" << target << "DaemonSync" << daemonSync;
+
+ quint64 blocksBehind = Utils::blocksBehind(height, target);
+
+ // Throttle UI updates to 10Hz to prevent spam during sync
+ static QDateTime lastThrottleTime = QDateTime::currentDateTime();
+ if (height < (target - 1) && lastThrottleTime.msecsTo(QDateTime::currentDateTime()) < 100) {
+ return;
+ }
+ lastThrottleTime = QDateTime::currentDateTime();
+
+ if (height >= (target - 1) && target > 0) {
+ m_lastSyncStatusUpdate = QDateTime::currentDateTime();
+
this->updateNetStats();
+ this->setStatusText(tr("Synchronized"));
+
+ // Persist sync state for next boot
+ conf()->set(Config::lastKnownNetworkHeight, static_cast<qulonglong>(target));
+ m_wallet->setCacheAttribute("feather.lastSync", QString::number(QDateTime::currentSecsSinceEpoch()));
+ } else {
+ if (target == 0) {
+ this->setStatusText(tr("Connecting..."));
+ return;
+ }
+
+ QString type = daemonSync ? tr("Blockchain") : tr("Wallet");
+ QString blocksStr = QLocale().toString(blocksBehind);
+ this->setStatusText(tr("%1 sync: %2 blocks behind").arg(type, blocksStr));
}
- this->setStatusText(Utils::formatSyncStatus(height, target, daemonSync));
- m_statusLabelStatus->setToolTip(QString("Wallet height: %1").arg(QString::number(height)));
+
+ this->updateSyncStatusToolTip();
}
void MainWindow::onConnectionStatusChanged(int status)
{
+ // Fix B: Override status when paused
+ if (conf()->get(Config::syncPaused).toBool()) {
+ QIcon icon = icons()->icon("status_offline.svg");
+ QString statusStr = tr("Sync Paused");
+
+ m_statusBtnConnectionStatusIndicator->setIcon(icon);
+ this->setStatusText(statusStr);
+
+ // Hide the "Net Stats" (D: 0.0 B) label since we aren't downloading
+ m_statusLabelNetStats->hide();
+
+ // Update tooltip to ensure it doesn't show "Synchronized"
+ this->updateSyncStatusToolTip();
+ return; // STOP EXECUTION HERE
+ }
+
// Note: Wallet does not emit this signal unless status is changed, so calling this function from MainWindow may
// result in the wrong connection status being displayed.
qDebug() << "Wallet connection status changed " << Utils::QtEnumToString(static_cast<Wallet::ConnectionStatus>(status));
+ if (m_updateNetworkInfoAction) { // Maybe not initialized on first function call
+ m_updateNetworkInfoAction->setEnabled(status != Wallet::ConnectionStatus_Disconnected && !conf()->get(Config::syncPaused).toBool());
+ }
+
// Update connection info in status bar.
QIcon icon;
+ QString statusStr;
if (conf()->get(Config::offlineMode).toBool()) {
icon = icons()->icon("status_offline.svg");
- this->setStatusText("Offline mode");
+ statusStr = "Offline mode";
} else {
switch(status){
case Wallet::ConnectionStatus_Disconnected:
- icon = icons()->icon("status_disconnected.svg");
- this->setStatusText("Disconnected");
+ {
+ icon = icons()->icon("status_offline.svg");
+ statusStr = "Disconnected";
+
+ // If we are waiting for a retry or scheduled sync, show that instead of "Disconnected"
+ if (m_wallet) {
+ qint64 seconds = m_wallet->secondsUntilNextRefresh();
+ if (seconds > 0) {
+ QString timeStr;
+ if (seconds > 60) timeStr = QString("%1 min").arg((seconds + 59) / 60);
+ else timeStr = QString("%1s").arg(seconds);
+ statusStr = tr("Disconnected (Retry in %1)").arg(timeStr);
+ } else if (m_wallet->lastSyncTime().isValid()) {
+ // Fallback to estimation only if not waiting for retry
+ qint64 secsSinceLastSync = m_wallet->lastSyncTime().secsTo(QDateTime::currentDateTime());
+ quint64 estimatedBlocksBehind = std::max(qint64(0), secsSinceLastSync) / 120;
+ if (estimatedBlocksBehind > 0) {
+ statusStr = tr("~%1 blocks behind").arg(QLocale().toString(estimatedBlocksBehind));
+ }
+ }
+ }
break;
+ }
case Wallet::ConnectionStatus_Connecting:
icon = icons()->icon("status_lagging.svg");
- this->setStatusText("Connecting to node");
+ statusStr = "Connecting to node";
break;
case Wallet::ConnectionStatus_WrongVersion:
icon = icons()->icon("status_disconnected.svg");
- this->setStatusText("Incompatible node");
+ statusStr = "Node Incompatible";
break;
case Wallet::ConnectionStatus_Synchronizing:
icon = icons()->icon("status_waiting.svg");
+ statusStr = "Synchronizing";
break;
case Wallet::ConnectionStatus_Synchronized:
icon = icons()->icon("status_connected.svg");
+ statusStr = "Synchronized";
break;
default:
icon = icons()->icon("status_disconnected.svg");
+ statusStr = "Disconnected";
break;
}
}
+ this->setStatusText(statusStr);
+
+ this->updateSyncStatusToolTip();
+
+ if (m_wallet) {
+ quint64 walletHeight = m_wallet->blockChainHeight();
+ quint64 daemonHeight = m_wallet->daemonBlockChainHeight();
+ quint64 targetHeight = m_wallet->daemonBlockChainTargetHeight();
+
+ if (walletHeight > 0) {
+ statusStr += QString("\nWallet %1. Daemon %2. Network %3")
+ .arg(QLocale::system().toString(walletHeight))
+ .arg(QLocale::system().toString(daemonHeight))
+ .arg(QLocale::system().toString(targetHeight));
+ }
+ }
+ // m_statusBtnConnectionStatusIndicator->setToolTip(statusStr);
m_statusBtnConnectionStatusIndicator->setIcon(icon);
+ this->updateBalance();
}
void MainWindow::onTransactionCreated(PendingTransaction *tx, const QVector<QString> &address) {
#ifdef WITH_SCANNER
OfflineTxSigningWizard wizard(this, m_wallet, tx);
wizard.exec();
-
+
if (!wizard.readyToCommit()) {
return;
} else {
#ifdef WITH_SCANNER
OfflineTxSigningWizard wizard{this, m_wallet};
wizard.exec();
-
+
if (wizard.readyToSign()) {
TxConfAdvDialog dialog{m_wallet, "", this, true};
dialog.setUnsignedTransaction(wizard.unsignedTransaction());
// Wallet signal may fire after AppContext is gone, causing segv
m_wallet->disconnect();
- this->disconnect();
this->saveGeo();
m_windowManager->closeWindow(this);
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"));
}
}
+ // Ensure connection for Data Saving Mode
+ if (m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) {
+ m_nodes->connectToNode();
+ }
+
TxImportDialog dialog(this, m_wallet);
dialog.exec();
}
}
}
m_statusBtnHwDevice->setIcon(this->hardwareDevicePairedIcon());
- m_wallet->startRefresh();
m_showDeviceError = false;
}
}
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;
}
"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 {
#include <QMainWindow>
#include <QSystemTrayIcon>
+#include <QWindow>
#include "components.h"
#include "SettingsDialog.h"
protected:
void changeEvent(QEvent* event) override;
+ void showEvent(QShowEvent *event) override;
private slots:
// TODO: use a consistent naming convention for slots
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);
CoinsWidget *m_coinsWidget = nullptr;
QPointer<QAction> m_clearRecentlyOpenAction;
+ QPointer<QAction> m_updateNetworkInfoAction;
+ QPointer<QAction> m_actionEnableWebsocket;
+
+ QDateTime m_lastSyncStatusUpdate;
// lower status bar
QPushButton *m_statusUpdateAvailable;
QString m_statusText;
int m_statusDots;
+ bool m_coinsRefreshing = false;
bool m_constructingTransaction = false;
bool m_statusOverrideActive = false;
bool m_showDeviceError = false;
EventFilter *m_eventFilter = nullptr;
qint64 m_userLastActive = QDateTime::currentSecsSinceEpoch();
+ QMetaObject::Connection m_visibilityConnection;
+
#ifdef CHECK_UPDATES
QSharedPointer<Updater> m_updater = nullptr;
#endif
void Settings::setupNetworkTab() {
// Node
- if (m_nodes) {
- ui->nodeWidget->setupUI(m_nodes);
- connect(ui->nodeWidget, &NodeWidget::nodeSourceChanged, m_nodes, &Nodes::onNodeSourceChanged);
- connect(ui->nodeWidget, &NodeWidget::connectToNode, m_nodes, QOverload<const FeatherNode&>::of(&Nodes::connectToNode));
- } else {
- m_nodes = new Nodes(this, nullptr);
- ui->nodeWidget->setupUI(m_nodes);
- ui->nodeWidget->setCanConnect(false);
+ std::function<void()> setupNodeWidget = [this]{
+ if (m_nodes) {
+ ui->nodeWidget->setupUI(m_nodes);
+ connect(ui->nodeWidget, &NodeWidget::nodeSourceChanged, m_nodes, &Nodes::onNodeSourceChanged);
+ connect(ui->nodeWidget, &NodeWidget::connectToNode, m_nodes, QOverload<const FeatherNode&>::of(&Nodes::connectToNode));
+ } else {
+ m_nodes = new Nodes(this, nullptr);
+ ui->nodeWidget->setupUI(m_nodes);
+ ui->nodeWidget->setCanConnect(false);
+ }
+ };
+ setupNodeWidget();
+
+ // Data Saving Mode
+ QCheckBox *cbDataSaver = new QCheckBox("Data Saving Mode (Pause Sync on startup)", this);
+ cbDataSaver->setChecked(conf()->get(Config::syncPaused).toBool());
+ cbDataSaver->setToolTip("Prevents the wallet from automatically connecting to nodes on startup.");
+
+ connect(cbDataSaver, &QCheckBox::toggled, [](bool checked){
+ conf()->set(Config::syncPaused, checked);
+ });
+
+ // Add to Node tab layout
+ if (auto *layout = qobject_cast<QVBoxLayout*>(ui->Node->layout())) {
+ layout->insertWidget(0, cbDataSaver);
}
// Proxy
connect(ui->proxyWidget, &NetworkProxyWidget::proxySettingsChanged, this, &Settings::onProxySettingsChanged);
+ // Offline mode
+ ui->checkBox_offlineMode->setChecked(conf()->get(Config::offlineMode).toBool());
+ connect(ui->checkBox_offlineMode, &QCheckBox::toggled, [this](bool checked){
+ conf()->set(Config::offlineMode, checked);
+ this->enableWebsocket(!checked && !conf()->get(Config::disableWebsocket).toBool());
+ emit offlineMode(checked);
+ });
+
// Websocket
// [Obtain third-party data]
ui->checkBox_enableWebsocket->setChecked(!conf()->get(Config::disableWebsocket).toBool());
this->enableWebsocket(checked);
});
- // Overview
- ui->checkBox_offlineMode->setChecked(conf()->get(Config::offlineMode).toBool());
- connect(ui->checkBox_offlineMode, &QCheckBox::toggled, [this](bool checked){
- conf()->set(Config::offlineMode, checked);
- emit offlineMode(checked);
- this->enableWebsocket(!checked);
- });
+ // Sync
+ QComboBox *comboSyncInterval = new QComboBox(this);
+ comboSyncInterval->setEditable(true);
+
+ struct IntervalPreset {
+ QString label;
+ int seconds;
+ };
+
+ QList<IntervalPreset> presets = {
+ {"30 seconds", 30},
+ {"1 minute", 60},
+ {"2 minutes", 120},
+ {"5 minutes", 300},
+ {"10 minutes", 600},
+ {"15 minutes", 900},
+ {"20 minutes", 1200},
+ {"30 minutes", 1800},
+ {"45 minutes", 2700},
+ {"1 hour", 3600},
+ {"1.5 hours", 5400},
+ {"3 hours", 10800},
+ {"5 hours", 18000},
+ {"10 hours", 36000},
+ {"1 day", 86400},
+ {"1 week", 604800},
+ {"1 month", 2592000}
+ };
+
+ for (const auto &preset : presets) {
+ comboSyncInterval->addItem(preset.label, preset.seconds);
+ }
+
+ int currentInterval = conf()->get(Config::syncInterval).toInt();
+ bool found = false;
+ for (int i = 0; i < comboSyncInterval->count(); ++i) {
+ if (comboSyncInterval->itemData(i).toInt() == currentInterval) {
+ comboSyncInterval->setCurrentIndex(i);
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ comboSyncInterval->setCurrentText(QString("%1 seconds").arg(currentInterval));
+ }
+
+ auto updateConfig = [comboSyncInterval](const QString &text){
+ int seconds = 0;
+ if (comboSyncInterval->currentIndex() != -1 && comboSyncInterval->currentText() == comboSyncInterval->itemText(comboSyncInterval->currentIndex())) {
+ seconds = comboSyncInterval->currentData().toInt();
+ } else {
+ // Try to parse simple number as seconds
+ bool ok;
+ seconds = text.split(" ").first().toInt(&ok);
+ if (!ok) return;
+ }
+ if (seconds < 30) {
+ seconds = 30;
+ }
+ conf()->set(Config::syncInterval, seconds);
+ };
+
+ connect(comboSyncInterval, &QComboBox::currentTextChanged, updateConfig);
+
+ QHBoxLayout *hLayoutSync = new QHBoxLayout();
+ hLayoutSync->addWidget(new QLabel("Time between syncs:", this));
+ hLayoutSync->addWidget(comboSyncInterval);
+ hLayoutSync->addStretch();
+
+ // Add to Node tab
+ if (auto *layout = qobject_cast<QVBoxLayout*>(ui->Node->layout())) {
+ layout->addLayout(hLayoutSync);
+ }
}
void Settings::setupStorageTab() {
WalletManager::instance()->setLogLevel(toggled ? conf()->get(Config::logLevel).toInt() : -1);
});
+ // [Disable terminal output]
+ QCheckBox *cbDisableStdout = new QCheckBox("Disable terminal output (silence most logs)", this);
+ cbDisableStdout->setChecked(conf()->get(Config::disableLoggingStdout).toBool());
+ connect(cbDisableStdout, &QCheckBox::toggled, [](bool toggled){
+ conf()->set(Config::disableLoggingStdout, toggled);
+ });
+ // Insert into the logging layout (verticalLayout_2)
+ // We add it after checkBox_enableLogging
+ int index = ui->verticalLayout_2->indexOf(ui->checkBox_enableLogging);
+ ui->verticalLayout_2->insertWidget(index + 1, cbDisableStdout);
+
// [Log level]
+ ui->comboBox_logLevel->clear();
+ ui->comboBox_logLevel->addItems({"Fatal", "Warning", "Info", "Debug"});
ui->comboBox_logLevel->setCurrentIndex(conf()->get(Config::logLevel).toInt());
+
connect(ui->comboBox_logLevel, QOverload<int>::of(&QComboBox::currentIndexChanged), [](int index){
conf()->set(Config::logLevel, index);
+ qInfo() << "Log level changed to:" << index;
if (!conf()->get(Config::disableLogging).toBool()) {
WalletManager::instance()->setLogLevel(index);
}
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);
});
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() {
</item>
</layout>
</item>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_17">
+ <item>
+ <spacer name="horizontalSpacer_3">
+ <property name="orientation">
+ <enum>Qt::Orientation::Horizontal</enum>
+ </property>
+ <property name="sizeType">
+ <enum>QSizePolicy::Policy::Fixed</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>30</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="checkBox_trayLeftClickTogglesFocus">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string>Left click system tray icon to toggle focus</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
</layout>
</item>
<item>
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();
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 ########################
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();
}
m_docsDialog->deleteLater();
}
- torManager()->stop();
-
deleteLater();
qDebug() << "Calling QApplication::quit()";
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);
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();
bool m_openWalletTriedOnce = false;
bool m_openingWallet = false;
bool m_initialNetworkConfigured = false;
+ bool m_closing = false;
QThread *m_cleanupThread;
};
Name=Feather Wallet
GenericName=Monero Wallet
Comment=A free Monero desktop wallet
-Icon=feather
+Icon=FeatherWallet
Exec=feather
Terminal=false
Categories=Network;
ClickableLabel::~ClickableLabel() = default;
void ClickableLabel::mousePressEvent(QMouseEvent* event) {
- emit clicked();
+ if (event->button() == Qt::LeftButton) {
+ emit clicked();
+ }
+ QLabel::mousePressEvent(event);
}
WindowModalDialog::WindowModalDialog(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);
</widget>
</item>
<item row="1" column="0">
+ <widget class="QLabel" name="label_30">
+ <property name="text">
+ <string><html><head/><body><p><span style=" font-weight:700;">Build:</span></p></body></html></string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLabel" name="label_buildTag">
+ <property name="text">
+ <string>TextLabel</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
<widget class="QLabel" name="label_20">
<property name="text">
<string><html><head/><body><p><span style=" font-weight:700;">Monero:</span></p></body></html></string>
</property>
</widget>
</item>
- <item row="1" column="1">
+ <item row="2" column="1">
<widget class="QLabel" name="label_moneroVersion">
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
- <item row="2" column="0">
+ <item row="3" column="0">
<widget class="QLabel" name="label_22">
<property name="text">
<string><html><head/><body><p><span style=" font-weight:700;">Qt:</span></p></body></html></string>
</property>
</widget>
</item>
- <item row="2" column="1">
+ <item row="3" column="1">
<widget class="QLabel" name="label_qtVersion">
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
- <item row="4" column="0">
+ <item row="5" column="0">
<widget class="QLabel" name="label_24">
<property name="text">
<string><html><head/><body><p><span style=" font-weight:700;">SSL:</span></p></body></html></string>
</property>
</widget>
</item>
- <item row="4" column="1">
+ <item row="5" column="1">
<widget class="QLabel" name="label_sslVersion">
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
- <item row="3" column="0">
+ <item row="4" column="0">
<widget class="QLabel" name="label_26">
<property name="text">
<string><html><head/><body><p><span style=" font-weight:700;">Tor:</span></p></body></html></string>
</property>
</widget>
</item>
- <item row="3" column="1">
+ <item row="4" column="1">
<widget class="QLabel" name="label_torVersion">
<property name="text">
<string>TextLabel</string>
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";
}
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");
}
}
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");
}
--- /dev/null
+// SPDX-License-Identifier: BSD-3-Clause
+// SPDX-FileCopyrightText: The Monero Project
+
+#include "SyncRangeDialog.h"
+
+#include <QVBoxLayout>
+#include <QHBoxLayout>
+#include <QFormLayout>
+#include <QComboBox>
+#include <QSpinBox>
+#include <QDateEdit>
+#include <QLabel>
+#include <QDialogButtonBox>
+
+#include "utils/Utils.h"
+#include "utils/RestoreHeightLookup.h"
+
+SyncRangeDialog::SyncRangeDialog(QWidget *parent, Wallet *wallet)
+ : QDialog(parent)
+ , m_wallet(wallet)
+{
+ setWindowTitle(tr("Sync Date Range"));
+ setWindowIcon(QIcon(":/assets/images/appicons/64x64.png"));
+ setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+ setWindowFlags(windowFlags() | Qt::MSWindowsFixedSizeDialogHint);
+
+ auto *layout = new QVBoxLayout(this);
+ auto *formLayout = new QFormLayout;
+ formLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
+
+ m_toDateEdit = new QDateEdit(QDate::currentDate());
+ m_toDateEdit->setCalendarPopup(true);
+ m_toDateEdit->setDisplayFormat("yyyy-MM-dd");
+
+ int defaultDays = 7;
+
+ // Preset durations dropdown
+ m_presetCombo = new QComboBox;
+ m_presetCombo->addItem(tr("1 day"), 1);
+ m_presetCombo->addItem(tr("7 days"), 7);
+ m_presetCombo->addItem(tr("30 days"), 30);
+ m_presetCombo->addItem(tr("90 days"), 90);
+ m_presetCombo->addItem(tr("1 year"), 365);
+ m_presetCombo->addItem(tr("Custom..."), -1);
+ m_presetCombo->setCurrentIndex(1); // Default to 7 days
+
+ m_daysSpinBox = new QSpinBox;
+ m_daysSpinBox->setRange(1, 3650); // 10 years
+ m_daysSpinBox->setValue(defaultDays);
+ m_daysSpinBox->setSuffix(tr(" days"));
+ m_daysSpinBox->setVisible(false); // Hidden until "Custom..." is selected
+
+ // Layout for preset + custom spinbox
+ auto *daysLayout = new QHBoxLayout;
+ daysLayout->setContentsMargins(0, 0, 0, 0);
+ daysLayout->addWidget(m_presetCombo, 1);
+ daysLayout->addWidget(m_daysSpinBox, 0);
+
+ m_fromDateEdit = new QDateEdit(QDate::currentDate().addDays(-defaultDays));
+ m_fromDateEdit->setCalendarPopup(true);
+ m_fromDateEdit->setDisplayFormat("yyyy-MM-dd");
+ m_fromDateEdit->setToolTip(tr("Calculated from 'End date' and day span."));
+
+ m_infoLabel = new QLabel;
+ m_infoLabel->setWordWrap(true);
+ m_infoLabel->setStyleSheet("QLabel { color: #888; font-size: 11px; }");
+
+ formLayout->addRow(tr("Day span:"), daysLayout);
+ formLayout->addRow(tr("Start date:"), m_fromDateEdit);
+ formLayout->addRow(tr("End date:"), m_toDateEdit);
+
+ layout->addLayout(formLayout);
+ layout->addWidget(m_infoLabel);
+
+ connect(m_fromDateEdit, &QDateEdit::dateChanged, this, &SyncRangeDialog::updateInfo);
+ connect(m_toDateEdit, &QDateEdit::dateChanged, this, &SyncRangeDialog::updateFromDate);
+ connect(m_daysSpinBox, QOverload<int>::of(&QSpinBox::valueChanged), this, &SyncRangeDialog::updateFromDate);
+
+ // Connect preset dropdown
+ connect(m_presetCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index) {
+ int days = m_presetCombo->itemData(index).toInt();
+ if (days == -1) {
+ // Custom mode: show spinbox, keep current value
+ m_daysSpinBox->setVisible(true);
+ } else {
+ // Preset mode: hide spinbox, set value
+ m_daysSpinBox->setVisible(false);
+ m_daysSpinBox->setValue(days);
+ }
+ });
+
+ // Init info
+ updateInfo();
+
+ auto *btnBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
+ connect(btnBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
+ connect(btnBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ layout->addWidget(btnBox);
+
+ resize(320, height());
+}
+
+QDate SyncRangeDialog::fromDate() const {
+ return m_fromDateEdit->date();
+}
+
+QDate SyncRangeDialog::toDate() const {
+ return m_toDateEdit->date();
+}
+
+quint64 SyncRangeDialog::estimatedBlocks() const {
+ return m_estimatedBlocks;
+}
+
+quint64 SyncRangeDialog::estimatedSize() const {
+ return m_estimatedSize;
+}
+
+void SyncRangeDialog::updateInfo() {
+ NetworkType::Type nettype = m_wallet->nettype();
+ QString filename = Utils::getRestoreHeightFilename(nettype);
+ std::unique_ptr<RestoreHeightLookup> lookup(RestoreHeightLookup::fromFile(filename, nettype));
+ if (!lookup || lookup->data.isEmpty()) {
+ m_infoLabel->setText(tr("Unable to estimate - restore height data unavailable"));
+ m_estimatedBlocks = 0;
+ m_estimatedSize = 0;
+ return;
+ }
+
+ QDate start = m_fromDateEdit->date();
+ QDate end = m_toDateEdit->date();
+
+ uint64_t startHeight = lookup->dateToHeight(start.startOfDay().toSecsSinceEpoch());
+ uint64_t endHeight = lookup->dateToHeight(end.endOfDay().toSecsSinceEpoch());
+
+ if (endHeight < startHeight) endHeight = startHeight;
+ m_estimatedBlocks = endHeight - startHeight;
+ m_estimatedSize = Utils::estimateSyncDataSize(m_estimatedBlocks);
+
+ m_infoLabel->setText(tr("Scanning ~%1 blocks\nEst. download size: %2")
+ .arg(m_estimatedBlocks)
+ .arg(Utils::formatBytes(m_estimatedSize)));
+}
+
+void SyncRangeDialog::updateFromDate() {
+ m_fromDateEdit->setDate(m_toDateEdit->date().addDays(-m_daysSpinBox->value()));
+ updateInfo();
+}
--- /dev/null
+// SPDX-License-Identifier: BSD-3-Clause
+// SPDX-FileCopyrightText: The Monero Project
+
+#ifndef FEATHER_SYNCRANGEDIALOG_H
+#define FEATHER_SYNCRANGEDIALOG_H
+
+#include <QDialog>
+#include <QDate>
+
+#include "libwalletqt/Wallet.h"
+
+class QComboBox;
+class QSpinBox;
+class QDateEdit;
+class QLabel;
+
+class SyncRangeDialog : public QDialog
+{
+Q_OBJECT
+
+public:
+ explicit SyncRangeDialog(QWidget *parent, Wallet *wallet);
+ ~SyncRangeDialog() override = default;
+
+ QDate fromDate() const;
+ QDate toDate() const;
+ quint64 estimatedBlocks() const;
+ quint64 estimatedSize() const;
+
+private:
+ void updateInfo();
+ void updateFromDate();
+
+ Wallet *m_wallet;
+ QComboBox *m_presetCombo;
+ QSpinBox *m_daysSpinBox;
+ QDateEdit *m_fromDateEdit;
+ QDateEdit *m_toDateEdit;
+ QLabel *m_infoLabel;
+
+ quint64 m_estimatedBlocks = 0;
+ quint64 m_estimatedSize = 0;
+};
+
+#endif //FEATHER_SYNCRANGEDIALOG_H
connect(ui->btn_import, &QPushButton::clicked, this, &TxImportDialog::onImport);
+ ui->line_txid->setMinimumWidth(600);
this->adjustSize();
+
+ this->layout()->setSizeConstraint(QLayout::SetFixedSize);
}
void TxImportDialog::onImport() {
#include "WalletManager.h"
#include "WalletListenerImpl.h"
+#include "utils/config.h"
#include "config.h"
#include "constants.h"
#include "model/CoinsModel.h"
#include "utils/ScopeGuard.h"
+#include "utils/RestoreHeightLookup.h"
+#include "utils/Utils.h"
#include "wallet/wallet2.h"
, m_useSSL(true)
, m_coins(new Coins(this, wallet->getWallet(), this))
, m_storeTimer(new QTimer(this))
+ , m_lastRefreshTime(std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch()).count())
{
m_walletListener = new WalletListenerImpl(this);
m_walletImpl->setListener(m_walletListener);
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 ####################
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)
// #################### Synchronization (Refresh) ####################
void Wallet::startRefresh() {
- m_refreshEnabled = true;
m_refreshEnabled = true;
m_refreshNow = true;
}
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();
{
const auto now = std::chrono::steady_clock::now();
const auto elapsed = now - last;
- if (elapsed >= refreshInterval || m_refreshNow)
+ if (elapsed >= std::chrono::seconds(m_refreshInterval) || m_refreshNow)
{
- m_refreshNow = false;
+ if (m_syncPaused && !m_refreshNow) {
+ last = std::chrono::steady_clock::now();
+ std::this_thread::sleep_for(std::chrono::milliseconds(250));
+ continue;
+ }
+ m_refreshNow = false;
+ auto loopStartTime = std::chrono::time_point_cast<std::chrono::microseconds>(std::chrono::steady_clock::now());
// get daemonHeight and targetHeight
// daemonHeight and targetHeight will be 0 if call to get_info fails
quint64 daemonHeight = m_walletImpl->daemonBlockChainHeight();
bool success = daemonHeight > 0;
+ if (success) {
+ m_lastRefreshTime = loopStartTime.time_since_epoch().count();
+ last = loopStartTime;
+ } else {
+ // If sync failed, retry according to the interval (respects Data Saving)
+ auto retryDelay = std::chrono::seconds(m_refreshInterval);
+ qCritical() << "Refresher: Sync failed. Retry delay set to:" << retryDelay.count();
+ auto nextTime = loopStartTime - std::chrono::seconds(m_refreshInterval) + retryDelay;
+ m_lastRefreshTime = nextTime.time_since_epoch().count();
+ last = nextTime;
+ }
+
+ qDebug() << "Refresher: Interval met. Elapsed:" << std::chrono::duration_cast<std::chrono::seconds>(elapsed).count()
+ << "Interval:" << m_refreshInterval << "RefreshNow:" << m_refreshNow;
+
+
quint64 targetHeight = 0;
if (success) {
targetHeight = m_walletImpl->daemonBlockChainTargetHeight();
// 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) {
m_newWallet = false;
}
+ quint64 walletHeight = m_walletImpl->blockChainHeight();
m_walletImpl->refresh();
}
- last = std::chrono::steady_clock::now();
}
}
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);
} else {
setConnectionStatus(ConnectionStatus_Disconnected);
}
+
+ if (success) {
+ m_lastSyncTime = QDateTime::currentDateTime();
+ }
}
quint64 Wallet::blockChainHeight() const {
return m_wallet2->get_blockchain_current_height();
}
+qint64 Wallet::secondsUntilNextRefresh() const {
+ if (m_syncPaused || !m_refreshEnabled) {
+ return -1;
+ }
+
+ if (this->isHwBacked() && !this->isDeviceConnected()) {
+ return -2;
+ }
+
+ auto now = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
+ auto elapsed = std::chrono::microseconds(now - m_lastRefreshTime.load());
+ auto interval = std::chrono::seconds(m_refreshInterval);
+
+ if (elapsed >= interval) {
+ return 0;
+ }
+
+ return std::chrono::duration_cast<std::chrono::seconds>(interval - elapsed).count();
+}
+
quint64 Wallet::daemonBlockChainHeight() const {
return m_daemonBlockChainHeight;
}
return m_daemonBlockChainTargetHeight;
}
-void Wallet::syncStatusUpdated(quint64 height, quint64 target) {
- if (height >= (target - 1)) {
- // TODO: is this needed?
+void Wallet::setSyncPaused(bool paused) {
+ m_syncPaused = paused;
+ if (paused) {
+ pauseRefresh();
+ } else {
+ m_refreshNow = true;
+ m_wallet2->set_offline(false);
+ startRefresh();
+ }
+}
+
+QDateTime Wallet::lastSyncTime() const {
+ return m_lastSyncTime;
+}
+
+void Wallet::setRefreshInterval(int seconds) {
+ m_refreshInterval = seconds;
+}
+
+void Wallet::skipToTip() {
+ if (!m_wallet2) return;
+
+ uint64_t target = m_daemonBlockChainTargetHeight;
+ if (target == 0) {
+ qWarning() << "Cannot skip to tip: Network target unknown. Connect first.";
+ return;
+ }
+
+ QMutexLocker locker(&m_asyncMutex);
+ m_wallet2->set_refresh_from_block_height(target);
+ m_lastSyncTime = QDateTime::currentDateTime();
+
+ pauseRefresh();
+ emit syncStatus(target, target, true);
+}
+
+void Wallet::syncDateRange(const QDate &start, const QDate &end) {
+ if (!m_wallet2)
+ return;
+
+ // Convert dates to heights with internal table lookup
+ cryptonote::network_type nettype = m_wallet2->nettype();
+ QString filename = Utils::getRestoreHeightFilename(static_cast<NetworkType::Type>(nettype));
+
+ std::unique_ptr<RestoreHeightLookup> lookup(RestoreHeightLookup::fromFile(filename, static_cast<NetworkType::Type>(nettype)));
+ uint64_t startHeight = lookup->dateToHeight(start.startOfDay().toSecsSinceEpoch());
+ uint64_t endHeight = lookup->dateToHeight(end.startOfDay().toSecsSinceEpoch());
+
+ if (startHeight >= endHeight)
+ return;
+
+ {
+ QMutexLocker locker(&m_asyncMutex);
+ m_stopHeight = endHeight;
+ m_rangeSyncActive = true;
+ m_wallet2->set_refresh_from_block_height(startHeight);
+ }
+ pauseRefresh();
+ startRefresh();
+}
+
+void Wallet::fullSync() {
+ if (!m_wallet2)
+ return;
+
+ // Reset range sync just in case
+ m_rangeSyncActive = false;
+
+ // Retrieve original creation height from persistent storage
+ uint64_t originalHeight = 0;
+ QString storedHeight = this->getCacheAttribute("feather.creation_height");
+ if (!storedHeight.isEmpty()) {
+ originalHeight = storedHeight.toULongLong();
+ } else {
+ // Fallback: if skipToTip() was used, this may be the current tip, missing all transactions
+ originalHeight = m_wallet2->get_refresh_from_block_height();
+ qWarning() << "fullSync: No stored creation height found (feather.creation_height). "
+ << "Falling back to current refresh height:" << originalHeight
+ << ". This may miss transactions if skipToTip() was previously used.";
+ }
+
+ {
+ QMutexLocker locker(&m_asyncMutex);
+ m_wallet2->set_refresh_from_block_height(originalHeight);
+ }
+ // Trigger rescan
+ pauseRefresh();
+ startRefresh();
+
+ qInfo() << "Full Sync triggered. Rescanning from original restore height:" << originalHeight;
+}
+
+void Wallet::syncStatusUpdated(quint64 height, quint64 targetHeight) {
+ if (m_rangeSyncActive && height >= m_stopHeight) {
+ // At end of requested date range, jump to tip
+ m_rangeSyncActive = false;
+ this->skipToTip();
+ return;
+ }
+
+ if (height >= (targetHeight - 1)) {
this->updateBalance();
}
+ emit syncStatus(height, targetHeight, false);
+}
- emit syncStatus(height, target, false);
+bool Wallet::importTransaction(const QString &txid) {
+ if (!m_wallet2 || txid.isEmpty())
+ return false;
+
+ // If scanning a specific TX, we shouldn't be constrained by range sync
+ if (m_rangeSyncActive) {
+ m_rangeSyncActive = false;
+ }
+
+ try {
+ std::unordered_set<crypto::hash> txids;
+ crypto::hash txid_hash;
+ if (!epee::string_tools::hex_to_pod(txid.toStdString(), txid_hash)) {
+ qWarning() << "Invalid transaction id: " << txid;
+ return false;
+ }
+ txids.insert(txid_hash);
+ m_wallet2->scan_tx(txids);
+ qInfo() << "Successfully imported transaction:" << txid;
+ this->updateBalance();
+ this->history()->refresh();
+ return true;
+ } catch (const std::exception &e) {
+ qWarning() << "Failed to import transaction: " << txid << ", error: " << e.what();
+ }
+ return false;
}
void Wallet::onNewBlock(uint64_t walletHeight) {
+ if (m_syncPaused) {
+ return;
+ }
// Called whenever a new block gets scanned by the wallet
quint64 daemonHeight = m_daemonBlockChainTargetHeight;
return m_walletImpl->importOutputsFromStr(outputs);
}
-bool Wallet::importTransaction(const QString& txid) {
- std::vector<std::string> txids = {txid.toStdString()};
- return m_walletImpl->scanTransactions(txids);
-}
-
// #################### Wallet cache ####################
void Wallet::store() {
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();
#include "rows/TxBacklogEntry.h"
#include <set>
+#include <atomic>
class WalletListenerImpl;
//! 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;
quint64 unlockedBalance() const;
quint64 unlockedBalance(quint32 accountIndex) const;
quint64 unlockedBalanceAll() const;
-
+
quint64 viewOnlyBalance(quint32 accountIndex) const;
void updateBalance();
// ##### 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)
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();
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
//! 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);
void connectionStatusChanged(int status) const;
void currentSubaddressAccountChanged() const;
-
void syncStatus(quint64 height, quint64 target, bool daemonSync = false);
void balanceUpdated(quint64 balance, quint64 spendable);
quint64 m_daemonBlockChainHeight;
quint64 m_daemonBlockChainTargetHeight;
+ QDateTime m_lastSyncTime;
ConnectionStatus m_connectionStatus;
Coins *m_coins;
CoinsModel *m_coinsModel;
+ std::atomic<int> m_refreshInterval{10};
+
QMutex m_asyncMutex;
QString m_daemonUsername;
QString m_daemonPassword;
QTimer *m_storeTimer = nullptr;
std::set<std::string> m_selectedInputs;
+
+ std::atomic<quint64> m_stopHeight{0};
+ std::atomic<bool> m_rangeSyncActive{false};
+ std::atomic<bool> m_syncPaused{false};
+ std::atomic<int64_t> m_lastRefreshTime{0};
};
#endif // FEATHER_WALLET_H
+
// SPDX-FileCopyrightText: The Monero Project
#include <QSslSocket>
+#include <iostream>
+#include <QIcon>
+#include <QGuiApplication>
#include "Application.h"
#include "constants.h"
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();
}
// 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);
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) {
conf()->set(Config::restartRequired, false);
- if (!quiet) {
+ if (!quiet && !conf()->get(Config::disableLogging).toBool()) {
QList<QPair<QString, QString>> info;
info.emplace_back("Feather", FEATHER_VERSION);
info.emplace_back("Monero", MONERO_VERSION);
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);
wm->setEventFilter(&filter);
int exitCode = Application::exec();
- qDebug() << "Application::exec() returned";
+ qInstallMessageHandler(nullptr);
return exitCode;
}
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:
#include "CoinsProxyModel.h"
#include "CoinsModel.h"
#include "libwalletqt/rows/CoinsInfo.h"
+#include <QtGlobal>
CoinsProxyModel::CoinsProxyModel(QObject *parent, Coins *coins)
: QSortFilterProxyModel(parent)
}
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
{
const QList<QAction*> actions = m_columnActions->actions();
for (auto& action : actions) {
- Q_ASSERT(static_cast<QMetaType::Type>(action->data().type()) == QMetaType::Int);
- if (static_cast<QMetaType::Type>(action->data().type()) != QMetaType::Int) {
+ Q_ASSERT(static_cast<QMetaType::Type>(action->data().typeId()) == QMetaType::Int);
+ if (static_cast<QMetaType::Type>(action->data().typeId()) != QMetaType::Int) {
continue;
}
int columnIndex = action->data().toInt();
// Verify action carries a column index as data. Since QVariant.toInt()
// below will accept anything that's interpretable as int, perform a type
// check here to make sure data actually IS int
- Q_ASSERT(static_cast<QMetaType::Type>(action->data().type()) == QMetaType::Int);
- if (static_cast<QMetaType::Type>(action->data().type()) != QMetaType::Int) {
+ Q_ASSERT(static_cast<QMetaType::Type>(action->data().typeId()) == QMetaType::Int);
+ if (static_cast<QMetaType::Type>(action->data().typeId()) != QMetaType::Int) {
return;
}
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:
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:
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;
this->setup();
}
});
+ connect(windowManager(), &WindowManager::websocketStatusChanged, this, &TickersWidget::updateDisplay);
this->updateBalance();
}
#include "utils/os/tails.h"
#include "utils/os/whonix.h"
#include "libwalletqt/Wallet.h"
+#include "utils/nodes.h"
#include "WindowManager.h"
namespace Utils {
}
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;
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");
}
}
+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) : "?";
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"));
#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";
+}
}
#include "networktype.h"
class SubaddressIndex;
+class Wallet;
+class Nodes;
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);
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
connect(webSocket, &QWebSocket::stateChanged, this, &WebsocketClient::onStateChanged);
connect(webSocket, &QWebSocket::connected, this, &WebsocketClient::onConnected);
connect(webSocket, &QWebSocket::disconnected, this, &WebsocketClient::onDisconnected);
+ // one liner: for both Qt 6.4 (older) and Qt 6.5+ (newer)
+ // connect(webSocket, QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error), this, &WebsocketClient::onError);
+#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
+ connect(webSocket, &QWebSocket::errorOccurred, this, &WebsocketClient::onError);
+#else
connect(webSocket, QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error), this, &WebsocketClient::onError);
+#endif
connect(webSocket, &QWebSocket::binaryMessageReceived, this, &WebsocketClient::onbinaryMessageReceived);
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) {
void WebsocketClient::stop() {
qDebug() << Q_FUNC_INFO;
m_stopped = true;
- webSocket->close();
+ webSocket->abort();
m_connectionTimeout.stop();
m_pingTimer.stop();
}
{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}},
{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}},
disableWebsocket,
// Network -> Offline
+ disableAutoRefresh,
offlineMode,
// Storage -> Logging
writeStackTraceToDisk,
disableLogging,
+ disableLoggingStdout,
logLevel,
// Storage -> Misc
lockOnMinimize,
showTrayIcon,
minimizeToTray,
+ trayLeftClickTogglesFocus,
// Transactions
multiBroadcast,
// Tickers
tickers,
tickersShowFiatBalance,
+
+ // Sync & data saver
+ syncPaused,
+ syncPausedAlsoDisconnectWebSocket,
+ syncInterval,
+ lastKnownNetworkHeight,
+ lastSyncTimestamp,
+ lastPriceUpdateTimestamp,
};
enum PrivacyLevel {
if (m_wallet) {
connect(m_wallet, &Wallet::walletRefreshed, this, &Nodes::onWalletRefreshed);
+ connect(m_wallet, &Wallet::connectionStatusChanged, this, &Nodes::onConnectionStatusChanged);
}
}
}
}
+ qInfo() << "Nodes::connectToNode calling initAsync with:" << node.toAddress();
m_wallet->initAsync(node.toAddress(), true, 0, proxyAddress);
m_connection = 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;
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) {
}
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();
}
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;
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);
+ }
}
}
}
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();
}
}
void Nodes::exhausted() {
- // Do nothing
+ // Do nothing (matches upstream behavior)
}
QList<FeatherNode> Nodes::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;
}
public slots:
void connectToNode();
void connectToNode(const FeatherNode &node);
+ void disconnectCurrentNode();
void onWSNodesReceived(QList<FeatherNode>& nodes);
void onNodeSourceChanged(NodeSource nodeSource);
void setCustomNodes(const QList<FeatherNode>& nodes);
private slots:
void onWalletRefreshed();
+ void onConnectionStatusChanged(int status);
private:
Wallet *m_wallet = nullptr;
bool m_enableAutoconnect = true;
bool m_allowConnection = false;
+ bool m_privacySwitchDone = false; // Tracks if allTorExceptInitSync switch has fired
FeatherNode pickEligibleNode();
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) {
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();
}
#define FEATHER_PRICES_H
#include <QObject>
+#include <QDateTime>
#include "utils/Utils.h"
explicit Prices(QObject *parent = nullptr);
QMap<QString, double> rates;
QMap<QString, marketStruct> markets;
+ QDateTime lastUpdateTime;
public slots:
void cryptoPricesReceived(const QJsonArray &data);
#include "NodeWidget.h"
#include "ui_NodeWidget.h"
+#include <QtGlobal>
#include <QAction>
#include <QDesktopServices>
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);
QString lineContent;
QString error;
- int idx;
- bool isMultiline;
+ int idx = 0;
+ bool isMultiline = false;
};
class PayToEdit : public QPlainTextEdit
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