- Implemented 'Smart Sync': scans only necessary blocks (tip-10) for unlocking funds.
- Fixed 'Import Transaction' over-scanning: added Smart Restore to jump wallet height for fresh wallets.
- UI: Removed obsolete 'Scan Mempool' setting; enabled ephemeral scan on connections.
- Stability: Ensured 'Import Transaction' is non-blocking async.
#include "dialog/OutputSweepDialog.h"
#include "utils/Icons.h"
#include "utils/Utils.h"
+#include "utils/config.h"
#ifdef WITH_SCANNER
#include "wizard/offline_tx_signing/OfflineTxSigningWizard.h"
}
void CoinsWidget::onSweepOutputs() {
- if (!m_wallet->isConnected()) {
+ if (!m_wallet->isConnected() && !conf()->get(Config::syncPaused).toBool()) {
Utils::showError(this, "Unable to create transaction", "Wallet is not connected to a node.",
{"Wait for the wallet to automatically connect to a node.", "Go to File -> Settings -> Network -> Node to manually connect to a node."},
"nodes");
return;
}
- if (!m_wallet->isSynchronized()) {
+ if (!m_wallet->isSynchronized() && !conf()->get(Config::syncPaused).toBool()) {
Utils::showError(this, "Unable to create transaction", "Wallet is not synchronized", {"Wait for wallet synchronization to complete"}, "synchronization");
return;
}
#include "utils/Icons.h"
#include "WebsocketNotifier.h"
+#include <QClipboard>
+#include <QJsonObject>
+#include <QJsonDocument>
+#include <QJsonArray>
+
HistoryWidget::HistoryWidget(Wallet *wallet, QWidget *parent)
: QWidget(parent)
, ui(new Ui::HistoryWidget)
m_copyMenu->addAction("Date", this, [this]{copy(copyField::Date);});
m_copyMenu->addAction("Description", this, [this]{copy(copyField::Description);});
m_copyMenu->addAction("Amount", this, [this]{copy(copyField::Amount);});
+ m_copyMenu->addAction("Row as JSON", this, [this]{copy(copyField::JSON);});
ui->history->setContextMenuPolicy(Qt::CustomContextMenu);
connect(ui->history, &QTreeView::customContextMenuRequested, this, &HistoryWidget::showContextMenu);
conf()->get(Config::timeFormat).toString()));
case copyField::Amount:
return WalletManager::displayAmount(abs(tx.balanceDelta));
+ case copyField::JSON: {
+ QJsonObject obj;
+ obj.insert("txid", tx.hash);
+ obj.insert("amount", static_cast<double>(tx.amount));
+ obj.insert("fee", static_cast<double>(tx.fee));
+ obj.insert("height", static_cast<double>(tx.blockHeight));
+ obj.insert("timestamp", tx.timestamp.toSecsSinceEpoch());
+ obj.insert("direction", tx.direction == TransactionRow::Direction_In ? "in" : "out");
+ obj.insert("payment_id", tx.paymentId);
+ obj.insert("description", tx.description);
+ obj.insert("confirmations", static_cast<double>(tx.confirmations));
+ obj.insert("failed", tx.failed);
+ obj.insert("pending", tx.pending);
+ obj.insert("coinbase", tx.coinbase);
+ obj.insert("label", tx.label);
+
+ QJsonArray subaddrIndices;
+ for (const auto &idx : tx.subaddrIndex) subaddrIndices.append(static_cast<int>(idx));
+ obj.insert("subaddr_index", subaddrIndices);
+ obj.insert("subaddr_account", static_cast<int>(tx.subaddrAccount));
+
+ QJsonDocument doc(obj);
+ return QString::fromUtf8(doc.toJson(QJsonDocument::Indented));
+ }
default:
return QString("");
}
TxID = 0,
Description,
Date,
- Amount
+ Amount,
+ JSON
};
void copy(copyField field);
#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::syncPaused) {
+ this->setSyncPaused(conf()->get(Config::syncPaused).toBool());
+ }
+ });
+
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);
+
+ m_actionPauseSync = new QAction(tr("Pause Sync"), this);
+ m_actionPauseSync->setCheckable(true);
+ m_actionPauseSync->setChecked(conf()->get(Config::syncPaused).toBool());
+ m_statusLabelStatus->addAction(m_actionPauseSync);
+
+ m_actionEnableWebsocket = new QAction(tr("Enable Websocket"), this);
+ m_actionEnableWebsocket->setCheckable(true);
+ m_actionEnableWebsocket->setChecked(!conf()->get(Config::disableWebsocket).toBool());
+
+ connect(m_actionEnableWebsocket, &QAction::toggled, this, [](bool checked){
+ conf()->set(Config::disableWebsocket, !checked);
+ if (checked) {
+ websocketNotifier()->websocketClient->restart();
+ } else {
+ websocketNotifier()->websocketClient->stop();
+ }
+ WindowManager::instance()->onWebsocketStatusChanged(checked);
+ });
+
+
+ QAction *skipSyncAction = new QAction(tr("Skip Sync"), this);
+ m_statusLabelStatus->addAction(skipSyncAction);
+
+ QAction *syncRangeAction = new QAction(tr("Sync Date Range..."), this);
+ m_statusLabelStatus->addAction(syncRangeAction);
+
+ QAction *scanToTipAction = new QAction(tr("Sync Unconfirmed"), this);
+ m_statusLabelStatus->addAction(scanToTipAction);
+
+ QAction *fullSyncAction = new QAction(tr("Full Sync"), this);
+ m_statusLabelStatus->addAction(fullSyncAction);
+
+ QAction *scanTxAction = new QAction(tr("Import Transaction"), this);
+ m_statusLabelStatus->addAction(scanTxAction);
+
+ m_updateNetworkInfoAction = new QAction(tr("Scan mempool when paused"), this);
+ m_statusLabelStatus->addAction(m_updateNetworkInfoAction);
+
+ connect(m_actionPauseSync, &QAction::toggled, this, [this](bool checked) {
+ qInfo() << "Pause Sync toggled. Checked =" << checked;
+ conf()->set(Config::syncPaused, checked);
+ });
+
+ connect(skipSyncAction, &QAction::triggered, this, [this](){
+ if (!m_wallet) return;
+
+ QString msg = tr("Skip sync will set your wallet's restore height to the current network height.\n\n"
+ "Use this if you know you haven't received any transactions since your last sync.\n"
+ "You can always use 'Full Sync' to rescan from the beginning.\n\n"
+ "Continue?");
+
+ if (QMessageBox::question(this, tr("Skip Sync"), msg) == QMessageBox::Yes) {
+ m_wallet->skipToTip();
+ this->setStatusText(tr("Skipped sync to tip."));
+ }
+ });
+
+ connect(syncRangeAction, &QAction::triggered, this, [this](){
+ if (!m_wallet) return;
+
+ SyncRangeDialog dialog(this, m_wallet);
+ if (dialog.exec() == QDialog::Accepted) {
+ m_wallet->syncDateRange(dialog.fromDate(), dialog.toDate());
+
+ this->setStatusText(tr("Syncing range %1 - %2 (~%3 blocks)\nEst. download size: %4")
+ .arg(dialog.fromDate().toString("yyyy-MM-dd"))
+ .arg(dialog.toDate().toString("yyyy-MM-dd"))
+ .arg(QLocale().toString(dialog.estimatedBlocks()))
+ .arg(Utils::formatBytes(dialog.estimatedSize())));
+ }
+ });
+
+ connect(scanToTipAction, &QAction::triggered, this, [this](){
+ if (!m_wallet) return;
+
+ QString msg = tr("Sync Unconfirmed (Smart Sync) will scan only the specific blocks required to unlock your pending funds (e.g. 10 confirmations).\n\n"
+ "This minimizes data usage by pausing immediately after verification.\n\n"
+ "Continue?");
+
+ if (QMessageBox::question(this, tr("Sync Unconfirmed"), msg) == QMessageBox::Yes) {
+ m_wallet->startSmartSync();
+ this->setStatusText(tr("Scanning to tip..."));
+ }
+ });
+
+ connect(fullSyncAction, &QAction::triggered, this, [this](){
+ if (m_wallet) {
+ QString estBlocks = "Unknown (waiting for node)";
+ QString estSize = "Unknown";
+
+ quint64 walletCreationHeight = m_wallet->getWalletCreationHeight();
+ quint64 daemonHeight = m_wallet->daemonBlockChainHeight();
+ quint64 blocksBehind = 0;
+
+ if (daemonHeight > 0) {
+ blocksBehind = (daemonHeight > walletCreationHeight) ? (daemonHeight - walletCreationHeight) : 0;
+ quint64 estimatedBytes = Utils::estimateSyncDataSize(blocksBehind);
+ estBlocks = QLocale().toString(blocksBehind);
+ estSize = QString("~%1").arg(Utils::formatBytes(estimatedBytes));
+ }
+
+ QString msg = tr("Full sync will rescan from your restore height.\n\n"
+ "Blocks to scan: %1\n"
+ "Estimated data: %2\n\n"
+ "Note: Cached blocks will be skipped.\n\n"
+ "Continue?")
+ .arg(estBlocks)
+ .arg(estSize);
+
+ if (QMessageBox::question(this, tr("Full Sync"), msg) == QMessageBox::Yes) {
+ m_wallet->fullSync();
+ if (estBlocks.startsWith("Unknown")) {
+ this->setStatusText(tr("Full sync started..."));
+ } else {
+ this->setStatusText(tr("Full sync started (%1 blocks)...").arg(estBlocks));
+ }
+ }
+ }
+ });
+
+ connect(scanTxAction, &QAction::triggered, this, &MainWindow::importTransaction);
}
void MainWindow::initPlugins() {
connect(m_walletUnlockWidget, &WalletUnlockWidget::closeWallet, this, &MainWindow::close);
connect(m_walletUnlockWidget, &WalletUnlockWidget::unlockWallet, this, &MainWindow::unlockWallet);
+ ui->tabWidget->setCurrentIndex(0);
ui->tabWidget->setCurrentIndex(0);
ui->stackedWidget->setCurrentIndex(0);
+
+ // Restore last network info update time
+ qulonglong lastNetInfoUpdate = conf()->get(Config::lastNetInfoUpdate).toULongLong();
+ if (lastNetInfoUpdate > 0) {
+ m_lastNetInfoUpdate = QDateTime::fromSecsSinceEpoch(lastNetInfoUpdate);
+ }
}
void MainWindow::initMenu() {
ui->radio_airgapUR->setChecked(true);
}
+ m_updateNetworkInfoAction->setCheckable(true);
+ connect(m_updateNetworkInfoAction, &QAction::toggled, this, [this](bool checked) {
+ if (!m_wallet) return;
+
+ m_wallet->setScanMempoolWhenPaused(checked);
+
+ if (checked) {
+ // Ensure we are connected if enabling
+ if (m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) {
+ m_nodes->connectToNode();
+ }
+ }
+ });
+
+
+ // We do NOT want to start syncing yet here, wait for wallet to be opened
// We can't use rich text for radio buttons
connect(ui->label_airgapUR, &ClickableLabel::clicked, [this] {
ui->radio_airgapUR->setChecked(true);
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());
+ m_wallet->setRefreshInterval(constants::defaultRefreshInterval);
+
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));
+ balance_str += QString(" <font color='#ffd60a'>(+%1 XMR unconfirmed)</font>").arg(WalletManager::displayAmount(balance - spendable, false, decimals));
}
}
+ // Show fiat currency if configured and balance is not hidden or spendable only.
if (conf()->get(Config::balanceShowFiat).toBool() && !hide) {
QString fiatCurrency = conf()->get(Config::preferredFiatCurrency).toString();
double balanceFiatAmount = appData()->prices.convert("XMR", fiatCurrency, balance / constants::cdiv);
- balance_str += QString(" (%1)").arg(Utils::amountToCurrencyString(balanceFiatAmount, fiatCurrency));
+ bool isCacheValid = appData()->prices.lastUpdateTime.isValid();
+ bool hasXmrPrice = appData()->prices.markets.contains("XMR");
+ bool hasFiatRate = fiatCurrency == "USD" || appData()->prices.rates.contains(fiatCurrency);
+
+ if (balance > 0 && (balanceFiatAmount == 0.0 || !isCacheValid)) {
+ if (conf()->get(Config::offlineMode).toBool() || conf()->get(Config::disableWebsocket).toBool() || m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) {
+ balance_str += " (offline)";
+ } else if (!hasXmrPrice || !hasFiatRate) {
+ balance_str += " (connecting)";
+ } else {
+ balance_str += " (unknown)";
+ }
+ } else {
+ QString approx = !conf()->get(Config::disableWebsocket).toBool() ? "" : "~ ";
+ balance_str += QString(" (%1%2)").arg(approx, Utils::amountToCurrencyString(balanceFiatAmount, fiatCurrency));
+ }
}
- m_statusLabelBalance->setToolTip("Click for details");
+ this->updateStatusToolTip();
+
+
m_statusLabelBalance->setText(balance_str);
+ m_statusLabelBalance->setProperty("copyableValue", copyableVal);
+}
+
+void MainWindow::updateStatusToolTip() {
+ QString toolTip = "Right-click for details";
+ if (appData()->prices.lastUpdateTime.isValid()) {
+ toolTip += QString("\nFiat updated: %1").arg(Utils::timeAgo(appData()->prices.lastUpdateTime));
+ }
+
+ m_statusLabelBalance->setToolTip(toolTip);
+
+ this->updateSyncStatusToolTip();
+}
+
+void MainWindow::updateSyncStatusToolTip() {
+ if (!m_wallet) return;
+
+ // Throttle updates to 1s to prevent overwhelming the event loop (e.g. m_updateBytes timer)
+ static QDateTime lastUpdate = QDateTime::currentDateTime();
+ if (lastUpdate.msecsTo(QDateTime::currentDateTime()) < 1000) {
+ return;
+ }
+ lastUpdate = QDateTime::currentDateTime();
+
+ bool isPaused = conf()->get(Config::syncPaused).toBool();
+
+ quint64 walletHeight = m_wallet->blockChainHeight();
+ quint64 daemonHeight = m_wallet->daemonBlockChainHeight();
+ quint64 blocksBehind = (daemonHeight > walletHeight) ? (daemonHeight - walletHeight) : 0;
+
+ // Build tooltip
+ QString tooltip = tr("Daemon Height: %1").arg(QLocale().toString(daemonHeight));
+
+ if (conf()->get(Config::lastNetInfoUpdate).toULongLong() > 0) {
+ tooltip += tr("\nLast network update: %1").arg(
+ Utils::timeAgo(QDateTime::fromSecsSinceEpoch(conf()->get(Config::lastNetInfoUpdate).toULongLong()))
+ );
+ }
+
+ if (blocksBehind > 0) {
+ tooltip += tr("\n~%1 blocks behind").arg(QLocale().toString(blocksBehind));
+ }
+
+ m_statusLabelStatus->setToolTip(tooltip);
}
void MainWindow::setStatusText(const QString &text, bool override, int timeout) {
+
if (override) {
m_statusOverrideActive = true;
+ // qDebug() << "STATUS (override):" << text;
m_statusLabelStatus->setText(text);
QTimer::singleShot(timeout, [this]{
m_statusOverrideActive = false;
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)) {
+ m_lastNetInfoUpdate = QDateTime::currentDateTime();
+
+ // Persist to global config (throttled to syncInterval)
+ static QDateTime lastConfigSave = QDateTime::currentDateTime();
+ int interval = constants::defaultRefreshInterval;
+ if (lastConfigSave.secsTo(QDateTime::currentDateTime()) > interval) {
+ conf()->set(Config::lastNetInfoUpdate, static_cast<qulonglong>(m_lastNetInfoUpdate.toSecsSinceEpoch()));
+ lastConfigSave = QDateTime::currentDateTime();
+ }
+
+ // qDebug() << "onSyncStatus: Height" << height << "Target" << target << "DaemonSync" << daemonSync;
+
+ quint64 blocksBehind = Utils::blocksBehind(height, target);
+
+ // Throttle UI updates to 10Hz to prevent spam during sync
+ static QDateTime lastThrottleTime = QDateTime::currentDateTime();
+ if (height < (target - 1) && lastThrottleTime.msecsTo(QDateTime::currentDateTime()) < 100) {
+ return;
+ }
+ lastThrottleTime = QDateTime::currentDateTime();
+
+ if (height >= (target - 1) && target > 0) {
+ m_lastSyncStatusUpdate = QDateTime::currentDateTime();
+
this->updateNetStats();
+ // this->setStatusText(tr("Synchronized")); // TODO: do we need this?
+
+ // Persist sync state for next boot
+ conf()->set(Config::lastKnownNetworkHeight, static_cast<qulonglong>(target));
+ m_wallet->setCacheAttribute("feather.lastSync", QString::number(QDateTime::currentSecsSinceEpoch()));
+ } else {
+ if (target == 0) {
+ this->setStatusText(tr("Connecting..."));
+ return;
+ }
+
+ QString blocksStr = QLocale().toString(blocksBehind);
+ if (conf()->get(Config::syncPaused).toBool()) {
+ this->setStatusText(this->getPausedStatusText());
+ } else {
+ QString type = daemonSync ? tr("Blockchain") : tr("Wallet");
+ this->setStatusText(tr("%1 sync: %2 blocks behind").arg(type, blocksStr));
+ }
}
- this->setStatusText(Utils::formatSyncStatus(height, target, daemonSync));
- m_statusLabelStatus->setToolTip(QString("Wallet height: %1").arg(QString::number(height)));
+
+ this->updateSyncStatusToolTip();
}
void MainWindow::onConnectionStatusChanged(int status)
qDebug() << "Wallet connection status changed " << Utils::QtEnumToString(static_cast<Wallet::ConnectionStatus>(status));
+ if (m_updateNetworkInfoAction) { // Maybe not initialized first call
+ m_updateNetworkInfoAction->setEnabled(true);
+ }
+
// Update connection info in status bar.
QIcon icon;
+ QString statusStr;
if (conf()->get(Config::offlineMode).toBool()) {
icon = icons()->icon("status_offline.svg");
- this->setStatusText("Offline mode");
+ statusStr = "Offline mode";
} else {
switch(status){
+ case Wallet::ConnectionStatus_Idle:
+ {
+ // If "Scan Mempool" is active, we show "Idle" (connected/active)
+ if (m_updateNetworkInfoAction->isChecked()) {
+ if (conf()->get(Config::proxy).toInt() == Config::Proxy::Tor) {
+ icon = icons()->icon("status_idle_proxy.svg");
+ } else {
+ icon = icons()->icon("status_idle.svg");
+ }
+ } else {
+ // "True Idle" - just waiting, no network activity
+ icon = icons()->icon("status_waiting.svg");
+ }
+ statusStr = this->getPausedStatusText();
+ m_statusLabelNetStats->hide();
+ break;
+ }
case Wallet::ConnectionStatus_Disconnected:
- icon = icons()->icon("status_disconnected.svg");
- this->setStatusText("Disconnected");
+ {
+ icon = icons()->icon("status_offline.svg");
+ statusStr = "Disconnected";
+
+ // If we are waiting for a retry or scheduled sync, show that instead of "Disconnected"
+ if (m_wallet) {
+ qint64 seconds = m_wallet->secondsUntilNextRefresh();
+ if (seconds > 0) {
+ QString timeStr;
+ if (seconds > 60) timeStr = QString("%1 min").arg((seconds + 59) / 60);
+ else timeStr = QString("%1s").arg(seconds);
+ statusStr = tr("Disconnected (Retry in %1)").arg(timeStr);
+ } else if (m_wallet->lastSyncTime().isValid()) {
+ // Fallback to estimation only if not waiting for retry
+ qint64 secsSinceLastSync = m_wallet->lastSyncTime().secsTo(QDateTime::currentDateTime());
+ quint64 estimatedBlocksBehind = std::max(qint64(0), secsSinceLastSync) / 120;
+ if (estimatedBlocksBehind > 0) {
+ statusStr = tr("~%1 blocks behind").arg(QLocale().toString(estimatedBlocksBehind));
+ }
+ }
+ }
break;
+ }
case Wallet::ConnectionStatus_Connecting:
icon = icons()->icon("status_lagging.svg");
- this->setStatusText("Connecting to node");
+ statusStr = "Connecting to node";
break;
case Wallet::ConnectionStatus_WrongVersion:
icon = icons()->icon("status_disconnected.svg");
- this->setStatusText("Incompatible node");
+ statusStr = "Node Incompatible";
break;
case Wallet::ConnectionStatus_Synchronizing:
icon = icons()->icon("status_waiting.svg");
+ statusStr = "Synchronizing";
break;
case Wallet::ConnectionStatus_Synchronized:
- icon = icons()->icon("status_connected.svg");
+ if (conf()->get(Config::proxy).toInt() == Config::Proxy::Tor) {
+ icon = icons()->icon("status_connected_proxy.svg");
+ } else {
+ icon = icons()->icon("status_connected.svg");
+ }
+ statusStr = "Synchronized";
break;
default:
icon = icons()->icon("status_disconnected.svg");
+ statusStr = "Disconnected";
break;
}
}
+ this->setStatusText(statusStr);
+
+ this->updateSyncStatusToolTip();
+
+ if (m_wallet) {
+ quint64 walletHeight = m_wallet->blockChainHeight();
+ quint64 daemonHeight = m_wallet->daemonBlockChainHeight();
+ quint64 targetHeight = m_wallet->daemonBlockChainTargetHeight();
+
+ if (walletHeight > 0) {
+ statusStr += QString("\nWallet %1. Daemon %2. Network %3")
+ .arg(QLocale::system().toString(walletHeight))
+ .arg(QLocale::system().toString(daemonHeight))
+ .arg(QLocale::system().toString(targetHeight));
+ }
+ }
+ // m_statusBtnConnectionStatusIndicator->setToolTip(statusStr);
m_statusBtnConnectionStatusIndicator->setIcon(icon);
+ this->updateBalance();
}
void MainWindow::onTransactionCreated(PendingTransaction *tx, const QVector<QString> &address) {
#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"));
}
}
- TxImportDialog dialog(this, m_wallet);
+ // Ensure connection for Data Saving Mode
+ if (m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) {
+ m_nodes->connectToNode();
+ }
+
+ TxImportDialog dialog(this, m_wallet, m_nodes);
dialog.exec();
}
}
}
m_statusBtnHwDevice->setIcon(this->hardwareDevicePairedIcon());
- m_wallet->startRefresh();
m_showDeviceError = false;
}
}
}
+QString MainWindow::getPausedStatusText() {
+ if (!m_wallet) return tr("Sync Paused");
+
+ quint64 walletHeight = m_wallet->blockChainHeight();
+ quint64 targetHeight = m_wallet->daemonBlockChainTargetHeight();
+
+ if (walletHeight > 0 && targetHeight > 0) {
+ quint64 blocksBehind = Utils::blocksBehind(walletHeight, targetHeight);
+ if (blocksBehind > 0) {
+ return tr("[SYNC PAUSED] - %1 blocks behind").arg(QLocale().toString(blocksBehind));
+ }
+ }
+ return tr("[SYNC PAUSED]");
+}
+
void MainWindow::updateNetStats() {
+ static quint64 prevBytes = 0;
+ static int trafficCooldown = 0;
+
+ quint64 currBytes = m_wallet ? m_wallet->getBytesReceived() : 0;
+ if (currBytes > prevBytes) {
+ trafficCooldown = 3; // Keep visible for 3 cycles (~3 seconds)
+ } else if (trafficCooldown > 0) {
+ trafficCooldown--;
+ }
+ prevBytes = currBytes;
+
+ bool showTraffic = trafficCooldown > 0;
+
if (!m_wallet || m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected
- || m_wallet->connectionStatus() == Wallet::ConnectionStatus_Synchronized)
+ || (m_wallet->connectionStatus() == Wallet::ConnectionStatus_Synchronized && !m_coinsRefreshing && !showTraffic))
{
m_statusLabelNetStats->hide();
+
+ if (m_wallet && m_wallet->connectionStatus() == Wallet::ConnectionStatus_Synchronized) {
+ qint64 seconds = m_wallet->secondsUntilNextRefresh();
+ this->setStatusText(tr("Synchronized"));
+ // if (seconds > 0) { ... } // Removed countdown display per user feedback
+ }
+ else if (m_wallet && m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) {
+ qint64 seconds = m_wallet->secondsUntilNextRefresh();
+ if (seconds > 0) {
+ QString timeStr;
+ if (seconds > 60) timeStr = QString("%1 min").arg((seconds + 59) / 60);
+ else timeStr = QString("%1s").arg(seconds);
+ this->setStatusText(QString("Disconnected (Retry in %1)").arg(timeStr));
+ } else {
+ if (conf()->get(Config::syncPaused).toBool()) {
+ this->setStatusText(this->getPausedStatusText());
+ } else {
+ this->setStatusText(tr("Connecting..."));
+ }
+ }
+ }
+ return;
+ }
+
+ if (conf()->get(Config::syncPaused).toBool()) {
+ m_statusLabelNetStats->hide();
+ this->setStatusText(this->getPausedStatusText());
return;
}
+ if (m_wallet && m_wallet->connectionStatus() == Wallet::ConnectionStatus_Synchronized) {
+ this->setStatusText(tr("Synchronized"));
+ }
+
m_statusLabelNetStats->show();
m_statusLabelNetStats->setText(QString("(D: %1)").arg(Utils::formatBytes(m_wallet->getBytesReceived())));
}
"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 {
MainWindow::~MainWindow() {
qDebug() << "~MainWindow" << QThread::currentThreadId();
}
+
+void MainWindow::setSyncPaused(bool checked) {
+ if (m_actionPauseSync && m_actionPauseSync->isChecked() != checked) {
+ const QSignalBlocker blocker(m_actionPauseSync);
+ m_actionPauseSync->setChecked(checked);
+ }
+
+ if (m_wallet) {
+ if (checked) {
+ qInfo() << "Pausing sync via setSyncPaused";
+ m_wallet->setSyncPaused(true);
+ m_nodes->disconnectCurrentNode();
+ this->onConnectionStatusChanged(Wallet::ConnectionStatus_Disconnected);
+ } else {
+ qInfo() << "Resuming sync via setSyncPaused";
+ m_wallet->setSyncPaused(false);
+ m_nodes->connectToNode();
+
+ if (!conf()->get(Config::disableWebsocket).toBool()) {
+ websocketNotifier()->websocketClient->restart();
+ }
+ this->setStatusText(tr("Resuming sync..."));
+ }
+ }
+}
#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);
+ void setSyncPaused(bool paused);
+ QString getPausedStatusText();
int findTab(const QString &title);
QIcon hardwareDevicePairedIcon();
CoinsWidget *m_coinsWidget = nullptr;
QPointer<QAction> m_clearRecentlyOpenAction;
+ QPointer<QAction> m_updateNetworkInfoAction;
+ QPointer<QAction> m_actionEnableWebsocket;
+ QPointer<QAction> m_actionPauseSync;
+
+ QDateTime m_lastSyncStatusUpdate;
+ QDateTime m_lastNetInfoUpdate;
// lower status bar
QPushButton *m_statusUpdateAvailable;
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
#include "SendWidget.h"
#include "ui_SendWidget.h"
+#include <QMessageBox>
+
#include "ColorScheme.h"
#include "constants.h"
#include "utils/AppData.h"
#include "Icons.h"
#include "libwalletqt/Wallet.h"
#include "libwalletqt/WalletManager.h"
+#include "WindowManager.h"
#if defined(WITH_SCANNER)
#include "wizard/offline_tx_signing/OfflineTxSigningWizard.h"
}
void SendWidget::sendClicked() {
- if (!m_wallet->isConnected()) {
+ if (conf()->get(Config::syncPaused).toBool()) {
+ QMessageBox msgBox(this);
+ msgBox.setIcon(QMessageBox::Warning);
+ msgBox.setWindowTitle("Are you sure? Create transaction?");
+ msgBox.setText("<b>Are you sure? Create transaction?</b>");
+ msgBox.setInformativeText("Wallet sync is paused. This may result in an invalid transaction or balance.\n\n• Go to File -> Settings -> Network -> Sync to resume sync.");
+ msgBox.setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel | QMessageBox::Help);
+ msgBox.setDefaultButton(QMessageBox::Cancel);
+
+ auto ret = msgBox.exec();
+
+ if (ret == QMessageBox::Help) {
+ WindowManager::instance()->showDocs(this, "synchronization");
+ return;
+ }
+
+ if (ret != QMessageBox::Ok) {
+ return;
+ }
+ }
+
+ if (!m_wallet->isConnected() && !conf()->get(Config::syncPaused).toBool()) {
Utils::showError(this, "Unable to create transaction", "Wallet is not connected to a node.",
{"Wait for the wallet to automatically connect to a node.", "Go to File -> Settings -> Network -> Node to manually connect to a node."},
"nodes");
return;
}
- if (!m_wallet->isSynchronized()) {
+ if (!m_wallet->isSynchronized() && !conf()->get(Config::syncPaused).toBool()) {
Utils::showError(this, "Unable to create transaction", "Wallet is not synchronized", {"Wait for wallet synchronization to complete"}, "synchronization");
return;
}
void Settings::setupNetworkTab() {
// Node
- if (m_nodes) {
- ui->nodeWidget->setupUI(m_nodes);
- connect(ui->nodeWidget, &NodeWidget::nodeSourceChanged, m_nodes, &Nodes::onNodeSourceChanged);
- connect(ui->nodeWidget, &NodeWidget::connectToNode, m_nodes, QOverload<const FeatherNode&>::of(&Nodes::connectToNode));
- } else {
- m_nodes = new Nodes(this, nullptr);
- ui->nodeWidget->setupUI(m_nodes);
- ui->nodeWidget->setCanConnect(false);
- }
+ std::function<void()> setupNodeWidget = [this]{
+ if (m_nodes) {
+ ui->nodeWidget->setupUI(m_nodes);
+ connect(ui->nodeWidget, &NodeWidget::nodeSourceChanged, m_nodes, &Nodes::onNodeSourceChanged);
+ connect(ui->nodeWidget, &NodeWidget::connectToNode, m_nodes, QOverload<const FeatherNode&>::of(&Nodes::connectToNode));
+ } else {
+ m_nodes = new Nodes(this, nullptr);
+ ui->nodeWidget->setupUI(m_nodes);
+ ui->nodeWidget->setCanConnect(false);
+ }
+ };
+ setupNodeWidget();
+
+
// Proxy
connect(ui->proxyWidget, &NetworkProxyWidget::proxySettingsChanged, this, &Settings::onProxySettingsChanged);
+ // Offline mode
+ ui->checkBox_offlineMode->setChecked(conf()->get(Config::offlineMode).toBool());
+ connect(ui->checkBox_offlineMode, &QCheckBox::toggled, [this](bool checked){
+ conf()->set(Config::offlineMode, checked);
+ this->enableWebsocket(!checked && !conf()->get(Config::disableWebsocket).toBool());
+ emit offlineMode(checked);
+ });
+
// Websocket
// [Obtain third-party data]
ui->checkBox_enableWebsocket->setChecked(!conf()->get(Config::disableWebsocket).toBool());
this->enableWebsocket(checked);
});
- // Overview
- ui->checkBox_offlineMode->setChecked(conf()->get(Config::offlineMode).toBool());
- connect(ui->checkBox_offlineMode, &QCheckBox::toggled, [this](bool checked){
- conf()->set(Config::offlineMode, checked);
- emit offlineMode(checked);
- this->enableWebsocket(!checked);
- });
+ // Add to Node tab
+ if (auto *layout = qobject_cast<QVBoxLayout*>(ui->Node->layout())) {
+
+ // Sync (Data Saving Mode)
+ QCheckBox *cbDataSaver = new QCheckBox("Data Saving Mode (Pause Sync on startup)", this);
+ cbDataSaver->setChecked(conf()->get(Config::syncPaused).toBool());
+ cbDataSaver->setToolTip("Prevents the wallet from automatically connecting to nodes on startup.");
+
+ connect(cbDataSaver, &QCheckBox::toggled, [](bool checked){
+ conf()->set(Config::syncPaused, checked);
+ });
+ layout->addWidget(cbDataSaver);
+ }
}
void Settings::setupStorageTab() {
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);
m_wizard->raise();
m_wizard->activateWindow();
}
+ else if (m_openingWallet && m_splashDialog) {
+ m_splashDialog->show();
+ m_splashDialog->raise();
+ m_splashDialog->activateWindow();
+ }
else {
// This shouldn't happen
this->close();
}
m_openingWallet = true;
+ m_splashDialog->setMessage("Opening wallet...");
+ m_splashDialog->show();
m_walletManager->openWalletAsync(path, password, constants::networkType, constants::kdfRounds, Utils::ringDatabasePath());
}
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;
};
<file>assets/images/status_connected_proxy.svg</file>
<file>assets/images/status_connected.svg</file>
<file>assets/images/status_disconnected.svg</file>
+ <file>assets/images/status_idle_proxy.svg</file>
+ <file>assets/images/status_idle.svg</file>
<file>assets/images/status_lagging.svg</file>
<file>assets/images/status_offline.svg</file>
<file>assets/images/status_waiting.svg</file>
Name=Feather Wallet
GenericName=Monero Wallet
Comment=A free Monero desktop wallet
-Icon=feather
+Icon=FeatherWallet
Exec=feather
Terminal=false
Categories=Network;
--- /dev/null
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ version="1.0"
+ id="svg7854"
+ height="512"
+ width="512"
+ viewBox="9 9 30 30">
+ <defs
+ id="defs7856">
+ <linearGradient
+ id="linearGradient860">
+ <stop
+ id="stop856"
+ offset="0"
+ style="stop-color:#90bb65;stop-opacity:1" />
+ <stop
+ id="stop858"
+ offset="1"
+ style="stop-color:#6ac017;stop-opacity:1" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient7577">
+ <stop
+ id="stop7579"
+ offset="0"
+ style="stop-color:#000000;stop-opacity:0.3137255;" />
+ <stop
+ id="stop7581"
+ offset="1"
+ style="stop-color:#ffffff;stop-opacity:1" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient5167">
+ <stop
+ style="stop-color:#76d717;stop-opacity:1"
+ offset="0"
+ id="stop5169" />
+ <stop
+ style="stop-color:#509e07;stop-opacity:1"
+ offset="1"
+ id="stop5171" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient5184">
+ <stop
+ style="stop-color:white;stop-opacity:1;"
+ offset="0"
+ id="stop5186" />
+ <stop
+ style="stop-color:white;stop-opacity:0;"
+ offset="1"
+ id="stop5188" />
+ </linearGradient>
+ <linearGradient
+ gradientTransform="matrix(2.1074616,0,0,2.1078593,-9.43551,-10.006786)"
+ y2="17.024479"
+ x2="16.657505"
+ y1="10.883683"
+ x1="15.011773"
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient8317"
+ xlink:href="#linearGradient7577" />
+ <radialGradient
+ gradientTransform="matrix(1.897257,0,0,1.897615,-6.10046,-6.6146433)"
+ r="7.5896134"
+ fy="20.410854"
+ fx="15.865708"
+ cy="20.410854"
+ cx="15.865708"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient8319"
+ xlink:href="#linearGradient5167" />
+ <radialGradient
+ r="5.96875"
+ fy="11.308558"
+ fx="14.05685"
+ cy="11.308558"
+ cx="14.05685"
+ gradientTransform="matrix(-4.2002315,0.5953403,0.2958442,2.0989386,75.31118,-18.732928)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient8321"
+ xlink:href="#linearGradient5184" />
+ <linearGradient
+ gradientTransform="matrix(1.7591324,0,0,1.7580929,-3.90899,-4.3562887)"
+ y2="26.431587"
+ x2="13.458839"
+ y1="2.0178134"
+ x1="8.9317284"
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient8323"
+ xlink:href="#linearGradient860" />
+ </defs>
+ <g id="layer1">
+ <ellipse
+ ry="14.997972"
+ rx="14.995141"
+ style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.4;fill:url(#linearGradient8317);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.11079514;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none"
+ id="path7691"
+ cx="24.00086"
+ cy="24.002029" />
+ <ellipse
+ ry="13.502028"
+ rx="13.49948"
+ cy="24.002029"
+ cx="24.000866"
+ id="path7968"
+ style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:url(#radialGradient8319);fill-opacity:0.15;fill-rule:nonzero;stroke:#336402;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none" />
+ <path
+ id="path7970"
+ d="M 25.3861,13.485003 C 20.31979,12.724926 15.45183,15.857848 14,20.764516 c 1.18871,3.18039 3.90811,5.70993 7.46677,6.47724 5.29459,1.141602 10.50115,-2.027543 12.01505,-7.143895 -1.18869,-3.180413 -3.90812,-5.709952 -7.46675,-6.477239 -0.217,-0.04678 -0.41248,-0.103152 -0.62897,-0.135619 z"
+ style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.2;fill:url(#radialGradient8321);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.09465754;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none" />
+ <ellipse
+ ry="12.509292"
+ rx="12.516688"
+ cy="24.009293"
+ cx="24.000891"
+ style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.54494413;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:url(#linearGradient8323);stroke-width:1.00000215;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none"
+ id="path7972" />
+ </g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ version="1.0"
+ id="svg7854"
+ height="512"
+ width="512"
+ viewBox="9 9 30 30">
+ <defs
+ id="defs7856">
+ <linearGradient
+ id="linearGradient860">
+ <stop
+ id="stop856"
+ offset="0"
+ style="stop-color:#479fc6;stop-opacity:1" />
+ <stop
+ id="stop858"
+ offset="1"
+ style="stop-color:#0c89c1;stop-opacity:1" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient7577">
+ <stop
+ id="stop7579"
+ offset="0"
+ style="stop-color:#000000;stop-opacity:0.3137255;" />
+ <stop
+ id="stop7581"
+ offset="1"
+ style="stop-color:#ffffff;stop-opacity:1" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient5167">
+ <stop
+ style="stop-color:#0090ef;stop-opacity:1"
+ offset="0"
+ id="stop5169" />
+ <stop
+ style="stop-color:#0062b2;stop-opacity:1"
+ offset="1"
+ id="stop5171" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient5184">
+ <stop
+ style="stop-color:white;stop-opacity:1;"
+ offset="0"
+ id="stop5186" />
+ <stop
+ style="stop-color:white;stop-opacity:0;"
+ offset="1"
+ id="stop5188" />
+ </linearGradient>
+ <linearGradient
+ gradientTransform="matrix(2.1074616,0,0,2.1078593,-9.43551,-10.006786)"
+ y2="17.024479"
+ x2="16.657505"
+ y1="10.883683"
+ x1="15.011773"
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient8317"
+ xlink:href="#linearGradient7577" />
+ <radialGradient
+ gradientTransform="matrix(1.897257,0,0,1.897615,-6.10046,-6.6146433)"
+ r="7.5896134"
+ fy="20.410854"
+ fx="15.865708"
+ cy="20.410854"
+ cx="15.865708"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient8319"
+ xlink:href="#linearGradient5167" />
+ <radialGradient
+ r="5.96875"
+ fy="11.308558"
+ fx="14.05685"
+ cy="11.308558"
+ cx="14.05685"
+ gradientTransform="matrix(-4.2002315,0.5953403,0.2958442,2.0989386,75.31118,-18.732928)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient8321"
+ xlink:href="#linearGradient5184" />
+ <linearGradient
+ gradientTransform="matrix(1.7591324,0,0,1.7580929,-3.90899,-4.3562887)"
+ y2="26.431587"
+ x2="13.458839"
+ y1="2.0178134"
+ x1="8.9317284"
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient8323"
+ xlink:href="#linearGradient860" />
+ </defs>
+ <g id="layer1">
+ <ellipse
+ ry="14.997972"
+ rx="14.995141"
+ style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.4;fill:url(#linearGradient8317);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.11079514;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none"
+ id="path7691"
+ cx="24.00086"
+ cy="24.002029" />
+ <ellipse
+ ry="13.502028"
+ rx="13.49948"
+ cy="24.002029"
+ cx="24.000866"
+ id="path7968"
+ style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:url(#radialGradient8319);fill-opacity:0.15;fill-rule:nonzero;stroke:#003f70;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none" />
+ <path
+ id="path7970"
+ d="M 25.3861,13.485003 C 20.31979,12.724926 15.45183,15.857848 14,20.764516 c 1.18871,3.18039 3.90811,5.70993 7.46677,6.47724 5.29459,1.141602 10.50115,-2.027543 12.01505,-7.143895 -1.18869,-3.180413 -3.90812,-5.709952 -7.46675,-6.477239 -0.217,-0.04678 -0.41248,-0.103152 -0.62897,-0.135619 z"
+ style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.2;fill:url(#radialGradient8321);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.09465754;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none" />
+ <ellipse
+ ry="12.509292"
+ rx="12.516688"
+ cy="24.009293"
+ cx="24.000891"
+ style="color:#000000;display:inline;overflow:visible;visibility:visible;opacity:0.54494413;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:url(#linearGradient8323);stroke-width:1.00000215;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;marker-start:none;marker-mid:none;marker-end:none"
+ id="path7972" />
+ </g>
+</svg>
ClickableLabel::~ClickableLabel() = default;
void ClickableLabel::mousePressEvent(QMouseEvent* event) {
- emit clicked();
+ if (event->button() == Qt::LeftButton) {
+ emit clicked();
+ }
+ QLabel::mousePressEvent(event);
}
WindowModalDialog::WindowModalDialog(QWidget *parent)
const quint64 kdfRounds = 1;
const QString seedLanguage = "English"; // todo: move me
+ const int defaultRefreshInterval = 30; // seconds
}
#endif //FEATHER_CONSTANTS_H
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."));
+
+ formLayout->addRow(tr("Day span:"), daysLayout);
+ formLayout->addRow(tr("Start date:"), m_fromDateEdit);
+ formLayout->addRow(tr("End date:"), m_toDateEdit);
+
+ layout->addLayout(formLayout);
+
+ m_infoLabel = new QLabel;
+ m_infoLabel->setWordWrap(true);
+ m_infoLabel->setStyleSheet("QLabel { color: #888; font-size: 11px; }");
+
+ layout->addWidget(m_infoLabel);
+
+ connect(m_fromDateEdit, &QDateEdit::dateChanged, this, &SyncRangeDialog::updateToDate);
+ connect(m_toDateEdit, &QDateEdit::dateChanged, this, &SyncRangeDialog::updateFromDate);
+ connect(m_daysSpinBox, QOverload<int>::of(&QSpinBox::valueChanged), this, &SyncRangeDialog::updateFromDate);
+
+ // Connect preset dropdown
+ connect(m_presetCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index) {
+ int days = m_presetCombo->itemData(index).toInt();
+ if (days == -1) {
+ // Custom mode: show spinbox, keep current value
+ m_daysSpinBox->setVisible(true);
+ } else {
+ // Preset mode: hide spinbox, set value
+ m_daysSpinBox->setVisible(false);
+ m_daysSpinBox->setValue(days);
+ }
+ });
+
+ // Init info
+ updateInfo();
+
+ auto *btnBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
+ connect(btnBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
+ connect(btnBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ layout->addWidget(btnBox);
+
+ resize(320, height());
+}
+
+QDate SyncRangeDialog::fromDate() const {
+ return m_fromDateEdit->date();
+}
+
+QDate SyncRangeDialog::toDate() const {
+ return m_toDateEdit->date();
+}
+
+quint64 SyncRangeDialog::estimatedBlocks() const {
+ return m_estimatedBlocks;
+}
+
+quint64 SyncRangeDialog::estimatedSize() const {
+ return m_estimatedSize;
+}
+
+void SyncRangeDialog::updateInfo() {
+ NetworkType::Type nettype = m_wallet->nettype();
+ QString filename = Utils::getRestoreHeightFilename(nettype);
+ std::unique_ptr<RestoreHeightLookup> lookup(RestoreHeightLookup::fromFile(filename, nettype));
+ if (!lookup || lookup->data.isEmpty()) {
+ m_infoLabel->setText(tr("Unable to estimate - restore height data unavailable"));
+ m_estimatedBlocks = 0;
+ m_estimatedSize = 0;
+ return;
+ }
+
+ QDate start = m_fromDateEdit->date();
+ QDate end = m_toDateEdit->date();
+
+ uint64_t startHeight = lookup->dateToHeight(start.startOfDay().toSecsSinceEpoch());
+ uint64_t endHeight = lookup->dateToHeight(end.endOfDay().toSecsSinceEpoch());
+
+ if (endHeight < startHeight) endHeight = startHeight;
+ m_estimatedBlocks = endHeight - startHeight;
+ m_estimatedSize = Utils::estimateSyncDataSize(m_estimatedBlocks);
+
+ m_infoLabel->setText(tr("Scanning ~%1 blocks\nEst. download size: %2")
+ .arg(m_estimatedBlocks)
+ .arg(Utils::formatBytes(m_estimatedSize)));
+}
+
+void SyncRangeDialog::updateFromDate() {
+ m_fromDateEdit->setDate(m_toDateEdit->date().addDays(-m_daysSpinBox->value()));
+ updateInfo();
+}
+
+void SyncRangeDialog::updateToDate() {
+ m_toDateEdit->setDate(m_fromDateEdit->date().addDays(m_daysSpinBox->value()));
+ updateInfo();
+}
--- /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();
+ void updateToDate();
+
+ Wallet *m_wallet;
+
+ // Date/Time
+ QComboBox *m_presetCombo;
+ QSpinBox *m_daysSpinBox;
+ QDateEdit *m_fromDateEdit;
+ QDateEdit *m_toDateEdit;
+
+ QLabel *m_infoLabel;
+
+ quint64 m_estimatedBlocks = 0;
+ quint64 m_estimatedSize = 0;
+};
+
+#endif //FEATHER_SYNCRANGEDIALOG_H
#include <QMessageBox>
#include "utils/NetworkManager.h"
+#include "utils/nodes.h"
-TxImportDialog::TxImportDialog(QWidget *parent, Wallet *wallet)
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QNetworkReply>
+
+
+
+TxImportDialog::TxImportDialog(QWidget *parent, Wallet *wallet, Nodes *nodes)
: WindowModalDialog(parent)
, ui(new Ui::TxImportDialog)
, m_wallet(wallet)
+ , m_nodes(nodes)
{
ui->setupUi(this);
connect(ui->btn_import, &QPushButton::clicked, this, &TxImportDialog::onImport);
+ connect(m_wallet, &Wallet::connectionStatusChanged, this, &TxImportDialog::updateStatus);
+ ui->line_txid->setMinimumWidth(600);
this->adjustSize();
+
+ this->layout()->setSizeConstraint(QLayout::SetFixedSize);
+
+ this->updateStatus(m_wallet->connectionStatus());
}
void TxImportDialog::onImport() {
- QString txid = ui->line_txid->text();
+ if (m_wallet->connectionStatus() == Wallet::ConnectionStatus_Disconnected) {
+ m_nodes->connectToNode();
+ m_wallet->setScanMempoolWhenPaused(true);
+ this->updateStatus(Wallet::ConnectionStatus_Connecting);
+ return;
+ }
+
+ QString txid = ui->line_txid->text().trimmed();
+ if (txid.isEmpty()) return;
if (m_wallet->haveTransaction(txid)) {
Utils::showWarning(this, "Transaction already exists in wallet", "If you can't find it in your history, "
return;
}
- if (m_wallet->importTransaction(txid)) {
- if (!m_wallet->haveTransaction(txid)) {
- Utils::showError(this, "Unable to import transaction", "This transaction does not belong to the wallet");
- return;
+ // Async Import: Fetch height from daemon, then Smart Sync to it.
+ ui->btn_import->setEnabled(false);
+ ui->btn_import->setText("Checking...");
+
+ QNetworkAccessManager* nam = getNetwork(); // Use global network manager
+ QString url = m_nodes->connection().toURL() + "/get_transactions";
+
+ QJsonObject req;
+ req["txs_hashes"] = QJsonArray({txid});
+
+ QNetworkRequest request(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+
+ QNetworkReply* reply = nam->post(request, QJsonDocument(req).toJson());
+
+ connect(reply, &QNetworkReply::finished, this, [this, reply, txid]() {
+ reply->deleteLater();
+ ui->btn_import->setEnabled(true);
+ ui->btn_import->setText("Import");
+
+ if (reply->error() != QNetworkReply::NoError) {
+ Utils::showError(this, "Connection error", reply->errorString());
+ return;
}
- Utils::showInfo(this, "Transaction imported successfully", "");
+
+ QJsonObject json = QJsonDocument::fromJson(reply->readAll()).object();
+ QJsonObject error = json.value("error").toObject();
+ if (!error.isEmpty()) {
+ Utils::showError(this, "Node error", error.value("message").toString());
+ return;
+ }
+
+ QJsonArray txs = json.value("txs").toArray();
+ bool found = false;
+
+ for (const auto &val : txs) {
+ QJsonObject tx = val.toObject();
+ if (tx.value("tx_hash").toString() == txid) {
+ found = true;
+ if (tx.value("in_pool").toBool()) {
+ Utils::showInfo(this, "Transaction is in mempool", "Feather will detect it automatically in a moment.");
+ this->accept();
+ return;
+ }
+
+ quint64 height = tx.value("block_height").toVariant().toULongLong();
+ if (height > 0) {
+ // Check if wallet is far behind (fresh restore?)
+ quint64 currentHeight = m_wallet->blockChainHeight();
+
+ if (height > currentHeight + 100000) {
+ // Jump ahead to avoid full scan
+ quint64 restoreHeight = (height > 20000) ? height - 20000 : 0;
+ m_wallet->setWalletCreationHeight(restoreHeight);
+ m_wallet->rescanBlockchainAsync();
+ Utils::showInfo(this, "Optimizing Sync", "Jumped to block " + QString::number(restoreHeight) + " to find transaction.");
+ }
+
+ m_wallet->startSmartSync(height + 10);
+ Utils::showInfo(this, "Import started", "Scanning block " + QString::number(height) + " for transaction...");
+ this->accept();
+ return;
+ }
+ }
+ }
+
+ if (!found) {
+ Utils::showError(this, "Transaction not found on node", "The connected node does not know this transaction.");
+ } else {
+ // Found but failed to get height? Fallback.
+ Utils::showError(this, "Failed to determine block height", "Could not read block height from node response.");
+ }
+ });
+}
+
+void TxImportDialog::updateStatus(int status) {
+ if (status == Wallet::ConnectionStatus_Disconnected) {
+ ui->btn_import->setText("Connect");
+ ui->btn_import->setEnabled(true);
+ } else if (status == Wallet::ConnectionStatus_Connecting || status == Wallet::ConnectionStatus_WrongVersion) {
+ ui->btn_import->setText("Connecting...");
+ ui->btn_import->setEnabled(false);
} else {
- Utils::showError(this, "Failed to import transaction", "");
+ ui->btn_import->setText("Import");
+ ui->btn_import->setEnabled(true);
}
- m_wallet->refreshModels();
}
TxImportDialog::~TxImportDialog() = default;
class TxImportDialog;
}
+class Nodes;
+
class TxImportDialog : public WindowModalDialog
{
Q_OBJECT
public:
- explicit TxImportDialog(QWidget *parent, Wallet *wallet);
+ explicit TxImportDialog(QWidget *parent, Wallet *wallet, Nodes *nodes);
~TxImportDialog() override;
private slots:
void onImport();
private:
+ void updateStatus(int status);
+
QScopedPointer<Ui::TxImportDialog> ui;
Wallet *m_wallet;
+ Nodes *m_nodes;
};
#include "Wallet.h"
+
+
#include <chrono>
#include <thread>
+#include <tuple>
+
+#include <QMetaObject>
#include "AddressBook.h"
#include "Coins.h"
#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);
m_coinsModel = new CoinsModel(this, m_coins);
if (this->status() == Status_Ok) {
- startRefreshThread();
+ // startRefreshThread(); // Moved to startRefresh()
// Store the wallet every 2 minutes
m_storeTimer->start(2 * 60 * 1000);
connect(m_subaddress, &Subaddress::corrupted, [this]{
emit keysCorrupted();
});
+
+ // Store original creation height if not already present
+ // This protects the restore height from being overwritten by "Skip Sync" or range syncs
+ if (!cacheAttributeExists("feather.creation_height")) {
+ quint64 height = m_wallet2->get_refresh_from_block_height();
+ setCacheAttribute("feather.creation_height", QString::number(height));
+ }
+
+ QString lastSyncStr = getCacheAttribute("feather.lastSync");
+ if (!lastSyncStr.isEmpty()) {
+ qint64 lastSync = lastSyncStr.toLongLong();
+ if (lastSync > 0) {
+ m_lastSyncTime = QDateTime::fromSecsSinceEpoch(lastSync);
+ }
+ }
}
// #################### Status ####################
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;
+void Wallet::startRefresh(bool force) {
+ startRefreshThread();
m_refreshEnabled = true;
- m_refreshNow = true;
+ if (force || !m_syncPaused) {
+ m_refreshNow = true;
+ }
}
void Wallet::pauseRefresh() {
m_refreshEnabled = false;
}
+void Wallet::updateNetworkStatus() {
+ const auto future = m_scheduler.run([this] {
+ if (!isHwBacked() || isDeviceConnected()) {
+ quint64 daemonHeight = m_walletImpl->daemonBlockChainHeight();
+ bool success = daemonHeight > 0;
+
+ quint64 targetHeight = 0;
+ if (success) {
+ targetHeight = m_walletImpl->daemonBlockChainTargetHeight();
+ }
+ bool haveHeights = (daemonHeight > 0 && targetHeight > 0);
+
+ emit heightsRefreshed(haveHeights, daemonHeight, targetHeight);
+ }
+ });
+}
+
void Wallet::startRefreshThread()
{
+ bool expected = false;
+ if (!m_refreshThreadStarted.compare_exchange_strong(expected, true)) {
+ return;
+ }
+
const auto future = m_scheduler.run([this] {
// Beware! This code does not run in the GUI thread.
- constexpr const std::chrono::seconds refreshInterval{10};
constexpr const std::chrono::milliseconds intervalResolution{100};
auto last = std::chrono::steady_clock::now();
{
const auto now = std::chrono::steady_clock::now();
const auto elapsed = now - last;
- if (elapsed >= refreshInterval || m_refreshNow)
+ if (elapsed >= std::chrono::seconds(m_refreshInterval) || m_refreshNow)
{
- m_refreshNow = false;
+ if (m_syncPaused && !m_rangeSyncActive) {
+ bool shouldScanMempool = m_refreshNow || m_scanMempoolWhenPaused;
+
+ if (shouldScanMempool) {
+ if (m_wallet2->get_daemon_address().empty()) {
+ qDebug() << "[SYNC PAUSED] Skipping mempool scan because daemon address is empty";
+ } else {
+ qDebug() << "[SYNC PAUSED] Scanning mempool because scans are enabled";
+ if (m_scheduler.stopping()) return;
+ scanMempool();
+ }
+ }
+
+ // Update network stats if we just scanned OR if we don't have stats yet (startup recovery)
+ if (shouldScanMempool || m_daemonBlockChainHeight == 0) {
+ quint64 daemonHeight = m_walletImpl->daemonBlockChainHeight();
+ quint64 targetHeight = (daemonHeight > 0) ? m_walletImpl->daemonBlockChainTargetHeight() : 0;
+ emit heightsRefreshed(daemonHeight > 0, daemonHeight, targetHeight);
+ }
+ m_refreshNow = false;
+ last = std::chrono::steady_clock::now();
+ continue;
+ }
+
+ m_refreshNow = false;
+ auto loopStartTime = std::chrono::time_point_cast<std::chrono::microseconds>(std::chrono::steady_clock::now());
// get daemonHeight and targetHeight
// daemonHeight and targetHeight will be 0 if call to get_info fails
quint64 daemonHeight = m_walletImpl->daemonBlockChainHeight();
bool success = daemonHeight > 0;
+ if (success) {
+ m_lastRefreshTime = loopStartTime.time_since_epoch().count();
+ last = loopStartTime;
+ } else {
+ // If sync failed, retry according to the interval (respects Data Saving)
+ auto retryDelay = std::chrono::seconds(m_refreshInterval);
+ qCritical() << "Refresher: Sync failed. Retry delay set to:" << retryDelay.count();
+ auto nextTime = loopStartTime - std::chrono::seconds(m_refreshInterval) + retryDelay;
+ m_lastRefreshTime = nextTime.time_since_epoch().count();
+ last = nextTime;
+ }
+
+ qDebug() << "Refresher: Interval met. Elapsed:" << std::chrono::duration_cast<std::chrono::seconds>(elapsed).count()
+ << "Interval:" << m_refreshInterval << "RefreshNow:" << m_refreshNow;
+
+
quint64 targetHeight = 0;
if (success) {
targetHeight = m_walletImpl->daemonBlockChainTargetHeight();
// Don't call refresh function if we don't have the daemon and target height
// We do this to prevent to UI from getting confused about the amount of blocks that are still remaining
if (haveHeights) {
+
QMutexLocker locker(&m_asyncMutex);
if (m_newWallet) {
m_newWallet = false;
}
+ quint64 walletHeight = m_walletImpl->blockChainHeight();
+
+ if (m_rangeSyncActive) {
+ uint64_t max_blocks = (m_stopHeight > walletHeight) ? (m_stopHeight - walletHeight) : 1;
+ uint64_t blocks_fetched = 0;
+ bool received_money = false;
+
+ // Ensure we respect the wallet creation height (restore height) if it's set higher than current
+ uint64_t startHeight = std::max((uint64_t)walletHeight, m_wallet2->get_refresh_from_block_height());
+
+ m_wallet2->refresh(m_wallet2->is_trusted_daemon(), startHeight, blocks_fetched, received_money, true, true, max_blocks);
+
+ if (m_walletImpl->blockChainHeight() >= m_stopHeight) {
+ m_rangeSyncActive = false;
+ if (m_syncPaused) {
+ setConnectionStatus(ConnectionStatus_Idle);
+ }
+ }
+ } else {
m_walletImpl->refresh();
}
- last = std::chrono::steady_clock::now();
+ }
}
}
if (daemonHeight < targetHeight) {
emit syncStatus(daemonHeight, targetHeight, true);
- }
- else {
+ } else {
this->syncStatusUpdated(walletHeight, daemonHeight);
+ emit syncStatus(daemonHeight, targetHeight, false);
}
- if (walletHeight < (targetHeight - 1)) {
+ if (m_syncPaused && !m_rangeSyncActive) {
+ setConnectionStatus(ConnectionStatus_Idle);
+ } else if (walletHeight < targetHeight) {
setConnectionStatus(ConnectionStatus_Synchronizing);
} else {
setConnectionStatus(ConnectionStatus_Synchronized);
} 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();
+ if (!m_scanMempoolWhenPaused) {
+ m_wallet2->set_offline(true);
+ }
+ } else {
+ m_wallet2->set_offline(false);
+ startRefresh(true);
+ }
+}
+
+void Wallet::setScanMempoolWhenPaused(bool enabled) {
+ m_scanMempoolWhenPaused = enabled;
+
+ // Immediately trigger a scan if enabled and paused
+ if (enabled && m_syncPaused) {
+ m_wallet2->set_offline(false);
+ startRefresh(true);
+ }
+ else if (!enabled && m_syncPaused) {
+ m_wallet2->set_offline(true);
+ }
+}
+
+QDateTime Wallet::lastSyncTime() const {
+ return m_lastSyncTime;
+}
+
+void Wallet::setRefreshInterval(int seconds) {
+ m_refreshInterval = seconds;
+}
+
+void Wallet::skipToTip() {
+ if (!m_wallet2) return;
+
+ uint64_t target = m_daemonBlockChainTargetHeight;
+ if (target == 0) {
+ qWarning() << "Cannot skip to tip: Network target unknown. Connect first.";
+ return;
+ }
+
+ QMutexLocker locker(&m_asyncMutex);
+ m_stopHeight = target;
+ m_rangeSyncActive = true;
+ m_wallet2->set_refresh_from_block_height(target);
+ m_lastSyncTime = QDateTime::currentDateTime();
+
+ setConnectionStatus(ConnectionStatus_Synchronized);
+ startRefresh(true);
+ emit syncStatus(target, target, true);
+}
+
+quint64 Wallet::getUnlockTargetHeight() const {
+ if (!m_wallet2) return 0;
+
+ uint64_t current = blockChainHeight();
+ uint64_t target = 0;
+
+ // Check incoming transfers (last 1000 blocks)
+ uint64_t min_height = (current > 1000) ? current - 1000 : 0;
+ uint64_t max_height = (uint64_t)-1;
+
+ std::list<std::pair<crypto::hash, tools::wallet2::payment_details>> in_payments;
+ m_wallet2->get_payments(in_payments, min_height, max_height);
+
+ for (const auto &p : in_payments) {
+ // Standard unlock time is block_height + 10
+ uint64_t unlock_height = p.second.m_block_height + 10;
+ // Explicit unlock_time override
+ if (p.second.m_unlock_time > 0) {
+ unlock_height = p.second.m_unlock_time;
+ }
+
+ if (unlock_height > current) {
+ target = std::max(target, unlock_height);
+ }
+ }
+
+ // Check outgoing transfers (change)
+ std::list<std::pair<crypto::hash, tools::wallet2::confirmed_transfer_details>> out_payments;
+ m_wallet2->get_payments_out(out_payments, min_height, max_height);
+
+ for (const auto &p : out_payments) {
+ // Change is locked for 10 blocks
+ uint64_t unlock_height = p.second.m_block_height + 10;
+ if (unlock_height > current) {
+ target = std::max(target, unlock_height);
+ }
+ }
+
+ return target;
+}
+
+void Wallet::startSmartSync(quint64 requestedTarget) {
+ if (!m_wallet2) return;
+
+ uint64_t tip = m_daemonBlockChainTargetHeight;
+ if (tip == 0) {
+ qWarning() << "Cannot start smart sync: Network target unknown. Connect first.";
+ return;
+ }
+
+ uint64_t current = blockChainHeight();
+ uint64_t target = tip;
+ uint64_t unlockTarget = getUnlockTargetHeight();
+
+ // "Smart Sync": Only scan what is needed to unlock funds
+ if (requestedTarget > 0) {
+ target = std::min((uint64_t)requestedTarget, tip);
+ qInfo() << "Smart Sync: Scanning to requested target:" << target;
+ } else if (unlockTarget > current) {
+ // If we have locked funds, scan to their unlock height (clamped to tip)
+ target = std::min(unlockTarget, tip);
+ qInfo() << "Smart Sync: Scanning to unlock target:" << target;
+ } else {
+ // No locked funds.
+ if (tip > current) {
+ // Minimal connectivity check
+ target = std::min(current + 10, tip);
+ qInfo() << "Smart Sync: No locked funds. Scanning small buffer to:" << target;
+ } else {
+ qInfo() << "Smart Sync: Already at tip.";
+ return;
+ }
+ }
+
+ QMutexLocker locker(&m_asyncMutex);
+ m_stopHeight = target;
+ m_rangeSyncActive = true;
+ m_pauseAfterSync = true;
+ m_lastSyncTime = QDateTime::currentDateTime();
+
+ setConnectionStatus(ConnectionStatus_Synchronizing);
+ startRefresh(true);
+ emit syncStatus(target, target, true);
+}
+
+void Wallet::syncDateRange(const QDate &start, const QDate &end) {
+ if (!m_wallet2)
+ return;
+
+ // Convert dates to heights with internal table lookup
+ cryptonote::network_type nettype = m_wallet2->nettype();
+ QString filename = Utils::getRestoreHeightFilename(static_cast<NetworkType::Type>(nettype));
+
+ std::unique_ptr<RestoreHeightLookup> lookup(RestoreHeightLookup::fromFile(filename, static_cast<NetworkType::Type>(nettype)));
+ uint64_t startHeight = lookup->dateToHeight(start.startOfDay().toSecsSinceEpoch());
+ uint64_t endHeight = lookup->dateToHeight(end.startOfDay().toSecsSinceEpoch());
+
+ if (startHeight >= endHeight)
+ return;
+
+ {
+ QMutexLocker locker(&m_asyncMutex);
+ m_stopHeight = endHeight;
+ m_rangeSyncActive = true;
+ m_wallet2->set_refresh_from_block_height(startHeight);
+ }
+ setConnectionStatus(ConnectionStatus_Synchronizing);
+ startRefresh(true);
+}
+
+
+
+void Wallet::fullSync() {
+ if (!m_wallet2)
+ return;
+
+ // Reset range sync just in case
+ m_rangeSyncActive = false;
+
+ // Retrieve original creation height from persistent storage
+ uint64_t originalHeight = 0;
+ QString storedHeight = this->getCacheAttribute("feather.creation_height");
+ if (!storedHeight.isEmpty()) {
+ originalHeight = storedHeight.toULongLong();
+ } else {
+ // Fallback: if skipToTip() was used, this may be the current tip, missing all transactions
+ originalHeight = m_wallet2->get_refresh_from_block_height();
+ qWarning() << "fullSync: No stored creation height found (feather.creation_height). "
+ << "Falling back to current refresh height:" << originalHeight
+ << ". This may miss transactions if skipToTip() was previously used.";
+ }
+
+ {
+ QMutexLocker locker(&m_asyncMutex);
+ m_wallet2->set_refresh_from_block_height(originalHeight);
+ }
+ // Trigger rescan
+ setConnectionStatus(ConnectionStatus_Synchronizing);
+ startRefresh(true);
+
+ qInfo() << "Full Sync triggered. Rescanning from original restore height:" << originalHeight;
+}
+
+void Wallet::syncStatusUpdated(quint64 height, quint64 targetHeight) {
+ if (m_rangeSyncActive && height >= m_stopHeight) {
+ // At end of requested date range, jump to tip
+ m_rangeSyncActive = false;
+
+ if (m_pauseAfterSync) {
+ m_pauseAfterSync = false;
+ // We reached the tip via scan. Just go back to paused/idle.
+ setSyncPaused(true);
+ } else {
+ // Normal date range sync behavior: skip the rest
+ this->skipToTip();
+ }
+ return;
+ }
+
+ if (height >= (targetHeight - 1)) {
this->updateBalance();
}
+ emit syncStatus(height, targetHeight, false);
+}
- emit syncStatus(height, target, false);
+bool Wallet::importTransaction(const QString &txid) {
+ if (!m_wallet2 || txid.isEmpty())
+ return false;
+
+ // If scanning a specific TX, we shouldn't be constrained by range sync
+ if (m_rangeSyncActive) {
+ m_rangeSyncActive = false;
+ }
+
+ try {
+ std::unordered_set<crypto::hash> txids;
+ crypto::hash txid_hash;
+ if (!epee::string_tools::hex_to_pod(txid.toStdString(), txid_hash)) {
+ qWarning() << "Invalid transaction id: " << txid;
+ return false;
+ }
+ txids.insert(txid_hash);
+ m_wallet2->scan_tx(txids);
+ qInfo() << "Successfully imported transaction:" << txid;
+ this->updateBalance();
+ this->history()->refresh();
+ return true;
+ } catch (const std::exception &e) {
+ qWarning() << "Failed to import transaction: " << txid << ", error: " << e.what();
+ }
+ return false;
}
+
+
void Wallet::onNewBlock(uint64_t walletHeight) {
+ if (m_syncPaused) {
+ return;
+ }
// Called whenever a new block gets scanned by the wallet
quint64 daemonHeight = m_daemonBlockChainTargetHeight;
}
}
+void Wallet::rescanBlockchainAsync() {
+ m_wallet2->rescan_blockchain(false, false, false);
+ // After rescan, the wallet's local height is reset to the refresh-from height.
+}
+
void Wallet::refreshModels() {
m_history->refresh();
m_coins->refresh();
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() {
currentSubaddressAccount(), subaddr_indices, m_selectedInputs, subtractFeeFromAmount);
QVector<QString> addresses{address};
- this->onTransactionCreated(ptImpl, addresses);
+ QMetaObject::invokeMethod(this, [this, ptImpl, addresses] {
+ this->onTransactionCreated(ptImpl, addresses);
+ }, Qt::QueuedConnection);
});
}
static_cast<Monero::PendingTransaction::Priority>(feeLevel),
currentSubaddressAccount(), subaddr_indices, m_selectedInputs, subtractFeeFromAmount);
- this->onTransactionCreated(ptImpl, addresses);
+ QMetaObject::invokeMethod(this, [this, ptImpl, addresses] {
+ this->onTransactionCreated(ptImpl, addresses);
+ }, Qt::QueuedConnection);
});
}
static_cast<Monero::PendingTransaction::Priority>(feeLevel));
QVector<QString> addresses {address};
- this->onTransactionCreated(ptImpl, addresses);
+ QMetaObject::invokeMethod(this, [this, ptImpl, addresses] {
+ this->onTransactionCreated(ptImpl, addresses);
+ }, Qt::QueuedConnection);
});
}
void Wallet::onTransactionCreated(Monero::PendingTransaction *mtx, const QVector<QString> &address) {
qDebug() << Q_FUNC_INFO;
- startRefresh();
PendingTransaction *tx = new PendingTransaction(mtx, this);
});
}
+void Wallet::scanMempool() {
+ QMutexLocker locker(&m_asyncMutex);
+ try {
+ std::vector<std::tuple<cryptonote::transaction, crypto::hash, bool>> process_txs;
+ m_wallet2->update_pool_state(process_txs, false, false);
+ // Refresh models so the UI picks up the new transaction(s)
+ // We invoke this on the main thread to ensure signals (beginResetModel) are processed synchronously
+ // with the data update, preventing race conditions or ignored updates in the view.
+ QMetaObject::invokeMethod(this, [this]{
+ if (m_history) m_history->refresh();
+ if (m_coins) m_coins->refresh();
+ if (m_subaddress) m_subaddress->refresh();
+ }, Qt::QueuedConnection);
+
+ emit updated();
+ } catch (const std::exception &e) {
+ qWarning() << "Failed to scan mempool:" << e.what();
+ }
+}
+
Wallet::~Wallet()
{
qDebug() << "~Wallet: Closing wallet" << QThread::currentThreadId();
pauseRefresh();
m_walletImpl->stop();
+ // Stop the wallet2 instance to interrupt any blocking network calls (e.g. init)
+ if (m_wallet2)
+ m_wallet2->stop();
m_scheduler.shutdownWaitForFinished();
#include "rows/TxBacklogEntry.h"
#include <set>
+#include <atomic>
class WalletListenerImpl;
ConnectionStatus_WrongVersion = 2,
ConnectionStatus_Connecting = 9,
ConnectionStatus_Synchronizing = 10,
- ConnectionStatus_Synchronized = 11
+ ConnectionStatus_Synchronized = 11,
+ ConnectionStatus_Idle = 12
};
Q_ENUM(ConnectionStatus)
//! 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();
const QString &proxyAddress = "");
// ##### Synchronization (Refresh) #####
- void startRefresh();
+ void startRefresh(bool force = false);
void pauseRefresh();
+ Q_INVOKABLE void updateNetworkStatus();
+
+ bool syncPaused() const;
+ void setSyncPaused(bool paused);
+ void setScanMempoolWhenPaused(bool enabled);
//! returns current wallet's block height
//! (can be less than daemon's blockchain height when wallet sync in progress)
quint64 daemonBlockChainTargetHeight() const;
void syncStatusUpdated(quint64 height, quint64 target);
+ quint64 getUnlockTargetHeight() const;
+ Q_INVOKABLE void skipToTip();
+ Q_INVOKABLE void startSmartSync(quint64 target = 0);
+ Q_INVOKABLE void syncDateRange(const QDate &start, const QDate &end);
+
+ void fullSync(); // Rescans from wallet creation height, not genesis block
+
+ Q_INVOKABLE void rescanBlockchainAsync();
+ bool importTransaction(const QString &txid);
void refreshModels();
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);
void onTransactionCreated(Monero::PendingTransaction *mtx, const QVector<QString> &address);
private:
+ void scanMempool();
friend class WalletManager;
friend class WalletListenerImpl;
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<bool> m_lastRefreshTime{0};
+ std::atomic<bool> m_pauseAfterSync{false};
+ std::atomic<bool> m_refreshThreadStarted{false};
+ std::atomic<bool> m_scanMempoolWhenPaused{false};
};
#endif // FEATHER_WALLET_H
+
// 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:
void WalletKeysFilesModel::refresh() {
this->clear();
this->findWallets();
- endResetModel();
+
}
void WalletKeysFilesModel::updateDirectories() {
const QString baseName = fileInfo.baseName();
const QString basePath = QString("%1/%2").arg(path).arg(baseName);
QString addr = QString("");
+ // Assume mainnet by default, set otherwise if needed
quint8 networkType = NetworkType::MAINNET;
if (Utils::fileExists(basePath + ".address.txt")) {
addr = _address;
if (addr.startsWith("5") || addr.startsWith("7"))
networkType = NetworkType::STAGENET;
- else if (addr.startsWith("9") || addr.startsWith("B"))
+ else if (addr.startsWith("9") || addr.startsWith("A") || addr.startsWith("B"))
networkType = NetworkType::TESTNET;
}
file.close();
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);
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::lastKnownNetworkHeight, {QS("lastKnownNetworkHeight"), 0}},
+ {Config::lastSyncTimestamp, {QS("lastSyncTimestamp"), 0}},
+ {Config::lastPriceUpdateTimestamp, {QS("lastPriceUpdateTimestamp"), 0}},
+ {Config::lastNetInfoUpdate, {QS("lastNetInfoUpdate"), 0}},
// Transactions
{Config::multiBroadcast, {QS("multiBroadcast"), true}},
{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,
+ lastKnownNetworkHeight,
+ lastNetInfoUpdate,
+ 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() && !forceReconnect) {
+ return;
+ }
+
// this function is responsible for automatically connecting to a daemon.
if (m_wallet == nullptr || !m_enableAutoconnect) {
return;
}
Wallet::ConnectionStatus status = m_wallet->connectionStatus();
+ if (status == Wallet::ConnectionStatus_Connecting && !forceReconnect) {
+ return;
+ }
+
bool wsMode = (this->source() == NodeSource::websocket);
if (wsMode && !m_wsNodesReceived && websocketNodes().count() == 0) {
}
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;
+ QTimer::singleShot(1000, this, [this]{
+ 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
ui->walletTable->setSelectionBehavior(QAbstractItemView::SelectRows);
ui->walletTable->setContextMenuPolicy(Qt::CustomContextMenu);
ui->walletTable->setModel(m_keysProxy);
- ui->walletTable->hideColumn(WalletKeysFilesModel::NetworkType);
+ // Only show 'wallet type' column in stagenet or testnet mode
+ if (constants::networkType == NetworkType::MAINNET)
+ ui->walletTable->hideColumn(WalletKeysFilesModel::NetworkType);
ui->walletTable->hideColumn(WalletKeysFilesModel::Path);
ui->walletTable->hideColumn(WalletKeysFilesModel::Modified);
ui->walletTable->header()->setSectionResizeMode(WalletKeysFilesModel::FileName, QHeaderView::Stretch);