diff --git a/src/app.cpp b/src/app.cpp index b604882..576743d 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -3084,45 +3084,19 @@ void App::renderStatusBar() int blocksLeft = chainTip - state_.sync.blocks; if (blocksLeft < 0) blocksLeft = 0; - // Calculate sync speed (blocks/sec) using a smoothed rolling average - static int s_prev_blocks = 0; - static double s_prev_time = 0.0; - static double s_blocks_per_sec = 0.0; - double now = ImGui::GetTime(); - if (s_prev_time == 0.0) { - // First sample — seed baseline, don't compute rate yet - s_prev_blocks = state_.sync.blocks; - s_prev_time = now; - } else if (state_.sync.blocks > s_prev_blocks) { - double dt = now - s_prev_time; - if (dt > 0.5) { - double raw = (state_.sync.blocks - s_prev_blocks) / dt; - // Exponential smoothing (alpha ~0.3 per update) - s_blocks_per_sec = s_blocks_per_sec > 0.0 - ? s_blocks_per_sec * 0.7 + raw * 0.3 - : raw; - s_prev_blocks = state_.sync.blocks; - s_prev_time = now; - } - } else if (now - s_prev_time > 10.0) { - // No new blocks for 10s — decay the displayed rate - s_blocks_per_sec *= 0.5; - s_prev_time = now; - } - - if (s_blocks_per_sec > 0.1) { - int eta_sec = (int)(blocksLeft / s_blocks_per_sec); + // App-owned smoothed sync-rate estimator: self-resets after a rescan/restart height drop (and on + // connect/disconnect via the pending flag), so the ETA never goes stale/absurd ("412h"); days-aware. + if (sync_eta_reset_pending_.exchange(false)) sync_eta_.reset(); + sync_eta_.update(state_.sync.blocks, ImGui::GetTime()); + const ImVec4 syncCol = ImGui::ColorConvertU32ToFloat4(ui::material::Warning()); + const long long etaSec = sync_eta_.etaSeconds(blocksLeft); + if (etaSec >= 0) { char eta[32]; - if (eta_sec >= 3600) - snprintf(eta, sizeof(eta), "%dh %dm", eta_sec / 3600, (eta_sec % 3600) / 60); - else if (eta_sec >= 60) - snprintf(eta, sizeof(eta), "%dm %ds", eta_sec / 60, eta_sec % 60); - else - snprintf(eta, sizeof(eta), "%ds", eta_sec); - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_eta"), - state_.sync.verification_progress * 100.0, blocksLeft, s_blocks_per_sec, eta); + util::formatEta(etaSec, eta, sizeof(eta)); + ImGui::TextColored(syncCol, TR("sb_syncing_eta"), + state_.sync.verification_progress * 100.0, blocksLeft, sync_eta_.rate(), eta); } else { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_basic"), + ImGui::TextColored(syncCol, TR("sb_syncing_basic"), state_.sync.verification_progress * 100.0, blocksLeft); } } else if (state_.connected && !isLiteBuild()) { @@ -6337,6 +6311,7 @@ void App::rescanBlockchain() // actually observe the restarted daemon rescanning — so the first poll (which may still reach the // pre-restart daemon and see rescanning=false) can't be misread as instant completion. state_.sync.rescanning = true; + sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // rescan drops height → reset ETA rescan_confirmed_active_ = false; user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast state_.sync.rescan_progress = 0.0f; @@ -8136,6 +8111,7 @@ std::string App::buildDiagnosticsReport() void App::restartDaemon() { + sync_eta_reset_pending_.store(true, std::memory_order_relaxed); if (!supportsFullNodeLifecycleActions()) { ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite")); return; diff --git a/src/app.h b/src/app.h index 8000ec2..685b21b 100644 --- a/src/app.h +++ b/src/app.h @@ -42,6 +42,7 @@ #include "resources/provision_state.h" #include "util/background_sampler.h" #include "util/coalescing_writer.h" +#include "util/sync_eta.h" #include "util/fetch_backoff.h" #include "util/idle_tracker.h" #include "util/pool_stats_service.h" @@ -1752,6 +1753,10 @@ private: std::atomic mining_toggle_in_progress_{false}; // Mining error state (main-thread only — every writer is in a MainCb or render). std::string mining_last_error_; + // Sync-ETA estimator (UI thread only). onConnected/onDisconnected may run off-thread, so they set + // the atomic flag and the status bar consumes it before update(). + util::SyncRateEstimator sync_eta_; + std::atomic sync_eta_reset_pending_{false}; bool mining_error_av_ = false; void setMiningError(std::string msg, bool av = false) { mining_last_error_ = std::move(msg); mining_error_av_ = av; } diff --git a/src/app_network.cpp b/src/app_network.cpp index b533e14..631aac5 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -741,6 +741,7 @@ void App::tryConnect() void App::onConnected() { maint_abort_.store(false); // maintenance calls queued before this connect have already failed (FIFO) + sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // fresh connect → recompute sync ETA ++rpc_session_; if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast")); state_.connected = true; @@ -854,6 +855,7 @@ void App::onConnected() void App::onDisconnected(const std::string& reason) { + sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // Persist what the history snapshot is still missing while the rows, the address set (the cache identity), // the pending-send filter and the cache key are all live: state_.clear(), pending_opids_.clear() and the // session reset below empty them, which would turn a later flush into a silent no-op. diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 3feff21..3d61dde 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -1548,9 +1548,13 @@ void RenderSettingsPage(App* app) { if (cardBtn(cx, cy, cw, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT)) app->showExportKeyDialog(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_key")); last = cy + bh; cy += bh + bgp; - if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all")); - last = cy + bh; cy += bh + bgp; + // Export-all only works over the full-node RPC; hide it in lite (which has its own key export + // in the lite wallet section) rather than dead-ending on "Not connected to the daemon". + if (fullNode) { + if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all")); + last = cy + bh; cy += bh + bgp; + } if (cardBtn(cx, cy, cw, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION)) ExportTransactionsDialog::show(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_csv")); last = cy + bh; @@ -2727,7 +2731,10 @@ void RenderSettingsPage(App* app) { ImGui::TextUnformatted(TR("transaction_url")); ImGui::SameLine(urlRowX + urlLblW); ImGui::SetNextItemWidth(urlInputW); + // These Explorer-tab controls persist on edit — they were the only ones still relying on the + // About-only Save button, which a user on this tab never sees. ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer)); + if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings()); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url")); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); @@ -2737,6 +2744,7 @@ void RenderSettingsPage(App* app) { ImGui::SameLine(urlRowX + urlLblW); ImGui::SetNextItemWidth(urlInputW); ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer)); + if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings()); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url")); ImGui::PopFont(); @@ -2754,10 +2762,12 @@ void RenderSettingsPage(App* app) { // Checkboxes + Block Explorer button (button wraps to its own row when it won't fit). const float expRowRight = ImGui::GetCursorScreenPos().x + contentW; - ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees); + if (ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees)) + saveSettingsPageState(app->settings()); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees")); ImGui::SameLine(0, Layout::spacingLg()); - ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices); + if (ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices)) + saveSettingsPageState(app->settings()); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices")); const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x + ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg(); diff --git a/src/util/sync_eta.h b/src/util/sync_eta.h new file mode 100644 index 0000000..3bf837e --- /dev/null +++ b/src/util/sync_eta.h @@ -0,0 +1,64 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// A tiny, pure block-sync rate estimator for the status-bar / peers-card ETA. Pure (no ImGui, no App): +// App owns one instance, feeds it the current height + a monotonic clock once per frame while syncing, +// and resets it on connect / disconnect / rescan / restart so a height drop never produces a stale or +// absurd ("412h") ETA. +#pragma once + +#include +#include + +namespace dragonx { +namespace util { + +class SyncRateEstimator { +public: + void reset() { prevBlocks_ = -1; prevTime_ = 0.0; rate_ = 0.0; } + + // Call once per frame while syncing. `now` is monotonic seconds (e.g. ImGui::GetTime()). + void update(int blocks, double now) { + // First sample, a rescan/restart height drop, or a clock reset → re-baseline, don't compute. + if (prevBlocks_ < 0 || blocks < prevBlocks_ || now < prevTime_) { + prevBlocks_ = blocks; prevTime_ = now; rate_ = 0.0; return; + } + const double dt = now - prevTime_; + if (blocks > prevBlocks_) { + if (dt > 0.5) { + const double raw = (blocks - prevBlocks_) / dt; + rate_ = rate_ > 0.0 ? rate_ * 0.7 + raw * 0.3 : raw; // smooth (same alpha as the old code) + prevBlocks_ = blocks; prevTime_ = now; + } + } else if (dt > 10.0) { // stalled: decay and re-baseline the clock + rate_ *= 0.5; prevTime_ = now; + if (rate_ < 0.1) rate_ = 0.0; + } + } + + double rate() const { return rate_; } // blocks/sec, 0 when unknown + + // <= 0 when unknown. Capped at ~1 year so a tiny rate can't overflow. + long long etaSeconds(int blocksLeft) const { + if (rate_ <= 0.1 || blocksLeft <= 0) return -1; + const double s = blocksLeft / rate_; + return s > 3.15e7 ? 31500000LL : static_cast(s); + } + +private: + int prevBlocks_ = -1; // -1 = unset (distinct from a legitimate height 0) + double prevTime_ = 0.0; + double rate_ = 0.0; +}; + +// Human ETA with days, e.g. "3d 4h", "5h 12m", "7m 9s", "42s". English unit letters (as the old code). +inline void formatEta(long long s, char* out, std::size_t n) { + if (s >= 86400) std::snprintf(out, n, "%lldd %lldh", s / 86400, (s % 86400) / 3600); + else if (s >= 3600) std::snprintf(out, n, "%lldh %lldm", s / 3600, (s % 3600) / 60); + else if (s >= 60) std::snprintf(out, n, "%lldm %llds", s / 60, s % 60); + else std::snprintf(out, n, "%llds", s); +} + +} // namespace util +} // namespace dragonx